Skip to content

PDF Source Viewer/Editor — Design Spec

0. Purpose & the one distinction that matters

A PDF is not one document with layers stacked on top of each other. It is several parallel representations of the same page that are free to disagree:

  1. Object/byte layer — the physical file: objects, dicts, streams, xref, trailer. The skeleton.
  2. Content layer (what gets painted) — drawing operators inside content streams (BT…Tj…ET, re, image Do). Glyphs at coordinates, with no inherent order or meaning.
  3. Tag layer (semantic / accessibility) — the StructTreeRoot: headings, paragraphs, lists, tables, figures, reading order. A separate tree that points into the content via MCIDs.
  4. The MCID glue — /MCID n … BDC marked-content + the ParentTree are how a <P> tag claims specific glyphs. Broken glue = “tagged but inaccessible.”

The existing /tags editor already owns layer 3 exceptionally well — it renders the page, overlays tag bboxes, shows reading order as a numbered thread, and edits roles/order/alt/tables/decorative through the flyer-tagger edit-op engine (see §2). It answers “is the semantic structure correct?”

This viewer answers a different, lower-level question: “do the layers agree, and what is actually in the file?” It is a forensic / “view source” tool, not a second tagging UI. Its reason to exist is the divergence view — surfacing where the painted content and the tag tree disagree, which is exactly where the hard accessibility bugs hide. Building another rendered-page-with-tag-overlays would duplicate /tags; that is explicitly a non-goal.

The CLI companion (tools/pdfdump/) already covers layers 1–3 on the terminal (--inflate, --tree). This spec is the web counterpart, with the visual correlation the CLI structurally can’t provide.


1. Feature set (grouped by layer)

A. Object/byte layer — “view source”

  • Object browser. Left rail listing every object (N G obj), including objects unpacked from /ObjStm. Click → pretty-printed dictionary with indirect refs (12 0 R) rendered as clickable links that jump to the target object. This is the web version of pdfdump --inflate.
  • Stream inspector. For a selected stream object: raw bytes (hex-escaped), or inflated (FlateDecode + predictors), or — for content streams — the token stream with operators highlighted. Object streams expand to their members.
  • Xref / trailer / catalog. Show the document root chain (trailer → /Root → Catalog → /Pages, /StructTreeRoot, /MarkInfo, /Lang) as a navigable graph so you can see whether the doc even claims to be tagged.
  • Cross-reference graph. Optional force-directed / collapsible view of the object reference graph (Catalog → Pages → Page → Contents/Resources/…).

B. Content layer — “what is actually painted”

  • Content-stream text extraction per page: the glyph runs with their MCIDs and bboxes, in content-stream order (not tag order).
  • Font & ToUnicode audit. Per font: embedded? subset? has a /ToUnicode CMap? This is the single most common cause of “text that copies as garbage.” Flag fonts with no ToUnicode.

C. Tag layer — “the semantic document”

  • Tag tree outline (reuse TreeView/OutlineView in read-only mode).
  • Metadata panel: /Lang, /MarkInfo /Marked, document /Title + DisplayDocTitle, XMP, PDF/UA identifier presence.

D. The divergence view (the reason this tool exists)

Render the page (pdf.js) and offer togglable overlays that make disagreements between layers visible:

  • Untagged content — glyphs/images painted on the page with no MCID / not reachable from StructTreeRoot. Highlighted red. (WCAG 1.1.1 / 1.3.1.)
  • Phantom tags — structure elements whose MCIDs resolve to nothing on the page.
  • Reading-order path — the tag-tree order drawn as a numbered path over the page (reuse ReadingOrderThread), so a sidebar-read-before-headline is visible at a glance.
  • Layout diagram — a boxes-and-arrows schematic of content regions + reading order (especially for multi-column pages). This is the “create a diagram of the layout” idea from the original question.
  • Artifact vs. real content — show what’s marked /Artifact (correctly ignored by AT) vs. real content, so you can spot decorative-misclassification.

E. Editing (thin, and deferred)

Editing is not the point of this tool, and every structural edit it would want already exists as a flyer-tagger edit-op. So editing is a thin pass-through: where the viewer surfaces a fixable problem, offer a “Fix in Tag Editor” deep link to /tags?job=… rather than reimplementing reorder/role/alt UIs. Only consider inline edits for things /tags does not cover (e.g. set /Lang, set document title + DisplayDocTitle, toggle /Marked) — all metadata-level, low-risk, single-write operations.


2. Reuse vs. build-new

Grounded in the current codebase (apps/web tag-editor, services/flyer-tagger, workers/api flyer.ts).

Reuse as-is

PiecePathUse
PDF render + overlay layerapps/web/src/components/tag-editor/TagCanvas.tsxpage canvas for the divergence view
Reading-order thread.../ReadingOrderThread.tsxreading-order overlay
Element / figure / cell overlays.../ElementOverlay.tsx, FigureOverlay.tsx, TableCellOverlay.tsxhighlight boxes
Tag-tree outline.../TreeView.tsx, OutlineView.tsx (read-only)tag layer panel
Geometry helpers (pure)apps/web/src/lib/tag-editor/geometry.tsbbox → CSS overlay, zoom-independent
Structure/table/figure/image readsservices/flyer-tagger/flyer_tagger/structedit.py (walk_elements, walk_tree, walk_table), altedit.py (list_figures), decorative.py (list_image_sites)tag + content data with normalized bboxes
API contract + Lambda-invoke patternworkers/api/src/routes/flyer.ts, apps/web/src/lib/api/flyer.tsmodel new read endpoints on these

Build new

PieceWhy it doesn’t exist yet
Object browser + stream inspector UI/tags never exposes the object/byte layer at all. New React views.
Object-model parse → JSONThe CLI parser (tools/pdfdump/pdfdump.py) does this in Python; port the logic to a flyer-tagger read-op (dump_objects, inflate_stream) so the web app gets it via the existing Lambda pattern.
Untagged-content / phantom-tag detectorNew analysis: diff the set of painted MCIDs (content streams) against the set reachable from StructTreeRoot. flyer-tagger already walks both sides (inject.py maps content→MCID, structedit.py walks the tree) — the diff is the new part.
Font / ToUnicode auditNot surfaced anywhere today. New read-op over /Resources /Font.
Layout diagramNew rendering; feed it the same normalized bboxes + reading order.
Cross-reference graphNew; optional.

Explicitly NOT rebuilt

Reorder / role-change / table-restructure / alt-text / decorative-toggle UIs and edit-ops — those are /tags + flyer-tagger’s job. The viewer deep-links into /tags for them.


3. Architecture

Browser (apps/web, new route /inspect?job=<id> — always from an existing job)
├─ react-pdf canvas (reuse TagCanvas) ← visual page
├─ Object browser / stream inspector (new) ← byte/object layer
├─ Tag tree panel (reuse TreeView) ← semantic layer
└─ Divergence overlays (new, reuse overlay primitives)
│ fetches JSON via workers/api
▼
workers/api (extend flyer.ts or a parallel inspect.ts)
GET /jobs/:id/objects → object map (dicts, refs, stream meta)
GET /jobs/:id/objects/:num/stream?inflate=1 → stream bytes / inflated / tokens
GET /jobs/:id/content/:page → painted glyph runs + MCIDs + bboxes
GET /jobs/:id/fonts → font/ToUnicode audit
GET /jobs/:id/divergence/:page → {untaggedRegions[], phantomTags[]}
(reuse existing) /structure, /tree, /images, /figures, /tables
│ RequestResponse Lambda invoke (existing pattern)
▼
services/flyer-tagger (new read-ops in a new module, e.g. inspect.py)
dump_objects(path) — port of tools/pdfdump object parser
inflate_stream(path, num) — FlateDecode + predictors (already in pdfdump)
page_content(path, page) — painted runs (reuse _scan_text_anchors/_mark_texts)
font_audit(path) — embedded/subset/ToUnicode per font
divergence(path, page) — painted-MCID set ⊖ tree-reachable-MCID set

Notes:

  • All new backend work is read-only and slots into the existing Lambda invoke + 409-stale pattern. No new write-ops for MVP.
  • Reuse the flyer_tagging_jobs job + R2 artifact plumbing. The viewer wants the same source PDF the editor already stored; no new storage.
  • bbox stays the normalized [x0,y0,x1,y1] top-left-origin fraction that structedit.py and geometry.ts already speak, so overlays line up for free.

4. Where it fits in the product family

  • Positioned as the inspector for Gevell PDF / Gevell Tags — the “view source / why is this failing?” companion to the /tags fix UI, and a natural sink for findings from Gevell Audit.
  • The divergence view is the differentiated capability: cheap tools show a tag tree; almost nothing shows painted-but-untagged content correlated on the rendered page. That is where remediation effort actually goes.

5. Build order (suggested)

  1. Object browser + stream inspector (port pdfdump → inspect.py read-ops
    • two new panels). Highest value, zero coupling to the visual layer.
  2. Font/ToUnicode audit (small read-op, high diagnostic value).
  3. Divergence view — the flagship. Untagged-content + phantom-tag overlays on TagCanvas. Requires the painted-MCID ⊖ tree-MCID diff.
  4. Layout diagram + reading-order path (mostly reuse).
  5. Thin metadata edits (/Lang, title, /Marked) — only if demand appears.

6. Entry point (decided) — integrated, never a direct upload

The viewer is embedded into the existing products, keyed by an existing ?job=<id>. There is no upload-to-inspect flow. It always inspects a PDF that one of the apps has already processed and stored in R2, so it inherits:

  • Auth for free — reuse the owner-only GET /jobs/:id check already on the flyer routes; no new authorization surface.
  • Source for free — the PDF is already in R2 under that job; nothing new to store, no new job table.

Concretely it surfaces as an “Inspect / View source” affordance on the surfaces that already carry a job id:

  • the /tags editor (its forensic sibling — “why is this failing?”),
  • the Gevell PDF results/download page,
  • Gevell Audit findings (deep-link a finding straight to the relevant page + overlay).

New route lives in apps/web (e.g. /inspect?job=<id>), mirroring how /tags?job=<id> mounts today. All data comes from the read-only endpoints in §3.

7. Open questions

  • Untagged/scanned PDFs have no StructTreeRoot; the object browser + font audit still work, the divergence view degrades to “everything is untagged.” Handle explicitly (same graceful message as pdfdump --tree).
  • Whether the layout diagram is worth the render cost vs. just the reading-order path over the real page.