Accessible Flipbook Builder — MVP Spec (single-shot demo)
Goal
A CLI tool that takes a tagged, accessible PDF and emits a fully static, self-hostable flipbook bundle: visually faithful page-flip presentation for sighted users, plus a parallel accessible DOM derived from the PDF’s tag tree for screen reader and keyboard users. No server, no database, no cookies, no trackers, no third-party requests. The output folder works from file:// or any static web server.
This is the demo for a future “Export as flipbook” step at the end of a PDF remediation pipeline. For the demo, the input is simply a PDF that is already tagged.
npx flipbook build input.pdf -o out/# → open out/index.htmlStack (fixed — do not substitute)
- Node 20+, TypeScript throughout, ESM.
pdfjs-dist(legacy build for Node) for ALL PDF work: page rendering, structure tree, text content, link annotations, outline, metadata.@napi-rs/canvasas the canvas implementation for Node-side rendering.esbuildto bundle the player (vanilla TS → oneplayer.js, oneplayer.css). No frontend framework.commanderfor the CLI. No other runtime dependencies.
Repo layout
/src cli.ts # commander entry: `build <input.pdf> -o <dir> [--dpi 150] [--max-pages N]` extract.ts # tag tree → intermediate content model; text runs, links, alt text, metadata, outline render.ts # page rasterization to PNG via pdfjs + napi-rs/canvas derive-html.ts # content model → semantic HTML strings (one fragment per page + full linear doc) emit.ts # writes the bundle: index.html, manifest.js, assets, player/player player.ts # flip UI + accessible layer + keyboard + announcer player.css/test-fixtures # (see "Test input" below)Stage 1 — Extraction (extract.ts)
For each page, via pdfjs:
page.getStructTree()→ role tree with MCIDs.page.getTextContent({ includeMarkedContent: true })→ text items carrying ids that join to the struct tree’s MCID leaves. Reassemble text runs per structure element in tag-tree order (tag order = reading order; the input is remediated, trust it).page.getAnnotations()→ Link annotations. Match link rects to overlapping text runs to recover anchor text; keep both URL actions and internal GoTo destinations (map dest → target page number viapdf.getDestination/getPageIndex).- Alt text: struct tree nodes expose
altwhere present (Figures). Carry it through. - Document level:
getMetadata()(title, language),getOutline()(for TOC).
Produce an intermediate JSON model:
type Node = | { kind: "heading"; level: 1|2|3|4|5|6; text: string } | { kind: "para"; runs: Run[] } | { kind: "list"; ordered: boolean; items: Node[][] } | { kind: "table"; rows: { header: boolean; cells: Node[][] }[] } | { kind: "figure"; alt: string; pageImageRef?: string } | { kind: "link"; href: string; internalPage?: number; text: string }; // links may also appear inside runsTag mapping: H1..H6→heading; H→heading at nesting depth; P→para; L/LI/Lbl/LBody→list; Table/TR/TH/TD/THead/TBody→table (TH or THead row → header cells, add scope); Figure→figure with alt; Link→anchor; Artifact and pagination artifacts → skip; unknown roles → treat children as inline content of a paragraph (never drop text).
Fallback (must-have): if getStructTree() returns null/empty (untagged PDF), fall back to plain reading-order paragraphs from getTextContent (group items into lines by y, lines into paragraphs by gap). Print a prominent CLI warning that output quality is degraded and the source should be remediated first. The tool must never crash on an untagged PDF.
Stage 2 — Rendering (render.ts)
- Render each page to PNG at
--dpi(default 150) →assets/pages/page-001.pngetc. - Record each page’s CSS pixel dimensions in the manifest (pages may vary in size).
- Also emit a half-resolution thumbnail per page for a future thumbnail strip (cheap now, useful later).
Stage 3 — HTML derivation (derive-html.ts)
- Serialize the content model to semantic HTML: real
h1..h6,p,ol/ul/li,table/th[scope]/td/caption,figure > img[alt](reuse the page render cropped? No — punt. For MVP, figures become<div role="img" aria-label="{alt}">placeholders in the reader layer; the visual layer already shows the real image). - Internal links become
<a href="#" data-goto-page="N">; the player intercepts and navigates. - Escape everything; no raw text interpolation.
- Emit two forms into the manifest: per-page fragments (for the synchronized accessible layer) and one concatenated linear document (for Reader view).
Stage 4 — Bundle (emit.ts)
Output folder:
out/ index.html manifest.js # window.__FLIPBOOK__ = {...} ← JS, NOT fetched JSON player.js player.css assets/pages/*.pngCritical: the manifest is a <script src="manifest.js"> assigning a global, and images load via relative <img src>. No fetch() anywhere — fetch of local files fails under file:// in Chrome, and working from file:// is a hard requirement of the demo.
Manifest contents: title, language, page count, per-page { image, width, height, html }, linear document HTML, outline as { title, page }[].
index.html: <html lang> from PDF metadata (default en), <title> from PDF title (fallback: filename), loads css/manifest/player, contains only a <div id="app"> shell plus a <noscript> message.
The player (player/player.ts) — the part to get exactly right
Two synchronized layers plus chrome:
Visual layer
- Shows current spread (two pages side by side on wide viewports, single page under 700px width).
- Page-turn: CSS 3D transform on a turning leaf (
rotateY,perspective,transform-originat the spine), ~450ms ease. Preload adjacent spreads’ images. - The entire visual layer has
aria-hidden="true"andinertwhere supported. It is decoration; the accessible layer is the content. @media (prefers-reduced-motion: reduce)→ no 3D animation, instant crossfade.
Accessible layer
- Visually-hidden (
.sr-onlyregion) container holding the current spread’s derived HTML, updated on navigation. Notdisplay:none— screen readers must read it. - A skip link (“Skip to page content”) as the first focusable element, targeting this region.
Reader view (second content mode, visible to everyone)
- A toolbar toggle “Reader view” swaps the visual layer for the full linear HTML document, styled for comfortable reading (max-width 70ch, 1.15rem+, high contrast, honors
prefers-color-scheme). This doubles as the low-vision / mobile / print-friendly mode and is the strongest conformance story: the accessible rendering is a first-class visible mode, not a hidden bolt-on.
Chrome & interaction
- Toolbar: Prev / Next buttons, “Page X of N” status, page-number jump input, Reader-view toggle, TOC button (slide-over panel from outline; items navigate).
- Keyboard (global, not just when toolbar focused):
←/→prev/next,PageUp/PageDown,Home/End,Escapecloses TOC. All controls real<button>s with visible focus rings (:focus-visible, 2px offset outline). aria-live="polite"announcer: on navigation, announce “Page 4 of 32” (and page heading if the page starts with one).- Focus management: opening TOC traps focus in the panel and restores on close; navigating pages does NOT steal focus.
- Deep linking:
#page=7on load and updated viahistory.replaceStateon navigation. - Touch: simple horizontal swipe for prev/next (threshold 40px). No fancy drag-the-corner physics in MVP.
- All player text contrast ≥ 4.5:1 in both light and dark schemes.
Explicitly OUT of scope (do not build)
Forms, embedded video/audio hotspots, search, zoom/pan, thumbnails UI, analytics of any kind, encrypted PDFs, OCR, per-figure image cropping, print stylesheet, multi-thousand-page performance work, i18n of chrome strings (hardcode English, but put strings in one const object).
Test input
Include a script npm run fixture that generates test-fixtures/sample.pdf — but note that producing a tagged PDF programmatically is nontrivial; if a well-tagged fixture can’t be generated reliably, instead document in the README: “test with any remediated/tagged PDF” and make the untagged fallback the smoke-test path so npm run demo (build the fixture or any provided PDF and open the result) always yields a working flipbook.
Acceptance checklist (definition of done)
npm i && npm run build && npx flipbook build somefile.pdf -o out/succeeds;out/index.htmlopened directly from disk (no server) shows a working flipbook.- Keyboard only: can reach every control, flip all pages, open/close TOC, jump to a page, switch to Reader view. Focus always visible.
- With the visual layer inspected:
aria-hidden="true"present; accessible region contains the current spread’s semantic HTML (headings/lists/tables as real elements). - Page navigation fires exactly one polite announcement.
prefers-reduced-motiondisables the 3D flip.- Zero network requests to any origin (verify in devtools: only local files).
- No console errors; TypeScript strict mode passes.
README to generate
Quick start, the two-layer architecture diagram (ASCII fine), the privacy claim (“static output, zero third-party requests, nothing to add to your privacy notice”), self-hosting instructions (copy folder to any web root), and the untagged-PDF caveat.