Skip to content

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.html

Stack (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/canvas as the canvas implementation for Node-side rendering.
  • esbuild to bundle the player (vanilla TS → one player.js, one player.css). No frontend framework.
  • commander for 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:

  1. page.getStructTree() → role tree with MCIDs.
  2. 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).
  3. 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 via pdf.getDestination/getPageIndex).
  4. Alt text: struct tree nodes expose alt where present (Figures). Carry it through.
  5. 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 runs

Tag 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.png etc.
  • 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/*.png

Critical: 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-origin at the spine), ~450ms ease. Preload adjacent spreads’ images.
  • The entire visual layer has aria-hidden="true" and inert where 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-only region) container holding the current spread’s derived HTML, updated on navigation. Not display: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, Escape closes 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=7 on load and updated via history.replaceState on 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)

  1. npm i && npm run build && npx flipbook build somefile.pdf -o out/ succeeds; out/index.html opened directly from disk (no server) shows a working flipbook.
  2. Keyboard only: can reach every control, flip all pages, open/close TOC, jump to a page, switch to Reader view. Focus always visible.
  3. With the visual layer inspected: aria-hidden="true" present; accessible region contains the current spread’s semantic HTML (headings/lists/tables as real elements).
  4. Page navigation fires exactly one polite announcement.
  5. prefers-reduced-motion disables the 3D flip.
  6. Zero network requests to any origin (verify in devtools: only local files).
  7. 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.