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:
+
+ - Show value under a given key: {%=key_name%}
+ - Show value under a given key in a map under a given key: {%=outer_key.inner_key %}
+
+
+
+ Conditional blocks
+
+ Put a value between conditional blocks to toggle its visibility depending on a value in the data object.
+
+ Examples:
+
+ - if-then branching: {%if condition %}THEN part comes here {%end%}
+ - if-then-else branching: {%if condition %}THEN part comes here {%else%} else part comes
+ here{%end%}
+
+
+
+ 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 "" (:close t) ">")))
+
+(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))))