Org chart paginated PDF export
Gevell Org Chart’s accessible PDF export (GET /api/orgcharts/:id/outputs/:oid/pdf)
historically rendered the WHOLE chart as a single, Chromium-printed page
(handleImageExport in workers/api/src/routes/org-chart/org-chart-export.ts,
backed by workers/api/src/utils/org-chart-image-export.ts). For a large chart
(hundreds of people) that page becomes unusably tall/wide and is not something
a person can actually print or scan.
This adds a second, paginated, WeasyPrint-rendered mode: large charts are split into fixed-size (Letter/Tabloid/A3 landscape) pages. A subtree that doesn’t fit on a page is replaced with a small “stub” box — a real link to a later page where that subtree is drawn in full — and the later page links back. Every person appears on exactly one page. A chart’s own saved view (root/depth/filter) is respected, same as every other export.
Development history (why box metrics changed, what each review round found, one-off verification numbers) lives in PR #4235’s description, not here — this doc describes current behavior only.
Route
GET /api/orgcharts/:id/outputs/:oid/pdf?mode=paginated&pageSize=letter|tabloid|a3
Reuses the existing /pdf route rather than adding a new one: ownership
checks, free-tier watermarking, and the download menu already live there,
and ?mode= is a precedent used elsewhere (?inline=true on
/wcag-report). Opt-in for now — see “Known limitations.” Flipping
the default is a one-line change in org-chart-export.ts’s /pdf handler.
The handler respects the output’s saved view (org_chart_views .root_person_id/max_depth/filter) the way regenerateOutputHtml
(generate.ts) does, via scopePeople() — so an “Engineering only” view
exports only Engineering. Every Supabase read checks error explicitly: a
genuine DB failure is a logged 500, never a silent empty list or a false
404 (only a missing-row PGRST116 becomes 404). If veraPDF fails AND the
PDF/UA-1 claim was somehow not stripped (a gate invariant violation), the
route refuses with a 500 rather than serve an unverified claim; a
legitimately stripped (unverifiable) PDF is still served, with log.warn.
Pipeline
people (scoped to the saved view) -> buildTree() -> paginate() -> renderPaginatedHtml() -> WeasyPrint /convert (pinned weasyprint==68.1) -> cleanPdfStructureSpans() -> postProcessAccessiblePdf() -> verifyAndEnforcePdfUaClaim() (veraPDF) -> PDF bytes. Files:
templates/index.ts (buildTree), org-chart-pdf/{layout,paginate, render-html}.ts, services/weasyprint/app.py, services/pdf-struct-cleaner.ts,
services/pdf-accessibility-postprocessor.ts, services/pdf-ua-claim-gate.ts.
org-chart-pdf/pipeline.ts’s generateAndVerifyOrgChartPdf() wraps the last
four steps — the SAME structure-cleanup / accessibility-postprocessor /
veraPDF-gate sequence convert.ts uses, via a direct /convert POST (not
generateAccessiblePdfWithWeasyPrint(), whose injectPrintStyles() would
fight the @page rules render-html.ts already writes). The WeasyPrint
fetch has a timeout (AbortSignal.timeout); only a network failure/timeout
or a 502/503/504 becomes WeasyPrintUnavailableError (“try again later”) —
any other non-OK status is a WeasyPrintRequestError carrying its real
status (a genuine document problem, not a down sidecar). org-chart-pdf/index.ts
is the module’s public entry point (generatePaginatedOrgChartPdf, plus
buildPaginatedOrgChartHtml/buildPaginatedPages for callers that stop
earlier).
d3-hierarchy isn’t a direct dependency anywhere in workers/api (only
resolves via root-level hoisting from another package’s transitive
dependency), so layout.ts hand-rolls a Reingold–Tilford-style layout
instead of taking an undeclared dependency on it.
Layout (layout.ts)
Pure, DOM-free. Given a TreeNode and BoxMetrics (fixed box width + a
conservative glyph-width line-wrap estimate — see metrics.ts), computes
absolute x/y/width/height for every node plus connector segments, in
standard (children centered under the parent) or hanging (past a
configurable depth, children stack vertically, indented — mirrors
HANGING_START_DEPTH in templates/top-down-tree.ts; the paginator can
force depth 0 for a whole hanging page) mode. Depth is relative to the
PAGE’s own root. Box height comes from text content via
estimateBoxHeight(); a stub box is sized from its actual link text
(stubLinkText()/estimateStubHeight()) — kept in sync with
render-html.ts’s renderStubBox wording, or the paginator under-sizes a
stub and WeasyPrint overflows it.
Two paginator-only layouts: layoutWrappedRow() wraps a root + a FLAT
leaf/stub child list into page-width rows (a dedicated left gutter,
TRUNK_GUTTER_PX, routes the connector trunk so it never crosses a
sibling’s box). layoutHangingGrid() handles “manager of managers-of-ICs”
shapes (VP → Directors → ICs): each report’s leaf children wrap into a
compact sub-grid beneath them, searching row counts for the shortest fit.
Every connector segment either produces is axis-aligned, so “does this
cross a box” is an exact check (assertNoConnectorCrossesABox).
Paginator (paginate.ts)
Input: the tree(s), the usable page box (computePageBox()), box metrics,
relationships. Output: an ordered PdfPage[], processed via a FIFO queue
so pages come out breadth-first — a page is always enqueued (and written
out) before any cut child relocated into it, or its back-link would
point at a page that doesn’t exist yet.
Fit order — cheapest layout first, cut as a last resort
Per page, tries progressively more disruptive layouts, stopping at the
first that fits (attemptFit()): 1) standard, the full uncut
subtree; 2) hanging at depth 1 (PAGINATOR_HANG_DEPTH) — direct
reports stay in a row, each of THEIR reports stacks vertically; 3)
layoutHangingGrid() — the “VP → Directors → ICs” shape, no cuts needed;
4) layoutWrappedRow() of direct children — the flat “manager + N
leaf ICs” shape step 3 doesn’t cover.
Only once 1–4 all fail does anything get cut — one subtree at a time,
re-trying 1–4 after each cut, stopping as soon as one fits. Picks the
“worst” direct child with descendants of its own (cuttableCandidates — a
childless leaf gains nothing from being cut) and stubs it, queuing its
subtree as a new page job. Preference: a) a department boundary
(person.department differs from the root’s — OrgChartUnit isn’t
threaded into TreeNode, so department is the practical signal); b)
larger footprint. The page’s own root is never cut.
After the cut loop stops (at the FIRST fit, which can leave an unnecessary cut), it tries placing the smallest cut child back inline first, repeating until no more fit. Whatever stays cut is then bin-packed (in tree order, never reordered) onto shared continuation pages with siblings that also fit a page alone — see “Sibling packing” — instead of each getting its own (often mostly-empty) page.
Once nothing is left worth cutting, the remaining direct-children row
(leaves + stubs) becomes a layoutWrappedRow() grid using the full
page width — this is what lets a many-VP overview page show every VP as a
stub on ONE page (every VP there IS cut, by design — the point of an
overview). Only if even that grid doesn’t fit does the row get chunked into
sibling part pages (“Direct reports of {title}, part k of n”), each
sized to fit; the root page keeps only itself plus one stub per part. Part
jobs are always queued before the cut children relocated into them.
Sibling packing
Cut sibling subtrees that each independently fit a page alone (checked via
attemptFit) are bin-packed, in tree order, onto shared continuation
pages instead of each getting its own page (packSubtreesIntoGroups). A
subtree that doesn’t fit alone still gets its own (possibly further-cut)
page via the regular queueing path.
Each packed subtree gets its own <h3> heading using the person’s own
name/title (packedSubtreeHeading() — never the shared parent’s
department, which several siblings would have identically), a unique
anchor id (subtree-{personId}, PdfStub.anchorId) that the stub on the
cutting page links straight to (not the top of the shared page), and its
own full, uncut subtree. The whole shared page gets ONE back link to the
common parent.
A packed page’s PdfPage.layout holds the COMBINED node/connector set
across every packed entry (each pre-shifted to its final Y before
concatenation), so every other invariant keeps working unmodified — tree
is a placeholder there; render-html.ts renders from packedSubtrees.
Invariants
Enforced in org-chart-pdf/index.ts’s buildPaginatedPages, which every
caller goes through:
breakManagerCycles(people)runs BEFOREbuildTree. A manager cycle (A→B→A) or self-manager (A→A) is never reachable from any rootbuildTreeproduces (every member’smanagerIdresolves to someone who exists, so none becomes a root or anyone’s reachable child) — they’d otherwise vanish with no page, no stub. Clears the lexicographically-smallest-id member’smanagerIdper cycle (promoting it to a root; the rest becomes a normal reachable chain),console.warns once per cycle, never mutates the input.assertEveryPersonAppearsOnce(people, pages)compares against the INPUTpeople, notbuildTree’s roots (a dropped cycle member would otherwise never even be in the “expected” set). A stub box’spersonIddoes NOT count as “appearing” — only real content does.assertLinksResolve(pages)— every stub’stargetPageId(andanchorId, against that page’spackedSubtrees) and everyparentRef.pageIdnames a real page.- Relationships resolve in ONE PASS over every finished page, not per-job during paging — a relationship’s person can land on a page created partway through another page’s processing (e.g. a part page), so resolving too early silently drops it.
Font embedding (fonts.ts)
WeasyPrint has no network access, so a @font-face pointing at Google
Fonts would silently fail and fall back to a system default — quietly
invalidating metrics.ts’s AVG_GLYPH_WIDTH_EM line-wrap estimate
(calibrated against the REAL font). fonts.ts embeds the OFL-1.1-licensed
Public Sans/Archivo WOFF2 files as base64 data: URIs, and throws (never
warns-and-falls-back) if a font’s bytes are missing — a silent fallback
would reintroduce exactly the drift embedding fixes.
The bytes are baked into fonts-data.generated.ts
(scripts/generate-org-chart-fonts.mjs, run manually on a .woff2 change)
rather than read from disk at runtime: a runtime readFileSync(join(dirname (fileURLToPath(import.meta.url)), …)) depends on import.meta.url, which
esbuild’s CJS Lambda bundle leaves undefined (a cold-start crash CI’s
verify-lambda-bundle check catches); __dirname isn’t a fix either
(undefined under Vitest/tsx’s native ESM). A plain string constant has no
runtime path to resolve, so it loads identically in the Lambda bundle, on
EC2 (server.ts), and in the CF Worker (index.ts — org-chart-export.ts
is a route there too).
Print HTML (render-html.ts)
One self-contained HTML document, one <section id="page-{id}"> per
PdfPage with break-before: page: an h1/h2 heading with
bookmark-level for the PDF outline (a packed subtree’s <h3> sets no
explicit bookmark-level but still produces one — WeasyPrint’s default UA
stylesheet gives h1–h3 a built-in level); a back link when the page has a
parent; the tree as nested <ul>/<li>, each box absolutely positioned and
carrying id="person-{id}" (so a same-page relationship note can link to
it); one inline <svg aria-hidden="true" role="presentation"> per page
for connectors; a stub <li> per cut subtree with descriptive link text.
Relationships — same-page and cross-page alike — render as a text note in
.relationship-notes below the chart, never an actual connector line
(ConnectorSegment.dashed exists in the type, unused for relationships).
Every user-controlled string is HTML-escaped (escapeHtml()); the @page
footer’s chart name uses CSS STRING escaping instead (escapeCssString()
— HTML-escaping a value inside a <style> element’s content: string is
the wrong escaping, and wouldn’t block a </style><script>… break-out).
Page-number links. href="#page-{id}" resolves at render time, but the
PRINTED page number isn’t known until WeasyPrint lays out the document —
every “page N” reference uses target-counter(attr(href), page) in a
.page-link::after rule, with the descriptive sentence as the <a>’s own
static text and only the trailing number generated.
Decorative connector SVGs are artifacts, not bare Figures, on the
WeasyPrint version this repo deploys (weasyprint==68.1,
services/weasyprint/Dockerfile): weasyprint/pdf/tags.py’s
_get_pdf_tag maps only img to /Figure there — a bare <svg> becomes
/NonStruct, needing no /Alt. (An earlier draft treated <svg> as a
Figure candidate in pdf-accessibility-postprocessor.ts, verified against
a newer WeasyPrint release; that shifted every real <img> Figure’s alt
text by one position on every Convert output and was reverted.) If a
future upgrade tags <svg> as /Figure, fix it in this org-chart path —
not the shared postprocessor — and re-verify against the pinned version.
Connector contrast. Stroke color/width are direct SVG presentation
attributes on every <line> (stroke, stroke-width="2.5",
vector-effect="non-scaling-stroke"), not only a CSS class — WeasyPrint’s
CSS-to-SVG cascade doesn’t reliably apply external-stylesheet rules to SVG.
Known limitations
- Department, not
OrgChartUnit, drives “unit boundary” cuts — the richer unit/group model isn’t threaded intoTreeNode. - Paginated mode is opt-in, not yet the default.
- Relationships render as text notes only, never an actual connector line — see “Print HTML.”
FOOTER_BAND_PXis a heuristic, not a guarantee, for pages dense with relationships:paginate.tsdecides fit BEFORE relationships are resolved (resolution needs every page to exist), so it can’t size around exactly how many relationship-note lines will land — a page where many land together could still overflow into an extra physical page. A real fix needs a two-pass paginate-then-revalidate step or speculative accounting during the fit search.apps/org-chart’s export UI gained one download-menu entry and new locale keys; the surrounding file isn’t otherwise i18n-wired.- Cost: WeasyPrint/veraPDF are self-hosted sidecars, not a metered third-party API — no cost-tracking entry needed.
- axe
color-contrast-enhancedruns in a REAL browser (Puppeteer + axe-core from CDN —org-chart-pdf-contrast.integration.test.ts), not jsdom (contrast needs real layout/getComputedStyle); excluded from default CI like this repo’s other browser a11y tests (npm run test:browser).
Tuning
metrics.ts:DEFAULT_BOX_WIDTH_PX,*_GAP_PX/*_PADDING_PX,STUB_PADDING_PX/STUB_BORDER_PX/NODE_BORDER_PX,DEFAULT_FONT_SIZE_PX(never belowMIN_FONT_SIZE_PX=12),AVG_GLYPH_WIDTH_EM(bigger = more lines estimated = more cuts, never fewer than reality; calibrated against the real embedded Public Sans) — highest-leverage numbers in the pipeline, multiplying across every box/gap/page.types.ts:PAGE_DIMENSIONS_PX(add a page size),PAGE_MARGIN_PX,HEADER_BAND_PX,FOOTER_BAND_PX(see “Known limitations” — NOT the real@pagefooter, which needs no content-height reservation).layout.ts:HANGING_START_DEPTH,HANG_INDENT_PX,TRUNK_GUTTER_PX.paginate.ts:PAGINATOR_HANG_DEPTH,SUBTREE_HEADING_BAND_PX.
Tests
{layout,paginate,render-html,fonts}.test.ts— packing bounds againstrealisticOrgChart541()(in sync with the real-artifact generator) andlargeChart; no-overlap/no-connector-crosses-a-box (incl. part/packed pages); wide-fan-out (incl. a manager-direct-reports shape plain leaves never exercise); multi-root; department-boundary cut preference; manager-cycle/self-manager breaking; link-resolution invariants; hostile-string escaping per user-controlled field; the 12px floor across px/pt/em/rem.integration.test.ts— skips cleanly without a reachableWEASYPRINT_URL; otherwise renders end-to-end and checks the PDF withpdf-lib(no AGPL): a stub’s GoTo resolves to the expected page, outline entry count, every Link annotation owned via/OBJR, veraPDF’s verdict when reachable.org-chart-pdf-contrast.integration.test.ts— real-browser axecolor-contrast-enhanced(npm run test:browser).org-chart-pdf-export.test.ts— route seam: ownership, DB-error handling, cross-chart output ids (404), unauthenticated (401), view scoping, watermark gating, no-WEASYPRINT_URL(501),WeasyPrintUnavailableError/WeasyPrintRequestError,pageSizehandling.pdf-accessibility-postprocessor.test.ts— the decorative-<img alt="">path this feature reuses (see “Print HTML” for why<svg>isn’t part of it on the pinned WeasyPrint version).