跳转到主要内容

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

JXLSxl3
Directives live inCell comments (jx:each(items="rows" lastCell="D4")) — invisible in the gridCell values ({{ @filter [Status] = "Open" }}) — visible, reviewable, diffable
Expression languageJEXL (${employee.payment * 1.1}) — a second language to learnExcel syntax ({{ [Payment] * 1.1 }}, IF, XLOOKUP, SUM) — what template authors already know
Data comes fromJava objects bound in code (context.putVar("employees", list))A second .xlsxrender(template, data) is a pure function: same inputs, same bytes
Block boundsExplicit lastCell="D4" coordinatesInferred from {{ ... }} markers (or explicit {{ @block A:D }} when you want it)
Escape hatchCustom Java commands — Turing-complete, unportableNone, 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

JXLSxl3 equivalentNotes
jx:each(items="rows" var="r" lastCell=…)A data block — a template row containing {{ [Column] }} markersNo 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:multisheetPut the pattern in the sheet name: Region-{{ [Region] }}Sheet per group; one file per group via output_file_patternFile per group
jx:link{{ HYPERLINK(url, label) }}ADR-0039
jx:params(formulas=…)Nothing to declare — native Excel formulas in the template are preserved as-isADR-0046
SUM over the expanded block{{ SUM([Renewal]) }} aggregate, or a plain Excel =SUM(...) formulaAggregates

What intentionally does not carry over

xl3 rejected three JXLS features with recorded reasoning, so the boundary stays a decision rather than a gap:

  • jx:image (data-driven image insertion) — rejected, ADR-0037. Images placed in the template survive rendering; inserting images from data does not fit the browser-safe, deterministic pipeline.
  • jx:updateCell (runtime cell mutation) — rejected, ADR-0042. {{ ... }} substitution already covers the use case without making evaluation order observable.
  • Custom commands (host-language escape hatch) — rejected, ADR-0034. A template that requires your Java/JS helper can't be handed to another team or another implementation.

If your JXLS templates lean on custom commands, that logic moves into the data file (precompute the column in whatever produces the data), not into the template.

The render call, side by side

JXLS (Java):

List<Employee> employees = loadEmployees();
Context context = new Context();
context.putVar("employees", employees);
JxlsHelper.getInstance().processTemplate(templateStream, outStream, context);

xl3 (Node.js or browser):

import { convert } from '@jinyoung4478/xl3';

const outputs = await convert(templateBuffer, dataBuffer);
// outputs: [{ filename: 'renewal-report.xlsx', buffer }, ...]

There is no context object to bind. Everything the render needs is in the two workbooks — which is what makes the output reproducible and the template testable without a host program.

Migration checklist

  1. Move data out of code. Whatever you putVar'd, export it to a sheet (one table per collection). This is usually the only real work.
  2. Delete the comments, write the cells. Each jx:each region becomes a one-row data block of {{ [Column] }} markers; lastCell bounds disappear.
  3. Rewrite JEXL as Excel expressions. ${...} arithmetic and conditionals map 1:1 onto {{ ... }} with IF/operators.
  4. Re-create grouping declaratively. groupBy/orderBy become @group/@sort/@subtotal cells inside the block.
  5. Run it and diff. convert() is deterministic, so a golden-file test (same inputs → same bytes) replaces visual spot-checking.

Try the migration on one template in the browser — no install — at xl3.io/try.

See also: ADR-0048 (the final JXLS boundary), spec/language.md "Directives".