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:
- Object/byte layer — the physical file: objects, dicts, streams, xref, trailer. The skeleton.
- Content layer (what gets painted) — drawing operators inside content
streams (
BT…Tj…ET,re, imageDo). Glyphs at coordinates, with no inherent order or meaning. - Tag layer (semantic / accessibility) — the
StructTreeRoot: headings, paragraphs, lists, tables, figures, reading order. A separate tree that points into the content via MCIDs. - The MCID glue —
/MCID n … BDCmarked-content + theParentTreeare 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 ofpdfdump --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
/ToUnicodeCMap? 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/OutlineViewin 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
| Piece | Path | Use |
|---|---|---|
| PDF render + overlay layer | apps/web/src/components/tag-editor/TagCanvas.tsx | page canvas for the divergence view |
| Reading-order thread | .../ReadingOrderThread.tsx | reading-order overlay |
| Element / figure / cell overlays | .../ElementOverlay.tsx, FigureOverlay.tsx, TableCellOverlay.tsx | highlight boxes |
| Tag-tree outline | .../TreeView.tsx, OutlineView.tsx (read-only) | tag layer panel |
| Geometry helpers (pure) | apps/web/src/lib/tag-editor/geometry.ts | bbox → CSS overlay, zoom-independent |
| Structure/table/figure/image reads | services/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 pattern | workers/api/src/routes/flyer.ts, apps/web/src/lib/api/flyer.ts | model new read endpoints on these |
Build new
| Piece | Why 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 → JSON | The 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 detector | New 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 audit | Not surfaced anywhere today. New read-op over /Resources /Font. |
| Layout diagram | New rendering; feed it the same normalized bboxes + reading order. |
| Cross-reference graph | New; 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 setNotes:
- 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_jobsjob + R2 artifact plumbing. The viewer wants the same source PDF the editor already stored; no new storage. bboxstays the normalized[x0,y0,x1,y1]top-left-origin fraction thatstructedit.pyandgeometry.tsalready 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/tagsfix UI, and a natural sink for findings fromGevell 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)
- Object browser + stream inspector (port
pdfdump→inspect.pyread-ops- two new panels). Highest value, zero coupling to the visual layer.
- Font/ToUnicode audit (small read-op, high diagnostic value).
- Divergence view — the flagship. Untagged-content + phantom-tag overlays on
TagCanvas. Requires the painted-MCID ⊖ tree-MCID diff. - Layout diagram + reading-order path (mostly reuse).
- 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/:idcheck 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
/tagseditor (its forensic sibling — “why is this failing?”), - the
Gevell PDFresults/download page, Gevell Auditfindings (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 aspdfdump --tree). - Whether the layout diagram is worth the render cost vs. just the reading-order path over the real page.