diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..8d2d8985 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +java-src/overview.html linguist-documentation +*.sh linguist-detectable=false \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..44a1961a --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +/target +/classes +/checkouts +pom.xml +pom.xml.asc +*.jar +*.class +/.lein-* +/.nrepl-port +.hgignore +.hg/ +*.iml +pom.xml +/javadoc +junit.xml +.~lock.* +.idea/ \ No newline at end of file diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 00000000..e48e0963 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,277 @@ +Eclipse Public License - v 2.0 + + THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE + PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION + OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. + +1. DEFINITIONS + +"Contribution" means: + + a) in the case of the initial Contributor, the initial content + Distributed under this Agreement, and + + b) in the case of each subsequent Contributor: + i) changes to the Program, and + ii) additions to the Program; + where such changes and/or additions to the Program originate from + and are Distributed by that particular Contributor. A Contribution + "originates" from a Contributor if it was added to the Program by + such Contributor itself or anyone acting on such Contributor's behalf. + Contributions do not include changes or additions to the Program that + are not Modified Works. + +"Contributor" means any person or entity that Distributes the Program. + +"Licensed Patents" mean patent claims licensable by a Contributor which +are necessarily infringed by the use or sale of its Contribution alone +or when combined with the Program. + +"Program" means the Contributions Distributed in accordance with this +Agreement. + +"Recipient" means anyone who receives the Program under this Agreement +or any Secondary License (as applicable), including Contributors. + +"Derivative Works" shall mean any work, whether in Source Code or other +form, that is based on (or derived from) the Program and for which the +editorial revisions, annotations, elaborations, or other modifications +represent, as a whole, an original work of authorship. + +"Modified Works" shall mean any work in Source Code or other form that +results from an addition to, deletion from, or modification of the +contents of the Program, including, for purposes of clarity any new file +in Source Code form that contains any contents of the Program. Modified +Works shall not include works that contain only declarations, +interfaces, types, classes, structures, or files of the Program solely +in each case in order to link to, bind by name, or subclass the Program +or Modified Works thereof. + +"Distribute" means the acts of a) distributing or b) making available +in any manner that enables the transfer of a copy. + +"Source Code" means the form of a Program preferred for making +modifications, including but not limited to software source code, +documentation source, and configuration files. + +"Secondary License" means either the GNU General Public License, +Version 2.0, or any later versions of that license, including any +exceptions or additional permissions as identified by the initial +Contributor. + +2. GRANT OF RIGHTS + + a) Subject to the terms of this Agreement, each Contributor hereby + grants Recipient a non-exclusive, worldwide, royalty-free copyright + license to reproduce, prepare Derivative Works of, publicly display, + publicly perform, Distribute and sublicense the Contribution of such + Contributor, if any, and such Derivative Works. + + b) Subject to the terms of this Agreement, each Contributor hereby + grants Recipient a non-exclusive, worldwide, royalty-free patent + license under Licensed Patents to make, use, sell, offer to sell, + import and otherwise transfer the Contribution of such Contributor, + if any, in Source Code or other form. This patent license shall + apply to the combination of the Contribution and the Program if, at + the time the Contribution is added by the Contributor, such addition + of the Contribution causes such combination to be covered by the + Licensed Patents. The patent license shall not apply to any other + combinations which include the Contribution. No hardware per se is + licensed hereunder. + + c) Recipient understands that although each Contributor grants the + licenses to its Contributions set forth herein, no assurances are + provided by any Contributor that the Program does not infringe the + patent or other intellectual property rights of any other entity. + Each Contributor disclaims any liability to Recipient for claims + brought by any other entity based on infringement of intellectual + property rights or otherwise. As a condition to exercising the + rights and licenses granted hereunder, each Recipient hereby + assumes sole responsibility to secure any other intellectual + property rights needed, if any. For example, if a third party + patent license is required to allow Recipient to Distribute the + Program, it is Recipient's responsibility to acquire that license + before distributing the Program. + + d) Each Contributor represents that to its knowledge it has + sufficient copyright rights in its Contribution, if any, to grant + the copyright license set forth in this Agreement. + + e) Notwithstanding the terms of any Secondary License, no + Contributor makes additional grants to any Recipient (other than + those set forth in this Agreement) as a result of such Recipient's + receipt of the Program under the terms of a Secondary License + (if permitted under the terms of Section 3). + +3. REQUIREMENTS + +3.1 If a Contributor Distributes the Program in any form, then: + + a) the Program must also be made available as Source Code, in + accordance with section 3.2, and the Contributor must accompany + the Program with a statement that the Source Code for the Program + is available under this Agreement, and informs Recipients how to + obtain it in a reasonable manner on or through a medium customarily + used for software exchange; and + + b) the Contributor may Distribute the Program under a license + different than this Agreement, provided that such license: + i) effectively disclaims on behalf of all other Contributors all + warranties and conditions, express and implied, including + warranties or conditions of title and non-infringement, and + implied warranties or conditions of merchantability and fitness + for a particular purpose; + + ii) effectively excludes on behalf of all other Contributors all + liability for damages, including direct, indirect, special, + incidental and consequential damages, such as lost profits; + + iii) does not attempt to limit or alter the recipients' rights + in the Source Code under section 3.2; and + + iv) requires any subsequent distribution of the Program by any + party to be under a license that satisfies the requirements + of this section 3. + +3.2 When the Program is Distributed as Source Code: + + a) it must be made available under this Agreement, or if the + Program (i) is combined with other material in a separate file or + files made available under a Secondary License, and (ii) the initial + Contributor attached to the Source Code the notice described in + Exhibit A of this Agreement, then the Program may be made available + under the terms of such Secondary Licenses, and + + b) a copy of this Agreement must be included with each copy of + the Program. + +3.3 Contributors may not remove or alter any copyright, patent, +trademark, attribution notices, disclaimers of warranty, or limitations +of liability ("notices") contained within the Program from any copy of +the Program which they Distribute, provided that Contributors may add +their own appropriate notices. + +4. COMMERCIAL DISTRIBUTION + +Commercial distributors of software may accept certain responsibilities +with respect to end users, business partners and the like. While this +license is intended to facilitate the commercial use of the Program, +the Contributor who includes the Program in a commercial product +offering should do so in a manner which does not create potential +liability for other Contributors. Therefore, if a Contributor includes +the Program in a commercial product offering, such Contributor +("Commercial Contributor") hereby agrees to defend and indemnify every +other Contributor ("Indemnified Contributor") against any losses, +damages and costs (collectively "Losses") arising from claims, lawsuits +and other legal actions brought by a third party against the Indemnified +Contributor to the extent caused by the acts or omissions of such +Commercial Contributor in connection with its distribution of the Program +in a commercial product offering. The obligations in this section do not +apply to any claims or Losses relating to any actual or alleged +intellectual property infringement. In order to qualify, an Indemnified +Contributor must: a) promptly notify the Commercial Contributor in +writing of such claim, and b) allow the Commercial Contributor to control, +and cooperate with the Commercial Contributor in, the defense and any +related settlement negotiations. The Indemnified Contributor may +participate in any such claim at its own expense. + +For example, a Contributor might include the Program in a commercial +product offering, Product X. That Contributor is then a Commercial +Contributor. If that Commercial Contributor then makes performance +claims, or offers warranties related to Product X, those performance +claims and warranties are such Commercial Contributor's responsibility +alone. Under this section, the Commercial Contributor would have to +defend claims against the other Contributors related to those performance +claims and warranties, and if a court requires any other Contributor to +pay any damages as a result, the Commercial Contributor must pay +those damages. + +5. NO WARRANTY + +EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT +PERMITTED BY APPLICABLE LAW, THE PROGRAM IS PROVIDED ON AN "AS IS" +BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR +IMPLIED INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF +TITLE, NON-INFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A PARTICULAR +PURPOSE. Each Recipient is solely responsible for determining the +appropriateness of using and distributing the Program and assumes all +risks associated with its exercise of rights under this Agreement, +including but not limited to the risks and costs of program errors, +compliance with applicable laws, damage to or loss of data, programs +or equipment, and unavailability or interruption of operations. + +6. DISCLAIMER OF LIABILITY + +EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT +PERMITTED BY APPLICABLE LAW, NEITHER RECIPIENT NOR ANY CONTRIBUTORS +SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING WITHOUT LIMITATION LOST +PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OR DISTRIBUTION OF THE PROGRAM OR THE +EXERCISE OF ANY RIGHTS GRANTED HEREUNDER, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + +7. GENERAL + +If any provision of this Agreement is invalid or unenforceable under +applicable law, it shall not affect the validity or enforceability of +the remainder of the terms of this Agreement, and without further +action by the parties hereto, such provision shall be reformed to the +minimum extent necessary to make such provision valid and enforceable. + +If Recipient institutes patent litigation against any entity +(including a cross-claim or counterclaim in a lawsuit) alleging that the +Program itself (excluding combinations of the Program with other software +or hardware) infringes such Recipient's patent(s), then such Recipient's +rights granted under Section 2(b) shall terminate as of the date such +litigation is filed. + +All Recipient's rights under this Agreement shall terminate if it +fails to comply with any of the material terms or conditions of this +Agreement and does not cure such failure in a reasonable period of +time after becoming aware of such noncompliance. If all Recipient's +rights under this Agreement terminate, Recipient agrees to cease use +and distribution of the Program as soon as reasonably practicable. +However, Recipient's obligations under this Agreement and any licenses +granted by Recipient relating to the Program shall continue and survive. + +Everyone is permitted to copy and distribute copies of this Agreement, +but in order to avoid inconsistency the Agreement is copyrighted and +may only be modified in the following manner. The Agreement Steward +reserves the right to publish new versions (including revisions) of +this Agreement from time to time. No one other than the Agreement +Steward has the right to modify this Agreement. The Eclipse Foundation +is the initial Agreement Steward. The Eclipse Foundation may assign the +responsibility to serve as the Agreement Steward to a suitable separate +entity. Each new version of the Agreement will be given a distinguishing +version number. The Program (including Contributions) may always be +Distributed subject to the version of the Agreement under which it was +received. In addition, after a new version of the Agreement is published, +Contributor may elect to Distribute the Program (including its +Contributions) under the new version. + +Except as expressly stated in Sections 2(a) and 2(b) above, Recipient +receives no rights or licenses to the intellectual property of any +Contributor under this Agreement, whether expressly, by implication, +estoppel or otherwise. All rights in the Program not expressly granted +under this Agreement are reserved. Nothing in this Agreement is intended +to be enforceable by any entity that is not a Contributor or Recipient. +No third-party beneficiary rights are created under this Agreement. + +Exhibit A - Form of Secondary Licenses Notice + +"This Source Code may also be made available under the following +Secondary Licenses when the conditions for such availability set forth +in the Eclipse Public License, v. 2.0 are satisfied: {name license(s), +version(s), and exceptions or additional permissions here}." + + Simply including a copy of this Agreement, including this Exhibit A + is not sufficient to license the Source Code under Secondary Licenses. + + If it is not possible or desirable to put the notice in a particular + file, then You may include the notice in a location (such as a LICENSE + file in a relevant directory) where a recipient would be likely to + look for such a notice. + + You may add additional accurate notices of copyright ownership. diff --git a/README.md b/README.md new file mode 100644 index 00000000..2571a9dd --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# Stencil Template Engine + +Stencil is a templating engine to produce Office Open XML (mostly Microsoft +Office's Word `.docx` files) from Java programs. It has a simple syntax and no +programming is needed to write or modify document templates. + +The aim of this project is to provide an easy-to-use and freely available tool +for generating office documents. + +You can use either Microsoft Word or LibreOffice to edit the document templates. +The template expressions are just simple texts, and you can event colour-code +them to make the template more readable. + + +## Getting Started + +- Read the [Documentation](docs/index.md) +- See the [Example templates](examples) + + +## Version + +**Latest stable** version is `0.2.1`. + +If you are using Maven, add the followings to your `pom.xml`: + +The dependency: + +``` xml + + io.github.erdos + stencil-core + 0.2.1 + +``` + +And the [Clojars](https://clojars.org) repository: + +``` xml + + clojars.org + https://repo.clojars.org + +``` + +Alternatively, if you are using Leiningen, add the following to +the `:dependencies` section of your `project.clj` +file: `[io.github.erdos/stencil-core "0.2.1"]` + +Previous versions are available on the [Stencil Clojars](https://clojars.org/io.github.erdos/stencil-core) page. + +## License + +Copyright (c) Janos Erdos. All rights reserved. The use and distribution terms +for this software are covered by the Eclipse Public License 2.0 +(https://www.eclipse.org/legal/epl-2.0/) which can be found in the file +`LICENSE.txt` at the root of this distribution. By using this software in any +fashion, you are agreeing to be bound by the terms of this license. You must not +remove this notice, or any other, from this software. diff --git a/build.sh b/build.sh new file mode 100755 index 00000000..263be8db --- /dev/null +++ b/build.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env sh + +lein clean && lein javac && lein uberjar diff --git a/docs/Contribution.md b/docs/Contribution.md new file mode 100644 index 00000000..83903116 --- /dev/null +++ b/docs/Contribution.md @@ -0,0 +1,11 @@ +## Contribution + +You are very welcome to contribute to the project. Feel free to open a **Pull Request** to reach out to the author by a private message. + +The author kindly encourages all future contributors to consider the following guidelines. + +- [Java Style Guide](http://cr.openjdk.java.net/~alundblad/styleguide/index-v6.html) +- [Clojure Style Guide](https://github.com/bbatsov/clojure-style-guide) +- Use a [Code formatter for Clojure](https://github.com/weavejester/cljfmt) +- Please write Javadoc documentation +- Please write unit tests (Ok, these two are really hard) diff --git a/docs/Functions.md b/docs/Functions.md new file mode 100644 index 00000000..77f4e87e --- /dev/null +++ b/docs/Functions.md @@ -0,0 +1,67 @@ +# Functions + +You can call functions from within the template files and embed the call result easily by writing +{%=functionName(arg1, arg2, arg3, ...)%} expression in the document template. + +This is a short description of the functions implemented in Stencil. + +## Basic Functions + +### Coalesce + +Accepts any number of arguments, returns the first not-empty value. + +**Exampe:** + +- to insert the first filled name value: {%=coalesce(partnerFullName, partnerShortName, partnerName)%} +- to insert the price of an item or default to zero: {%=coalesce(x.price, x.premium, 0)%} + +### Empty + +Decides if a parameter is empty or missing. Useful in conditional statements. + +**Example:** + +{%if empty(userName) %}Unknown User{%else%}{%=userName%}{%end%} + +If the value of `userName` is missing then `Unknown User` will be inserted, otherwise the value is used. + +The `empty()` function is useful when we want to either enumerate the contents +of an array or hide the whole paragraph when the array is empty. + + + +## String functions + +These functions deal with textual data. + +## Format + +Calls [String.format](https://docs.oracle.com/javase/7/docs/api/java/util/Formatter.html) function. + +**Example:** + +This example formats the value of `price` as a price string: +{%=format("$ %(,.2f"", price) %}. It may output `$ (6,217.58)`. + + +## Date + +Formats a date value according to a given [format string](https://docs.oracle.com/javase/6/docs/api/java/text/SimpleDateFormat.html). + +Arguments: + +1. First argument is a format string. +2. Second argument is a string containing a date value. + +**Example:** + +This example formats the value of `partner.birthDate` as a date string: {%=date("yyyy-MM-dd", partner.birthDate) %} + +Also, try these formats strings: + +- `"yyyy-MM-dd HH:mm:ss"` for example: `2018-02-28 13:01:31` +- `"EEE, dd MMM yyyy HH:mm:ss zzz"` (also known as RFC1123) +- `"EEEE, dd-MMM-yy HH:mm:ss zzz"` (a.k.a. RFC1036) +- `"EEE MMM d HH:mm:ss yyyy"` (ASCTIME) +- `"yyyy-MM-dd'T'HH:mm:ss.SSSXXX"` (ISO8601) diff --git a/docs/GettingStarted.md b/docs/GettingStarted.md new file mode 100644 index 00000000..70c5a9b3 --- /dev/null +++ b/docs/GettingStarted.md @@ -0,0 +1,68 @@ +# Stencil Templating for Programmers + +First of all, you need to add Stencil to the list of dependencies in your project. +The Stencil home page shows the current version you can use in your build files. + +## Java API + +First you need to add the dependency to your `pom.xml` file. +See the `README.md` file for information about the latest stable version. + +The public Java API is accessible in the `io.github.erdos.stencil.API` class. + +1. First, we have to prepare a template file. Call `API.prepare()` funtion with the template file. +2. Second, we can render the prepared template using the `API.render()` function. + +The following example takes a template from the file system, fills it with data +from the arguments and writes the rendered document back to the file system. + +``` java +public void renderInvoiceDocument(String userName, Integer totalCost) throws IOException { + + final File template = new File("/home/developer/templates/invoice.docx"); + + final PreparedTemplate prepared = API.prepare(template); + + final Map data = new HashMap<>(); + data.put("name", userName); + data.put("cost", totalCost); + + final EvaluatedDocument rendered = API.render(prepared, TemplateData.fromMap(data)); + + rendered.writeToFile(new File("/home/developer/rendered/invoice-" + userName + ".docx")); + } +``` + +## Clojure API + +Before writing any code, add the latest version of the stencil project to the +`:dependencies` section of your `project.clj` file. See the `README.md` file for +the latest stable version. + +You need to import the stencil api namespace: `(require '[stencil.api :refer :all])` + +First, we need to compile a template file. + +``` clojure +(def template-1 (prepare (clojure.java.io/resource "template1.docx"))) +``` + +Then, we can define a helper function to render the template. + +``` clojure +(defn render-template-1 [output-file data] + (render! template-1 data :output output-file)) +``` + +Call the function to render file. + +``` clojure +(render-template-1 "/tmp/output-1.docx" {"customerName" "John Doe"}) +``` + +This renders the template to `/tmp/output-1.docx` with the supplied data. + +## Converting to other formats + +If you need to convert the resulting document to other document types you can +use the amazing [JODConverter](https://github.com/sbraconnier/jodconverter) library. diff --git a/docs/Images.md b/docs/Images.md new file mode 100644 index 00000000..8c3acf66 --- /dev/null +++ b/docs/Images.md @@ -0,0 +1,3 @@ +# Handling Images + +Dynamically creating and handling images is not yet supported. diff --git a/docs/Math.md b/docs/Math.md new file mode 100644 index 00000000..a684371b --- /dev/null +++ b/docs/Math.md @@ -0,0 +1,32 @@ +# Math and Logics + +It is possible to embed complex mathematical equations and logical formulae in the document logic. + +## Simple math + +- Use of integers and floating point decimals are supported. +- Use of parentheses in expressions is supported. For example: `{ %=(x.price * (1 + x.tax_pct))% }` +- The following mathematical algebraic operators are supported: `+, -, *, /, %`. + +| operator | symbol | example | meaning | +|-----|----|-------------|---| +| addition | `+` | `a + b` | sum of `a` and `b` | +| subtraction | `-` | `a - b`, `x-1` | value of `a` minus `b` | +| multiplication | `*` | `a*b` | value of `a` multiplied by `b` | +| division | `/` | `a/b` | value of `a` divided by `b` | +| modulo | `%` | `a%b` | remainder of `a` after divided by `b` | + +## Logics in conditions + +You can use logical operators in the expressions. These are useful inside conditional expressions. + +| operator | symbol | example | meaning | +|-----|----|-------------|---| +| conjuction (and) | `&` | `a & b` | both `a` and `b` are not null and not false values | +| disjunction (or) | | | a|b | either `a` or `b` or both are not null or false | +| negation (not) | `!` | `!a` | value `a` is false or null | + +## Function calls + +There are simple functions you can call from within the template documents. +Read more on the [Functions documentation](Functions.md) diff --git a/docs/Syntax.md b/docs/Syntax.md new file mode 100644 index 00000000..0e5f4a8d --- /dev/null +++ b/docs/Syntax.md @@ -0,0 +1,91 @@ +# Template file syntax + +In Stencil your template files are DOCX documents. Therefore, you can edit the +templates in any word processing software, like LibreOffice. + +This is a quick introduction to the syntax of the language used by Stencil. + +## Substitution + +Syntax: {%=EXPRESSION%} + +You can use this form to embed textual value in your template. The `EXPRESSION` +part can be a variable name, function calls or a more complex mathematical expression. + +**Examples:** + +1. Insert the value of the `partnerName` key: {%=partnerName%} +2. Insert the circumference of a circle with radius under the `r` key: {%= r * 3.1415 * 2%} + +So you can write this in a template file: + + + +After applying Stencil with input data `{"customerName": "John Doe"}` you get this document: + + + +## Control structures + +You can embed control structures in your templates to implement advanced +templating logic. You can use it to repeatedly display segments or conditionally +hide parts of the document. + +### Conditional display + +This lets you conditionally hide or show parts of your document. + +Syntax: + +- {%if CONDITION%}THEN{%end%} +- {%if CONDITION%}THEN{%else%}ELSE{%end%} + +Here the `THEN` part is only shown when the `CONDITION` part is evaluated to a +true value. Otherwise the `ELSE` part is shown (when specified). + +- True values: everything except for `false` and `null`. Even an empty string or +an empty array are true too! +- False values: only `false` and `null`. + +Example: + +- {%if x.coverData.coverType == "LIFE"%}*Life insurance*{%else%}**Unknown**{%end%} + +In this example the text `Life insurance` is shown when the value +of `x.coverData.coverType` is equal to the `"LIFE"` string. + +You can also set a highlight color for the control markers. They markers will be +hidden after evaluating the template document, however, they make easier to +maintain the template file. Color coding the matching `if` and `else` pairs +also make it easier to understand a template if it gets complicated. + + + + +#### Reverted conditionals + +Sometimes it makes sense to swap the THEN and ELSE branches to make the expression more readable. +You can write `unless` instead of `if !` (if-not) to express the negation of the condition. + +For example, {%unless CONDITION%} Apples {%else%} Oranges {%end%} is the same as {%if ! CONDITION%} Oranges {%else%} Apples {%end%}. + +You should also write unless if you would write `if !` but witout an `ELSE` part. + +For example, {%unless CONDITION%} Apples {%end%} is the same as {%if ! CONDITION%} Apples {%end%} but easier to read. + +### Iteration + +You can iterate over the elements of a list to repeatedly embed content in your +document. The body part of the iteration is inserted for each item of the list. + +Syntax: + +- {%for x in elements %}BODY{%end%} + +In this example we iterate over the contents of the `elements` array. +The text `BODY` is inserted for every element. + +## Finding errors + +- Check that every control structure is properly closed! +- Check that the control code does not contain any unexpected whitespaces. diff --git a/docs/Tables.md b/docs/Tables.md new file mode 100644 index 00000000..5e1ecc5a --- /dev/null +++ b/docs/Tables.md @@ -0,0 +1,73 @@ +# Working with tables + +It is possible to dynamically modify tables in your documents. At the moment +conditional display of both rows and columns is supported. + +It is possible to dynamically hide both table columns and rows at the same time. +But hiding rows has always higher priority, meaning if you put a `hideColumn()` +marker in a row that is hidden, the marker can not take effect and the column +will not be hidden. + +> Do not put a `hideColumn` marker in a possibly hidden row. + +## Dynamic rows + +It is possible to dynamically show/hide or repeat table rows. + +### Repeating rows + +Conditionally hiding or repeating rows is similar to what we do with any other +kind of content. Just place a conditional expression in the _first column_ of +the affected row and close it in the _first column of the next row_. + +For example, when you want to hide a row, see the following: + +| Name | Price | +| ----- | ------ | +| {%for x in rows%} {%=x.name%} | {%=x.price%} | +| {%end%} | | + +### Hiding rows + +You can use multiple strategies to hide rows. First is to put the parts of the +conditional before/after the row to be hidden. + +The second way is to embed a {%=hideRow()%} marker to the +row you want to hide. + +## Dynamic columns + +It is a little bit tricky to dynamically manage column but it is certainly +possible! + +### Hiding columns + +It is a little more complicated to dynamically hide a column. + +Place a {%=hideColumn()%} marker to hide the current column. +It makes sense to include it inside a **conditional** block. + +The following example will hide the second column if the `price_hidden` property +is true. + +| Name | Price {%if price_hidden %} {%=hideColumn()%} {%end%} | +| ----- | ------ | +| Tennis ball | $12 | +| Basket ball | $123 | + +When you hide a column the table's dimensions need to be re-aligned. You can +specify strategies to control how the columns will be sized after the column is hidden. + +- *Cutting out the column:* {%=hideColumn('cut')%} will +remove a column and decrease the size of the table by the width of the removed column. +- *Resizing the last column* {%=hideColumn('resize-last')%} +will resize the last column in each rows so that the total width of the table is unchanged. +- *Keeping proportions* {%=hideColumn('rational')%} will +resize every column in a way that the ratios of their widths and the total width +of the table is unchanged. + +Default behaviour is `cut`. + +### Repeating columns + +It is currently not supported to insert repeating columns. Use copies of the column and conditional column hiding to achieve a similar effect. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..cb4b8296 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,26 @@ +# Stencil template engine + +Stencil is a templating engine to produce Office Open XML (mostly Microsoft Office's Word `.docx` files) +from within Java programs. It has a simple syntax so you will not need programmers to write or modify +document templates. + +You can find the project on [Stencil's GitHub](https://github.com/erdos/stencil) page. + +## For Users + +- Read about basic [Syntax](Syntax.md) +- Read about [Math and Logical Expressions](Math.md) +- Read about built-in [Functions](Functions.md) you can use in the templates +- Find out how to use [Dynamic Tables](Tables.md) +- Dynamic images are coming soon! + +## For Programmers + +- First of all, read the [Getting Started](GettingStarted.md) manual +- Source code is available on [Stencil's GitHub](https://github.com/erdos/stencil) +- If you wish to contribute, read the [Contributing](Contribution.md) page + +## Companies using Stencil + +1. [DBX Kft.](https://dbx.hu) uses Stencil in its enterprise insurance software +solutions for the massive production of policy documents. diff --git a/docs/screenshot-conditional-1-before.png b/docs/screenshot-conditional-1-before.png new file mode 100644 index 00000000..d95255f3 Binary files /dev/null and b/docs/screenshot-conditional-1-before.png differ diff --git a/docs/screenshot-function-empty-before.png b/docs/screenshot-function-empty-before.png new file mode 100644 index 00000000..e559d080 Binary files /dev/null and b/docs/screenshot-function-empty-before.png differ diff --git a/docs/screenshot-substitution-after.png b/docs/screenshot-substitution-after.png new file mode 100644 index 00000000..0fe16767 Binary files /dev/null and b/docs/screenshot-substitution-after.png differ diff --git a/docs/screenshot-substitution-before.png b/docs/screenshot-substitution-before.png new file mode 100644 index 00000000..0112f862 Binary files /dev/null and b/docs/screenshot-substitution-before.png differ diff --git a/examples/Purchase Reminder/data.json b/examples/Purchase Reminder/data.json new file mode 100644 index 00000000..a1e99426 --- /dev/null +++ b/examples/Purchase Reminder/data.json @@ -0,0 +1,12 @@ +{"customerName": "John Doe", + "shopName": "Example Shop", + "items": [{"name": "Dog food", + "description": "It is said to taste good.", + "price": "$ 123"}, + {"name": "Duct tape", + "description": "To fix things.", + "price": "$ 12"}, + {"name": "WD-40Dog food", + "description": "To fix other things.", + "price": "$ 12"}], + "total": "$ 147"} diff --git a/examples/Purchase Reminder/template.docx b/examples/Purchase Reminder/template.docx new file mode 100644 index 00000000..d1551282 Binary files /dev/null and b/examples/Purchase Reminder/template.docx differ diff --git a/java-src/io/github/erdos/stencil/API.java b/java-src/io/github/erdos/stencil/API.java new file mode 100644 index 00000000..651871d0 --- /dev/null +++ b/java-src/io/github/erdos/stencil/API.java @@ -0,0 +1,18 @@ +package io.github.erdos.stencil; + +import io.github.erdos.stencil.impl.NativeEvaluator; +import io.github.erdos.stencil.impl.NativeTemplateFactory; + +import java.io.File; +import java.io.IOException; + +public final class API { + + public static PreparedTemplate prepare(File templateFile) throws IOException { + return new NativeTemplateFactory().prepareTemplateFile(templateFile); + } + + public static EvaluatedDocument render(PreparedTemplate template, TemplateData data) { + return new NativeEvaluator().render(template, data); + } +} diff --git a/java-src/io/github/erdos/stencil/EvaluatedDocument.java b/java-src/io/github/erdos/stencil/EvaluatedDocument.java new file mode 100644 index 00000000..fee4de7d --- /dev/null +++ b/java-src/io/github/erdos/stencil/EvaluatedDocument.java @@ -0,0 +1,26 @@ +package io.github.erdos.stencil; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; + +/** + * An evaluated document ready to be converted to the final output format. + */ +public interface EvaluatedDocument { + + /** + * Content of document as input stream. + */ + InputStream getInputStream(); + + TemplateDocumentFormats getFormat(); + + /** + * Writes output of this document to a file + */ + default void writeToFile(File output) throws IOException { + Files.copy(getInputStream(), output.toPath()); + } +} diff --git a/java-src/io/github/erdos/stencil/PreparedTemplate.java b/java-src/io/github/erdos/stencil/PreparedTemplate.java new file mode 100644 index 00000000..6b5ef82b --- /dev/null +++ b/java-src/io/github/erdos/stencil/PreparedTemplate.java @@ -0,0 +1,59 @@ +package io.github.erdos.stencil; + +import java.io.File; +import java.time.LocalDateTime; + +/** + * Represents an already preprocessed template file. + *

+ * These files may be serialized or cached for later use. + */ +@SuppressWarnings("unused") +public interface +PreparedTemplate { + + /** + * Name of the original file. + * + * @return original template name + */ + String getName(); + + /** + * Original template file that was preprocessed. + * + * @return original template file + */ + File getTemplateFile(); + + /** + * Format of template file. Tries to guess from file name by default. + */ + default TemplateDocumentFormats getTemplateFormat() { + return TemplateDocumentFormats + .ofExtension(getTemplateFile().toString()) + .orElseThrow(() -> new IllegalStateException("Could not guess extension from file name " + getTemplateFile())); + } + + /** + * Time when the template was processed. + * + * @return template preprocess call time + */ + LocalDateTime creationDateTime(); + + /** + * Contains the preprocess result. + *

+ * Implementation detail. May be used for serializing these objects. May be used for debugging too. + * + * @return inner representation of prepared template + */ + Object getSecretObject(); + + + /** + * Set of template variables found in file. + */ + TemplateVariables getVariables(); +} \ No newline at end of file diff --git a/java-src/io/github/erdos/stencil/TemplateData.java b/java-src/io/github/erdos/stencil/TemplateData.java new file mode 100644 index 00000000..83862b0d --- /dev/null +++ b/java-src/io/github/erdos/stencil/TemplateData.java @@ -0,0 +1,49 @@ +package io.github.erdos.stencil; + +import java.util.Collections; +import java.util.Map; +import java.util.Objects; + +/** + * Contains data to fill template documents. Immutable. + */ +public final class TemplateData { + + private final Map data; + + private TemplateData(Map data) { + this.data = Collections.unmodifiableMap(Objects.requireNonNull(data)); + } + + /** + * Construct a new empty template data object. + */ + public static TemplateData empty() { + return new TemplateData(Collections.emptyMap()); + } + + /** + * Constructs a template data instance holding a map data structure. + * + * @param data map of template data. Possibly nested: values might contain maps or vectors recursively. + * @return constructed data holder. Never null. + * @throws IllegalArgumentException when input is null + */ + @SuppressWarnings("unused") + public static TemplateData fromMap(Map data) { + if (data == null) { + throw new IllegalArgumentException("Template data must not be null!"); + } else { + return new TemplateData(data); + } + } + + /** + * Returns contained data as a possibly nested map. + * + * @return template data map. Not null. + */ + public final Map getData() { + return data; + } +} diff --git a/java-src/io/github/erdos/stencil/TemplateDocumentFormats.java b/java-src/io/github/erdos/stencil/TemplateDocumentFormats.java new file mode 100644 index 00000000..26011ef3 --- /dev/null +++ b/java-src/io/github/erdos/stencil/TemplateDocumentFormats.java @@ -0,0 +1,48 @@ +package io.github.erdos.stencil; + +import java.util.Optional; + +/** + * These types are used when preprocessing a template document. + */ +public enum TemplateDocumentFormats { + + /** + * Microsoft Word Open XML Format Document file. + */ + DOCX, + + /** + * Microsoft PowerPoint Open XML Presentation file. + */ + PPTX, + + /** + * OpenDocument Text Document for LibreOffice. + */ + ODT, + + /** + * OpenDocument presentation files for LibreOffice. + */ + ODP, + + /** + * Raw XML file. + */ + XML, + + /** + * Simple text file without formatting. Like XML but without a header. + */ + TXT; + + public static Optional ofExtension(String fileName) { + for (TemplateDocumentFormats format : TemplateDocumentFormats.values()) { + if (fileName.toUpperCase().endsWith("." + format.name())) { + return Optional.of(format); + } + } + return Optional.empty(); + } +} diff --git a/java-src/io/github/erdos/stencil/TemplateFactory.java b/java-src/io/github/erdos/stencil/TemplateFactory.java new file mode 100644 index 00000000..6b7be2ce --- /dev/null +++ b/java-src/io/github/erdos/stencil/TemplateFactory.java @@ -0,0 +1,18 @@ +package io.github.erdos.stencil; + +import java.io.File; +import java.io.IOException; + +public interface TemplateFactory { + + /** + * Preprocesses a raw template file. + * + * @param templateFile raw template file of known type. + * @return preprocessed template file + * @throws IOException on file system error + * @throws IllegalArgumentException when argument is null, unknown type or does not exist + * @throws java.io.FileNotFoundException when file does not exist + */ + PreparedTemplate prepareTemplateFile(File templateFile) throws IOException; +} diff --git a/java-src/io/github/erdos/stencil/TemplateVariables.java b/java-src/io/github/erdos/stencil/TemplateVariables.java new file mode 100644 index 00000000..956e88a4 --- /dev/null +++ b/java-src/io/github/erdos/stencil/TemplateVariables.java @@ -0,0 +1,261 @@ +package io.github.erdos.stencil; + +import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Stream; + +import static java.util.Collections.singletonMap; +import static java.util.Collections.unmodifiableSet; +import static java.util.stream.Collectors.joining; +import static java.util.stream.Collectors.toList; + +/** + * Holds information about variables found in template file. Can be used to validate template data. + *

+ * A variable path looks like the following. + * + * A.B.C[].D + *

+ * A path is a string that contains tokens separated with . (dot) characters. The tokens represents keys in maps. + */ +public final class TemplateVariables { + + private final Set variables; + private final Node root; + + private TemplateVariables(Set variables) { + this.variables = unmodifiableSet(variables); + + Node r = LEAF; + for (String x : variables) { + r = reduce(r, x); + } + root = r; + } + + private Node reduce(Node originalNode, String path) { + if (path.isEmpty()) + return LEAF; + else if (originalNode == null) + originalNode = LEAF; + + final int indexOfEnd = path.indexOf("[]"), indexOfDot = path.indexOf("."); + + if (indexOfDot == 0) { // starts with a dot + return reduce(originalNode, path.substring(1)); + } else if (indexOfEnd == -1 && indexOfDot == -1) { // just a single word + // csak egy szo az egesz resz + return originalNode.accept(new NodeVisitor() { + @Override + public Node visitArray(Node wrapped) { + throw new IllegalArgumentException("expecting map, found array."); + } + + @Override + public Node visitMap(Map items) { + Map itms = new HashMap<>(items); + itms.put(path, LEAF); + return mapNode(itms); + } + + @Override + public Node visitLeaf() { + return mapNode(singletonMap(path, LEAF)); + } + }); + } else if (indexOfEnd == 0) { // start with [] sign + final String tail = path.substring(2); + return originalNode.accept(new NodeVisitor() { + @Override + public Node visitArray(Node wrapped) { + return listNode(reduce(wrapped, tail)); + } + + @Override + public Node visitMap(Map items) { + // should not happen + throw new IllegalArgumentException("Expected a vector, found a map!"); + } + + @Override + public Node visitLeaf() { + return listNode(reduce(LEAF, tail)); + } + }); + } else if (indexOfEnd == -1 || (indexOfDot < indexOfEnd) || indexOfEnd < indexOfDot) { + final int mindex = indexOfEnd == -1 ? indexOfDot : (indexOfDot == -1 ? indexOfEnd : Math.min(indexOfEnd, indexOfDot)); + final String head = path.substring(0, mindex); + final String tail = path.substring(mindex); + + return originalNode.accept(new NodeVisitor() { + @Override + public Node visitArray(Node wrapped) { + // should not happen. + throw new IllegalArgumentException("Expected map, found array!"); + } + + @Override + public Node visitMap(Map items) { + Map m = new HashMap<>(items); + m.putIfAbsent(head, LEAF); + m.compute(head, (k, x) -> reduce(x, tail)); + return mapNode(m); + } + + @Override + public Node visitLeaf() { + return mapNode(singletonMap(head, reduce(LEAF, tail))); + } + }); + } else { + throw new IllegalArgumentException("Illegal path string: " + path); + } + } + + public static TemplateVariables fromPaths(Collection allVariablePaths) { + return new TemplateVariables(new HashSet<>(allVariablePaths)); + } + + /** + * Returns all variable paths as an immutable set. + */ + @SuppressWarnings("unused") + public Set getAllVariables() { + return variables; + } + + /** + * Throws IllegalArgumentException exception when template data. + *

+ * Throws when referred keys are missing from data. + * + * @throws IllegalArgumentException then parameter does not match pattern + * @throws NullPointerException when parameter is null. + */ + @SuppressWarnings("WeakerAccess") + public void throwWhenInvalid(TemplateData templateData) throws IllegalArgumentException { + List rows = validate(templateData.getData(), root); + if (!rows.isEmpty()) { + String msg = rows.stream().map(x -> x.msg).collect(joining("\n")); + throw new IllegalArgumentException("Schema error: \n" + msg); + } + } + + private List validate(Object data, Node schema) { + return validateImpl("", data, schema).collect(toList()); + } + + @SuppressWarnings("unchecked") + private Stream validateImpl(String path, Object data, Node schema) { + return schema.accept(new NodeVisitor>() { + @Override + public Stream visitArray(Node wrapped) { + if (data instanceof List) { + final AtomicInteger index = new AtomicInteger(); + return ((List) data).stream().flatMap(x -> { + final String newPath = path + "[" + index.getAndIncrement() + "]"; + return validateImpl(newPath, x, wrapped); + }); + } else { + return Stream.of(new SchemaError(path, "Expecting list on path!")); + } + } + + @Override + public Stream visitMap(Map items) { + if (data instanceof Map) { + Map dataMap = (Map) data; + return items.entrySet().stream().flatMap(schemaEntry -> { + if (dataMap.containsKey(schemaEntry.getKey())) { + return validateImpl(path + "." + schemaEntry.getKey(), dataMap.get(schemaEntry.getKey()), schemaEntry.getValue()); + } else { + return Stream.of(new SchemaError(path, "Expected key " + schemaEntry.getKey())); + } + }); + } else { + return Stream.of(new SchemaError(path, "Expected map on path!")); + } + } + + @Override + public Stream visitLeaf() { + // ha valahol leaf van, az mindenhol korrekt. + return Stream.empty(); + } + }); + } + + @SuppressWarnings("unused") + class SchemaError { + private final String path; + private final String msg; + + SchemaError(String path, String msg) { + this.path = path; + this.msg = msg; + } + } + + interface Node { + T accept(NodeVisitor visitor); + } + + interface NodeVisitor { + T visitArray(Node wrapped); + + T visitMap(Map items); + + T visitLeaf(); + } + + /** + * Matches map data + */ + private static Node mapNode(Map items) { + return new Node() { + + @Override + public String toString() { + return items.toString(); + } + + @Override + public T accept(NodeVisitor visitor) { + return visitor.visitMap(items); + } + }; + } + + /** + * Matches list data + */ + private static Node listNode(Node wrapped) { + return new Node() { + + @Override + public String toString() { + return "[" + wrapped.toString() + "]"; + } + + @Override + public T accept(NodeVisitor visitor) { + return visitor.visitArray(wrapped); + } + }; + } + + /** + * Matches any data. + */ + private final static Node LEAF = new Node() { + @Override + public String toString() { + return "*"; + } + + @Override + public T accept(NodeVisitor visitor) { + return visitor.visitLeaf(); + } + }; +} diff --git a/java-src/io/github/erdos/stencil/exceptions/EvalException.java b/java-src/io/github/erdos/stencil/exceptions/EvalException.java new file mode 100644 index 00000000..afdde95b --- /dev/null +++ b/java-src/io/github/erdos/stencil/exceptions/EvalException.java @@ -0,0 +1,24 @@ +package io.github.erdos.stencil.exceptions; + +/** + * Indicates that an error happened during the evaluation of a stencil expression in a template. + */ +public final class EvalException extends RuntimeException { + + private EvalException(Exception cause) { + super(cause); + } + + private EvalException(String message) { + super(message); + } + + public static EvalException fromMissingValue(String expression) { + return new EvalException("Value is missing for expression: " + expression); + } + + public static EvalException wrapping(Exception cause) { + return new EvalException(cause); + } +} + diff --git a/java-src/io/github/erdos/stencil/exceptions/ParsingException.java b/java-src/io/github/erdos/stencil/exceptions/ParsingException.java new file mode 100644 index 00000000..112a5a44 --- /dev/null +++ b/java-src/io/github/erdos/stencil/exceptions/ParsingException.java @@ -0,0 +1,31 @@ +package io.github.erdos.stencil.exceptions; + +/** + * This class indicates an error while reading and parsing a stencil expression. + */ +public final class ParsingException extends RuntimeException { + + private final String expression; + + public static ParsingException fromMessage(String expression, String message) { + return new ParsingException(expression, message); + } + + public static ParsingException wrapping(String message, Exception cause) { + return new ParsingException(message, cause); + } + + private ParsingException(String expression, String message) { + super(message); + this.expression = expression; + } + + private ParsingException(String message, Exception cause) { + super(message, cause); + this.expression = ""; + } + + public String getExpression() { + return expression; + } +} diff --git a/java-src/io/github/erdos/stencil/functions/BasicFunctions.java b/java-src/io/github/erdos/stencil/functions/BasicFunctions.java new file mode 100644 index 00000000..88658c89 --- /dev/null +++ b/java-src/io/github/erdos/stencil/functions/BasicFunctions.java @@ -0,0 +1,75 @@ +package io.github.erdos.stencil.functions; + +import java.util.Collection; + +/** + * Common general purpose functions. + */ +@SuppressWarnings("unused") +public enum BasicFunctions implements Function { + + /** + * Selects value based on first argument. + * Usage: switch(expression, case-1, value-1, case-2, value-2, ..., optional-default-value) + */ + SWITCH { + @Override + public Object call(Object... arguments) throws IllegalArgumentException { + if (arguments.length < 3) { + throw new IllegalArgumentException("switch() function expects at least 3 args!"); + } + final Object expr = arguments[0]; + for (int i = 1; i < arguments.length; i += 2) { + final Object value = arguments[i]; + final Object result = arguments[i + 1]; + if (expr == null && value == null) + return result; + else if (expr != null && expr.equals(value)) + return result; + } + + if (arguments.length % 2 == 0) { + return arguments[arguments.length - 1]; + } else { + return null; + } + } + }, + + /** + * Returns the first non-null an non-empty value. + *

+ * Accepts any arguments. Skips null values, empty strings and empty collections. + */ + COALESCE { + @Override + public Object call(Object... arguments) { + for (Object arg : arguments) + if (arg != null && !"".equals(arg) && (!(arg instanceof Collection) || !((Collection) arg).isEmpty())) + return arg; + return null; + } + }, + + /** + * Returns true iff input is null, empty string or empty collection. + *

+ * Expects exactly 1 argument. + */ + EMPTY { + @Override + public Object call(Object... arguments) throws IllegalArgumentException { + if (arguments.length != 1) + throw new IllegalArgumentException("empty() function expects exactly 1 argument, " + arguments.length + " given."); + Object x = arguments[0]; + return (x == null || "".equals(x)) + || ((x instanceof Collection) && ((Collection) x).isEmpty()) + || ((x instanceof Iterable) && !((Iterable) x).iterator().hasNext()); + } + }; + + @Override + public String getName() { + return name().toLowerCase(); + } +} diff --git a/java-src/io/github/erdos/stencil/functions/DateFunctions.java b/java-src/io/github/erdos/stencil/functions/DateFunctions.java new file mode 100644 index 00000000..6273a526 --- /dev/null +++ b/java-src/io/github/erdos/stencil/functions/DateFunctions.java @@ -0,0 +1,120 @@ +package io.github.erdos.stencil.functions; + +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.Date; +import java.util.List; +import java.util.Optional; + +import static java.util.Arrays.asList; +import static java.util.Optional.empty; +import static java.util.Optional.of; + +/** + * Date handling functions + */ +public enum DateFunctions implements Function { + + + /** + * Formats a date object. + *

+ * First argument is date format string. Second argument is some date value (either some object or string). + *

+ * Example call: date("yyyy-MM-dd", x.birthDate) + */ + DATE { + @Override + public Object call(Object... arguments) throws IllegalArgumentException { + if (arguments.length != 2) + throw new IllegalArgumentException("date() function expects exactly 2 arguments!"); + if (arguments[0] == null || arguments[1] == null || arguments[1].toString().isEmpty()) + return null; + + final String pattern = arguments[0].toString(); + final Object datum = arguments[1]; + + final Optional d2 = DateFunctions.maybeLocalDate(datum); + if (d2.isPresent()) { + return d2.get().format(DateTimeFormatter.ofPattern(pattern)); + } + + final Optional d3 = DateFunctions.maybeLocalDateTime(datum); + if (d3.isPresent()) { + return d3.get().format(DateTimeFormatter.ofPattern(pattern)); + } + + final Optional d1 = DateFunctions.maybeDate(datum); + if (d1.isPresent()) { + return new SimpleDateFormat(pattern).format(d1.get()); + } + + throw new IllegalArgumentException("Could not parse date object " + datum.toString()); + } + }; + + // simple datetime format + public final static String DATETIME1 = "yyyy-MM-dd HH:mm:ss"; + + // new Date().toString() gives this format + public static final String DATE_TOSTRING = "EEE MMM dd HH:mm:ss Z yyyy"; + + + // new Date().toString() gives this format + public static final String DATE_DOTTED = "yyyy. MM. dd."; + + + // standard formats + public final static String RFC1123 = "EEE, dd MMM yyyy HH:mm:ss zzz"; + public final static String RFC1036 = "EEEE, dd-MMM-yy HH:mm:ss zzz"; + public final static String ASCTIME = "EEE MMM d HH:mm:ss yyyy"; + public final static String ISO8601 = "yyyy-MM-dd'T'HH:mm:ss.SSSXXX"; + + public final static List PATTERNS = asList(DATETIME1, RFC1036, RFC1123, ASCTIME, ISO8601, DATE_TOSTRING, DATE_DOTTED); + + private static Optional maybeDate(Object obj) { + if (obj instanceof Date) + return of((Date) obj); + String s = obj.toString(); + try { + return of(javax.xml.bind.DatatypeConverter.parseDateTime(s).getTime()); + } catch (IllegalArgumentException e) { + for (String p : PATTERNS) { + try { + return of(new SimpleDateFormat(p).parse(s)); + } catch (ParseException ignored) { + } + } + return empty(); + } + } + + private static Optional maybeLocalDate(Object obj) { + if (obj instanceof LocalDate) + return of((LocalDate) obj); + try { + return of(LocalDate.parse(obj.toString())); + } catch (DateTimeParseException e) { + return empty(); + } + } + + private static Optional maybeLocalDateTime(Object obj) { + if (obj instanceof LocalDateTime) + return of((LocalDateTime) obj); + try { + return of(LocalDateTime.parse(obj.toString())); + } catch (DateTimeParseException e) { + return empty(); + } + } + + @Override + public String getName() { + return name().toLowerCase(); + } +} diff --git a/java-src/io/github/erdos/stencil/functions/Function.java b/java-src/io/github/erdos/stencil/functions/Function.java new file mode 100644 index 00000000..1cd00821 --- /dev/null +++ b/java-src/io/github/erdos/stencil/functions/Function.java @@ -0,0 +1,29 @@ +package io.github.erdos.stencil.functions; + +/** + * A function object can be called from inside a template file. + *

+ * Function calls should return simple values that can be embedded in the document: numbers, bools, strings. + */ +@SuppressWarnings("unused") +public interface Function { + + /** + * A simple function call. + * + * @param arguments array of arguments, never null. + * @return a simple value to insert in the template file. + * @throws IllegalArgumentException when argument count or argument types are unexpected. + */ + Object call(Object... arguments) throws IllegalArgumentException; + + /** + * Name of the function used as function identifier. + *

+ * Must not contain whitespaces. Must not be empty. Must not change. + * It is used to look up the function from the template file. + * + * @return function identifier + */ + String getName(); +} diff --git a/java-src/io/github/erdos/stencil/functions/FunctionEvaluator.java b/java-src/io/github/erdos/stencil/functions/FunctionEvaluator.java new file mode 100644 index 00000000..b74e49b4 --- /dev/null +++ b/java-src/io/github/erdos/stencil/functions/FunctionEvaluator.java @@ -0,0 +1,62 @@ +package io.github.erdos.stencil.functions; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; + +public final class FunctionEvaluator { + + private final Map functions = new HashMap<>(); + + { + registerFunctions(BasicFunctions.values()); + registerFunctions(StringFunctions.values()); + registerFunctions(NumberFunctions.values()); + registerFunctions(DateFunctions.values()); + registerFunctions(LocaleFunctions.values()); + } + + private void registerFunction(Function function) { + if (function == null) + throw new IllegalArgumentException("Registered function must not be null."); + functions.put(function.getName().toLowerCase(), function); + } + + /** + * Registers a function to this evaluator engine. + * Registered functions can be invoked from inside template files. + * + * @param functions any number of function instances. + */ + @SuppressWarnings("WeakerAccess") + public void registerFunctions(Function... functions) { + for (Function function : functions) { + registerFunction(function); + } + } + + /** + * Calls a function by name. + * + * @param functionName Case Insensitive name of fn to call. + * @param arguments arguments dispatched to called function + * @return result of function call + * @throws IllegalArgumentException when function name is null or missing + */ + public Object call(String functionName, Object... arguments) { + if (functionName == null) + throw new IllegalArgumentException("Function name is missing"); + final Function fun = functions.get(functionName.toLowerCase()); + if (fun == null) + throw new IllegalArgumentException("Did not find function for name " + functionName); + return fun.call(arguments); + } + + /** + * Returns a thread-safe sequence of all registered functions. + */ + @SuppressWarnings("WeakerAccess") + public Iterable listFunctions() { + return new ArrayList<>(functions.values()); + } +} diff --git a/java-src/io/github/erdos/stencil/functions/LocaleFunctions.java b/java-src/io/github/erdos/stencil/functions/LocaleFunctions.java new file mode 100644 index 00000000..4c08c731 --- /dev/null +++ b/java-src/io/github/erdos/stencil/functions/LocaleFunctions.java @@ -0,0 +1,56 @@ +package io.github.erdos.stencil.functions; + +import java.text.NumberFormat; +import java.util.Locale; + +import static java.util.Locale.forLanguageTag; +import static java.util.Locale.getDefault; + +public enum LocaleFunctions implements Function { + + + /** + * Formats number as localized currency. An optional second argument can be used to specify locale code. + * Returns a string. + *

+ * Usage: currency(34) or currency(34, "HU") + */ + CURRENCY { + @Override + public Object call(Object... arguments) throws IllegalArgumentException { + return formatting(this, NumberFormat::getCurrencyInstance, arguments); + } + }, + + /** + * Formats number as localized percent. An optional second argument can be used to specify locale code. + * Returns a string. + *

+ * Usage: percent(34) or percent(34, "HU") + */ + PERCENT { + @Override + public Object call(Object... arguments) throws IllegalArgumentException { + return formatting(this, NumberFormat::getPercentInstance, arguments); + } + }; + + private static String formatting(Function function, java.util.function.Function fun, Object... arguments) { + if (arguments.length == 0 || arguments.length > 2) { + throw new IllegalArgumentException(function.getName() + "() function expects 1 or 2 arguments"); + } else if (arguments[0] == null) { + return null; + } else { + + final Object value = arguments[0]; + final Locale locale = (arguments.length == 2) ? forLanguageTag(arguments[1].toString()) : getDefault(); + + return fun.apply(locale).format(value); + } + } + + @Override + public String getName() { + return name().toLowerCase(); + } +} diff --git a/java-src/io/github/erdos/stencil/functions/NumberFunctions.java b/java-src/io/github/erdos/stencil/functions/NumberFunctions.java new file mode 100644 index 00000000..98be1543 --- /dev/null +++ b/java-src/io/github/erdos/stencil/functions/NumberFunctions.java @@ -0,0 +1,32 @@ +package io.github.erdos.stencil.functions; + +/** + * Common numeric functions. + */ +@SuppressWarnings("unused") +public enum NumberFunctions implements Function { + + /** + * Rounds a number to the closest integer. + *

+ * Expects 1 argument and returns null on null argument. + */ + ROUND { + @Override + public Object call(Object... arguments) { + if (arguments.length != 1) + throw new IllegalArgumentException("The round() function must have exactly 1 arguments!"); + else if (arguments[0] == null) + return null; + else if (!(arguments[0] instanceof Number)) + throw new IllegalArgumentException("The round() function expects a number argument!"); + else + return Math.round(((Number) arguments[0]).doubleValue()); + } + }; + + @Override + public String getName() { + return name().toLowerCase(); + } +} diff --git a/java-src/io/github/erdos/stencil/functions/StringFunctions.java b/java-src/io/github/erdos/stencil/functions/StringFunctions.java new file mode 100644 index 00000000..62a65877 --- /dev/null +++ b/java-src/io/github/erdos/stencil/functions/StringFunctions.java @@ -0,0 +1,100 @@ +package io.github.erdos.stencil.functions; + +import java.util.Arrays; +import java.util.IllegalFormatException; +import java.util.stream.Collectors; + +/** + * Common string functions. + */ +@SuppressWarnings("unused") +public enum StringFunctions implements Function { + + /** + * Calls the standard Java String.format function. + *

+ * Passes every argument to String.format. + */ + FORMAT { + @Override + public Object call(Object... arguments) { + if (arguments.length == 0) { + throw new IllegalArgumentException("At least one arg is expected!"); + } else if (arguments[0] == null || !(arguments[0] instanceof String)) { + throw new IllegalArgumentException("Unexpected first arg must be string!"); + } else { + try { + return String.format((String) arguments[0], Arrays.copyOfRange(arguments, 1, arguments.length)); + } catch (ClassCastException | IllegalFormatException e) { + throw new IllegalArgumentException(e); + } + } + } + }, + + /** + * Converts parameters to strings and concatenates the result. + * Returns empty string on empty or null arguments. + */ + STR { + @Override + public Object call(Object... arguments) throws IllegalArgumentException { + StringBuilder builder = new StringBuilder(); + for (Object argument : arguments) { + if (argument != null) + builder.append(argument.toString()); + } + return builder.toString(); + } + }, + + /** + * Returns lowercase string of input. Expects 1 argument. Returns null on null input. + */ + LOWERCASE { + @Override + public Object call(Object... arguments) throws IllegalArgumentException { + if (arguments.length != 1) + throw new IllegalArgumentException("lowerCase() function expects exactly 1 argument!"); + if (arguments[0] == null) + return null; + return arguments[0].toString().toLowerCase(); + } + }, + + /** + * Returns UPPERCASE string of input. Expects 1 argument. Returns null on null input. + */ + UPPERCASE { + @Override + public Object call(Object... arguments) throws IllegalArgumentException { + if (arguments.length != 1) + throw new IllegalArgumentException("upperCase() function expects exactly 1 argument!"); + if (arguments[0] == null) + return null; + return arguments[0].toString().toUpperCase(); + } + }, + + /** + * Converts Every First Letter Of Every Word To Upper Case. + * Expects 1 argument. Returns null on null input. + */ + TITLECASE { + @Override + public Object call(Object... arguments) throws IllegalArgumentException { + if (arguments.length != 1) + throw new IllegalArgumentException("upperCase() function expects exactly 1 argument!"); + if (arguments[0] == null) + return null; + return Arrays.stream(arguments[0].toString().split("\\s")) + .map(x -> x.substring(0, 1).toUpperCase() + x.substring(1).toLowerCase()) + .collect(Collectors.joining(" ")); + } + }; + + @Override + public String getName() { + return name().toLowerCase(); + } +} diff --git a/java-src/io/github/erdos/stencil/functions/package-info.java b/java-src/io/github/erdos/stencil/functions/package-info.java new file mode 100644 index 00000000..f5202c4a --- /dev/null +++ b/java-src/io/github/erdos/stencil/functions/package-info.java @@ -0,0 +1,22 @@ +/** + * General purpose functions. + *

+ * Function implementations come here. + * + * + *

Custom Functions

+ *

+ * It is possible to define custom functions on the host code and invoke them from within the template files. + * Custom functions must implement the {@link io.github.erdos.stencil.functions.Function} interface and be registered + * to the template evaluator engine. + *

+ * Functions are found by their case-insensitive name so make sure not to override any existing function implementation. + *

+ * Functions on null arguments should return null. + *

+ * Functions are variadic, i.e. they accept a variable number of arguments. Handling the correct number and type of + * arguments is the responsibility of the functions implementation. + *

+ * Make your functions as flexible as possible by considering proper type and error handling. + */ +package io.github.erdos.stencil.functions; \ No newline at end of file diff --git a/java-src/io/github/erdos/stencil/impl/CachingTemplateFactory.java b/java-src/io/github/erdos/stencil/impl/CachingTemplateFactory.java new file mode 100644 index 00000000..54ec7f75 --- /dev/null +++ b/java-src/io/github/erdos/stencil/impl/CachingTemplateFactory.java @@ -0,0 +1,48 @@ +package io.github.erdos.stencil.impl; + +import io.github.erdos.stencil.PreparedTemplate; +import io.github.erdos.stencil.TemplateFactory; + +import java.io.File; +import java.io.IOException; +import java.time.ZoneOffset; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Wraps a TemplateFactory instance and proxies calls only iff template file has not been changed since last call. + */ +@SuppressWarnings("unused") +public final class CachingTemplateFactory implements TemplateFactory { + private final TemplateFactory templateFactory; + private final Map cache = new ConcurrentHashMap<>(); + + /** + * Constructs a new wrapping instance. Caches in memory. + * + * @param templateFactory instance to wrap + * @throws IllegalArgumentException on null input. + */ + public CachingTemplateFactory(TemplateFactory templateFactory) { + if (templateFactory == null) + throw new IllegalArgumentException("can not wrap null object!"); + + this.templateFactory = templateFactory; + } + + @Override + public PreparedTemplate prepareTemplateFile(File templateFile) throws IOException { + if (cache.containsKey(templateFile)) { + PreparedTemplate stored = cache.get(templateFile); + if (stored.creationDateTime().toEpochSecond(ZoneOffset.UTC) <= templateFile.lastModified()) { + stored = templateFactory.prepareTemplateFile(templateFile); + cache.put(templateFile, stored); + } + return stored; + } else { + final PreparedTemplate stored = templateFactory.prepareTemplateFile(templateFile); + cache.put(templateFile, stored); + return stored; + } + } +} diff --git a/java-src/io/github/erdos/stencil/impl/ClojureHelper.java b/java-src/io/github/erdos/stencil/impl/ClojureHelper.java new file mode 100644 index 00000000..a8d2061e --- /dev/null +++ b/java-src/io/github/erdos/stencil/impl/ClojureHelper.java @@ -0,0 +1,70 @@ +package io.github.erdos.stencil.impl; + +import clojure.lang.IFn; +import clojure.lang.Keyword; +import clojure.lang.RT; +import clojure.lang.Symbol; + +/** + * Clojure utilities. + */ +@SuppressWarnings("WeakerAccess") +public class ClojureHelper { + + /** + * Clojure :stream keyword + */ + public static final Keyword KV_STREAM = Keyword.intern("stream"); + + /** + * Clojure :variables keyword + */ + public static final Keyword KV_VARIABLES = Keyword.intern("variables"); + + /** + * Clojure :template keyword + */ + public static final Keyword + KV_TEMPLATE = Keyword.intern("template"); + + + /** + * Clojure :data keyword + */ + public static final Keyword KV_DATA = Keyword.intern("data"); + + /** + * Clojure :format keyword + */ + public static final Keyword KV_FORMAT = Keyword.intern("format"); + + /** + * Clojure :function keyword + */ + public static final Keyword KV_FUNCTION = Keyword.intern("function"); + + // requires stencil.process namespace so stencil is loaded. + static { + final IFn req = RT.var("clojure.core", "require"); + req.invoke(Symbol.intern("stencil.process")); + } + + /** + * Finds a function in stencil.process namespace and returns it. + * + * @param functionName name of var + * @return function with for given name + */ + public static IFn findFunction(String functionName) { + return RT.var("stencil.process", functionName); + } + + + /** + * Shuts down clojure agents. Needed to speed up quitting from Clojure programs. + */ + public static void callShutdownAgents() { + RT.var("clojure.core", "shutdown-agents").run(); + } +} + diff --git a/java-src/io/github/erdos/stencil/impl/DeleteOnCloseFileInputStream.java b/java-src/io/github/erdos/stencil/impl/DeleteOnCloseFileInputStream.java new file mode 100644 index 00000000..e4387e98 --- /dev/null +++ b/java-src/io/github/erdos/stencil/impl/DeleteOnCloseFileInputStream.java @@ -0,0 +1,26 @@ +package io.github.erdos.stencil.impl; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; + +/** + * This implementation deletes the file after calling close() method. + */ +@SuppressWarnings("unused") +public final class DeleteOnCloseFileInputStream extends FileInputStream { + + private final File file; + + public DeleteOnCloseFileInputStream(File file) throws FileNotFoundException { + super(file); + this.file = file; + } + + @Override + public void close() throws IOException { + super.close(); + boolean success = file.delete(); + } +} diff --git a/java-src/io/github/erdos/stencil/impl/DirWatcherTemplateFactory.java b/java-src/io/github/erdos/stencil/impl/DirWatcherTemplateFactory.java new file mode 100644 index 00000000..0d095827 --- /dev/null +++ b/java-src/io/github/erdos/stencil/impl/DirWatcherTemplateFactory.java @@ -0,0 +1,211 @@ +package io.github.erdos.stencil.impl; + +import io.github.erdos.stencil.PreparedTemplate; +import io.github.erdos.stencil.TemplateFactory; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; +import java.nio.file.WatchEvent; +import java.nio.file.WatchKey; +import java.nio.file.WatchService; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.DelayQueue; +import java.util.concurrent.Delayed; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.stream.Stream; + +import static java.lang.System.currentTimeMillis; +import static java.nio.file.StandardWatchEventKinds.*; +import static java.util.Arrays.stream; +import static java.util.concurrent.TimeUnit.MILLISECONDS; + +/** + * Watches file system and automatically loads template files on file changes. + */ +@SuppressWarnings("unused") +public final class DirWatcherTemplateFactory implements TemplateFactory { + + private final File templatesDirectory; + private final TemplateFactory factory; + + private final DelayQueue> delayQueue = new DelayQueue<>(); + private final Map> delays = new ConcurrentHashMap<>(); + private final AtomicBoolean running = new AtomicBoolean(false); + + /** + * Default ctor. + * + * @param templatesDirectory not null absolute path directory + * @param factory wrapped factory + */ + @SuppressWarnings("WeakerAccess") + public DirWatcherTemplateFactory(File templatesDirectory, TemplateFactory factory) { + if (templatesDirectory == null) + throw new IllegalArgumentException("Template directory parameter is null!"); + if (!templatesDirectory.exists()) + throw new IllegalArgumentException("Templates directory does not exist: " + templatesDirectory); + if (!templatesDirectory.isDirectory()) + throw new IllegalArgumentException("Templates directory parameter is not a directory!"); + if (factory == null) + throw new IllegalArgumentException("Parent factory is missing!"); + + this.templatesDirectory = templatesDirectory; + this.factory = factory; + } + + public File getTemplatesDirectory() { + return templatesDirectory; + } + + private Optional handle(File f) { + assert (f.isAbsolute()); + + try { + final PreparedTemplate template = factory.prepareTemplateFile(f); + // TODO: we may use logging here + return Optional.of(template); + } catch (IOException e) { + e.printStackTrace(); + return Optional.empty(); + } + } + + /** + * Starts directory watcher and tries to load all files. + * + * @throws IOException on file system errors + * @throws IllegalStateException if already started + */ + public void start() throws IOException, IllegalStateException { + if (running.getAndSet(true)) + throw new IllegalStateException("Already running!"); + + Path path = templatesDirectory.toPath(); + final WatchService ws = path.getFileSystem().newWatchService(); + final WatchKey waka = path.register(ws, ENTRY_CREATE, ENTRY_DELETE, ENTRY_MODIFY); + + new Thread(() -> { + try { + initAllFiles(); + } catch (Exception ignored) { + // intentionally left blank + } + + try { + while (running.get()) { + if (delayQueue.isEmpty()) { + addEvents(ws.take()); + } + + List> elems = new LinkedList<>(); + if (0 < delayQueue.drainTo(elems)) { + elems.forEach((x) -> { + delays.remove(x.getElem()); + handle(x.getElem()); + }); + } else { + long delay = delayQueue.peek().getDelay(TimeUnit.MILLISECONDS); + WatchKey poll = ws.poll(delay, TimeUnit.MILLISECONDS); + if (poll != null) { + addEvents(poll); + + } + } + } + } catch (InterruptedException e) { + e.printStackTrace(); + } + }).start(); + } + + private void initAllFiles() { + recurse(templatesDirectory).forEach(this::handle); + } + + private Stream recurse(File f) { + if (f != null && f.isDirectory()) { + final String[] files = f.list(); + if (files == null) { + return Stream.empty(); + } else { + return stream(files).map(x -> new File(f, x)).flatMap(this::recurse); + } + } else { + return Stream.empty(); + } + } + + @SuppressWarnings("unchecked") + private void addEvents(WatchKey key) { + assert (key != null); + for (WatchEvent event : key.pollEvents()) { + final WatchEvent ev = (WatchEvent) event; + final File f = new File(templatesDirectory, ev.context().toFile().getName()); + if (delays.containsKey(f)) { + DelayedContainer container = delays.get(f); + delays.remove(f); + delayQueue.remove(container); + } + + final DelayedContainer newCont = new DelayedContainer<>(1000L, f); + delays.put(f, newCont); + delayQueue.add(newCont); + } + key.reset(); + } + + @SuppressWarnings("WeakerAccess") + public void stop() { + if (!running.getAndSet(false)) + throw new IllegalStateException("Already stopped!"); + delays.clear(); + delayQueue.clear(); + } + + @Override + public PreparedTemplate prepareTemplateFile(File templateFile) throws IOException { + if (templateFile == null) + throw new IllegalArgumentException("templateFile argument must not be null!"); + if (templateFile.isAbsolute()) + throw new IllegalArgumentException("templateFile must not be an absolute file!"); + else + return handle(templateFile) + .orElseThrow(() -> new IllegalArgumentException("Can not build template file: " + templateFile)); + } + + private final class DelayedContainer implements Delayed { + private final long expiration; + private final X contents; + + private DelayedContainer(long millis, X contents) { + if (millis <= 0) + throw new IllegalArgumentException("Millis must be positive!"); + this.expiration = currentTimeMillis() + millis; + this.contents = contents; + } + + private X getElem() { + return contents; + } + + public String toString() { + return "D+" + expiration; + } + + @Override + public long getDelay(TimeUnit unit) { + return unit.convert(expiration - currentTimeMillis(), MILLISECONDS); + } + + @Override + public int compareTo(Delayed delayed) { + return (int) (getDelay(MILLISECONDS) - delayed.getDelay(MILLISECONDS)); + } + } +} diff --git a/java-src/io/github/erdos/stencil/impl/FileHelper.java b/java-src/io/github/erdos/stencil/impl/FileHelper.java new file mode 100644 index 00000000..6a20218e --- /dev/null +++ b/java-src/io/github/erdos/stencil/impl/FileHelper.java @@ -0,0 +1,76 @@ +package io.github.erdos.stencil.impl; + +import java.io.File; +import java.io.IOException; +import java.util.UUID; + +/** + * File handling utilities. + * Some methods may be called from Clojure core. + */ +@SuppressWarnings("WeakerAccess") +public final class FileHelper { + + private final static File TEMP_DIRECTORY = new File(System.getProperty("java.io.tmpdir")); + + public static String extension(File f) { + return extension(f.getName()); + } + + public static String extension(String filename) { + String[] parts = filename.split("\\."); + return parts[parts.length - 1].trim().toLowerCase(); + } + + /** + * Returns file name without extension part. + * + * @return simple file name without extension. + * @throws NullPointerException the input is null + */ + public static String removeExtension(File f) { + String fileName = f.getName(); + if (fileName.contains(".")) { + int loc = fileName.lastIndexOf('.'); + return fileName.substring(0, loc); + } else { + return fileName; + } + } + + /** + * Creates a temporary file that is guaranteed not to exist on file system. + * + * @param prefix file name starts with this file + * @param suffix file name ends with this file + * @return a new file object pointing to a non-existing file in temp directory. + */ + public static File createNonexistentTempFile(String prefix, String suffix) { + return new File(TEMP_DIRECTORY, prefix + UUID.randomUUID().toString() + suffix); + } + + /** + * Creates a directory. Recursively creates parent directories too. + * + * @param directory not null dir to create + * @throws IOException on IO error + * @throws IllegalArgumentException when input is null or already exists + */ + public static void forceMkdir(final File directory) throws IOException { + if (directory == null) + throw new IllegalArgumentException("Missing directory for forceMkdir"); + if (directory.exists()) { + if (!directory.isDirectory()) { + throw new IOException("File exists and not a directory: " + directory); + } + } else { + if (!directory.mkdirs()) { + // Double-check that some other thread or process hasn't made + // the directory in the background + if (!directory.isDirectory()) { + throw new IOException("Unable to create directory " + directory); + } + } + } + } +} diff --git a/java-src/io/github/erdos/stencil/impl/Logging.java b/java-src/io/github/erdos/stencil/impl/Logging.java new file mode 100644 index 00000000..409c7075 --- /dev/null +++ b/java-src/io/github/erdos/stencil/impl/Logging.java @@ -0,0 +1,30 @@ +package io.github.erdos.stencil.impl; + +import org.slf4j.Logger; + +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Consumer; +import java.util.function.Supplier; + +/** + * Logging support + */ +@SuppressWarnings("WeakerAccess") +public class Logging { + + /** + * Returns a consumer that can be used to print elapsed time. + */ + public static Consumer> debugStopWatch(Logger logger) { + AtomicLong lastMeasure = new AtomicLong(0); + return (msg) -> { + long now = System.currentTimeMillis(); + long previous = lastMeasure.getAndSet(now); + + if (previous == 0) { + logger.debug(msg.get()); + } else + logger.debug(msg.get(), now - previous); + }; + } +} \ No newline at end of file diff --git a/java-src/io/github/erdos/stencil/impl/NativeEvaluator.java b/java-src/io/github/erdos/stencil/impl/NativeEvaluator.java new file mode 100644 index 00000000..80e63bfa --- /dev/null +++ b/java-src/io/github/erdos/stencil/impl/NativeEvaluator.java @@ -0,0 +1,127 @@ +package io.github.erdos.stencil.impl; + +import clojure.lang.AFunction; +import clojure.lang.IFn; +import io.github.erdos.stencil.EvaluatedDocument; +import io.github.erdos.stencil.PreparedTemplate; +import io.github.erdos.stencil.TemplateData; +import io.github.erdos.stencil.TemplateDocumentFormats; +import io.github.erdos.stencil.exceptions.EvalException; +import io.github.erdos.stencil.functions.FunctionEvaluator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Consumer; +import java.util.function.Supplier; + +import static io.github.erdos.stencil.impl.Logging.debugStopWatch; +import static java.util.Collections.emptyList; + +/** + * Default implementation that calls the engine written in Clojure. + */ +public final class NativeEvaluator { + + private final static Logger LOGGER = LoggerFactory.getLogger(NativeEvaluator.class); + + private final FunctionEvaluator functions = new FunctionEvaluator(); + + + /** + * Evaluates a preprocessed template using the given data. + * + * @param template preprocessed template file + * @param data contains template variables + * @return evaluated document ready to save to fs + * @throws IllegalArgumentException when any arg is null + */ + public EvaluatedDocument render(PreparedTemplate template, TemplateData data) { + if (template == null) { + throw new IllegalArgumentException("Template object is missing!"); + } else if (data == null) { + throw new IllegalArgumentException("Template data is missing!"); + } + + final Consumer> stopwatch = debugStopWatch(LOGGER); + stopwatch.accept(() -> "Starting document rendering for template " + template.getName()); + + final IFn fn = ClojureHelper.findFunction("do-eval-stream"); + + Map argsMap = makeArgsMap(template.getSecretObject(), data.getData()); + + final Object result; + try { + result = fn.invoke(argsMap); + } catch (Exception e) { + throw EvalException.wrapping(e); + } + + final InputStream stream = resultInputStream((Map) result); + + return build(stream, template.getTemplateFormat()); + } + + + public FunctionEvaluator getFunctionEvaluator() { + return functions; + } + + private static EvaluatedDocument build(InputStream stream, TemplateDocumentFormats format) { + return new EvaluatedDocument() { + @Override + public InputStream getInputStream() { + return stream; + } + + @Override + public TemplateDocumentFormats getFormat() { + return format; + } + }; + } + + private static InputStream resultInputStream(Map result) { + if (!result.containsKey(ClojureHelper.KV_STREAM)) { + throw new IllegalArgumentException("Input map does not contains :stream key!"); + } else { + return (InputStream) result.get(ClojureHelper.KV_STREAM); + } + } + + @SuppressWarnings("unchecked") + private Map makeArgsMap(Object template, Object data) { + final Map result = new HashMap(); + result.put(ClojureHelper.KV_TEMPLATE, template); + result.put(ClojureHelper.KV_DATA, data); + result.put(ClojureHelper.KV_FUNCTION, new FunctionCaller()); + return result; + } + + private final class FunctionCaller extends AFunction { + + /** + * First argument is callable fn name as string. + * Second argument is a collection of argumets to pass to fn. + */ + @Override + @SuppressWarnings("unchecked") + public Object invoke(Object functionName, Object argsList) { + if (!(functionName instanceof String)) { + throw new IllegalArgumentException("First argument must be a String!"); + } else if (argsList == null) { + argsList = emptyList(); + } else if (!(argsList instanceof Collection)) { + throw new IllegalArgumentException("Second argument must be a collection!"); + } + + final Object[] args = new ArrayList((Collection) argsList).toArray(); + + return functions.call(functionName.toString(), args); + } + } +} \ No newline at end of file diff --git a/java-src/io/github/erdos/stencil/impl/NativeTemplateFactory.java b/java-src/io/github/erdos/stencil/impl/NativeTemplateFactory.java new file mode 100644 index 00000000..f2fa4571 --- /dev/null +++ b/java-src/io/github/erdos/stencil/impl/NativeTemplateFactory.java @@ -0,0 +1,100 @@ +package io.github.erdos.stencil.impl; + +import clojure.lang.IFn; +import clojure.lang.Keyword; +import io.github.erdos.stencil.PreparedTemplate; +import io.github.erdos.stencil.TemplateDocumentFormats; +import io.github.erdos.stencil.TemplateFactory; +import io.github.erdos.stencil.TemplateVariables; +import io.github.erdos.stencil.exceptions.ParsingException; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.time.LocalDateTime; +import java.util.*; + +import static io.github.erdos.stencil.TemplateDocumentFormats.ofExtension; +import static java.util.Collections.emptySet; +import static java.util.Collections.unmodifiableSet; + +@SuppressWarnings("unused") +public final class NativeTemplateFactory implements TemplateFactory { + + @Override + public PreparedTemplate prepareTemplateFile(final File inputTemplateFile) throws IOException { + final Optional templateDocFormat = ofExtension(inputTemplateFile.getName()); + + if (!templateDocFormat.isPresent()) { + throw new IllegalArgumentException("Unexpected type of file: " + inputTemplateFile.getName()); + } + + try (InputStream input = new FileInputStream(inputTemplateFile)) { + return prepareTemplateImpl(templateDocFormat.get(), input); + } + } + + /** + * Retrieves content of :variables keyword from map as a set. + */ + @SuppressWarnings("unchecked") + private Set variableNames(Map prepared) { + return prepared.containsKey(ClojureHelper.KV_VARIABLES) + ? unmodifiableSet(new HashSet((Collection) prepared.get(ClojureHelper.KV_VARIABLES))) + : emptySet(); + } + + @SuppressWarnings("unchecked") + private PreparedTemplate prepareTemplateImpl(TemplateDocumentFormats templateDocFormat, InputStream input) { + final IFn prepareFunction = ClojureHelper.findFunction("prepare-template"); + + final String format = templateDocFormat.name(); + final Map prepared; + + try { + prepared = (Map) prepareFunction.invoke(format, input); + } catch (ParsingException e) { + throw e; + } catch (Exception e) { + throw ParsingException.wrapping("Could not parse template file!", e); + } + + final String templateName = (String) prepared.get(Keyword.intern("template-name")); + final File templateFile = (File) prepared.get(Keyword.intern("template-file")); + final LocalDateTime now = LocalDateTime.now(); + final TemplateVariables vars = TemplateVariables.fromPaths(variableNames(prepared)); + + return new PreparedTemplate() { + @Override + public String getName() { + return templateName; + } + + @Override + public File getTemplateFile() { + return templateFile; + } + + @Override + public TemplateDocumentFormats getTemplateFormat() { + return templateDocFormat; + } + + @Override + public LocalDateTime creationDateTime() { + return now; + } + + @Override + public Object getSecretObject() { + return prepared; + } + + @Override + public TemplateVariables getVariables() { + return vars; + } + }; + } +} \ No newline at end of file diff --git a/java-src/io/github/erdos/stencil/impl/ZipHelper.java b/java-src/io/github/erdos/stencil/impl/ZipHelper.java new file mode 100644 index 00000000..160e3902 --- /dev/null +++ b/java-src/io/github/erdos/stencil/impl/ZipHelper.java @@ -0,0 +1,56 @@ +package io.github.erdos.stencil.impl; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +import static io.github.erdos.stencil.impl.FileHelper.forceMkdir; + +/** + * Various helpers for handling ZIP files. + */ +public final class ZipHelper { + + /** + * Unzips contents of a zip file under the target directory. Closes stream. + * The unzipped files keep their relative paths from the zip file. + * That is files from the root of the zip file will be put directly under target directory, etc. + * + * @param zipFileStream input stream of a ZIP file + * @param unzipTargetDirectory a directory where zip contents are put + * @throws IllegalArgumentException when any param is null. + * @throws IllegalStateException when target file already exists. + * @throws IOException on file system error + */ + public static void unzipStreamIntoDirectory(InputStream zipFileStream, File unzipTargetDirectory) throws IOException { + if (zipFileStream == null) { + throw new IllegalArgumentException("zip file stream is null!"); + } else if (unzipTargetDirectory == null) { + throw new IllegalArgumentException("target directory is null!"); + } else if (unzipTargetDirectory.exists()) { + throw new IllegalStateException("unzip target dir already exists: " + unzipTargetDirectory); + } + + forceMkdir(unzipTargetDirectory); + + byte[] buffer = new byte[1024]; + int len; + + try (ZipInputStream zis = new ZipInputStream(zipFileStream)) { + for (ZipEntry zipEntry = zis.getNextEntry(); zipEntry != null; zipEntry = zis.getNextEntry()) { + File newFile = new File(unzipTargetDirectory, zipEntry.getName()); + forceMkdir(newFile.getParentFile()); + try (FileOutputStream fos = new FileOutputStream(newFile)) { + while ((len = zis.read(buffer)) > 0) { + fos.write(buffer, 0, len); + } + } + } + zis.closeEntry(); + } + zipFileStream.close(); + } +} diff --git a/java-src/overview.html b/java-src/overview.html new file mode 100644 index 00000000..a3d03f49 --- /dev/null +++ b/java-src/overview.html @@ -0,0 +1,54 @@ + + + +

Word processor compatible templating system

+ +

+ This software allows clients to use office suite documents for templating automated document generation. +

+ +

Template Syntax

+ +

Template files may contain the following expressions.

+ +
+

Value substitution

+ +

Used to display value from the template data object.

+ Examples: + +
+
+

Conditional blocks

+

+ Put a value between conditional blocks to toggle its visibility depending on a value in the data object. +

+ Examples: + +
+
+

Repeating blocks

+

Repeat a block for each item in a list in the data object.

+

For example if xs is a list then the following repeats the BODY part for every item: {%for x in xs%} BODY {%end%}

+

The BODY part has a new binding on every iteration so you can access every element in a collection via the x iterator variable.

+
+
+

Function calls and arithmetic

+

You can call custom functions in expressions. For example: {%=coalesce(x.price, x.cost, "N/A")%}

+

You can also call many arithmetic operators. For example: {%= x*x+x+2%}

+
+ +

Custom Functions

+

+ See: {@link stencil.functions} package. +

+ + + diff --git a/java-test/io/github/erdos/stencil/IntegrationTest.java b/java-test/io/github/erdos/stencil/IntegrationTest.java new file mode 100644 index 00000000..89b6da0b --- /dev/null +++ b/java-test/io/github/erdos/stencil/IntegrationTest.java @@ -0,0 +1,9 @@ +package io.github.erdos.stencil; + + +/** + * Annotates a test that needs a LibreOffice process in the background. + */ +public interface IntegrationTest { + /* category marker */ +} diff --git a/java-test/io/github/erdos/stencil/TemplateVariablesTest.java b/java-test/io/github/erdos/stencil/TemplateVariablesTest.java new file mode 100644 index 00000000..92eec93d --- /dev/null +++ b/java-test/io/github/erdos/stencil/TemplateVariablesTest.java @@ -0,0 +1,91 @@ +package io.github.erdos.stencil; + +import org.junit.Test; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import static io.github.erdos.stencil.TemplateData.fromMap; +import static io.github.erdos.stencil.TemplateVariables.fromPaths; +import static java.util.Arrays.asList; +import static java.util.Collections.*; +import static org.junit.Assert.fail; + +public class TemplateVariablesTest { + + @Test + public void testSimple() { + final Set schema = set("a.x.p", "a.x.q"); + Map data = map("a", map("x", map("p", null, "q", 23))); + + fromPaths(schema).throwWhenInvalid(fromMap(data)); + } + + + @Test + public void testArray() { + final Set schema = set("x[]"); + + // valid + final Map data = singletonMap("x", asList(1, 2, 3)); + fromPaths(schema).throwWhenInvalid(fromMap(data)); + + try { + // invalid + final TemplateData data2 = fromMap(singletonMap("a", singletonMap("x", asList(1, 2, 3)))); + fromPaths(schema).throwWhenInvalid(data2); + fail("Should have thrown!"); + } catch (IllegalArgumentException ignored) { + } + } + + @Test + public void testNestedArray() { + final Set schema = set("a.x[][].p"); + + // valid + final Map data = singletonMap("a", singletonMap("x", singletonList(singletonList(singletonMap("p", 1))))); + fromPaths(schema).throwWhenInvalid(fromMap(data)); + + try { + // invalid + fromPaths(schema).throwWhenInvalid(fromMap(singletonMap("a", singletonMap("x", asList(1, 2, 3))))); + fail("Should have thrown!"); + } catch (IllegalArgumentException ignored) { + } + } + + @Test + public void testNestedArraySimple() { + final Set schema = set("a.b.c.d"); + + try { + // invalid + final Map data = singletonMap("a", 3); + fromPaths(schema).throwWhenInvalid(fromMap(data)); + fail("Should have thrown!"); + } catch (IllegalArgumentException ignored) { + } + } + + @SafeVarargs + private static Set set(T... elems) { + return unmodifiableSet(new HashSet<>(asList(elems))); + } + + private static Map map(K k1, V v1) { + Map m = new HashMap<>(); + m.put(k1, v1); + return unmodifiableMap(m); + } + + + private static Map map(K k1, V v1, K k2, V v2) { + Map m = new HashMap<>(); + m.put(k1, v1); + m.put(k2, v2); + return unmodifiableMap(m); + } +} \ No newline at end of file diff --git a/java-test/io/github/erdos/stencil/functions/BasicFunctionsTest.java b/java-test/io/github/erdos/stencil/functions/BasicFunctionsTest.java new file mode 100644 index 00000000..767b93d2 --- /dev/null +++ b/java-test/io/github/erdos/stencil/functions/BasicFunctionsTest.java @@ -0,0 +1,44 @@ +package io.github.erdos.stencil.functions; + +import org.junit.Test; + +import static io.github.erdos.stencil.functions.BasicFunctions.COALESCE; +import static io.github.erdos.stencil.functions.BasicFunctions.EMPTY; +import static io.github.erdos.stencil.functions.BasicFunctions.SWITCH; +import static java.util.Arrays.asList; +import static java.util.Collections.emptyList; +import static junit.framework.TestCase.assertTrue; +import static org.junit.Assert.*; + +public class BasicFunctionsTest { + + @Test + public void testSwitch() { + assertEquals(3, SWITCH.call("a", "b", 1, "c", 2, "a", 3)); + assertEquals(3, SWITCH.call("a", "b", 1, "c", 2, "a", 3, "default")); + assertEquals(1, SWITCH.call("a", "a", 1)); + assertEquals(555, SWITCH.call(null, "b", 1, null, 555, "a", 3, "default")); + + assertNull(SWITCH.call("a", "x", 1, "y", 2)); + } + + @Test + public void testCoalesce() { + assertEquals(3, COALESCE.call("", null, emptyList(), 3, emptyList())); + assertEquals(3, COALESCE.call(3, null, null, emptyList())); + + assertNull(COALESCE.call()); + assertNull(COALESCE.call(null, emptyList(), "", null, emptyList())); + } + + @Test + public void testEmpty() { + assertTrue((Boolean) EMPTY.call("")); + assertTrue((Boolean) EMPTY.call(emptyList())); + assertTrue((Boolean) EMPTY.call((Object) null)); + + assertFalse((Boolean) EMPTY.call("aasd")); + assertFalse((Boolean) EMPTY.call(asList(1, 2, 3))); + assertFalse((Boolean) EMPTY.call(234)); + } +} \ No newline at end of file diff --git a/java-test/io/github/erdos/stencil/functions/DateFunctionsTest.java b/java-test/io/github/erdos/stencil/functions/DateFunctionsTest.java new file mode 100644 index 00000000..d623c0ca --- /dev/null +++ b/java-test/io/github/erdos/stencil/functions/DateFunctionsTest.java @@ -0,0 +1,43 @@ +package io.github.erdos.stencil.functions; + +import org.junit.Test; + +import static io.github.erdos.stencil.functions.DateFunctions.DATE; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +public class DateFunctionsTest { + + @Test + public void testDate1() { + + // simple datetime format + assertEquals("2017-01-01 08:11:12", DATE.call("yyyy-MM-dd HH:mm:ss", "2017-01-01T08:11:12")); + + // simple datetime format + assertEquals("2001-07-04", DATE.call("yyyy-MM-dd", "2001-07-04 23:43:43")); + + + // default LocalDateTime format + assertEquals("2018-05-24", DATE.call("yyyy-MM-dd", "2018-05-24T11:49:52.520")); + + // default java.util.Date string format + assertEquals("2018-05-24", DATE.call("yyyy-MM-dd", "Thu May 24 11:50:50 CEST 2018")); + + // default java.sql.Timestamp format + assertEquals("2018-05-24", DATE.call("yyyy-MM-dd", "2018-05-24 22:33:44.345")); + + } + + @Test(expected = IllegalArgumentException.class) + public void testNonParseable() { + DATE.call("yyyy-MM-dd", "asdfsaf"); + } + + @Test + public void testNullCases() { + assertNull(DATE.call("yyyy-MM-dd", "")); + assertNull(DATE.call("yyyy-MM-dd", null)); + assertNull(DATE.call(null, "2011-02-02")); + } +} \ No newline at end of file diff --git a/java-test/io/github/erdos/stencil/functions/FunctionEvaluatorTest.java b/java-test/io/github/erdos/stencil/functions/FunctionEvaluatorTest.java new file mode 100644 index 00000000..dcc9db02 --- /dev/null +++ b/java-test/io/github/erdos/stencil/functions/FunctionEvaluatorTest.java @@ -0,0 +1,22 @@ +package io.github.erdos.stencil.functions; + +import org.junit.Test; + +import static io.github.erdos.stencil.functions.BasicFunctions.EMPTY; +import static org.junit.Assert.assertTrue; + +public class FunctionEvaluatorTest { + + @Test + public void allFunctionsMustBeNullSafe() { + for (Function fun : new FunctionEvaluator().listFunctions()) { + if (fun == EMPTY) continue; + try { + Object result = fun.call((Object) null); + assertTrue("Not for " + fun, result == null || result.equals("")); + } catch (IllegalArgumentException ignored) { + // ok + } + } + } +} \ No newline at end of file diff --git a/java-test/io/github/erdos/stencil/functions/LocaleFunctionsTest.java b/java-test/io/github/erdos/stencil/functions/LocaleFunctionsTest.java new file mode 100644 index 00000000..143ad672 --- /dev/null +++ b/java-test/io/github/erdos/stencil/functions/LocaleFunctionsTest.java @@ -0,0 +1,29 @@ +package io.github.erdos.stencil.functions; + +import org.junit.Test; + +import static io.github.erdos.stencil.functions.LocaleFunctions.CURRENCY; +import static io.github.erdos.stencil.functions.LocaleFunctions.PERCENT; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +public class LocaleFunctionsTest { + + @Test + public void testCurrency() { + assertNull(CURRENCY.call(new Object[]{null})); + assertEquals("123 Ft", CURRENCY.call(123, "HU-HU")); + assertEquals("123,45 Ft", CURRENCY.call(123.45, "HU-HU")); + assertEquals("0 Ft", CURRENCY.call(0.0, "HU-HU")); + assertEquals("-1 Ft", CURRENCY.call(-1, "HU-HU")); + } + + @Test + public void testPercent() { + assertNull(PERCENT.call(new Object[]{null})); + assertEquals("12 300%", PERCENT.call(123, "HU-HU")); + assertEquals("95%", PERCENT.call(0.95, "HU-HU")); + assertEquals("1%", PERCENT.call(0.0095, "HU-HU")); + assertEquals("-200%", PERCENT.call(-2, "HU-HU")); + } +} \ No newline at end of file diff --git a/java-test/io/github/erdos/stencil/functions/StringFunctionsTest.java b/java-test/io/github/erdos/stencil/functions/StringFunctionsTest.java new file mode 100644 index 00000000..4bd6b87e --- /dev/null +++ b/java-test/io/github/erdos/stencil/functions/StringFunctionsTest.java @@ -0,0 +1,30 @@ +package io.github.erdos.stencil.functions; + +import org.junit.Assert; +import org.junit.Test; + +import static io.github.erdos.stencil.functions.StringFunctions.FORMAT; +import static org.junit.Assert.assertEquals; + +public class StringFunctionsTest { + + @Test + public void testFormat() { + assertEquals("", FORMAT.call("")); + assertEquals("158", FORMAT.call("%x", 344)); + + try { + FORMAT.call("%x"); + Assert.fail("Kevetel kellene!"); + } catch (IllegalArgumentException ignored) { + // direkt ures! + } + + try { + FORMAT.call(); + Assert.fail("Kevetel kellene!"); + } catch (IllegalArgumentException ignored) { + // direkt ures! + } + } +} \ No newline at end of file diff --git a/java-test/io/github/erdos/stencil/impl/DirWatcherTemplateFactoryTest.java b/java-test/io/github/erdos/stencil/impl/DirWatcherTemplateFactoryTest.java new file mode 100644 index 00000000..1ab4a932 --- /dev/null +++ b/java-test/io/github/erdos/stencil/impl/DirWatcherTemplateFactoryTest.java @@ -0,0 +1,115 @@ +package io.github.erdos.stencil.impl; + +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import io.github.erdos.stencil.PreparedTemplate; +import io.github.erdos.stencil.TemplateFactory; +import io.github.erdos.stencil.TemplateVariables; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.Set; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class DirWatcherTemplateFactoryTest implements TemplateFactory { + + private final Set calledFiles = new HashSet<>(); + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + private DirWatcherTemplateFactory factory; + private File folder; + + @Before + public void cleanup() throws IOException { + calledFiles.clear(); + folder = temporaryFolder.newFolder(); + factory = new DirWatcherTemplateFactory(folder, this); + factory.start(); + } + + @After + public void after() { + factory.stop(); + } + + @Test + public void testFileChange() throws IOException, InterruptedException { + File file1 = makeFile("asd"); + assertFalse(calledFiles.contains(file1)); + Thread.sleep(1100L); + assertTrue(calledFiles.contains(file1)); + } + + @Test + public void testFileChange2() throws IOException, InterruptedException { + Thread.sleep(2000L); + File file1 = makeFile("asd"); + assertFalse(calledFiles.contains(file1)); + + Thread.sleep(200L); + makeFile("asd"); + File file2 = makeFile("fedfed"); + + Thread.sleep(600L); + assertFalse(calledFiles.contains(file1)); + + makeFile("asd"); + Thread.sleep(600L); + assertFalse(calledFiles.contains(file1)); + assertTrue(calledFiles.contains(file2)); + + makeFile("asd"); + Thread.sleep(1100L); + assertTrue(calledFiles.contains(file1)); + } + + + private File makeFile(String fname) throws IOException { + final File file1 = new File(folder, fname).getAbsoluteFile(); + try (OutputStream fo = new FileOutputStream(file1)) { + fo.write("Hello".getBytes()); + } + return file1; + } + + @Override + public PreparedTemplate prepareTemplateFile(File templateFile) { + calledFiles.add(templateFile); + return new PreparedTemplate() { + + @Override + public String getName() { + return templateFile.getName(); + } + + @Override + public File getTemplateFile() { + return templateFile; + } + + @Override + public LocalDateTime creationDateTime() { + return null; + } + + @Override + public Object getSecretObject() { + return null; + } + + @Override + public TemplateVariables getVariables() { + return null; + } + }; + } +} diff --git a/java-test/io/github/erdos/stencil/scenarios/ExampleCallTest.java b/java-test/io/github/erdos/stencil/scenarios/ExampleCallTest.java new file mode 100644 index 00000000..f845e9e0 --- /dev/null +++ b/java-test/io/github/erdos/stencil/scenarios/ExampleCallTest.java @@ -0,0 +1,35 @@ +package io.github.erdos.stencil.scenarios; + +import io.github.erdos.stencil.API; +import io.github.erdos.stencil.EvaluatedDocument; +import io.github.erdos.stencil.PreparedTemplate; +import io.github.erdos.stencil.TemplateData; +import org.junit.Ignore; + +import java.io.File; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +/** + * Collection of test codes used in documentation. + */ +@Ignore +@SuppressWarnings("unused") +public class ExampleCallTest { + + public void renderInvoiceDocument(String userName, Integer totalCost) throws IOException { + + final File template = new File("/home/developer/templates/invoice.docx"); + + final PreparedTemplate prepared = API.prepare(template); + + final Map data = new HashMap<>(); + data.put("name", userName); + data.put("cost", totalCost); + + final EvaluatedDocument rendered = API.render(prepared, TemplateData.fromMap(data)); + + rendered.writeToFile(new File("/home/developer/rendered/invoice-" + userName + ".docx")); + } +} diff --git a/java-test/io/github/erdos/stencil/scenarios/LoopTest.java b/java-test/io/github/erdos/stencil/scenarios/LoopTest.java new file mode 100644 index 00000000..3b49393a --- /dev/null +++ b/java-test/io/github/erdos/stencil/scenarios/LoopTest.java @@ -0,0 +1,35 @@ +package io.github.erdos.stencil.scenarios; + +import org.junit.Test; + +import java.util.HashMap; +import java.util.Map; + +import static java.util.Arrays.asList; +import static java.util.Collections.emptyList; +import static java.util.Collections.singletonMap; +import static io.github.erdos.stencil.scenarios.TableColumnsTest.testWithData; + +/** + * Tests {%for e in elems%} expressions. + */ +public class LoopTest { + + /** + * A simple iterator is looped over values of an array. + */ + @Test + public void testLoop1() { + Map data = new HashMap<>(); + + data.put("elems", + asList(singletonMap("value", "one"), + singletonMap("value", "2"), + singletonMap("value", "three"))); + + testWithData("test-control-loop.docx", + data, + asList("one", "2", "three"), + emptyList()); + } +} diff --git a/java-test/io/github/erdos/stencil/scenarios/SemanticErrorsTest.java b/java-test/io/github/erdos/stencil/scenarios/SemanticErrorsTest.java new file mode 100644 index 00000000..68c1f042 --- /dev/null +++ b/java-test/io/github/erdos/stencil/scenarios/SemanticErrorsTest.java @@ -0,0 +1,36 @@ +package io.github.erdos.stencil.scenarios; + +import io.github.erdos.stencil.API; +import io.github.erdos.stencil.PreparedTemplate; +import io.github.erdos.stencil.TemplateData; +import io.github.erdos.stencil.exceptions.EvalException; +import org.junit.Test; + +import java.io.File; +import java.io.IOException; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.file.Paths; + +import static java.util.Objects.requireNonNull; +import static junit.framework.TestCase.assertTrue; + +public class SemanticErrorsTest { + + @Test(expected = EvalException.class) + public void testSyntaxError1() throws URISyntaxException, IOException { + final String testFileName = "failures/test-semantic-error-1.docx"; + + + final URL testFileUrl = requireNonNull(TableColumnsTest.class.getClassLoader().getResource(testFileName)); + final File testFile = Paths.get(testFileUrl.toURI()).toFile(); + + assertTrue(testFile.exists()); + + // this should throw an error. + PreparedTemplate preparedTemplate = API.prepare(testFile); + + + API.render(preparedTemplate, TemplateData.empty()); + } +} diff --git a/java-test/io/github/erdos/stencil/scenarios/SyntaxErrorsTest.java b/java-test/io/github/erdos/stencil/scenarios/SyntaxErrorsTest.java new file mode 100644 index 00000000..b3dbc05b --- /dev/null +++ b/java-test/io/github/erdos/stencil/scenarios/SyntaxErrorsTest.java @@ -0,0 +1,30 @@ +package io.github.erdos.stencil.scenarios; + +import io.github.erdos.stencil.API; +import io.github.erdos.stencil.exceptions.ParsingException; +import org.junit.Test; + +import java.io.File; +import java.io.IOException; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.file.Paths; + +import static java.util.Objects.requireNonNull; +import static junit.framework.TestCase.assertTrue; + +public class SyntaxErrorsTest { + + + @Test(expected = ParsingException.class) + public void testSyntaxError1() throws URISyntaxException, IOException { + final String testFileName = "failures/test-syntax-error-1.docx"; + final URL testFileUrl = requireNonNull(TableColumnsTest.class.getClassLoader().getResource(testFileName)); + final File testFile = Paths.get(testFileUrl.toURI()).toFile(); + + assertTrue(testFile.exists()); + + // this should throw an error. + API.prepare(testFile); + } +} diff --git a/java-test/io/github/erdos/stencil/scenarios/TableColumnsTest.java b/java-test/io/github/erdos/stencil/scenarios/TableColumnsTest.java new file mode 100644 index 00000000..dc4602e5 --- /dev/null +++ b/java-test/io/github/erdos/stencil/scenarios/TableColumnsTest.java @@ -0,0 +1,221 @@ +package io.github.erdos.stencil.scenarios; + +import org.junit.Test; +import io.github.erdos.stencil.API; +import io.github.erdos.stencil.EvaluatedDocument; +import io.github.erdos.stencil.PreparedTemplate; +import io.github.erdos.stencil.TemplateData; +import io.github.erdos.stencil.impl.ZipHelper; + +import javax.xml.stream.XMLInputFactory; +import javax.xml.stream.XMLStreamException; +import javax.xml.stream.XMLStreamReader; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static java.util.Arrays.asList; +import static java.util.Collections.emptyList; +import static java.util.stream.Collectors.joining; +import static junit.framework.TestCase.assertNotNull; +import static junit.framework.TestCase.assertTrue; +import static org.junit.Assert.assertFalse; + +public class TableColumnsTest { + + /** + * All table columns are visible. + */ + @Test + public void testAllColumnsVisible() { + Map data = new HashMap<>(); + + data.put("testerName", "Wizard"); + data.put("col1", true); + data.put("col2", true); + data.put("col3", true); + data.put("col4", true); + + testWithData("test-table-columns.docx", + data, + asList("Hello Wizard!", + "A1", "A2", "A3", "A4", + "B1+B2", "B3", "B4", + "C1", "C2+C3", "C4", + "D1", "D2", "D3+D4", + "E1+E2+E3", "E4", + "F1", "F2+F3+F4", + "G1+G2+G3+G4"), + emptyList()); + } + + /** + * Hiding first column must not affect other columns. + */ + @Test + public void testHidingColumn1() { + Map data = new HashMap<>(); + + data.put("testerName", "Genius"); + data.put("col1", false); + data.put("col2", true); + data.put("col3", true); + data.put("col4", true); + + testWithData("test-table-columns.docx", + data, + asList("Hello Genius!", + "A2", "A3", "A4", + "B1+B2", "B3", "B4", + "C2+C3", "C4", + "D2", "D3+D4", + "E1+E2+E3", "E4", + "F2+F3+F4", + "G1+G2+G3+G4"), + asList("A1", "C1", "D1", "F1")); + } + + /** + * Hiding second column must not affect other columns. + */ + @Test + public void testHidingColumn2() { + Map data = new HashMap<>(); + + data.put("testerName", "Wizard"); + data.put("col1", true); + data.put("col2", false); + data.put("col3", true); + data.put("col4", true); + + testWithData("test-table-columns.docx", + data, + asList("Hello Wizard!", + "A1", "A3", "A4", + "B1+B2", "B3", "B4", + "C1", "C2+C3", "C4", + "D1", "D3+D4", + "E1+E2+E3", "E4", + "F1", "F2+F3+F4", + "G1+G2+G3+G4"), + asList("A2", "D2")); + } + + /** + * Hiding all columns must remove all. + */ + @Test + public void testHidingColumnAll() { + Map data = new HashMap<>(); + + data.put("testerName", "Wizard"); + data.put("col1", false); + data.put("col2", false); + data.put("col3", false); + data.put("col4", false); + + testWithData("test-table-columns.docx", + data, + asList("Hello Wizard!", "Some text after the table"), + asList("A2", "B1+B2", "D2", "E1+E2+E3", "F2+F3+F4", "G1+G2+G3+G4")); + } + + /** + * Unit test implementation. + * + * @param testFileName file name of template file. + * @param data template data. + * @param mustContain the result must contain these words. + * @param mustNotContain the result must not contains these words. + */ + @SuppressWarnings("SameParameterValue") + public static void testWithData(String testFileName, Map data, List mustContain, List mustNotContain) { + try { + final URL testFileUrl = TableColumnsTest.class.getClassLoader().getResource(testFileName); + + assertNotNull(testFileUrl); + + final File testFile = Paths.get(testFileUrl.toURI()).toFile(); + assertTrue(testFile.exists()); + + final PreparedTemplate prepared = API.prepare(testFile); + final EvaluatedDocument result = API.render(prepared, TemplateData.fromMap(data)); + + final File temporaryDocx = File.createTempFile("sdf", ".docx"); + assertTrue(temporaryDocx.delete()); + result.writeToFile(temporaryDocx); + + System.out.println("Temporary docx file: " + temporaryDocx); + + final File tmpdir = File.createTempFile("stencil-test", ""); + assertTrue(tmpdir.delete()); // so that we can create directory + tmpdir.deleteOnExit(); + + try (FileInputStream docxStream = new FileInputStream(temporaryDocx)) { + ZipHelper.unzipStreamIntoDirectory(docxStream, tmpdir); + } + + final String allWords = tree(tmpdir) + .stream() + .filter(x -> x.getName().endsWith(".xml")) + .map(TableColumnsTest::extractWords) + .collect(joining(" ")); + + mustContain.forEach(w -> assertTrue("Should contain " + w, allWords.contains(w))); + mustNotContain.forEach(w -> assertFalse("Should not contain " + w, allWords.contains(w))); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + /** + * Returns all files from a directory recursively. + */ + private static List tree(File f) { + assertTrue(f.exists()); + assertTrue(f.isDirectory()); + + //noinspection ConstantConditions + return Arrays.stream(f.listFiles()).flatMap(d -> { + if (d.isDirectory()) return tree(d).stream(); + else return Stream.of(d); + }).collect(Collectors.toList()); + } + + /** + * Returns a concatenation of all string literals from an xml file. + * + * @param xmlFile an existing XML file + * @return string of text contents + */ + private static String extractWords(File xmlFile) { + assertTrue(xmlFile.getName().endsWith(".xml")); + assertTrue(xmlFile.exists()); + + final StringBuilder buffer = new StringBuilder(); + try (FileInputStream inputStream = new FileInputStream(xmlFile)) { + final XMLStreamReader reader = XMLInputFactory.newFactory().createXMLStreamReader(inputStream); + + while (reader.hasNext()) { + final int next = reader.next(); + + if (next == XMLStreamReader.CHARACTERS || next == XMLStreamReader.SPACE) { + buffer.append(reader.getText()); + } + } + reader.close(); + return buffer.toString(); + } catch (IOException | XMLStreamException e) { + throw new RuntimeException(e); + } + } +} diff --git a/java-test/io/github/erdos/stencil/scenarios/VariablesTest.java b/java-test/io/github/erdos/stencil/scenarios/VariablesTest.java new file mode 100644 index 00000000..58e0d6ef --- /dev/null +++ b/java-test/io/github/erdos/stencil/scenarios/VariablesTest.java @@ -0,0 +1,51 @@ +package io.github.erdos.stencil.scenarios; + +import org.junit.Test; + +import java.util.HashMap; +import java.util.Map; + +import static java.util.Arrays.asList; +import static io.github.erdos.stencil.scenarios.TableColumnsTest.testWithData; + +/** + * Test IF-THEN, IF-THEN-ELSE, UNLESS-THEN, UNLESS-THEN-ELSE statements + */ +public class VariablesTest { + + /** + * First branches are executed in every conditional expression. + */ + @Test + public void testConditionals1() { + Map data = new HashMap<>(); + + data.put("condition1", true); + data.put("condition2", true); + data.put("condition3", false); + data.put("condition4", false); + + testWithData("test-control-conditionals.docx", + data, + asList("Apple", "Banana", "Date", "Elderberry"), + asList("Cherry", "Fig")); + } + + /** + * Second branches are executed in every conditional expression. + */ + @Test + public void testConditionals2() { + Map data = new HashMap<>(); + + data.put("condition1", false); + data.put("condition2", false); + data.put("condition3", true); + data.put("condition4", true); + + testWithData("test-control-conditionals.docx", + data, + asList("Cherry", "Fig"), + asList("Apple", "Banana", "Date", "Elderberry")); + } +} diff --git a/javadoc.sh b/javadoc.sh new file mode 100755 index 00000000..5fa118d3 --- /dev/null +++ b/javadoc.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env sh + +lein javadoc diff --git a/project.clj b/project.clj new file mode 100644 index 00000000..b41475bf --- /dev/null +++ b/project.clj @@ -0,0 +1,29 @@ +(defproject io.github.erdos/stencil-core "0.2.1" + :url "https://github.com/erdos/stencil" + :description "Templating engine for office documents." + :license {:name "Eclipse Public License - v 2.0" + :url "https://www.eclipse.org/legal/epl-2.0/"} + :min-lein-version "2.0.0" + :java-source-paths ["java-src"] + :javac-options ["-target" "8" "-source" "8"] + :aot :all + :dependencies [[org.clojure/clojure "1.9.0"] + [org.clojure/data.xml "0.2.0-alpha5"] + [org.slf4j/slf4j-api "1.8.0-beta2"]] + :pom-addition ([:properties ["maven.compiler.source" "8"] ["maven.compiler.target" "8"]]) + :pom-plugins [[org.apache.maven.plugins/maven-surefire-plugin "2.22.0"]] + + :plugins [[lein-javadoc "0.3.0"] + [lein-test-out "0.3.1"]] + :aliases {"junit" ["with-profile" "test" "do" "test-out" "junit" "junit.xml"]} + :javadoc-opts {:package-names ["stencil"] + :additional-args ["-overview" "java-src/overview.html" + "-top" ""]} + :repl-options {:init-ns stencil.api} + :jar-exclusions [#".*\.xml"] + :profiles {:test {:dependencies [[junit/junit "4.12"] + [org.xmlunit/xmlunit-core "2.5.1"] + [hiccup "1.0.5"]] + :resource-paths ["test-resources"] + :test-paths ["java-test"] + :java-source-paths ["java-src"]}}) diff --git a/src/stencil/api.clj b/src/stencil/api.clj new file mode 100644 index 00000000..dbe56e68 --- /dev/null +++ b/src/stencil/api.clj @@ -0,0 +1,28 @@ +(ns stencil.api + "A simple public API for document generation from templates." + (:import [io.github.erdos.stencil API PreparedTemplate TemplateData])) + +(set! *warn-on-reflection* true) +(set! *unchecked-math* :warn-on-boxed) + +(defn prepare + "Creates a prepared template instance from an input document." + [input] + (cond + (instance? PreparedTemplate input) input + (nil? input) (throw (ex-info "Template is missing!" {})) + :otherwise (API/prepare (clojure.java.io/file input)))) + +(defn- make-template-data [x] + (if (map? x) + (TemplateData/fromMap ^java.util.Map x) + (throw (ex-info (str "Unsupported template data type " (type x) "!") + {:template-data x})))) + +(defn render! + "Takes a prepared template instance and renders it. + By default it returns an InputStream of the rendered document." + [template template-data & {:as opts}] + (let [template (prepare template) + template-data (make-template-data template-data)] + (API/render template ^TemplateData template-data))) diff --git a/src/stencil/cleanup.clj b/src/stencil/cleanup.clj new file mode 100644 index 00000000..d971d7d1 --- /dev/null +++ b/src/stencil/cleanup.clj @@ -0,0 +1,188 @@ +(ns stencil.cleanup + " + + This namespace annotates and normalizes Control AST. + + data flow is the following: + + valid XML String -> tokens -> Annotated Control AST -> Normalized Control AST -> Evaled AST -> Hiccup or valid XML String + " + (:require [stencil [util :refer :all] [types :refer :all]])) + +(set! *warn-on-reflection* true) + +(declare control-ast-normalize) + +(defn- tokens->ast-step [[queue & ss0 :as stack] token] + (case (:cmd token) + (:if :for) (conj (mod-stack-top-conj stack token) []) + + :else (conj (mod-stack-top-last ss0 update :blocks (fnil conj []) {:children queue}) []) + + :end (mod-stack-top-last ss0 update :blocks conj {:children queue}) + + (:echo nil) (mod-stack-top-conj stack token))) + +(defn tokens->ast + "Flat token listabol nested AST-t csinal (listak listai)" + [tokens] + (let [result (reduce tokens->ast-step '([]) tokens)] + (assert (= 1 (count result))) + (peek result))) + +(defn nested-tokens-fmap-postwalk + "Melysegi bejaras egy XML fan. + + https://en.wikipedia.org/wiki/Depth-first_search" + [f-cmd-block-before f-cmd-block-after f-child nested-tokens] + (let [update-children + #(update % :children + (partial nested-tokens-fmap-postwalk + f-cmd-block-before f-cmd-block-after + f-child))] + (vec + (for [token nested-tokens] + (if (:cmd token) + (as-> token token + (update token :blocks + (partial mapv + (comp (partial f-cmd-block-after token) + update-children + (partial f-cmd-block-before token))))) + (f-child token)))))) + +(defn annotate-environments + "Vegigmegy minden tokenen es a parancs blokkok :before es :after kulcsaiba + beleteszi az adott token kornyezetet." + [control-ast] + (let [stack (volatile! ())] + (nested-tokens-fmap-postwalk + (fn before-cmd-block [_ block] + (assoc block :before @stack)) + + (fn after-cmd-block [_ block] + (let [stack-before (:before block) + [a b] (stacks-difference-key :open stack-before @stack)] + (assoc block :before a :after b))) + + (fn child [item] + (cond + (:open item) + (vswap! stack conj item) + + (:close item) + (if (= (:close item) (:open (first @stack))) + (vswap! stack next) + (throw (ex-info "Unexpected stack state" {:stack @stack, :item item})))) + item) + control-ast))) + +(defn stack-revert-close + "Megfordítja a listát es az :open elemeket :close elemekre kicseréli." + [stack] (reduce (fn [stack item] (if (:open item) (conj stack (->CloseTag (:open item))) stack)) () stack)) + +;; egy {:cmd ...} parancs objektumot kibont: +;; a :blocks kulcs alatt levo elemeket normalizalja es specialis kulcsok alatt elhelyezi +;; igy amikor vegrehajtjuk a parancs objektumot, akkor az eredmeny is +;; valid fa lesz, tehat a nyito-bezaro tagek helyesen fognak elhelyezkedni. +(defmulti control-ast-normalize-step :cmd) + +;; Itt nincsen blokk, amit normalizálni kellene +(defmethod control-ast-normalize-step :echo [echo-command] echo-command) +(defmethod control-ast-normalize-step :include [echo-command] echo-command) + +;; A feltételes elágazásoknál mindig generálunk egy javított THEN ágat +(defmethod control-ast-normalize-step :if [control-ast] + (case (count (:blocks control-ast)) + 2 (let [[then else] (:blocks control-ast) + then2 (concat (map control-ast-normalize (:children then)) + (stack-revert-close (:before else)) + (:after else)) + else2 (concat (stack-revert-close (:before then)) + (:after then) + (map control-ast-normalize (:children else)))] + (-> (dissoc control-ast :blocks) + (assoc :then then2, :else else2))) + + 1 (let [[then] (:blocks control-ast) + else (:after then)] + (-> (dissoc control-ast :blocks) + (assoc :then (map control-ast-normalize (:children then)), :else else))))) + +;; Egy ciklusnal kulon kell valasztani a kovetkezo eseteket: +;; - body-run-none: a body resz egyszer sem fut le, mert a lista nulla elemu. +;; - body-run-once: a body resz eloszor fut le, ha a lista legalabb egy elemu +;; - body-run-next: a body resz masodik, harmadik, stb. beillesztese, haa lista legalabb 2 elemu. +;; Ezekbol az esetekbol kell futtataskor a megfelelo(ket) kivalasztani es behelyettesiteni. +(defmethod control-ast-normalize-step :for [control-ast] + (assert (= 1 (count (:blocks control-ast))) + "Egy ciklusnak csak egy body resze lehet!") + (let [[{:keys [children before after]}] (:blocks control-ast) + children (mapv control-ast-normalize children)] + (-> control-ast + (dissoc :blocks) + (assoc :body-run-none (vec (concat (stack-revert-close before) after)) + :body-run-once (vec children) + :body-run-next (vec (concat (stack-revert-close after) before children)))))) + +(defn control-ast-normalize + "Mélységi bejárással rekurzívan normalizálja az XML fát." + [control-ast] + (cond + (vector? control-ast) (mapv control-ast-normalize control-ast) + (:text control-ast) control-ast + (:open control-ast) control-ast + (:close control-ast) control-ast + (:cmd control-ast) (control-ast-normalize-step control-ast) + (:open+close control-ast) control-ast + :else (throw (ex-info (str "Unexpected object: " (type control-ast)) {:ast control-ast})))) + +(defn find-variables [control-ast] + ;; meg a normalizalas lepes elott + ;; amikor van benne blocks + ;; mapping: {Sym -> Str} + (letfn [(resolve-sym [mapping s] + (assert (map? mapping)) + (assert (symbol? s)) + ;; megprobal egy adott szimbolumot a mapping alapjan rezolvalni. + ;; visszaad egy stringet + (if (.contains (name s) ".") + (let [[p1 p2] (vec (.split (name s) "\\." 2))] + (if-let [pt (mapping (symbol p1))] + (str pt "." p2) + (name s))) + (mapping s (name s)))) + (expr [mapping rpn] + (assert (sequential? rpn)) ;; RPN kifejezes kell legyen + (keep (partial resolve-sym mapping) (filter symbol? rpn))) + ;; iff rpn expr consists of 1 variable only -> resolves that one variable. + (maybe-variable [mapping rpn] + (when (and (= 1 (count rpn)) (symbol? (first rpn))) + (resolve-sym mapping (first rpn)))) + (collect [m xs] (mapcat (partial collect-1 m) xs)) + (collect-1 [mapping x] + (case (:cmd x) + :echo (expr mapping (:expression x)) + + :if (concat (expr mapping (:condition x)) + (collect mapping (apply concat (:blocks x)))) + + :for (let [variable (maybe-variable mapping (:expression x)) + exprs (expr mapping (:expression x)) + mapping (if variable + (assoc mapping (:variable x) (str variable "[]")) + mapping)] + (concat exprs (collect mapping (apply concat (:blocks x))))) + []))] + (distinct (collect {} control-ast)))) + +; (find-variables []) + +(defn process [raw-token-seq] + (let [ast (tokens->ast raw-token-seq) + executable (control-ast-normalize (annotate-environments ast))] + {:variables (find-variables ast) + :dynamic? (boolean (some :cmd executable)) + :executable executable})) + +:OK diff --git a/src/stencil/eval.clj b/src/stencil/eval.clj new file mode 100644 index 00000000..785be523 --- /dev/null +++ b/src/stencil/eval.clj @@ -0,0 +1,40 @@ +(ns stencil.eval + "converts Normalized Control AST -> Evaled token seq" + (:require [stencil.infix :refer [eval-rpn]] + [stencil.types :refer [control?]] + [stencil.util :refer [trace]])) + +(set! *warn-on-reflection* true) + +(defmulti ^:private eval-step (fn [function data item] (or (:cmd item) [:tag (:tag item)]))) + +(defmethod eval-step :default [_ _ item] [item]) + +(defmethod eval-step :if [function data item] + (let [condition (eval-rpn data function (:condition item))] + (trace "Condition %s evaluated to %s" (:condition item) condition) + (if condition + (mapcat (partial eval-step function data) (:then item)) + (mapcat (partial eval-step function data) (:else item))))) + +(defmethod eval-step :echo [function data item] + (let [value (eval-rpn data function (:expression item))] + (trace "Echoing %s as %s" (:expression item) value) + [{:text (if (control? value) value (str value))}])) + +(defmethod eval-step :for [function data item] + (let [items (seq (eval-rpn data function (:expression item)))] + (trace "Loop on %s will repeat %s times" (:expression item) (count items)) + (if (seq items) + (let [datas (map #(assoc data (name (:variable item)) %) items) + bodies (cons (:body-run-once item) (repeat (:body-run-next item)))] + (mapcat (fn [data body] (mapcat (partial eval-step function data) body)) datas bodies)) + (:body-run-none item)))) + +(defn normal-control-ast->evaled-seq [data function items] + (assert (map? data)) + (assert (ifn? function)) + (assert (or (nil? items) (sequential? items))) + (mapcat (partial eval-step function data) items)) + +;; TODO: creating image files for qr code or barcode should take place here diff --git a/src/stencil/functions.clj b/src/stencil/functions.clj new file mode 100644 index 00000000..230940ce --- /dev/null +++ b/src/stencil/functions.clj @@ -0,0 +1,28 @@ +(ns stencil.functions + "Function definitions" + (:require [stencil.types :refer :all])) + +(set! *warn-on-reflection* true) + +(defmulti call-fn (fn [function-name & args-seq] function-name)) + +(defmethod call-fn "range" + ([_ x] (range x)) + ([_ x y] (range x y)) + ([_ x y z] (range x y z))) + +;; finds first nonempy argument +(defmethod call-fn "coalesce" [_ & args-seq] + (some #(when-not (empty? %) %) args-seq)) + +(defmethod call-fn "length" [_ items] (count items)) + +(defmethod call-fn "hideColumn" [_ & args] + (case (first args) + ("cut") (->HideTableColumnMarker :cut) + ("resize-last" "resizeLast" "resize_last") (->HideTableColumnMarker :resize-last) + ("rational") (->HideTableColumnMarker :rational) + ;; default + (->HideTableColumnMarker))) + +(defmethod call-fn "hideRow" [_] (->HideTableRowMarker)) diff --git a/src/stencil/infix.clj b/src/stencil/infix.clj new file mode 100644 index 00000000..2b2cc2ce --- /dev/null +++ b/src/stencil/infix.clj @@ -0,0 +1,280 @@ +(ns stencil.infix + "Parsing and evaluating infix algebraic expressions. + + https://en.wikipedia.org/wiki/Shunting-yard_algorithm" + (:require [stencil.util :refer :all] + [stencil.functions :refer [call-fn]])) + +(set! *warn-on-reflection* true) + +(def ^:dynamic ^:private *calc-vars* {}) + +(defrecord FnCall [fn-name]) + +(def ops + {\+ :plus + \- :minus + \* :times + \/ :divide + \% :mod + \( :open + \) :close + \! :not + \= :eq + \< :lt + \> :gt + \& :and + \| :or}) + +(def ops2 {[\> \=] :gte + [\< \=] :lte + [\! \=] :neq + [\= \=] :eq + [\& \&] :and + [\| \|] :or}) + +(def digits + "A szam literalok igy kezdodnek" + (set "1234567890")) + +(def identifier + "Characters found in an identifier" + (set "qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM_.1234567890")) + +(def operation-tokens + "Operator precedences. + + source: http://en.cppreference.com/w/cpp/language/operator_precedence + " + {:open -999 + ;;;:close -998 + :comma -998 + + :or -21 + :and -20 + + :eq -10 :neq -10, + + :lt -9 :gt -9 :lte -9 :gte -9 + + :plus 2 :minus 2 + :times 3 :divide 4 + :power 5 + :not 6 + :neg 7}) + +(defn- precedence [token] + (get operation-tokens token)) + +(defn read-string-literal + "Beolvas egy string literalt. + Visszaad egy ketelemu vektort, ahol az elso elem a beolvasott string literal, + a masodik elem a maradek karakter szekvencia." + [characters] + (letfn [(read-until [x] + (loop [[c & cs] (next characters) + out ""] + (cond (nil? c) (throw (ex-info "String parse error" + {:reason "Unexpected end of stream"})) + (= c (first "\\")) (recur (next cs) (str out (first cs))) + (= c x) [out cs] + :else (recur cs (str out c)))))] + (case (first characters) + \" (read-until \") ;; programmer quotes + \' (read-until \') ;; programmer quotes + \“ (read-until \”) ;; english double quotes + \‘ (read-until \’) ;; english single quotes + \’ (read-until \’) ;; hungarian single quotes (felidezojel) + \„ (read-until \”)))) ;; hungarian double quotes (macskakorom) + + +(defn read-number "Beolvas egy szamot. + Visszad egy ketelemu vektort, ahol az elso elem a beolvasott szam, + a masodik elem a maradek karakter szekvencia. + A beolvasott szam vagy Double vagy Long." + [characters] + (let [content (take-while (set "1234567890._") characters) + content ^String (apply str content) + content (.replaceAll content "_" "") + number (if (some #{\.} content) + (Double/parseDouble content) + (Long/parseLong content))] + [number (drop (count content) characters)])) + +;; TODO: harapni kell, ha ket szam token vagy egymas utan `1 1` +;; TODO harapni kell, ha ket operator token van egymas utan `op op` +;; TODO harapni kell, ha `,)` `)(` `()` `(,` `,,` `,op` `op,` `op)` `(op` `1(` `)2` szerepel! +(defn tokenize + "Az eredeti stringbol token listat csinal." + [original-string] + (loop [[first-char & next-chars :as characters] (str original-string) + tokens []] + (cond + (empty? characters) + tokens + + (contains? #{\space \tab \newline} first-char) + (recur next-chars tokens) + + (contains? #{\, \;} first-char) + (recur next-chars (conj tokens :comma)) + + (contains? ops2 [first-char (first next-chars)]) + (recur (next next-chars) (conj tokens (ops2 [first-char (first next-chars)]))) + + (and (= \- first-char) (or (nil? (peek tokens)) (keyword? (peek tokens)))) + (recur next-chars (conj tokens :neg)) + + (contains? ops first-char) + (recur next-chars (conj tokens (ops first-char))) + + (contains? digits first-char) + (let [[n tail] (read-number characters)] + (recur tail (conj tokens n))) + + (#{\" \' \“ \‘ \’ \„} first-char) + (let [[s tail] (read-string-literal characters)] + (recur tail (conj tokens s))) + + :else + (let [content (apply str (take-while identifier characters))] + (if (seq content) + (let [tail (drop-while #{\space \tab} (drop (count content) characters))] + (if (= \( (first tail)) + (recur (next tail) (conj tokens (->FnCall content))) + (recur tail (conj tokens (symbol content))))) + (throw (ex-info (str "Unexpected character: " first-char) + {:character first-char}))))))) + +;; TODO: harapni kell, ha a vegrehajtas utan az opstack-ban van zarojel! +;; TODO: harapni kell ha illegalis allapot all elo! +(defn tokens->rpn + "Classic Shunting-Yard Algorithm extension to handle vararg fn calls." + [tokens] + (loop [[e0 & next-expr :as expr] tokens ;; bemeneti token lista + opstack () ;; shunting-yard algo verme + result [] ;; a kimeno rpn tokenek listaja + + ;; ha fuggvenyhivas van, ide mentjuk a fuggveny nevet + functions ()] + (cond + (empty? expr) (into result (remove #{:open} opstack)) + + (number? e0) + (recur next-expr opstack (conj result e0) functions) + + (symbol? e0) + (recur next-expr opstack (conj result e0) functions) + + (string? e0) + (recur next-expr opstack (conj result e0) functions) + + (= :open e0) + (recur next-expr (conj opstack :open) result (conj functions nil)) + + (instance? FnCall e0) + (recur next-expr (conj opstack :open) result + (conj functions {:fn (:fn-name e0) + :args (if (= :close (first next-expr)) 0 1)})) + ;; (recur next-expr (conj opstack :fncall) result (conj functions {:fn e0})) + + (= :close e0) + (let [[popped-ops [x & keep-ops]] + (split-with #(and (not= :open %)) opstack)] + (recur next-expr + keep-ops + (into result + (concat + (remove #{:comma} popped-ops) + (some-> functions first vector))) + (next functions))) + + :otherwise + (let [[popped-ops keep-ops] + (split-with #(>= (precedence %) (precedence e0)) opstack)] + (recur next-expr + (conj keep-ops e0) + (into result (remove #{:open :comma} popped-ops)) + (if (= :comma e0) + (if (first functions) + (update-peek functions update :args inc) + (throw (ex-info "Unexpected ',' character!" {}))) + functions)))))) + +(defn reduce-step-dispatch [_ cmd] + (cond (string? cmd) :string + (number? cmd) :number + (symbol? cmd) :symbol + (keyword? cmd) cmd + (map? cmd) FnCall + :else (throw (ex-info (str "Unexpected opcode: " cmd) {:opcode cmd})))) + +(defmulti ^:private reduce-step reduce-step-dispatch) +(defmulti ^:private action-arity (partial reduce-step-dispatch [])) + +(defn validate-rpn [rpn] + (let [steps (map #(- 1 (action-arity %)) rpn)] + (if (or (not-every? pos? (reductions + steps)) (not (= 1 (reduce + steps)))) + (throw (ex-info (str "Wrong tokens, unsupported arities" rpn " " (vec steps)) {:rpn rpn})) + rpn))) + +(defmethod call-fn :default [fn-name & args-seq] + (if-let [default-fn (::functions *calc-vars*)] + (default-fn fn-name args-seq) + (throw (new IllegalArgumentException (str "Unknown function: " fn-name))))) + +(defmethod action-arity FnCall [{:keys [args]}] args) + +(defmethod reduce-step FnCall [stack {:keys [fn args]}] + (try + (let [[ops new-stack] (split-at args stack) + ops (reverse ops) + result (apply call-fn fn ops)] + (conj new-stack result)) + (catch clojure.lang.ArityException e + (throw (ex-info (str "Wrong arity: " (.getMessage e)) + {:fn fn :expected args :got (count ops) :ops (vec ops)}))))) + +(defmacro def-reduce-step [cmd args body] + (assert (keyword? cmd)) + (assert (every? symbol? args)) + `(do (defmethod action-arity ~cmd [_#] ~(count args)) + (defmethod reduce-step ~cmd [[~@args ~'& stack#] action#] + (let [~'+action+ action#] (conj stack# ~body))))) + +(def-reduce-step :string [] +action+) +(def-reduce-step :number [] +action+) +(def-reduce-step :symbol [] (get-in *calc-vars* (vec (.split (name +action+) "\\.")))) + +(def-reduce-step :neg [s0] (- s0)) +(def-reduce-step :times [s0 s1] (* s0 s1)) +(def-reduce-step :divide [s0 s1] (/ s1 s0)) +(def-reduce-step :plus [s0 s1] (+ s0 s1)) +(def-reduce-step :minus [s0 s1] (- s1 s0)) +(def-reduce-step :eq [a b] (= a b)) +(def-reduce-step :or [a b] (or a b)) +(def-reduce-step :not [b] (not b)) +(def-reduce-step :and [a b] (and a b)) +(def-reduce-step :neq [a b] (not= a b)) +(def-reduce-step :mod [s0 s1] (mod s0 s1)) +(def-reduce-step :lt [s0 s1] (< s1 s0)) +(def-reduce-step :lte [s0 s1] (<= s1 s0)) +(def-reduce-step :gt [s0 s1] (> s1 s0)) +(def-reduce-step :gte [s0 s1] (>= s1 s0)) +(def-reduce-step :power [s0 s1] (Math/pow s0 s1)) + +(defn eval-rpn + ([bindings default-function tokens] + (assert (ifn? default-function)) + (eval-rpn (assoc bindings ::functions default-function) tokens)) + ([bindings tokens] + (assert (map? bindings)) + (assert (seq tokens)) + (binding [*calc-vars* bindings] + (let [result (reduce reduce-step () tokens)] + (assert (= 1 (count result))) + (first result))))) + +(def parse (comp validate-rpn tokens->rpn tokenize)) + +:OK diff --git a/src/stencil/merger.clj b/src/stencil/merger.clj new file mode 100644 index 00000000..ea1270c4 --- /dev/null +++ b/src/stencil/merger.clj @@ -0,0 +1,123 @@ +(ns stencil.merger + "Token listaban a text tokenekbol kiszedi a parancsokat es action tokenekbe teszi." + (:require [clojure.test :refer [deftest testing is are]] + [stencil + [cleanup :refer :all] + [types :refer :all] + [util :refer [prefixes suffixes]]])) + +(set! *warn-on-reflection* true) + +(defn peek-next-text [tokens] + "Returns a lazy seq of text content characters from the token list." + ((fn f [stack tokens] + (when-let [[t & ts] (seq tokens)] + (if-let [text (:text t)] + (concat (for [[t & trs] (suffixes text)] + {:char t + :stack stack + :text-rest trs + :rest ts}) + (lazy-seq (f stack ts))) + (recur (cons t stack) ts)))) + nil tokens)) + +(defn find-first-code [^String s] + (assert (string? s)) + (let [ind (.indexOf s (str open-tag))] + (when-not (neg? ind) + (let [str-before (.substring s 0 ind) + after-idx (.indexOf s (str close-tag))] + (if (neg? after-idx) + (cond-> {:action-part (.substring s (+ ind (count open-tag)))} + (not (zero? ind)) (assoc :before (.substring s 0 ind))) + (cond-> {:action (.substring s (+ ind (count open-tag)) + after-idx)} + (not (zero? ind)) (assoc :before (.substring s 0 ind)) + (not (= (+ (count close-tag) after-idx) (count s))) + (assoc :after (.substring s (+ (count close-tag) after-idx))))))))) + +(defn text-split-tokens [^String s] + (assert (string? s)) + (loop [s s + output []] + (if-let [x (some-> s find-first-code)] + (if (:action-part x) + {:tokens (if-let [b (:before x)] (conj output {:text b}) output) + :action-part (:action-part x)} + (recur (:after x) + (if (seq (:before x)) + (conj output {:text (:before x)} {:action (:action x)}) + (conj output {:action (:action x)})))) + (if (seq s) + {:tokens (conj output {:text s})} + {:tokens output})))) + +(declare cleanup-runs) + +(defn -find-end-tag [last-chars-count next-token-list] + (assert (int? last-chars-count)) + (assert (pos? last-chars-count)) + (assert (sequential? next-token-list)) + (when (= (drop last-chars-count open-tag) + (take (- (count open-tag) last-chars-count) + (map :char (peek-next-text next-token-list)))) + (nth (peek-next-text next-token-list) + (dec (- (count open-tag) last-chars-count))))) + +(defn -last-chars-count [sts-tokens] + (assert (sequential? sts-tokens)) + (when (:text (last sts-tokens)) + (some #(when (.endsWith + (str (apply str (:text (last sts-tokens)))) (apply str %)) + (count %)) + (prefixes open-tag)))) + +(defn cleanup-runs-1 [token-list] + (assert (sequential? token-list)) + (assert (:text (first token-list))) + ;; feltehetjuk, hogy text token van elol. + (let [sts (text-split-tokens (:text (first token-list)))] + (if (:action-part sts) + ;; Ha van olyan akcio resz, amit elkezdtunk de nem irtunk vegig... + (let [next-token-list (cons {:text (:action-part sts)} + (next token-list)) + + [this that] (split-with #(not= (seq close-tag) + (take (count close-tag) (map :char %))) + (suffixes (peek-next-text next-token-list))) + that (first (nth that (dec (count close-tag)))) + action-content (apply str (map (comp :char first) this))] + (assert (map? that)) ;; TODO: mi van ha nem lezarhato az elem? + (concat + (:tokens sts) + [{:action action-content}] + (reverse (:stack that)) + (if (seq (:text-rest that)) + (lazy-seq (cleanup-runs-1 (cons {:text (apply str (:text-rest that))} (:rest that)))) + (lazy-seq (cleanup-runs (:rest that)))))) + (if-let [last-chars-count (-last-chars-count (:tokens sts))] + (if-let [this (-find-end-tag last-chars-count (next token-list))] + (concat + (butlast (:tokens sts)) + [{:text (apply str (drop-last + last-chars-count + (:text (last (:tokens sts)))))}] + (lazy-seq + (cleanup-runs-1 + (concat + [{:text (str open-tag (apply str (:text-rest this)))}] + (reverse (:stack this)) + (:rest this))))) + (concat (:tokens sts) (cleanup-runs (next token-list)))) + (concat (:tokens sts) (cleanup-runs (next token-list))))))) + +(defn cleanup-runs [token-list] + (when-let [[t & ts] (seq token-list)] + (if (:text t) + (cleanup-runs-1 token-list) + (cons t (lazy-seq (cleanup-runs ts)))))) + +(def map-actions-in-token-list cleanup-runs) + +:OK diff --git a/src/stencil/postprocess/ignored_tag.clj b/src/stencil/postprocess/ignored_tag.clj new file mode 100644 index 00000000..1fe02460 --- /dev/null +++ b/src/stencil/postprocess/ignored_tag.clj @@ -0,0 +1,68 @@ +(ns stencil.postprocess.ignored-tag + "In docx files there might be an Ignored attribute which contains an XML namespace alias list. + The contents of this attribute must be a valid ns alias list on the output document too!" + (:require [clojure.data.xml.pu-map :as pu-map] + [clojure.string :as s])) + +(def ^:private ignorable-tag :xmlns.http%3A%2F%2Fschemas.openxmlformats.org%2Fmarkup-compatibility%2F2006/Ignorable) +(def ^:private choice-tag :xmlns.http%3A%2F%2Fschemas.openxmlformats.org%2Fmarkup-compatibility%2F2006/Choice) + +;; like clojure.walk/postwalk but keeps metadata and calls fn only on nodes +(defn- postwalk-xml [f xml-tree] + (if (map? xml-tree) + (f (update xml-tree :content (partial mapv (partial postwalk-xml f)))) + xml-tree)) + +(defn- map-str [f s] (s/join " " (keep f (s/split s #"\s+")))) + +(defn- gen-alias [] (name (gensym "xml"))) + +(defn- update-if-present [m path f] (if (get-in m path) (update-in m path f) m)) + +(defn- update-choice-requires + "Updates the Requires attribute of a Choice tag with the fn" + [elem f] + (assert (ifn? f)) + (if (= (:tag elem) choice-tag) + (update-in elem [:attrs :Requires] f) + elem)) + +(defn- with-pu [object pu-map] + (assert (map? pu-map)) + (assert (:tag object)) + (with-meta object + {:clojure.data.xml/nss + (apply pu-map/assoc pu-map/EMPTY (interleave (vals pu-map) (keys pu-map)))})) + +;; first call this +(defn map-ignored-attr + "Replaces values in ignorable-tag and requires-tag attributes to + the namespace names they are aliased by." + [xml-tree] + (postwalk-xml + (fn [form] + (let [p->url (get-in (meta form) [:clojure.data.xml/nss :p->u])] + (-> form + (update-if-present [:attrs ignorable-tag] (partial map-str p->url)) + (update-choice-requires (partial map-str p->url))))) + xml-tree)) + +;; last call this +(defn unmap-ignored-attr + "Walks XML tree and replaces xml namespaces with aliases. + Call just before serializing the XML tree." + [xml-tree] + (let [found (volatile! {}) ;; url -> alias mapping + find! (fn [uri] + (or (get @found uri) + (get (vswap! found assoc uri (gen-alias)) uri)))] + (with-pu + (postwalk-xml + (fn [form] + (-> form + (update-if-present [:attrs ignorable-tag] (partial map-str find!)) + (update-choice-requires (partial map-str find!)))) + xml-tree) + @found))) + +:OK diff --git a/src/stencil/postprocess/table.clj b/src/stencil/postprocess/table.clj new file mode 100644 index 00000000..37029493 --- /dev/null +++ b/src/stencil/postprocess/table.clj @@ -0,0 +1,334 @@ +(ns stencil.postprocess.table + "XML fa utofeldolgozasat vegzo kod." + (:require [clojure.zip :as zip] + [stencil.types :refer :all] + [stencil.util :refer :all])) + +(set! *warn-on-reflection* true) + +;; az ennel keskenyebb oszlopokat kidobjuk! +(def min-col-width 20) + +(def ooxml-val :xmlns.http%3A%2F%2Fschemas.openxmlformats.org%2Fwordprocessingml%2F2006%2Fmain/val) +(def ooxml-w :xmlns.http%3A%2F%2Fschemas.openxmlformats.org%2Fwordprocessingml%2F2006%2Fmain/w) + +(defn- loc-cell? [loc] (some-> loc zip/node :tag name #{"tc" "td" "th"})) +(defn- loc-row? [loc] (some-> loc zip/node :tag name #{"tr"})) +(defn- loc-table? [loc] (some-> loc zip/node :tag name #{"tbl" "table"})) + +(defn- find-first-in-tree [pred tree] + (assert (zipper? tree)) + (assert (fn? pred)) + (find-first (comp pred zip/node) (take-while (complement zip/end?) (iterate zip/next tree)))) + +(defn- find-last-child [pred tree] + (assert (zipper? tree)) + (assert (fn? pred)) + (last (filter (comp pred zip/node) (take-while some? (iterate zip/right (zip/down tree)))))) + +(defn- first-right-sibling + "Finds first right sibling that matches the predicate." + [pred loc] (find-first pred (iterations zip/right loc))) + +(defn- first-parent + "Finds closest parent element that matches the predicate." + [pred loc] + (assert (ifn? pred)) + (assert (zipper? loc)) + (find-first pred (iterations zip/up loc))) + +(defn- find-enclosing-cell [loc] (first-parent loc-cell? loc)) +(defn- find-enclosing-row [loc] (first-parent loc-row? loc)) +(defn- find-enclosing-table [loc] (first-parent loc-table? loc)) + +(defn- find-closest-row-right [loc] (first-right-sibling loc-row? loc)) +(defn- find-closest-cell-right [loc] (first-right-sibling loc-cell? loc)) + +(defn- goto-nth-sibling-cell [n loc] + (assert (int? n)) + (assert (zipper? loc)) + (nth (filter loc-cell? (iterations zip/right (zip/leftmost loc))) n)) + +(defn- find-first-child [pred loc] + (assert (ifn? pred)) + (assert (zipper? loc)) + (find-first (comp pred zip/node) (take-while some? (iterations zip/right (zip/down loc))))) + +(defn- ensure-child [tag-name loc] + (assert string? tag-name) + (assert (zipper? loc)) + (or (find-first-child #(and (map? %) (#{tag-name} (name (:tag %)))) loc) + (zip/next (zip/insert-child loc {:tag (keyword tag-name) :content []})))) + +;; finds first child with given tag name +(defn- child-of-tag [tag-name loc] + (assert (zipper? loc)) + (assert (string? tag-name)) + (find-first-child #(some-> % :tag name (= tag-name)) loc)) + +(defn- cell-width + "Az aktualis table cella logikai szelesseget adja vissza. Alapertelmezetten 1." + [loc] + (assert (zipper? loc)) + (assert (loc-cell? loc)) + (let [cell-loc (find-enclosing-cell loc) + cell (zip/node cell-loc)] + (case (name (:tag cell)) + ;; html + ("td" "th") (-> cell :attrs :colspan ->int (or 1)) + + ;; ooxml + "tc" (or (some->> loc (child-of-tag "tcPr") (child-of-tag "gridSpan") zip/node :attrs ooxml-val ->int) 1)))) + +(defn shrink-column + "Decreases logical width of current TD cell." + [col-loc shrink-amount] + (assert (zipper? col-loc)) + (assert (loc-cell? col-loc)) + (assert (pos? shrink-amount)) + (assert (integer? shrink-amount)) + (let [old-width (cell-width col-loc)] + (assert (< shrink-amount old-width)) + (case (name (:tag (zip/node col-loc))) + "td" (zip/edit col-loc update-in [:attrs :colspan] - shrink-amount) + ("th" "tc") (-> (->> col-loc (child-of-tag "tcPr") (child-of-tag "gridSpan")) + (zip/edit update-in [:attrs ooxml-val] #(str (- (->int %) shrink-amount))) (zip/up) (zip/up))))) + +(defn- current-column-indices + "Visszaadja egy halmazban, hogy hanyadik oszlop(ok)ban vagyunk benne eppen." + [loc] + (assert (zipper? loc)) + (let [cell (find-enclosing-cell loc) + current-cell-width (cell-width cell) + cells-before (filter loc-cell? (next (iterations zip/left cell))) + before-sum (reduce + (map cell-width cells-before))] + (set (for [i (range current-cell-width)] (+ before-sum i))))) + +(defn remove-columns + "Egy adott row alatt az adott sorszamu oszlopokat eltavolitja. + Figyelembe veszi a cellak COLSPAN ertekeit, tehat ha egy cella + tobb oszlopot is elfoglal, akkor elobb csak keskenyiti es csak akkor + tavolitja el, ha a szelessege nulla lenne. + + Visszater a row lokatorral." + [row-loc removable-columns column-resize-strategy] + (assert (zipper? row-loc) "Elso parameter zipper legyen!") + (assert (seq removable-columns)) + (assert (contains? column-resize-modes column-resize-strategy)) + (let [row-loc (find-enclosing-row row-loc) + removable-columns (set removable-columns)] + (assert (some? row-loc) "Nem cell-ben vagyunk!") + (assert (seq removable-columns) "Melyik oszlopokat tavolitsuk el?") + + (loop [current-loc (goto-nth-sibling-cell 0 (zip/down row-loc)) + current-idx (int 0)] + (let [column-width (cell-width current-loc) + col-indices (for [i (range column-width)] (+ current-idx i)) + last-column? (nil? (find-closest-cell-right (zip/right current-loc))) + shrink-by (count (filter removable-columns col-indices))] + (if last-column? + (if (pos? shrink-by) + (if (>= shrink-by column-width) + (find-enclosing-row (zip/remove current-loc)) + (find-enclosing-row (shrink-column current-loc shrink-by))) + (find-enclosing-row current-loc)) + (if (pos? shrink-by) + (if (>= shrink-by column-width) + (recur (find-closest-cell-right (zip/next (zip/remove current-loc))) + (int (+ current-idx column-width))) + (recur (find-closest-cell-right (zip/right (shrink-column current-loc shrink-by))) + (int (+ current-idx column-width)))) + (recur (find-closest-cell-right (zip/right current-loc)) + (int (+ current-idx column-width))))))))) + +(defn- map-children [f loc] + (assert (fn? f)) + (assert (zipper? loc)) + (zip/edit loc update :content (partial map f))) + +(defn map-each-rows [f table & colls] + (assert (fn? f)) + (assert (loc-table? table)) + (if-let [first-row (find-closest-row-right (zip/down table))] + (loop [current-row first-row + colls colls] + (let [fixed-row (apply f current-row (map first colls))] + (if-let [next-row (some-> fixed-row zip/right find-closest-row-right)] + (recur next-row (map next colls)) + (find-enclosing-table fixed-row)))) + table)) + +(defn remove-children-at-indices [loc indices] + (assert (zipper? loc)) + (assert (set? indices)) + (zip/edit loc update :content + (partial keep-indexed (fn [idx child] (when-not (contains? indices idx) child))))) + +(defn calc-column-widths [original-widths expected-total strategy] + (assert (seq original-widths)) + (assert (number? expected-total)) + (case strategy + :cut + (do ; (assert (= expected-total (reduce + original-widths))) + original-widths) + + :rational + (let [original-total (reduce + original-widths)] + (for [w original-widths] (* expected-total (/ w original-total)))) + + :resize-last + (concat (butlast original-widths) + [(reduce - expected-total (butlast original-widths))]))) + +(defn map-children-filtered [filter-fn map-fn loc & args] + (zip/edit loc update :content + (fn [content] + (loop [out [], content content, args args] + (if-let [[head tail] (seq content)] + (if (filter-fn head) + (recur (conj out (apply map-fn head (map first args))) (next content) (map next args)) + (recur (conj out head) (next content) args)) + out))))) + +(defn table-resize-widths + "Elavolitja a table grid-bol anem haznalatos oszlopokat." + [table-loc column-resize-strategy removed-column-indices] + (assert (loc-table? table-loc)) + (assert (keyword? column-resize-strategy)) + (assert (set? removed-column-indices)) + (let [find-grid-loc (fn [loc] (find-first-in-tree (every-pred map? #(some-> % :tag name (#{"tblGrid"}))) loc)) + total-width (fn [table-loc] + (assert (zipper? table-loc)) + (some->> table-loc + find-grid-loc + (zip/children) + (keep (comp ->int ooxml-w :attrs)) + (reduce +))) + + ;; egy sor cellainak az egyedi szelesseget is beleirja magatol + fix-row-widths (fn [grid-widths row] + (assert (zipper? row)) + (assert (every? number? grid-widths)) + (loop [cell (some-> row zip/down find-closest-cell-right) + parent row + grid-widths grid-widths] + (if-not cell + parent + (-> (->> cell (ensure-child "tcPr") (ensure-child "tcW")) + (zip/edit assoc-in [:attrs ooxml-w] (str (reduce + (take (cell-width cell) grid-widths)))) + (zip/up) (zip/up) + (as-> * (recur (some-> * zip/right find-closest-cell-right) + (zip/up *) (drop (cell-width cell) grid-widths))))))) + + fix-table-cells-widths (fn [table-loc grid-widths] + (assert (sequential? grid-widths)) + (assert (loc-table? table-loc)) + (map-each-rows (partial fix-row-widths grid-widths) table-loc)) + + ;; a tablazat teljes szelesseget beleirja a grid szelessegek szummajakent + fix-table-width (fn [table-loc] + (assert (zipper? table-loc)) + (-> (some->> table-loc (child-of-tag "tblPr") (child-of-tag "tblW")) + (some-> (zip/edit assoc-in [:attrs ooxml-w] (str (total-width table-loc))) + (find-enclosing-table)) + (or table-loc)))] + (if-let [grid-loc (find-grid-loc table-loc)] + (let [result-table (find-enclosing-table (remove-children-at-indices grid-loc removed-column-indices))] + (if-let [widths (->> result-table find-grid-loc zip/children (keep (comp ->int ooxml-w :attrs)) seq)] + (let [new-widths (calc-column-widths widths (total-width grid-loc) column-resize-strategy)] + (-> (find-grid-loc result-table) + (zip/edit update :content (partial map (fn [w cell] (assoc-in cell [:attrs ooxml-w] (str (int w)))) new-widths)) + (find-enclosing-table) + (fix-table-width) + (fix-table-cells-widths new-widths))) + result-table)) + table-loc))) + +;; visszaadja soronkent a jobboldali margo objektumot +(defn get-right-borders [original-start-loc] + (for [row (zip/children (find-enclosing-table original-start-loc)) + :when (and (map? row) (#{"tr"} (name (:tag row)))) + :let [last-of-tag (fn [tag xs] (last (filter #(and (map? %) (some-> % :tag name #{tag})) (:content xs))))]] + (some->> row (last-of-tag "tc") (last-of-tag "tcPr") (last-of-tag "tcBorders") (last-of-tag "right")))) + +(defn- table-set-right-borders + "Ha egy tablazat utolso oszlopat tavolitottuk el, akkor az utolso elotti oszlop cellaibol a border-right ertekeket + at kell masolni az utolso oszlop cellaiba" + [table-loc right-borders] + (assert (sequential? right-borders)) + (map-each-rows + (fn [row border] + (if border + (if-let [last-col (find-last-child #(and (map? %) (some-> % :tag name #{"tc"})) row)] + (-> last-col + (->> (ensure-child "tcPr") (ensure-child "tcBorders") (ensure-child "right")) + (zip/replace border) + (find-enclosing-row)) + row) + row)) + (find-enclosing-table table-loc) right-borders)) + +(defn- remove-current-column + "A jelenlegi csomoponthoz tartozo oszlopot eltavolitja a tablazatbol. + Visszater a gyoker elemmel." + [start-loc column-resize-strategy] + (let [column-indices (current-column-indices start-loc) + table (find-enclosing-table start-loc) + right-borders (get-right-borders table) + column-last? (nil? (find-closest-cell-right (zip/right (find-enclosing-cell start-loc))))] + (-> (map-each-rows #(remove-columns % column-indices column-resize-strategy) table) + (find-enclosing-table) + (table-resize-widths column-resize-strategy column-indices) + (cond-> column-last? (table-set-right-borders right-borders)) + (zip/root)))) + +;; TODO: handle rowspan property! +(defn- remove-current-row [start] + (-> start (find-enclosing-row) (zip/remove) (zip/root))) + +(defn remove-columns-by-markers-1 + "Megkeresi az elso HideTableColumnMarkert es a tablazatbol a hozza tartozo + oszlopot kitorli. Visszaadja az XML fat." + [xml-tree] + (if-let [marker (find-first-in-tree hide-table-column-marker? (xml-zip xml-tree))] + (let [resize-strategy (:columns-resize (zip/node marker))] + (remove-current-column marker resize-strategy)) + xml-tree)) + +(defn remove-rows-by-markers-1 + "Megkeresi az elso HideTableRowMarkert es a tablazatbol a hozza tartozo + sort kitorli. Visszaadja az XML fat." + [xml-tree] + (if-let [marker (find-first-in-tree hide-table-row-marker? (xml-zip xml-tree))] + (remove-current-row marker) + xml-tree)) + +(defn remove-table-thin-columns-1 + "Ha a tablazatban van olyan oszlop, amely szelessege nagyon kicsi, az egesz oszlopot eltavolitja." + [xml-tree] + ;; Ha talalunk olyan gridCol oszlopot, ami nagyon kicsi + (if-let [loc (find-first-in-tree #(and (map? %) + (some-> % :tag name (#{"gridCol"})) + (some-> % :attrs ooxml-w ->int (< min-col-width))) (xml-zip xml-tree))] + (let [col-idx (count (filter #(some-> % zip/node :tag) (next (iterations zip/left loc)))) + table-loc (find-enclosing-table (zip/remove loc))] + (zip/root (map-each-rows #(remove-columns % #{col-idx} :rational) table-loc))) + xml-tree)) + +(defn remove-empty-table-rows-1 [xml-tree] + ;; TODO: implement this + xml-tree) + +(defn remove-empty-tables-1 [xml-tree] + ;; TODO: implement this + xml-tree) + +(defn fix-tables [xml-tree] + (->> xml-tree + (fixpt remove-table-thin-columns-1) + (fixpt remove-columns-by-markers-1) + (fixpt remove-rows-by-markers-1) + (fixpt remove-empty-table-rows-1) + (fixpt remove-empty-tables-1))) + +:ok diff --git a/src/stencil/process.clj b/src/stencil/process.clj new file mode 100644 index 00000000..6aecbcc2 --- /dev/null +++ b/src/stencil/process.clj @@ -0,0 +1,117 @@ +(ns stencil.process + "A konvertalas folyamat osszefogasa" + (:gen-class) + (:import [java.io File PipedInputStream PipedOutputStream InputStream] + [java.util.zip ZipEntry ZipOutputStream] + [io.github.erdos.stencil.impl FileHelper ZipHelper]) + (:require [clojure.data.xml :as xml] + [clojure.data.xml.pu-map :as pu-map] + [clojure.java.io :as io] + [clojure.string :as s] + [stencil.postprocess.ignored-tag :as ignored-tag] + [stencil + [tokenizer :as tokenizer] + [cleanup :as cleanup] + [eval :as eval] + [tree-postprocess :as tree-postprocess]])) + +(set! *warn-on-reflection* true) + +(defn- ->executable [readable] + (with-open [r (io/reader readable)] + (cleanup/process (tokenizer/parse-to-tokens-seq r)))) + +(defmulti prepare-template + ;; extension: template file name extension + ;; stream: template file contents + (fn [extension stream] (some-> extension name .trim .toLowerCase keyword))) + +(defmethod prepare-template :default [ext _] + (throw (ex-info (format "Unrecognized extension: '%s'" ext) {:extension ext}))) + +(defmethod prepare-template :xml [_ stream] + (let [m (->executable stream)] + {:variables (:variables m) + :type :xml + :executable (:executable m)})) + +(defmethod prepare-template :docx [suffix ^InputStream stream] + (assert (some? suffix)) + (assert (instance? InputStream stream)) + (let [zip-dir (FileHelper/createNonexistentTempFile "stencil-" (str suffix ".zip.contents"))] + (with-open [zip-stream stream] ;; FIXME: maybe not deleted immediately + (ZipHelper/unzipStreamIntoDirectory zip-stream zip-dir)) + (let [xml-files (for [w (.list (File. zip-dir "word")) + :when (.endsWith (str w) ".xml")] + (str "word/" w)) + execs (zipmap xml-files (map #(->executable (File. zip-dir (str %))) xml-files))] + ;; TODO: maybe make it smarter by loading only important xml files + ;; such as document.xml and footers/headers + {:zip-dir zip-dir + :type :docx + :variables (set (mapcat :variables (vals execs))) + :exec-files (into {} (for [[k v] execs + :when (:dynamic? v)] + [k (:executable v)]))}))) + + +(defn- run-executable-and-return-writer + "Returns a function that writes output to its output-stream parameter" + [executable function data] + (let [result (-> (eval/normal-control-ast->evaled-seq data function executable) + (tokenizer/tokens-seq->document) + (tree-postprocess/postprocess) + (ignored-tag/unmap-ignored-attr))] + (fn [output-stream] + (let [writer (io/writer output-stream)] + (xml/emit result writer) + (.flush writer))))) + +(defmulti do-eval-stream (comp :type :template)) + +(defmethod do-eval-stream :docx [{:keys [template data function]}] + (assert (:zip-dir template)) + (assert (:exec-files template)) + (let [data (into {} data) + {:keys [zip-dir exec-files]} template + source-dir (io/file zip-dir) + pp (.toPath source-dir) + outstream (new PipedOutputStream) + input-stream (new PipedInputStream outstream) + executed-files (into {} + (for [[rel-path executable] exec-files] + [rel-path (run-executable-and-return-writer executable function data)]))] + (future + (try + (with-open [zipstream (new ZipOutputStream outstream)] + (doseq [file (file-seq source-dir) + :when (not (.isDirectory ^File file)) + :let [path (.toPath ^File file) + rel-path (str (.relativize pp path)) + ze (new ZipEntry rel-path)]] + (.putNextEntry zipstream ze) + (if-let [writer (get executed-files rel-path)] + (writer zipstream) + (java.nio.file.Files/copy path zipstream)) + (.closeEntry zipstream))) + (catch Throwable e + (println "Zipping exception: " e)))) + {:stream input-stream + :format :docx})) + +(defmethod do-eval-stream :xml [{:keys [template data function] :as input}] + (assert (:executable template)) + (let [data (into {} data) + executable (:executable template) + out-stream (new PipedOutputStream) + input-stream (new PipedInputStream out-stream) + writer (run-executable-and-return-writer executable function data)] + (future + ;; TODO: itt hogyan kezeljunk hibat? + (try + (with-open [out-stream out-stream] + (writer out-stream)) + (catch Throwable e + (println "Evaling exception: " e)))) + {:stream input-stream + :format :xml})) diff --git a/src/stencil/tokenizer.clj b/src/stencil/tokenizer.clj new file mode 100644 index 00000000..9dca79bd --- /dev/null +++ b/src/stencil/tokenizer.clj @@ -0,0 +1,105 @@ +(ns stencil.tokenizer + "Fog egy XML dokumentumot es tokenekre bontja" + (:require [clojure.data.xml :as xml] + [clojure.string :as s] + [stencil.postprocess.ignored-tag :as ignored-tag] + [stencil.infix :as infix] + [stencil.types :refer :all] + [stencil.merger :refer [map-actions-in-token-list]] + [stencil.util :refer :all])) + +(set! *warn-on-reflection* true) + +(defn text->cmd [^String text] + (assert (string? text)) + (let [text (.trim text)] + (cond + (#{"end" "endfor" "endif"} text) {:cmd :end} + (= text "else") {:cmd :else} + + (.startsWith text "if ") + {:cmd :if + :condition (infix/parse (.substring text 3))} + + (.startsWith text "unless ") + {:cmd :if + :condition (conj (vec (infix/parse (.substring text 7))) :not)} + + (.startsWith text "for ") + (let [[v expr] (vec (.split (.substring text 4) " in "))] + {:cmd :for + :variable (symbol (.trim ^String v)) + :expression (infix/parse expr)}) + + (.startsWith text "=") + {:cmd :echo + :expression (infix/parse (.substring text 1))} + + ;; `else if` expression + (seq (re-seq #"^else\s+if\s+" text)) + (let [prefix-len (count (first (re-seq #"^else\s+if\s+" text)))] + {:cmd :else-if + :expression (infix/parse (.substring text prefix-len))}) + + :otherwise (throw (ex-info "Unexpected command" {:command text}))))) + +(defn- structure->seq [parsed] + (cond + (string? parsed) [{:text parsed}] ; (split-text-token parsed) + + (map? parsed) (if (seq (:content parsed)) + (concat [(cond-> {:open (:tag parsed)} + (seq (:attrs parsed)) (assoc :attrs (:attrs parsed)))] + (mapcat structure->seq (:content parsed)) + [{:close (:tag parsed)}]) + [(cond-> {:open+close (:tag parsed)} + (seq (:attrs parsed)) (assoc :attrs (:attrs parsed)))]) + :otherwise (throw (ex-info "Unexpected node: " {:node parsed})))) + +(defn- map-token [token] (if (:action token) (text->cmd (:action token)) token)) + +(defn parse-to-tokens-seq + "Parses input and returns a token sequence." + [input] + (let [parsed (xml/parse input)] + (assert (map? parsed)) + (->> parsed + (ignored-tag/map-ignored-attr) + (structure->seq) + (map-actions-in-token-list) + (map map-token)))) + +(def empty-stack '(())) + +(defn- tokens-seq-reducer [stack token] + (cond + (:text token) + (mod-stack-top-conj stack (:text token)) + + (:open+close token) + (let [elem (xml/element (:open+close token) (:attrs token))] + (mod-stack-top-conj stack elem)) + + (:open token) + (let [elem (xml/element (:open token) (:attrs token))] + (-> stack (mod-stack-top-conj elem) (conj []))) + + (:close token) + (let [[s & stack] stack] + ;; TODO: itt megnezhetnenk, hogy a verem tetejen milyen elem volt utoljara es ossze lehetne hasonlitani oket. + (if (seq s) + (mod-stack-top-last stack assoc :content s) + stack)) + + :default + (throw (ex-info (str "Unexpected token!" token) {:token token})))) + +(defn tokens-seq->document + "From token seq builds an XML tree." + [tokens-seq] + (let [result (reduce tokens-seq-reducer empty-stack tokens-seq)] + (assert (= 1 (count result)) (str (pr-str result))) + (assert (= 1 (count (first result)))) + (ffirst result))) + +:OK diff --git a/src/stencil/tree_postprocess.clj b/src/stencil/tree_postprocess.clj new file mode 100644 index 00000000..c754d476 --- /dev/null +++ b/src/stencil/tree_postprocess.clj @@ -0,0 +1,22 @@ +(ns stencil.tree-postprocess + "XML fa utofeldolgozasat vegzo kod." + (:require [clojure.zip :as zip] + [stencil.postprocess.table :refer :all] + [stencil.types :refer :all] + [stencil.util :refer :all])) + +(set! *warn-on-reflection* true) + +(defn deref-delayed-values + "Walks the tree (Depth First) and evaluates DelayedValueMarker objects." + [xml-tree] + (loop [loc (xml-zip xml-tree)] + (if (zip/end? loc) + (zip/root loc) + (if (instance? clojure.lang.IDeref (zip/node loc)) + (recur (zip/next (zip/edit loc deref))) + (recur (zip/next loc)))))) + +(def postprocess (comp deref-delayed-values fix-tables)) + +:ok \ No newline at end of file diff --git a/src/stencil/types.clj b/src/stencil/types.clj new file mode 100644 index 00000000..f778e257 --- /dev/null +++ b/src/stencil/types.clj @@ -0,0 +1,49 @@ +(ns stencil.types + (:require [clojure.pprint]) + (:gen-class)) + +(set! *warn-on-reflection* true) + +(def open-tag "{%") +(def close-tag "%}") + +(defrecord OpenTag [open]) +(defmethod clojure.pprint/simple-dispatch OpenTag [t] (print (str "<" (:open t) ">"))) + +(defrecord CloseTag [close]) +(defmethod clojure.pprint/simple-dispatch CloseTag [t] (print (str ""))) + +(defrecord TextTag [text]) +(defmethod clojure.pprint/simple-dispatch TextTag [t] (print (str "'" (:text t) "'"))) + +(defn ->text [t] (->TextTag t)) +(defn ->close [t] (->CloseTag t)) +(def ->open ->OpenTag) + +;; egyedi parancs objektumok + +;; ez a marker jeloli, hogy egy oszlopot el kell rejteni. +(defrecord HideTableColumnMarker [columns-resize]) + +(def column-resize-modes #{:resize-last :rational :cut}) + +(defn ->HideTableColumnMarker + ([] (HideTableColumnMarker. :cut)) + ([x] (assert (column-resize-modes x)) + (HideTableColumnMarker. x))) + +;; ez a marker jeloli, hogy egy egesz sort el kell rejteni. +(defrecord HideTableRowMarker []) + +(defn hide-table-column-marker? [x] (instance? HideTableColumnMarker x)) +(defn hide-table-row-marker? [x] (instance? HideTableRowMarker x)) + +;; ez a marker valamilyen kesleltetett erteket jelol. +(defrecord DelayedValueMarker [delay-object] + clojure.lang.IDeref + (deref [_] @delay-object)) + +(defmulti control? type) +(defmethod control? :default [_] false) +(defmethod control? HideTableColumnMarker [_] true) +(defmethod control? HideTableRowMarker [_] true) diff --git a/src/stencil/util.clj b/src/stencil/util.clj new file mode 100644 index 00000000..ab80e633 --- /dev/null +++ b/src/stencil/util.clj @@ -0,0 +1,77 @@ +(ns stencil.util + (:require [clojure.zip]) + (:import [io.github.erdos.stencil.exceptions EvalException ParsingException])) + +(set! *warn-on-reflection* true) + +(defn stacks-difference-key + "Mindkey listanak levagja azt a kozos prefixet, amire a kulcsfuggveny ua az erteket adja." + [key-fn stack1 stack2] + (let [cnt (count (take-while true? + (map (fn [a b] (= (key-fn a) (key-fn b))) + (reverse stack1) (reverse stack2))))] + [(take (- (count stack1) cnt) stack1) + (take (- (count stack2) cnt) stack2)])) + +(def stacks-difference + "mindket listanak levagja a kozos szuffixet" + (partial stacks-difference-key identity)) + +(defn mod-stack-top-last + "Egy stack legfelso elemenek legutolso elemet modositja. + Ha nincs elem, IllegalStateException kivetelt dob." + [stack f & args] + (assert (list? stack)) + (assert (ifn? f)) + (conj (rest stack) + (conj (pop (first stack)) + (apply f (peek (first stack)) args)))) + +(defn mod-stack-top-conj + "Egy stack legfelso elemehez hozzafuz egy elemet" + [stack & items] + (conj (rest stack) (apply conj (first stack) items))) + +(defn update-peek + "Egy stack legfelso elemet modositja." + [xs f & args] + (assert (ifn? f)) + (conj (pop xs) (apply f (peek xs) args))) + +(defn fixpt [f x] (let [fx (f x)] (if (= fx x) x (recur f fx)))) +(defn zipper? [loc] (-> loc meta (contains? :zip/branch?))) +(defn iterations [f xs] (take-while some? (iterate f xs))) +(defn find-first [pred xs] (first (filter pred xs))) + +(def xml-zip + "Like clojure.zip/xml-zip but more flexible." + ;; TODO: milyen modon jobb??? + ;; ha a content tomb ures akkor sem leaf node. + (partial clojure.zip/zipper + map? + (comp seq :content) + (fn [node children] (assoc node :content (and children (apply vector children)))))) + +(defn suffixes [xs] (take-while seq (iterate next xs))) +(defn prefixes [xs] (take-while seq (iterate butlast xs))) + +(defn ->int [x] + (cond (nil? x) nil + (string? x) (Integer/parseInt (str x)) + (number? x) (int x) + :default (assert false (format "Unexpected type %s of %s" (type x) (str x))))) + +(def print-trace? false) + +(defmacro trace [msg & details] + (assert (string? msg) "Log message must be a string") + `(when print-trace? + (println (format ~msg ~(for [d details] `(pr-str ~d)))))) + +(defn parsing-exception [expression message] + (ParsingException/fromMessage (str expression) (str message))) + +(defn eval-exception-missing [expression] + (EvalException/fromMissingValue (str expression))) + +:OK diff --git a/test-and-doc.jenkinsfile b/test-and-doc.jenkinsfile new file mode 100644 index 00000000..02a69028 --- /dev/null +++ b/test-and-doc.jenkinsfile @@ -0,0 +1,32 @@ +node { + properties([ + buildDiscarder(logRotator(artifactDaysToKeepStr: '', artifactNumToKeepStr: '', daysToKeepStr: '5', numToKeepStr: '8')), + disableConcurrentBuilds(), + pipelineTriggers([cron('''0 6-18/3 * * *''')])]) + timeout(time: 45) { + stage('Checkout') { + checkout([$class: 'GitSCM', branches: [[name: "*/master"]], doGenerateSubmoduleConfigurations: false, userRemoteConfigs: [[credentialsId: '14733f3b-5278-42d5-b2b4-fed8e9ec2545', url: 'git@github.com:erdos/stencil.git']]]) + } + stage('Compile') { + sh 'lein compile' + } + stage('Java tests') { + sh 'lein pom' + sh 'mvn test -DexcludeGroupIds=stencil.IntegrationTest' + } + stage('Clojure tests') { + sh 'lein test' + } + stage('Generate Javadoc') { + sh './javadoc.sh' + } + stage('Report') { + junit 'target/surefire-reports/*.xml' + publishHTML([allowMissing: false, alwaysLinkToLastBuild: false, keepAll: true, + reportDir: 'javadoc', reportFiles: 'index.html', reportName: 'Javadoc', reportTitles: '']) + } + stage('Archive') { + archiveArtifacts 'javadoc/' + } + } +} diff --git a/test-resources/failures/test-semantic-error-1.docx b/test-resources/failures/test-semantic-error-1.docx new file mode 100644 index 00000000..a9d93409 Binary files /dev/null and b/test-resources/failures/test-semantic-error-1.docx differ diff --git a/test-resources/failures/test-syntax-error-1.docx b/test-resources/failures/test-syntax-error-1.docx new file mode 100644 index 00000000..e12913d3 Binary files /dev/null and b/test-resources/failures/test-syntax-error-1.docx differ diff --git a/test-resources/test-control-conditionals.docx b/test-resources/test-control-conditionals.docx new file mode 100644 index 00000000..9e9c5ef0 Binary files /dev/null and b/test-resources/test-control-conditionals.docx differ diff --git a/test-resources/test-control-loop.docx b/test-resources/test-control-loop.docx new file mode 100644 index 00000000..b5a5ff3a Binary files /dev/null and b/test-resources/test-control-loop.docx differ diff --git a/test-resources/test-table-columns.docx b/test-resources/test-table-columns.docx new file mode 100644 index 00000000..a8b29e2b Binary files /dev/null and b/test-resources/test-table-columns.docx differ diff --git a/test/stencil/cleanup_test.clj b/test/stencil/cleanup_test.clj new file mode 100644 index 00000000..8093c280 --- /dev/null +++ b/test/stencil/cleanup_test.clj @@ -0,0 +1,244 @@ +(ns stencil.cleanup-test + (:require [clojure.test :refer [deftest testing is are]] + [stencil + [cleanup :refer :all] + [types :refer :all]])) + +(deftest stack-revert-close-test + (testing "Egyszeru es ures esetek" + (is (= [] (stack-revert-close []))) + (is (= [] (stack-revert-close nil))) + (is (= [] (stack-revert-close [{:whatever 1} {:else 2}])))) + (testing "A kinyito elemeket be kell csukni" + (is (= [(->CloseTag "a") (->CloseTag "b")] + (stack-revert-close [{:open "b"} {:open "a"}]))))) + +(deftest tokens->ast-test + (testing "Egyszeru esetek" + (are [input output] (= output (tokens->ast input)) + [] [] + + ;; nem valtozott + [{:open 1} {:close 1}] + [{:open 1} {:close 1}]))) + +(deftest tokens->ast-if-test + (testing "felteteles elagazasra egyszeru tesztesetek" + (are [input output] (= output (tokens->ast input)) + ;; if-then-fi + [{:cmd :if :condition 1} + {:text "Then"} + {:cmd :end}] + [{:cmd :if :condition 1 + :blocks [{:children [{:text "Then"}]}]}] + + ;; if-then-else-fi + [{:cmd :if :condition 1} + {:text "Then"} + {:cmd :else} + {:text "Else"} + {:cmd :end}] + [{:cmd :if, :condition 1 + :blocks [{:children [{:text "Then"}]} {:children [{:text "Else"}]}]}]))) + +(deftest normal-ast-test-1 + (is (= (control-ast-normalize + (annotate-environments + [(->OpenTag "html") + (->OpenTag "a") + (->TextTag "Inka") + {:cmd :if + :blocks [{:children [(->TextTag "ikarusz") + (->CloseTag "a") + (->TextTag "bela") + (->OpenTag "b") + (->TextTag "Hello")]} + {:children [(->TextTag "Virag") + (->CloseTag "b") + (->TextTag "Hajdiho!") + (->OpenTag "c") + (->TextTag "Bogar")]}]} + (->TextTag "Kaktusz") + (->CloseTag "c") + (->CloseTag "html")])) + + [(->OpenTag "html") + (->OpenTag "a") + (->TextTag "Inka") + {:cmd :if + :then [(->TextTag "ikarusz") + (->CloseTag "a") + (->TextTag "bela") + (->OpenTag "b") + (->TextTag "Hello") + (->CloseTag "b") + (->OpenTag "c")] + :else [(->CloseTag "a") + (->OpenTag "b") + (->TextTag "Virag") + (->CloseTag "b") + (->TextTag "Hajdiho!") + (->OpenTag "c") + (->TextTag "Bogar")]} + (->TextTag "Kaktusz") + (->CloseTag "c") + (->CloseTag "html")]))) + +(def (->open "i")) (def </i> (->close "i")) +(def (->open "b")) (def </b> (->close "b")) +(def (->open "j")) (def </j> (->close "j")) +(def (->open "a")) (def </a> (->close "a")) + +(deftest normal-ast-test-0 + (testing "Amikor a formazas a THEN blokk kozepeig tart, akkor az ELSE blokk-ba is be kell tenni a lezaro taget." + (is (= (control-ast-normalize + (annotate-environments + [{:cmd :if + :blocks [{:children [(->text "bela") (->text "Hello")]} + {:children [(->text "Virag")]}]} + (->close "b")])) + + [{:cmd :if + :then [(->text "bela") (->text "Hello")] + :else [ (->text "Virag")]} + (->close "b")])))) + +(deftest normal-ast-test-0-deep + (testing "Amikor a formazas a THEN blokk kozepeig tart, akkor az ELSE blokk-ba is be kell tenni a lezaro taget." + (is (= (control-ast-normalize + (annotate-environments + [{:cmd :if + :blocks [{:children [(->text "bela") (->text "Hello") </i>]} + {:children [ (->text "Virag") </j>]}]} + </b>])) + + [{:cmd :if + :then [(->text "bela") (->text "Hello") </i>] + :else [ (->text "Virag") </j>]} + </b>])))) + +(deftest normal-ast-test-condition-only-then + (testing "Az elagazasban eredeetileg csak THEN ag volt de beszurjuk az else agat is." + (is (= (control-ast-normalize + (annotate-environments + [ + {:cmd :if + :blocks [{:children [(->text "bela") (->text "Hello") </i>]}]} + </b> + </a>])) + + [ {:cmd :if + :then [(->text "bela") (->text "Hello") </i>] + :else []} + </b> + </a>])))) + +(defn >>for-loop [& children] {:cmd :for :blocks [{:children (vec children)}]}) + +(deftest test-normal-ast-for-loop-1 + (testing "ismetleses ciklusban" + (is (= (control-ast-normalize + (annotate-environments + [ + (->text "before") + (>>for-loop (->text "inside1") </a> (->text "inside2")) + (->text "after") + </b>])) + [ + (->text "before") + {:cmd :for + :body-run-none [</a> ] + :body-run-once [(->text "inside1") </a> (->text "inside2")] + :body-run-next [</b> (->text "inside1") </a> (->text "inside2")]} + (->text "after") + </b>])))) + +(deftest test-find-variables + (testing "Empty test cases" + (is (= () (find-variables []))) + (is (= () (find-variables nil))) + (is (= () (find-variables [{:open "a"} {:close "a"}])))) + + (testing "Variables from simple subsitutions" + (is (= ["a"] (find-variables [{:cmd :echo :expression '[a 1 :plus]}])))) + + (testing "Variables from if conditions" + (is (= ["a"] (find-variables [{:cmd :if :condition '[a 1 :eq]}])))) + + (testing "Variables from if branches" + (is (= ["x"] (find-variables [{:cmd :if :condition [] + :blocks [[] [{:cmd :echo :expression '[x]}]]}])))) + + (testing "Variables from loop expressions" + (is (= ["xs" "xs[]"] + (find-variables '[{:cmd :for, :variable y, :expression [xs], + :blocks [[{:cmd :echo, :expression [y 1 :plus]}]]}]))) + (is (= ["xs" "xs[]" "xs[][]"] + (find-variables '[{:cmd :for, :variable y, :expression [xs] + :blocks [[{:cmd :for :variable w :expression [y] + :blocks [[{:cmd :echo :expression [1 w :plus]}]]}]]}]))) + (is (= ["xs" "xs[].z.k"] + (find-variables + '[{:cmd :for :variable y :expression [xs] + :blocks [[{:cmd :echo :expression [y.z.k 1 :plus]}]]}])))) + + (testing "Variables from loop bindings and bodies" + ;; TODO: impls this test +) + (testing "Variables from loop bodies (nesting)" + ;; TODO: impls this test +) + + (testing "Nested loop bindings" + ;; TODO: legyen e gymasban ket for ciklus meg egy echo? +)) + +(deftest test-process-if-then-else + (is (= + '[{:open :body} + {:open :a} + {:cmd :if, :condition [a], + :then [{:close :a} + {:open :a} + {:text "THEN"} + {:close :a} + {:open :a, :attrs {:a "three"}}], + :else ()} + {:close :a} + {:close :body}] + + (:executable (process '({:open :body} + {:open :a} + {:cmd :if, :condition [a]} + {:close :a} + {:open :a} + {:text "THEN"} + {:close :a} + {:open :a, :attrs {:a "three"}} + {:cmd :end} + {:close :a} + {:close :body})))))) + +(deftest test-process-if-nested + (is (= + [ + {:cmd :if, :condition '[x.a], + :then [</a> + {:cmd :if, :condition '[x.b], + :then [ {:text "THEN"}] + :else []} + </a>] + :else ()}] + (:executable + (process + [ + ,,{:cmd :if, :condition '[x.a]} + </a> + {:cmd :if, :condition '[x.b]} + + ,,{:text "THEN"} + ,,{:cmd :end} + </a> + {:cmd :end}]))))) + +:OK diff --git a/test/stencil/eval_test.clj b/test/stencil/eval_test.clj new file mode 100644 index 00000000..c4b199a8 --- /dev/null +++ b/test/stencil/eval_test.clj @@ -0,0 +1,76 @@ +(ns stencil.eval-test + (:require [clojure.test :refer [testing is are deftest]] + [stencil.eval :refer :all])) + +(def -text1- {:text "text1"}) + +(def ^:private test-data + {"truthy" true + "falsey" false + "abc" {"def" "Okay"} + "list0" [] + "list1" [1] + "list3" [1 2 3]}) + +(defn- test-eval [input expected] + (is (= expected + (normal-control-ast->evaled-seq test-data {} input)))) + +(deftest test-no-change + (test-eval [{:open "a"} {:close "a"}] + [{:open "a"} {:close "a"}])) + +(deftest test-if + (testing "THEN branch" + (test-eval [-text1- + {:cmd :if :condition '[truthy] + :then [{:text "ok"}] + :else [{:text "err"}]} + -text1-] + [-text1- + {:text "ok"} + -text1-])) + + (testing "ELSE branch" + (test-eval [-text1- + {:cmd :if :condition '[falsey] + :then [{:text "ok"}] + :else [{:text "err"}]}] + [-text1- + {:text "err"}]))) + +(deftest test-echo + (testing "Simple math expression" + (test-eval [{:cmd :echo :expression '[1 2 :plus]}] + [{:text "3"}])) + (testing "Nested data access with path" + (test-eval [{:cmd :echo :expression '[abc.def]}] + [{:text "Okay"}]))) + +(deftest test-for + (testing "loop without any items" + (test-eval [{:cmd :for + :variable "index" + :expression '[list0] + :body-run-once [{:text "xx"}] + :body-run-none [{:text "meh"}] + :body-run-next [{:text "x"}]}] + [{:text "meh"}])) + + (testing "loop with exactly 1 item" + (test-eval [{:cmd :for + :variable "index" + :expression '[list1] + :body-run-once [{:cmd :echo :expression '[index]}] + :body-run-none [{:text "meh"}] + :body-run-next [{:text "x"}]}] + [{:text "1"}])) + + (testing "loop with exactly 3 items" + (test-eval [{:cmd :for + :variable "index" + :expression '[list3] + :body-run-once [{:cmd :echo :expression '[index]}] + :body-run-none [{:text "meh"}] + :body-run-next [{:text "x"} {:cmd :echo :expression '[index]}]}] + [{:text "1"} {:text "x"} {:text "2"} {:text "x"} {:text "3"}]))) diff --git a/test/stencil/ignored_tag_test.clj b/test/stencil/ignored_tag_test.clj new file mode 100644 index 00000000..ac9e234b --- /dev/null +++ b/test/stencil/ignored_tag_test.clj @@ -0,0 +1,90 @@ +(ns stencil.ignored-tag-test + (:require [clojure.data.xml :as xml] + [clojure.java.io :as io] + [clojure.walk :as walk] + [clojure.test :refer [deftest is are testing]] + [stencil.tokenizer :as tokenizer] + [stencil.postprocess.ignored-tag :refer :all])) + +;; make all private maps public! +(let [target (the-ns 'stencil.postprocess.ignored-tag)] + (doseq [[k v] (ns-map target) + :when (and (var? v) (= target (.ns v)))] + (eval `(defn ~(symbol (str "-" k)) [~'& args#] (apply (deref ~v) args#))))) + +(deftest with-pu-test + (testing "The xmlns alias is inserted despite element not being used." + (is (= "" + (xml/emit-str (-with-pu {:tag "Elephant"} {"a" "b"})))))) + +(def test-data-2 + (str "" + "" + "" + "")) + +(defn clear-all-metas [x] (-postwalk-xml (fn [c] (if (meta c) (with-meta c {}) c)) x)) + +(deftest test-ignored-tag-2 + (testing + "The value in the Ignorable tag is mapped so that the namespace it + references doesn't change." + (-> test-data-2 + (java.io.StringReader.) (tokenizer/parse-to-tokens-seq) (tokenizer/tokens-seq->document) + (clear-all-metas) (unmap-ignored-attr) (xml/emit-str) (xml/parse-str) + (as-> * + (let [ignorable-value + (-> * :content first :attrs + :xmlns.http%3A%2F%2Fschemas.openxmlformats.org%2Fmarkup-compatibility%2F2006/Ignorable) + ignorable-ns (-> * meta :clojure.data.xml/nss :p->u (get ignorable-value))] + (is (not (empty? ignorable-value))) + (is (= "ns2" ignorable-ns))))))) + +(def test-data-1 + (str + "" + "" + "")) + +(deftest test-ignored-tag-1 + (-> test-data-1 + (java.io.StringReader.) + + (tokenizer/parse-to-tokens-seq) + (tokenizer/tokens-seq->document) + + (unmap-ignored-attr) + (xml/emit-str) + + ;; TODO: check here for sth. +)) +:ok + +(def test-data-3 + (str + "" + "" + "" + "")) + +(deftest test-ignored-tag-1 + (-> test-data-3 + (java.io.StringReader.) + + (tokenizer/parse-to-tokens-seq) + (tokenizer/tokens-seq->document) + + (unmap-ignored-attr) + (xml/emit-str) + + ;; TODO: check here for sth. +)) diff --git a/test/stencil/infix_test.clj b/test/stencil/infix_test.clj new file mode 100644 index 00000000..f53bb66e --- /dev/null +++ b/test/stencil/infix_test.clj @@ -0,0 +1,153 @@ +(ns stencil.infix-test + (:import [clojure.lang ExceptionInfo]) + (:require [stencil.infix :as infix :refer :all] + [stencil.types :refer [hide-table-column-marker?]] + [clojure.test :refer [deftest testing is are]])) + +(defn- run [xs] (infix/eval-rpn {} (infix/parse xs))) + +(deftest tokenize-test + (testing "simple fn call" + (is (= [(->FnCall "sin") 1 :plus 2 :close] (infix/tokenize " sin(1+2)")))) + + (testing "comma" + (is (= [:open 1 :comma 2 :comma 3 :close] (infix/tokenize " (1,2 ,3) "))))) + +(deftest tokenize-string-literal + (testing "spaces are kept" + (is (= [" "] (infix/tokenize " \" \" "))) + (is (= [" x "] (infix/tokenize " \" x \" ")))) + (testing "escape characters are supported" + (is (= ["aaa\"bbb"] (infix/tokenize "\"aaa\\\"bbb\""))))) + +(deftest tokenize-string-fun-eq + (testing "tricky" + (is (= ["1" :eq #stencil.infix.FnCall{:fn-name "str"} 1 :close] + (infix/tokenize "\"1\" = str(1)"))) + (is (= ["1" 1 {:fn "str" :args 1} :eq] + (infix/parse "\"1\" = str(1)"))))) + +(deftest parse-simple + (testing "Empty" + (is (thrown? ExceptionInfo (infix/parse nil))) + (is (thrown? ExceptionInfo (infix/parse "")))) + + (testing "Simple values" + (is (= [12] (infix/parse " 12 ") (infix/parse "12"))) + (is (= '[ax.y] (infix/parse " ax.y ")))) + + (testing "Simple operations" + (is (= [1 2 :plus] + (infix/parse "1 + 2") + (infix/parse "1+2 ") + (infix/parse "1+2"))) + (is (= [3 2 :times] (infix/parse "3*2")))) + + (testing "Parentheses" + (is (= [3 2 :plus 4 :times] + (infix/parse "(3+2)*4"))) + (is (= [3 2 :plus 4 1 :minus :times] + (infix/parse "(3+2)*(4 - 1)"))))) + +(deftest all-ops-supported + (testing "Minden operatort vegre tudunk hajtani?" + (let [ops (-> #{} + (into (vals infix/ops)) + (into (vals infix/ops2)) + (into (keys infix/operation-tokens)) + (disj :open :close :comma)) + known-ops (set (filter keyword? (keys (methods @#'infix/reduce-step))))] + (is (every? known-ops ops))))) + +(deftest basic-arithmetic + (testing "Alap matek" + + (testing "Egyszeru ertekek" + (is (= 12 (run " 12 ")))) + + (testing "Aritmetika" + (is (= 5 (run "2 +3"))) + (is (= 3 (run "5 - 2"))) + (is (= 6 (run "2 * 3"))) + (is (= 3 (run "6/2")))) + + (testing "Simple op precedence" + (is (= 7 (run "1+2*3"))) + (is (= 7 (run "2*3+1"))) + (is (= 9 (run "(1+2)*3"))) + (is (= 9 (run "3*(1+2)")))) + + (testing "Osszehasonlito muveletek" + (is (true? (run "3 = 3"))) + (is (false? (run "3 == 4"))) + (is (true? (run "3 != 4"))) + (is (false? (run "34 != 34")))) + + (testing "Osszehasonlito muveletek - 2" + (is (true? (run "3 < 4"))) + (is (false? (run "4 < 2"))) + (is (true? (run "3 <= 3"))) + (is (true? (run "34 >= 2")))) + + (testing "Logikai muveletek" + (is (true? (run "3 = 3 && 4 == 4")))) + + :ok)) + +(deftest operator-precedeces + (testing "Operator precedencia" + (is (= 36 (run "2*3+5*6")))) + + (testing "Operator precedencia - tobb tagu" + (is (= 36 (run "2*(1+1+1) + (7 - 2) * 6"))) + (is (= 16 (run "6 + (5)*2"))) + (is (= 22 (run "2*( 1+5) + (5)*2"))) + (is (= 7 (run "(2-1)*2+(7-2)")))) + + (testing "Advanced operator precedences" + ;; https://gist.github.com/PetrGlad/1175640#gistcomment-876889 + (is (= 21 (run "1+((2+3)*4)")))) + + :ok) + +(deftest negativ-szamok + (is (= -123 (run " -123"))) + (is (= -6 (run "-3*2"))) + (is (= -6 (run "2*-3"))) + (is (= -6 (run "2*(-3)"))) + (testing "a minusz jel precedenciaja nagyon magas" + (is (= 20 (run "10/-1*-2 "))))) + +(deftest range-function + (testing "Wrong arity calls" + ;; fontos, hogy nem csak tovabbhivunk a range fuggvenyre, + ;; mert az vegtelen szekvenciat eredmenyezne. + (is (thrown? ExceptionInfo (run "range()"))) + (is (thrown? ExceptionInfo (run "range(1,2,3,4)")))) + + (testing "fn to create a number range" + (is (= [0 1 2 3 4] (run "range(5)"))) + (is (= [1 2 3 4] (run "range (1,5)"))) + (is (= [1 3 5] (run "range( 1, 6, 2)"))))) + +(deftest length-function + (testing "Simple cases" + (is (= 2 (run "length(\"ab\")")))) + (testing "Simple cases" + (is (= true (run "length(\"\")==0"))) + (is (= true (run "1 = length(\" \")"))))) + +(deftest test-colhide-expr + (is (hide-table-column-marker? (run "hideColumn()")))) + +(deftest test-unexpected + (is (thrown? ExceptionInfo (parse "aaaa:bbbb")))) + +;; TODO: fix this test case. +#_ +(deftest tokenize-wrong-tokens + (testing "syntax errors should be thrown" + (are [x] (thrown? ExceptionInfo (infix/parse x)) + "1++2" "1 2 3" "++" "1+" "+ 1" "2)(3" "(23)3" "2,3,4" ",,,,2" ",3" "+,-" "+-2" "2 3 *" "* 3 4"))) + +:ok diff --git a/test/stencil/merger_test.clj b/test/stencil/merger_test.clj new file mode 100644 index 00000000..0f643506 --- /dev/null +++ b/test/stencil/merger_test.clj @@ -0,0 +1,74 @@ +(ns stencil.merger-test + (:require [stencil.merger :refer :all] + [clojure.test :refer [deftest testing is are]])) + +(deftest peek-next-text-test + (testing "Simple case" + (is (= nil (peek-next-text nil))) + (is (= nil (peek-next-text []))) + (is (= nil (peek-next-text [{:open 1} {:open 2} {:close 2}]))) + (is (= '({:char \a, :stack nil, :text-rest (\b), :rest ({:text "cd"})} + {:char \b, :stack nil, :text-rest nil, :rest ({:text "cd"})} + {:char \c, :stack nil, :text-rest (\d), :rest nil} + {:char \d, :stack nil, :text-rest nil, :rest nil}) + (peek-next-text [{:text "ab"} {:text "cd"}]))))) + +(deftest find-first-code-test + (testing "Simple cases" + (are [x res] (is (= res (find-first-code x))) + "asdf{%xy%}gh" {:action "xy" :before "asdf" :after "gh"} + "{%xy%}gh" {:action "xy" :after "gh"} + "asdf{%xy%}" {:action "xy" :before "asdf"} + "{%xy%}" {:action "xy"} + "a{%xy" {:action-part "xy" :before "a"} + "{%xy" {:action-part "xy"}))) + +(deftest text-split-tokens-test + (testing "Simple cases" + (are [x expected] (is (= expected (text-split-tokens x))) + + "a{%a%}b{%d" + {:tokens [{:text "a"} {:action "a"} {:text "b"}] :action-part "d"} + + "{%a%}{%x%}" + {:tokens [{:action "a"} {:action "x"}]} + + "" + {:tokens []}))) + +(deftest cleanup-runs-test + (testing "Simple cases" + (are [x expected] (= expected (cleanup-runs x)) + [{:text "{%1234%}"} {:text "{%a%}{%b%}"}] + [{:action "1234"} {:action "a"} {:action "b"}] + + [{:text "a{%1234%}b{%5678%}c"}] + [{:text "a"} {:action "1234"} {:text "b"} {:action "5678"} {:text "c"}])) + + (testing "Simple embedding" + (are [x expected] (= expected (cleanup-runs x)) + [{:open "A"} {:text "eleje {% a %} kozepe {% b %} vege"}] + '({:open "A"} {:text "eleje "} {:action " a "} {:text " kozepe "} {:action " b "} {:text " vege"}))) + + (testing "Base cases" + (are [x expected] (= expected (cleanup-runs x)) + [{:text "asdf{%123"} {:open "a"} {:text "456%}xyz"}] + [{:text "asdf"} {:action "123456"} {:open "a"} {:text "xyz"}] + + [{:text "asdf{%1"} {:text "23"} {:open "A"} {:text "%"} {:close "A"} {:text "}gh"}] + [{:text "asdf"} {:action "123"} {:open "A"} {:close "A"} {:text "gh"}] + + ;; the first action ends with a character from the closing sequence + [{:text "asdf{%1234%"} {:open "X"} {:text "}ghi"}] + [{:text "asdf"} {:action "1234"} {:open "X"} {:text "ghi"}] + + [{:text "asdf{"} {:open "!"} {:text "%1234"} {:close "!"} {:text "%}"}] + [{:text "asdf"} {:action "1234"} {:open "!"} {:close "!"}] + + [{:text "asdf{%1234"} {:text "56%"} {:text "}ghi"}] + [{:text "asdf"} {:action "123456"} {:text "ghi"}])) + + (testing "Unchanged" + (are [x expected] (= expected (cleanup-runs x)) + [{:text "asdf{"} {:text "{aaa"}] + [{:text "asdf{"} {:text "{aaa"}]))) diff --git a/test/stencil/tokenizer_test.clj b/test/stencil/tokenizer_test.clj new file mode 100644 index 00000000..8a6a2e56 --- /dev/null +++ b/test/stencil/tokenizer_test.clj @@ -0,0 +1,76 @@ +(ns stencil.tokenizer-test + (:require [stencil.tokenizer :as t] + [clojure.test :refer [deftest testing is]])) + +(defn- run [s] + (t/parse-to-tokens-seq (java.io.ByteArrayInputStream. (.getBytes (str s))))) + +(deftest read-tokens-nested + (testing "Read a list of nested tokens" + (is (= (run "") + [{:open :a} + {:open :b} + {:open+close :c} + {:close :b} + {:open+close :d} + {:close :a}])))) + +(deftest read-tokens-attributes-ns + (testing "Namespace support in attributes" + (is (= (run "") + [{:open+close :a + :attrs {:one "1" + :two "2" + :xmlns.http%3A%2F%2Fwww.w3.org%2FXML%2F1998%2Fnamespace/three "3"}}])))) + +(deftest read-tokens-echo + (testing "Simple echo command" + (is (= (run "elotte {%=a%} utana") + [{:open :a} + {:text "elotte "} + {:cmd :echo :expression '(a)} + {:text " utana"} + {:close :a}])))) + +(deftest read-tokens-if-then + (testing "Simple conditional with THEN branch only" + (is (= (run "elotte {% if x%} akkor {% end %} utana") + [{:open :a} + {:text "elotte "} + {:cmd :if :condition '(x)} + {:text " akkor "} + {:cmd :end} + {:text " utana"} + {:close :a}])))) + +(deftest read-tokens-if-then-else + (testing "Simple conditional with THEN+ELSE branches" + (is (= (run "elotte {% if x%} akkor {% else %} egyebkent {% end %} utana") + [{:open :a} + {:text "elotte "} + {:cmd :if :condition '(x)} + {:text " akkor "} + {:cmd :else} + {:text " egyebkent "} + {:cmd :end} + {:text " utana"} + {:close :a}])))) + +(deftest read-tokens-unless-then + (testing "Simple conditional with THEN branch only" + (is (= (run "{%unless x%} akkor {% end %}") + [{:open :a} {:cmd :if :condition '(x :not)} {:text " akkor "} {:cmd :end} {:close :a}])))) + +(deftest read-tokens-unless-then-else + (testing "Simple conditional with THEN branch only" + (is (= (run "{%unless x%} akkor {%else%} egyebkent {%end %}") + [{:open :a} {:cmd :if :condition '(x :not)} {:text " akkor "} {:cmd :else} {:text " egyebkent "} {:cmd :end} {:close :a}])))) + +(deftest read-tokens-if-elif-then-else + (testing "If-elis-then-else branching" + (is (= '({:open :a} {:text "Hello "} {:cmd :if, :condition [x]} {:text "iksz"} + {:cmd :else-if, :expression [y]} {:text "ipszilon"} {:cmd :else} + {:text "egyebkent"} {:cmd :end} {:text " Hola"} {:close :a}) + (run "Hello {%if x%}iksz{%else if y%}ipszilon{%else%}egyebkent{%end%} Hola"))))) + +:OK diff --git a/test/stencil/tree_postprocess_test.clj b/test/stencil/tree_postprocess_test.clj new file mode 100644 index 00000000..12727ac8 --- /dev/null +++ b/test/stencil/tree_postprocess_test.clj @@ -0,0 +1,175 @@ +(ns stencil.tree-postprocess-test + (:require [clojure.zip :as zip] + [stencil.types :refer :all] + [clojure.test :refer [deftest is are testing]] + [stencil.util :refer [xml-zip]] + [stencil.tree-postprocess :refer :all] + [stencil.postprocess.table :refer :all])) + +(defn- table [& contents] {:tag "tbl" :content (vec contents)}) +(defn- cell [& contents] {:tag "tc" :content (vec contents)}) +(defn- row [& contents] {:tag "tr" :content (vec contents)}) +(defn- cell-of-width [width & contents] + (assert (integer? width)) + {:tag "tc" :content (vec (list* {:tag "tcPr" :content [{:tag "gridSpan" :attrs {:xmlns.http%3A%2F%2Fschemas.openxmlformats.org%2Fwordprocessingml%2F2006%2Fmain/val (str width)}}]} contents))}) + +(defn tbl-grid [& vals] + {:tag :tblGrid, :content (for [v vals] {:tag :gridCol :attrs {ooxml-w (str v)}})}) + +(defn cell-width + ([w] {:tag :tcPr, :content [{:tag :tcW :attrs {ooxml-val (str w)}}]}) + ([span w] {:tag :tcPr, :content [{:tag :tcW :attrs {ooxml-w (str w)}} + {:tag :gridSpan :attrs {ooxml-val (str span)}}]})) + +(defn into-hiccup [c] (if (map? c) (vec (list* (keyword (name (:tag c))) (into {} (:attrs c)) (map into-hiccup (:content c)))) c)) + +(deftest test-row-hiding-simple + (testing "Second row is being hidden here." + (is (= (table (row (cell "first")) + (row (cell "third"))) + (postprocess (table (row (cell "first")) + (row (cell "second" (->HideTableRowMarker))) + (row (cell "third")))))))) + +(deftest test-column-merging-simple + (testing "Second column is being hidden here." + (is (= (table (row (cell "x1")) + (row (cell "d1"))) + (postprocess (table (row (cell "x1") (cell (->HideTableColumnMarker))) + (row (cell "d1") (cell "d2")))))))) + +(deftest test-column-merging-joined + (testing "Second column is being hidden here." + (is (= (table (row (cell "x1") (cell "x3")) + (row (cell "d1") (cell-of-width 1 "d2"))) + (postprocess (table (row (cell "x1") (cell (->HideTableColumnMarker) "x2") (cell "x3")) + (row (cell "d1") (cell-of-width 2 "d2")))))))) + +(deftest test-column-merging-super-complex + (testing "Second column is being hidden here." + (is (= + (table + (row (cell "H1") (cell "J1")) + (row (cell-of-width 1 "H2 + I2") (cell "J2")) + (row (cell "H") (cell-of-width 1 "I3 + J3")) + (row (cell "H") (cell "J")) + (row (cell-of-width 2 "F + G + H + I + J"))) + (postprocess + (table + (row (cell-of-width 2 "F1+G1" (->HideTableColumnMarker)) (cell "H1") (cell "I1" (->HideTableColumnMarker)) (cell "J1")) + (row (cell "F2") (cell "G2") (cell-of-width 2 "H2 + I2") (cell "J2")) + (row (cell-of-width 2 "F + G") (cell "H") (cell-of-width 2 "I3 + J3")) + (row (cell "F") (cell "G") (cell "H") (cell "I") (cell "J")) + (row (cell-of-width 5 "F + G + H + I + J")))))))) + +(deftest test-column-merging-super-complex-2 + (testing "Second column is being hidden here." + (is (= + (table + (row (cell "H1") (cell-of-width 2 "J1")) + (row (cell-of-width 1 "H2 + I2") (cell-of-width 2 "J2")) + (row (cell "H") (cell-of-width 2 "I3 + J3")) + (row (cell "H") (cell-of-width 2 "J")) + (row (cell-of-width 3 "F + G + H + I + J"))) + (postprocess + (table + (row (cell-of-width 2 "F1+G1" (->HideTableColumnMarker)) (cell "H1") (cell-of-width 2 "I1" (->HideTableColumnMarker)) (cell-of-width 2 "J1")) + (row (cell "F2") (cell "G2") (cell-of-width 3 "H2 + I2") (cell-of-width 2 "J2")) + (row (cell-of-width 2 "F + G") (cell "H") (cell-of-width 4 "I3 + J3")) + (row (cell "F") (cell "G") (cell "H") (cell-of-width 2 "I") (cell-of-width 2 "J")) + (row (cell-of-width 7 "F + G + H + I + J")))))))) + +(deftest test-column-merging-super-complex-3 + (testing "Second column is being hidden here." + (is (= + (table (row (cell "X1") (cell "X3")) (row (cell "Y1") (cell "Y3"))) + (postprocess (table (row (cell "X1") (cell-of-width 2 "X2" (->HideTableColumnMarker)) (cell "X3")) + (row (cell "Y1") (cell-of-width 2 "Y2") (cell "Y3")))))))) + +(deftest test-preprocess-remove-thin-cols + (testing "There are infinitely thin columns that are being removed." + (is (= + (table + {:tag :tblGrid + :content [{:tag :gridCol :attrs {:xmlns.http%3A%2F%2Fschemas.openxmlformats.org%2Fwordprocessingml%2F2006%2Fmain/w "2000"}} + {:tag :gridCol :attrs {:xmlns.http%3A%2F%2Fschemas.openxmlformats.org%2Fwordprocessingml%2F2006%2Fmain/w "3000"}}]} + (row (cell "X1") (cell "X3")) + (row (cell "Y1") (cell "Y3"))) + (postprocess + (table + {:tag :tblGrid + :content [{:tag :gridCol :attrs {:xmlns.http%3A%2F%2Fschemas.openxmlformats.org%2Fwordprocessingml%2F2006%2Fmain/w "2000"}} + {:tag :gridCol :attrs {:xmlns.http%3A%2F%2Fschemas.openxmlformats.org%2Fwordprocessingml%2F2006%2Fmain/w "4"}} + {:tag :gridCol :attrs {:xmlns.http%3A%2F%2Fschemas.openxmlformats.org%2Fwordprocessingml%2F2006%2Fmain/w "3000"}}]} + (row (cell "X1") (cell "X2") (cell "X3")) + (row (cell "Y1") (cell "Y2") (cell "Y3")))))))) + +;; TODO: ennek az egesz tablaatot is at kellene mereteznie!!!! +(deftest test-column-cut + (testing "We hide second column and expect cells to KEEP size" + (is (= (table (row (cell-of-width 1 "X1") (cell-of-width 2 "X3")) + (row (cell-of-width 1 "Y1") (cell-of-width 2 "Y3"))) + (postprocess + (table (row (cell-of-width 1 "X1") (cell-of-width 3 "X2" (->HideTableColumnMarker :cut)) (cell-of-width 2 "X3")) + (row (cell-of-width 1 "Y1") (cell-of-width 3 "Y2") (cell-of-width 2 "Y3")))))))) + +(deftest resize-cut + (is (= + (table {:tag :tblGrid, + :content [{:tag :gridCol, :attrs {ooxml-w "1000"}} + {:tag :gridCol, :attrs {ooxml-w "2000"}}]} + (row) (row) (row)) + (zip/node + (table-resize-widths + (xml-zip (table (tbl-grid 1000 2000 2600 500) (row) (row) (row))) + :cut + #{2 3}))))) + +(deftest resize-last + (is (= + (table + {:tag :tblGrid, + :content [{:tag :gridCol, :attrs {ooxml-w "1000"}} + {:tag :gridCol, :attrs {ooxml-w "5000"}}]} + (row) (row) (row)) + (zip/node + (table-resize-widths + (xml-zip (table (tbl-grid 1000 2000 2500 500) (row) (row) (row))) + :resize-last + #{2 3}))))) + +(deftest resize-rational + (is (= + (into-hiccup (table {:tag :tblPr :content [{:tag :tblW, :attrs {ooxml-w "6000"}}]} + (tbl-grid 2000 4000) + (row (cell (cell-width 1 2000) "a") + (cell (cell-width 1 4000) "b")) + (row (cell (cell-width 2 6000) "ab")))) + + (into-hiccup (zip/node + (table-resize-widths + (xml-zip (table {:tag :tblPr + :content [{:tag :tblW :attrs {ooxml-w "?"}}]} + (tbl-grid "1000" "2000" "2500" "500") + (row (cell-of-width 1 "a") (cell-of-width 1 "b")) + (row (cell-of-width 2 "ab")))) + + :rational + #{2 3})))))) + +(deftest test-column-hiding-border-right + (let [border-1 {:tag "tcPr" :content [{:tag "tcBorders" :content [{:tag "right" :attrs {:a 1}}]}]} + border-2 {:tag "tcPr" :content [{:tag "tcBorders" :content [{:tag "right" :attrs {:a 2}}]}]}] + (testing "Second column is being hidden here." + (is (= (into-hiccup (table (row (cell border-1 "ALMA")) + (row (cell border-2 "NARANCS")))) + (into-hiccup (postprocess (table (row (cell "ALMA") (cell border-1 (->HideTableColumnMarker) "KORTE")) + (row (cell "NARANCS") (cell border-2 "BARACK")))))))))) + +(deftest resize-rational-2 + (is (= '(nil {:attrs {:x 1}, :tag :right}) + (get-right-borders (xml-zip (table (tbl-grid "1000" "2000" "2500" "500") + (row (cell "a") (cell-of-width 1 "b")) + (row (cell "v") (cell {:tag :tcPr :content [{:tag :tcBorders :content [{:tag :right :attrs {:x 1}}]}]} "dsf")))))))) + +:OK diff --git a/test/stencil/util_test.clj b/test/stencil/util_test.clj new file mode 100644 index 00000000..08757d8c --- /dev/null +++ b/test/stencil/util_test.clj @@ -0,0 +1,41 @@ +(ns stencil.util-test + (:require [clojure.test :refer [deftest testing is]] + [stencil.util :refer :all])) + +(deftest stacks-difference-test + (testing "Empty cases" + (is (= [[] []] (stacks-difference nil nil))) + (is (= [[] []] (stacks-difference () ()))) + (is (= [[] []] (stacks-difference '(:a :b :c) '(:a :b :c))))) + + (testing "simple cases" + (is (= [[:a :b] []] (stacks-difference '(:a :b) ()))) + (is (= [[] [:a :b]] (stacks-difference '() '(:a :b)))) + (is (= [[:a] [:b]] (stacks-difference '(:a :x :y) '(:b :x :y)))) + (is (= [[:a] []] (stacks-difference '(:a :x :y) '(:x :y)))) + (is (= [[] [:b]] (stacks-difference '(:x :y) '(:b :x :y)))))) + +(deftest mod-stack-top-last-test + (testing "Invalid input" + (is (thrown? IllegalStateException (mod-stack-top-last '([]) inc))) + (is (thrown? NullPointerException (mod-stack-top-last '() inc)))) + + (testing "simple cases" + (is (= '([3]) (mod-stack-top-last '([2]) inc))) + (is (= '([1 1 2] [1 1 1]) + (mod-stack-top-last '([1 1 1] [1 1 1]) inc))))) + +(deftest mod-stack-top-conj-test + (testing "empty input" + (is (= '([2]) (mod-stack-top-conj '() 2))) + (is (= '([2]) (mod-stack-top-conj '([]) 2)))) + + (testing "simple cases" + (is (= '([1 2]) (mod-stack-top-conj '([1]) 2))) + (is (= '([1 1 1] [2 2] [3 3]) + (mod-stack-top-conj '([1 1] [2 2] [3 3]) 1))))) + +(deftest update-peek-test + (testing "simple cases" + (is (thrown? IllegalStateException (update-peek [] inc))) + (is (= [1 1 1 2] (update-peek [1 1 1 1] inc))))) diff --git a/test/stencil/various.clj b/test/stencil/various.clj new file mode 100644 index 00000000..62604489 --- /dev/null +++ b/test/stencil/various.clj @@ -0,0 +1,26 @@ +(ns stencil.various + "Not used yet." + (:require [clojure.data.xml.pu-map :as pu-map] + [clojure.string :as s] + [stencil.postprocess.ignored-tag :refer :all])) + +(defn- url-decode [s] (java.net.URLDecoder/decode (str s) "UTF-8")) + +(defn- tag-and-attrs-namespaces [form] + (when (map? form) + (for [x (keep namespace (list* (:tag form) (keys (:attrs form)))) + :when (.startsWith (str x) "xmlns.")] + (url-decode (.substring (str x) 6))))) + +(defn- elem-meta-namespaces [form] + (when (map? form) + (->> form meta :clojure.data.xml/nss :u->ps keys + (remove #{"http://www.w3.org/2000/xmlns/" + "http://www.w3.org/XML/1998/namespace"})))) + +(defn- collect-all-nss [form] + (->> form + (tree-seq map? :content) + (map (juxt tag-and-attrs-namespaces elem-meta-namespaces)) + (flatten) + (into (sorted-set))))