Skip to content

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 BEFORE buildTree. A manager cycle (A→B→A) or self-manager (A→A) is never reachable from any root buildTree produces (every member’s managerId resolves 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’s managerId per 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 INPUT people, not buildTree’s roots (a dropped cycle member would otherwise never even be in the “expected” set). A stub box’s personId does NOT count as “appearing” — only real content does.
  • assertLinksResolve(pages) — every stub’s targetPageId (and anchorId, against that page’s packedSubtrees) and every parentRef.pageId names 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).

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 into TreeNode.
  • 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_PX is a heuristic, not a guarantee, for pages dense with relationships: paginate.ts decides 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-enhanced runs 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 below MIN_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 @page footer, 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 against realisticOrgChart541() (in sync with the real-artifact generator) and largeChart; 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 reachable WEASYPRINT_URL; otherwise renders end-to-end and checks the PDF with pdf-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 axe color-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, pageSize handling.
  • 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).