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 toFieldBox/(one small component perschema.type).- The side panel —
FieldList.tsx,Toolbar.tsx, and, once a field is selected,PropertyPanel/index.tsx— a thin dispatcher to onePropertyPanel<Type>.tsxper schema type, each split into a "Data" and a "Style" tab.BindingEditor.tsx(the generic path/array/ section/chart/kpi binding editor) andPropertyPanelFields.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 aTextSchema.contentor aKpiSchema.title/subtitleinto the string that actually gets drawn. Formatting insideDATE/CURRENCYis intentionally independent of the Designer's own UI language (localeprop) — 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 flatRecord<schemaName, string>(or a stringified 2D array, for tables) that the canvas preview andgenerate.tsboth read from.resolveChartItems/aggregateChartItems— resolve a chart'sBindingagainst the real array, applyfilters, group the tail into "Other" pasttopN.resolveKpiValue— resolve akpi-typeBindinginto 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.tsimplements 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/AVGtake a raw array path, not a value. InSUM(items.total),items.totalmeans "thetotalcolumn of theitemsarray". That is whycallnodes keepargSources(the original text) alongside the parsedargs.
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, nopdf-libinvolved:layoutDocument.ts— the pagination pass:Template+ data + bindings in, aLayoutDocument(pages of positioned, value-resolvedPlacements) out.layoutTypes.ts—BodyItem/FlowBoundsshapes.bodyLayout.ts— groups a page's schemas intoBodyItems ordered by Y (buildBodyItems), plus the helpers (boundsOf,gapAfter) the pass uses to walk that sequence.pageLayout.ts—normalizePageDefs(single-pageTemplatevs. multi-pageTemplate.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 byrender/index.ts'sdrawFieldOfType: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 insrc/fields/kpi/card.ts).renderText.ts/renderImage.ts— the two simplest field types;renderImage.tsalso 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 coordinatedrawTableSlicereturns. Table geometry is deterministic (TABLE_ROW_HEIGHT_MMis 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 fromrender/. Section measurement lives inlayout/sectionLayout.tsand the table metrics inpdf/tableMetrics.tsfor that reason;render/renderTable.tsimportsrgbfrom pdf-lib as a value, and dragging that into the layout graph would cost the layout its independence — the property that lets the sameLayoutDocumentfeed 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 needfont: inheritplusappearance: button, and thatborder: 0setsborder-style: none(so any laterborder-widthcomputes to zero — it has to beborder: 0 solid).theme.css(2,770 lines) — the reset plus the whole look. It@importsreset.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)) becausereact-rndcomputes its drag delta against that transform.TabPanel'sgridTemplateRowsis 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__mainintheme.css, not in the part. className/style/...restgo to the element that gives a component its name; every other element it renders is reachable throughparts, by role.cxmerges classes (dedupe by exact token,undefinedrather than""when nothing is left) andmergeStylelets the consumer'sstylewin, sincestyleis 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 insrc/against an allowlist: it must bejpd-*. That catches leftover Tailwind utilities and, which a blacklist wouldn't, an un-namespaced class — which is exactly howsection-bodyandsection-drag-handleonce 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.cssdoesn'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@layerdeclaration, the scoping of the*rule, and the token blocks (:root, the[data-jpd-theme="dark"]/.darkpair, 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:
| Peer | Needed by | Cost if forced on everyone |
|---|---|---|
react, react-dom | <Designer> and the UI components | a Node backend resolving React it never renders |
react-rnd | PageCanvas.tsx only (drag/resize) | see below — it drags React in with it |
pdfjs-dist | the /preview graph only | ~35MB installed |
wawoff2 | fontUtils.ts, lazily, for .woff2 fonts | a 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:
-
test/entryBoundaries.test.ts(runs innpm test) — walks the source graph fromsrc/index.ts,src/server.ts, andsrc/preview.tsfollowing relative imports, and asserts:/serverreaches neitherreact,react-domnorreact-rnd;- neither the main entry nor
/serverreachespdfjs-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 andreact-rndfrom 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.tslegitimately doesexport type { Locale, Dict } from "./i18n", and./i18n/index.tsre-exports the ReactI18nProvider. None of that reachesdist/server.*— neither the JS nor the.d.ts, which tsup inlines. -
examples/no-preview(builds in CI) — an app that never installspdfjs-dist, covering the realnode_modulesrather than the source tree. Omitting the dependency is not enough on its own: the example links the package withfile:../.., 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'snode_modules(where it lives as a devDependency). That's why itsbuildscript ends withcheck-no-pdfjs.mjs, which greps the emitted bundle for pdf.js symbols (GlobalWorkerOptions,PDFDocumentLoadingTask,pdf.worker.min.mjs). Never addpdfjs-distto that example — it voids the check. -
The tarball check in
.github/workflows/ci.yml— afternpm install ./pkg.tgz, assertsnode_modules/pdfjs-distdoes 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.