19 · JXLS to xl3 — the JavaScript alternative
Scenario
Your team renders Excel reports with JXLS
on the JVM, and now needs the same thing in Node.js or the browser — or
you searched "JXLS for JavaScript" and found only 8-year-old wrappers
around node-java. xl3 is the maintained answer: an Excel-to-Excel
template engine where the spreadsheet itself is the template.
This is not a coincidence of feature overlap. xl3's spec absorbed JXLS's ~10 years of edge-case experience item by item — merged data-row cells, named ranges, print areas, outline levels, multi-line text all have dedicated ADRs and conformance fixtures. The operating principle (ADR-0034): borrow JXLS's experience, not its syntax.
The model difference in one table
| JXLS | xl3 | |
|---|---|---|
| Directives live in | Cell comments (jx:each(items="rows" lastCell="D4")) — invisible in the grid | Cell values ({{ @filter [Status] = "Open" }}) — visible, reviewable, diffable |
| Expression language | JEXL (${employee.payment * 1.1}) — a second language to learn | Excel syntax ({{ [Payment] * 1.1 }}, IF, XLOOKUP, SUM) — what template authors already know |
| Data comes from | Java objects bound in code (context.putVar("employees", list)) | A second .xlsx — render(template, data) is a pure function: same inputs, same bytes |
| Block bounds | Explicit lastCell="D4" coordinates | Inferred from {{ ... }} markers (or explicit {{ @block A:D }} when you want it) |
| Escape hatch | Custom Java commands — Turing-complete, unportable | None, by design — the template stays a handover artifact any implementation can render (ADR-0048) |
The consequence: a JXLS template is owned by whoever can edit cell comments and Java bindings — a developer. An xl3 template is owned by whoever can edit a spreadsheet.
Directive mapping
| JXLS | xl3 equivalent | Notes |
|---|---|---|
jx:each(items="rows" var="r" lastCell=…) | A data block — a template row containing {{ [Column] }} markers | No loop declaration at all; the block expands one output row per source row. See Getting started |
${r.name} | {{ [Name] }} | Column reference into the source row |
${r.amount * 1.1} | {{ [Amount] * 1.1 }} | Excel operators, not JEXL |
jx:if(condition=…) on a cell | {{ IF([Renewal] > 10000, "Priority", "Standard") }} | Conditional cells |
jx:if used to drop rows | {{ @filter [Status] = "Open" }} | Multiple @filters AND together |
jx:each with orderBy | {{ @sort [Total] desc }} | |
jx:each with groupBy | {{ @group [Region] }} + {{ @subtotal SUM([Renewal]) }} | Interleaved subtotal rows, N-level nesting — Group and subtotal |
jx:each(direction="RIGHT") | {{ @repeat right 3 }} | |
| Multiple collections | {{ @source Renewals }} per block, {{ @join Customers on Customers[Account] = Renewals[Account] }} | Multi-source + @join |
jx:multisheet | Put the pattern in the sheet name: Region-{{ [Region] }} | Sheet per group; one file per group via output_file_pattern — File per group |
jx:link | {{ HYPERLINK(url, label) }} | ADR-0039 |
jx:params(formulas=…) | Nothing to declare — native Excel formulas in the template are preserved as-is | ADR-0046 |
| SUM over the expanded block | {{ SUM([Renewal]) }} aggregate, or a plain Excel =SUM(...) formula | Aggregates |