Skip to main content

Architecture

A map of the source tree grouped by responsibility, not by import order — useful context if you're modifying the package itself rather than just consuming it.

Editor (src/designer/ + src/components/)​

Since 3.0.0 the state and the layout are separate files. context/DesignerProvider.tsx owns selection state, the tab bar (Fields/Data/Style/Filter/Page), clipboard (copy/paste), keyboard shortcuts, and every mutation on Template/Binding[] (add/remove/ reorder schemas, update a binding, resize page bands…), exposing them through five contexts split by change frequency — context/contexts.ts declares them, context/hooks.ts holds the five accessors plus five derived selectors, and context/derived.ts the derivations. useClipboardAndDelete registers Delete/Ctrl+C/Ctrl+V exactly once, here, rather than per part.

Designer.tsx is now a 101-line preset (it was 986): three providers and two parts in a two-column layout. parts/ holds the ten placeable pieces — one file each, every one a two-component pair (a gate wrapper calling useTabGate, then a *Body with the real hooks, because no hook may run before the gate's return null). Most reproduce the root element Designer.tsx used to render, so extracting them added no DOM level; three wrap in .jpd-part because the component inside has no single stable root. parts/DesignerSidebar.tsx is the only part that imports other parts — an invariant guarded by test/designer/partBoundaries.test.ts.

The pieces that do the work are unchanged and still exported with today's props, so the headless-by-props path keeps working. Underneath the parts:

  • PageCanvas.tsx — the actual page: one <Rnd> (react-rnd) per schema for drag/resize, the header/footer/margin bands drawn in red, the grid, marquee (box) selection, zoom controls. Delegates what a field actually looks like to FieldBox/ (one small component per schema.type).
  • The side panel — FieldList.tsx, Toolbar.tsx, and, once a field is selected, PropertyPanel/index.tsx — a thin dispatcher to one PropertyPanel<Type>.tsx per schema type, each split into a "Data" and a "Style" tab. BindingEditor.tsx (the generic path/array/ section/chart/kpi binding editor) and PropertyPanelFields.tsx (shared X/Y/width/height inputs) are reused across several of them.

Selection, editing, and binding all live in the same React tree — no module bridge, no imperative API between the canvas and the panel.

Bindings and templates (src/bindings/, src/fields/table/columns.ts)​

Two of them are worth calling out, because they are the same operation from opposite ends. reindexTableForNewHead rewrites the whole header list and re-derives every slot by matching each new name against the old head; a renamed name is by definition absent from the old head, so that slot falls through to a blank. renameColumnInTable is the narrow one: it changes head[index] and nothing else, which is what makes a rename keep the column's token, style and width.

table/columnFormula.ts owns two things the rest of the package must not duplicate: tokenFor(key), the single rule for turning a JSON key into a token, and columnFormulaFor, which reproduces the resolver's own precedence (a design cell containing { beats the binding) so the editor cannot show something different from what the PDF will use.

table/normalizeColumns.ts converts raw-key columns into {label, formula} in a saved template plus its bindings. It is not a migrateTemplate step and cannot be: the column's key lives in the bindings, which are not part of Template.

bindings.ts is pure logic over strings/plain objects, with no third-party dependency:

  • resolveToken/renderTemplate — evaluate a {token}/ {FUNCTION(...)} template against the real JSON document. This is what turns a TextSchema.content or a KpiSchema.title/subtitle into the string that actually gets drawn. Formatting inside DATE/ CURRENCY is intentionally independent of the Designer's own UI language (locale prop) — it's part of the generated report's content, written by whoever authors the template, not the tool's chrome.
  • buildInputs — turns the whole JSON document + Binding[] into a flat Record<schemaName, string> (or a stringified 2D array, for tables) that the canvas preview and generate.ts both read from.
  • resolveChartItems/aggregateChartItems — resolve a chart's Binding against the real array, apply filters, group the tail into "Other" past topN.
  • resolveKpiValue — resolve a kpi-type Binding into a single aggregated number (sum/count/avg/min/max).
  • describeBinding/describeBindingShort — short human-readable summaries used only in the editor UI.

fields/table/columns.ts holds the pure functions that keep a TableSchema's head/content/footer/columnStyles in sync with its Binding (array) when a column is added/removed/reordered/reformatted from the panel.

Degrade or fail: where the line is​

src/pdf/textSafety.ts holds the two halves of one decision, and the split is worth understanding before touching either.

A problem in the data degrades. A control character ( , , NUL) is replaced with a space at every path that reaches the page — truncateToWidth is the funnel for table cells, KPI and chart labels, and renderText.ts sanitises its own value because it is the one path that does not truncate. No font has a glyph for a control character, not even a complete Unicode one, so this is the only possible rendering rather than content loss. Before this, a in a customer name failed the whole document.

A character that a complete font would render, but this one cannot, fails — and withGlyphContext is what makes the failure usable. It wraps a draw call and, on error, finds the offending character by testing candidates one by one against the font, rather than matching pdf-lib's error message (the same mistake the expression engine's error hierarchy exists to avoid). The candidate list is a lazy function, so resolving a chart's labels or flattening a table's rows costs nothing on the normal path. It is applied at five points: the text field, the KPI and the chart in render/index.ts, and inside drawTableSlice, which is the funnel for all three table paths (body, repeating band, nested in a section).

docs/USAGE.md, "What can and cannot fail a generation", is the user-facing version of the same table — keep the two in step.

The expression engine (src/expressions/)​

parse -> AST -> evaluate, in four files: tokenize.ts, parse.ts, evaluate.ts, plus a functions.ts registry (the 11 names in CUSTOM_FIELD_FUNCTIONS). dataAccess.ts and formatters.ts hold the path lookup, value comparison and DATE/CURRENCY formatting — they live here rather than in bindings.ts because bindings.ts imports the engine, so the engine cannot import back.

Three decisions worth knowing before touching it:

  • An operator is only an operator when surrounded by whitespace on both sides. {my-key} and {my key} are paths; {a - b} is subtraction; {a -b} is neither. This is not a quirk to clean up — it is what lets a JSON key contain a hyphen or a space, templates in production rely on it, and a conventional tokenizer would break {my-key} into three tokens and return 0. tokenize.ts implements it explicitly, with tests.
  • Intermediate values are string | number, coerced at each operator boundary. The engine this replaced kept every intermediate as a string and re-parsed it at every nesting level, which is why it had no operator precedence ({a + b * c} gave 20, not 14), no parenthesised grouping ({(a + b) * c} gave 0), and threw on {"x" + 1} and {a / 0} — those two fell into an infinite recursion that the nesting guard caught, reporting a depth problem that did not exist.
  • SUM/COUNT/AVG take a raw array path, not a value. In SUM(items.total), items.total means "the total column of the items array". That is why call nodes keep argSources (the original text) alongside the parsed args.

No eval/new Function: a template can come from an untrusted source, so the evaluator walks the AST. MAX_EXPRESSION_DEPTH (40) guards the V8 call stack against a malformed or malicious template.

Errors are a two-member hierarchy in errors.ts — ExpressionSyntaxError and ExpressionDepthError, both under ExpressionError. The base class is what resolve.ts catches, and that matters: generation swallows template problems (the field renders empty, the editor flags it) and lets anything else propagate, because swallowing everything would hide an engine bug. Recognising one of them by matching its message would be the same mistake in a different shape — and it was: the depth error used to be a plain Error sniffed by regex, which is why a too-deeply-nested expression took the whole PDF down.

PDF generation (src/pdf/)​

generate.ts is the entry point (generatePdf(template, data, bindings, options?)) — pure JS, no DOM, safe to run in Node. It is a thin orchestrator: it asks layout/ where everything goes, then draws what comes back. It contains no pagination decision of its own (see "Layout and drawing" below).

  • layout/ — pure functions, no pdf-lib involved:
    • layoutDocument.ts — the pagination pass: Template + data + bindings in, a LayoutDocument (pages of positioned, value-resolved Placements) out.
    • layoutTypes.ts — BodyItem/FlowBounds shapes.
    • bodyLayout.ts — groups a page's schemas into BodyItems ordered by Y (buildBodyItems), plus the helpers (boundsOf, gapAfter) the pass uses to walk that sequence.
    • pageLayout.ts — normalizePageDefs (single-page Template vs. multi-page Template.pages).
    • sectionLayout.ts — how many repeats a bound section has and how tall each one is once its master-detail tables have grown.
  • render/ — the actual drawing, one file per field type, dispatched by render/index.ts's drawFieldOfType:
    • renderTable.ts — header/body/footer rows, per-column style overrides, pagination when a table doesn't fit on one page.
    • renderSection.ts — repeats the group of member fields once per item of the bound array, growing/paginating with the rest of the body.
    • renderChart.ts — pie/donut/bar, legend placement (font size configurable), color palette (src/fields/chart/colors.ts).
    • renderKpi.ts — the colored card + Material Symbols icon path (src/materialIcons.ts), configurable font sizes/icon size/corner radius (defaults in src/fields/kpi/card.ts).
    • renderText.ts/renderImage.ts — the two simplest field types; renderImage.ts also owns the image size/count safety limits (MAX_IMAGE_BYTES/MAX_DISTINCT_IMAGES).

Layout and drawing: one pass, then paint​

layout/layoutDocument.ts decides all pagination for a Template + data + bindings, without drawing anything and without touching pdf-lib. It returns a LayoutDocument: one entry per physical page, each holding Placements that are already positioned (yMm from the flow cursor) and already resolved (the text value, the rows of a table slice). generate.ts then walks that array and paints; it contains no pagination decision at all.

That shape is recent and worth understanding, because it replaced a real hazard. Pagination used to be computed twice: the drawing loop decided page breaks and drew in the same pass, and countBodyPages walked the body a second time purely to produce the total — {pageCount} has to be right on the first physical page, so the count must finish before the first mark. The two shared only the atomic decisions in pagination.ts (needsNewPageForItem, computeTableSlice); the cursor advance, the table-slicing loop and the section repetition existed in two copies. A change to one of them meant "the dry run said 7 pages, the drawing made 8" — silent, and very hard to trace.

Now the page count is pages.length, so divergence is structurally impossible.

Two consequences worth keeping:

  • The cursor after a table advances by computeTableSlice().heightMm, not by the Y coordinate drawTableSlice returns. Table geometry is deterministic (TABLE_ROW_HEIGHT_MM is fixed and a cell truncates rather than wraps), so the height is a function of the row count — which is exactly why the layout can know where a table ends without drawing it.
  • layout/ must not import from render/. Section measurement lives in layout/sectionLayout.ts and the table metrics in pdf/tableMetrics.ts for that reason; render/renderTable.ts imports rgb from pdf-lib as a value, and dragging that into the layout graph would cost the layout its independence — the property that lets the same LayoutDocument feed something other than pdf-lib later.

{pageNumber}/{pageCount} are resolved by the renderer, not the layout, and only for the repeating bands (header/footer/margin) — no body field uses them. That is what removes the chicken-and-egg that made the dry run necessary in the first place.

Supporting modules (still directly under src/pdf/, used by both layout/ and render/): pagination.ts (splitting the body across pages against headerHeight/footerHeight/marginLeft/marginRight — see src/page/zones.ts for how the editor classifies a field into header/footer/margin/body by position alone), fontUtils.ts (embedding a custom TTF via fontkit, normalizeFontBytes), backgroundImage.ts (turning an uploaded PNG/JPEG into the page's background PNG — it accepts images only, see the entry points below), color.ts, resolvers.ts, and pdfWorker.ts (wiring up pdf.js's worker, browser-only).

Only downloadPdf, Designer, PdfPreview*, and the UI components touch the DOM. Everything else under src/pdf/, src/bindings/, and src/types/ is safe to import in a Node backend — see Backend integration and Server-only usage.

Styling (src/css/theme.css, src/css/reset.css)​

Two hand-written stylesheets, and no build step for either — 3.0.0 removed Tailwind from the package entirely (tailwindcss and @tailwindcss/cli out of devDependencies, the build:css script gone, src/style.css deleted, npm run build reduced to tsup). They reach dist/ through tsup's publicDir: "src/css" and are exposed as json-pdf-designer/theme.css and json-pdf-designer/reset.css.

  • reset.css (236 lines) — the appearance-free subset: only what the editor used to inherit from Tailwind's Preflight. It exists as a separate public export for the consumer who writes the appearance themselves and doesn't want our look; without it they'd have to rediscover on their own that a dozen classes need font: inherit plus appearance: button, and that border: 0 sets border-style: none (so any later border-width computes to zero — it has to be border: 0 solid).
  • theme.css (2,770 lines) — the reset plus the whole look. It @imports reset.css, so one import is enough for the consumer and there is only one copy of the reset to keep in step.

Neither is minified, on purpose: they are the public contract, read to learn the class and token names, and the consumer's bundler minifies them.

Both exports have example coverage, and so does the mode that takes neither. The five apps in examples/ sit at five different points on the spectrum: theme.css as it ships (report-builder), theme.css rethemed by token only (composed-layout), theme.css in dark mode (no-preview), reset.css alone (headless-designer), and no package CSS at all (custom-ui). That coverage is the point: until 3.0.0 reset.css was a public export with zero examples exercising it. test/docsFreshness.test.ts guards the split — it reads each example's real CSS import lines and fails when one moves without the docs moving with it, and it also pins the reset-only trap: PdfPreview reads --jpd-shadow-page-preview inline through canvas.style.boxShadow, so a reset.css-only consumer must declare that token or the page shadow disappears with no error. See Theming.

No global reset​

Up to 2.1.1 dist/style.css was compiled Tailwind with Preflight included — a reset that landed on the consumer's whole app. It doesn't any more. Each .jpd-* class carries the reset its own element depended on, and the only rule with a * is scoped to the editor's roots and wrapped in :where(), i.e. specificity zero, so any consumer rule beats it. box-sizing is the one that genuinely cannot be solved per-class: .jpd-input is width: 100% with padding and a border, and children the consumer passes into Card/Modal need to inherit it too.

@layer json-pdf-designer​

Every rule the package ships lives in one cascade layer, declared first (@layer json-pdf-designer, utilities;) before any rule. Unlayered CSS beats layered CSS regardless of specificity, so a consumer's rule wins by default — which is what makes the className a component accepts actually mean something. Declaring the order up front is what stops a consumer who imports their own Tailwind before our sheet from ending up with our reset beating their utilities (measured, in an example app: a bg-sky-600 computed as rgba(0,0,0,0) before the declaration existed).

Where the responsibility splits​

The dividing line runs the same way in CSS as it does in the component API:

  • The part owns what makes it work. The canvas owns the sheet geometry (mm→px, transform: scale(zoom)) because react-rnd computes its drag delta against that transform. TabPanel's gridTemplateRows is the collapse mechanism.
  • The consumer owns layout. The scrolling viewport around the canvas, and the sidebar's 320px width — which is why that width lives in .jpd-designer__main in theme.css, not in the part.
  • className/style/...rest go to the element that gives a component its name; every other element it renders is reachable through parts, by role. cx merges classes (dedupe by exact token, undefined rather than "" when nothing is left) and mergeStyle lets the consumer's style win, since style is the last resort.

State is data-* rather than a class throughout — the rule being that if the JSX would have to concatenate or choose a class string, it becomes an attribute. Dark mode is [data-jpd-theme="dark"] on :root, with .dark kept as an alias for 2.x consumers, and deliberately no media query. The user-facing version of all of this is Theming — keep the two in step.

What guards a hand-written stylesheet​

There is no compiler behind these files, so two test files stand in for one:

  • test/noTailwind.test.ts — scans every class-position token in src/ against an allowlist: it must be jpd-*. That catches leftover Tailwind utilities and, which a blacklist wouldn't, an un-namespaced class — which is exactly how section-body and section-drag-handle once got into a library package, where they collide with the consumer's CSS. The migration touched ~285 class sites, and half migrated is the worst possible state: theme.css doesn't style the leftover utilities and Tailwind no longer exists to generate them, so the un-migrated part renders unstyled with no error at all.
  • test/theme.test.ts — covers the four things in the sheet that break silently: the @layer declaration, the scoping of the * rule, and the token blocks (:root, the [data-jpd-theme="dark"]/.dark pair, the light island) staying in agreement. It strips comments before scanning, because the file's own header documents the syntax the tests look for.

UI language (src/i18n/)​

The Designer's own UI text (buttons, tabs, warnings, placeholders) comes from a small dictionary — en.ts (canonical, default) and pt-BR.ts (typed against it, so a missing key is a compile error, not a silent blank string). I18nProvider/useT/useLocale (React context) wire the <Designer locale="en" | "pt-BR"> prop through to every component; a component used standalone, without <Designer> on top, still renders correct (English) text via the context's default value. This only covers the editor's chrome — it never touches how {DATE(...)}/{CURRENCY(...)} format the generated report's own content.

Three entry points (index.ts, server.ts, preview.ts)​

src/index.ts exports the full public API — React components (Designer, the UI kit) plus generation/bindings/types. src/server.ts mirrors the non-React subset only — same generation/bindings/types, zero references to react anywhere in its compiled output — built as a separate bundle (dist/ server.{js,cjs,d.ts}) exposed via the json-pdf-designer/server subpath. Keeping them as two hand-maintained lists (rather than one generated from the other) means an export accidentally added to only one of them is easy to spot in review — see the comment at the top of src/index.ts.

src/preview.ts is the third entry (json-pdf-designer/preview) and follows the same pattern for a different reason: pdfjs-dist is an optional peer dependency (~35MB installed), and its graph is the only one allowed to import it. Rendering the preview is also the only thing the package uses pdf.js for — which is why backgroundImage.ts accepts images only: rasterizing an uploaded PDF would have pulled pdf.js back into <Designer>, and therefore into the main entry, for everyone. A lazy import() would not help either: a bundler still has to resolve the specifier at build time.

The optional peers, and why they are optional​

Five packages are peer dependencies with optional: true, and none of them is optional by accident:

PeerNeeded byCost if forced on everyone
react, react-dom<Designer> and the UI componentsa Node backend resolving React it never renders
react-rndPageCanvas.tsx only (drag/resize)see below — it drags React in with it
pdfjs-distthe /preview graph only~35MB installed
wawoff2fontUtils.ts, lazily, for .woff2 fontsa Node-only WASM path bundlers warn about

react-rnd is the subtle one. It was a plain dependency until 2.0.0, so it was installed unconditionally — and because its own react/react-dom peers are not optional, npm then installed the whole React stack (react, react-dom, react-draggable, re-resizable, scheduler, prop-types, …: about 8.7MB) even in a project that only ever imports /server. Our own optional: true on react was silently defeated one level down. A backend install now resolves fontkit, pdf-lib and tiny-inflate plus their own trees, and nothing React.

How the boundaries are enforced​

An import in the wrong file breaks a packaging promise, not a build: it costs every consumer megabytes they never asked for, or leaves a real dependency undeclared, and it would only surface in someone else's npm install. Three checks guard it, each covering an angle the others can't:

  1. test/entryBoundaries.test.ts (runs in npm test) — walks the source graph from src/index.ts, src/server.ts, and src/preview.ts following relative imports, and asserts:

    • /server reaches neither react, react-dom nor react-rnd;
    • neither the main entry nor /server reaches pdfjs-dist.

    Fastest and most precise: it names the guilty file. Two control cases assert the walk still sees what it should — pdf.js from /preview, React and react-rnd from the main entry — so a broken walker can't make the other assertions pass vacuously.

    It skips type-only statements (import type / export type … from), which are erased at compile time: src/server.ts legitimately does export type { Locale, Dict } from "./i18n", and ./i18n/index.ts re-exports the React I18nProvider. None of that reaches dist/server.* — neither the JS nor the .d.ts, which tsup inlines.

  2. examples/no-preview (builds in CI) — an app that never installs pdfjs-dist, covering the real node_modules rather than the source tree. Omitting the dependency is not enough on its own: the example links the package with file:../.., and a bundler resolves bare imports from the file's real path, so a leaked import would still find pdf.js in the parent repo's node_modules (where it lives as a devDependency). That's why its build script ends with check-no-pdfjs.mjs, which greps the emitted bundle for pdf.js symbols (GlobalWorkerOptions, PDFDocumentLoadingTask, pdf.worker.min.mjs). Never add pdfjs-dist to that example — it voids the check.

  3. The tarball check in .github/workflows/ci.yml — after npm install ./pkg.tgz, asserts node_modules/pdfjs-dist does not exist. This one proves the packaging claim: npm does not pull the peer in on its own, i.e. it is still genuinely optional.