Status: proposal. Nothing in this repository is built.
This is a plan of work, written to be handed to a person or an AI agent and acted on. Everything in it should be up for debate, then acted on according to the best possible result within the context of the requirements stated in what "done" looks like.
Close this issue when the work lands. The design record afterwards is the git history, the
issues each decision cites, design-notes.md, API.md and the code. A requirements document that outlives its build goes stale and starts lying; Iris's last one was retired for exactly that reason. The final PR in this plan deletes the mirrored file and moves each surviving decision into the doc whose job it is.
1. What we are building, in one sentence
Give Iris a second output: the same PDF the user uploaded, now tagged for accessibility, carrying any form data they entered, and visually identical to the original in every other respect.
2. Who this is for
A person using a screen reader receives a PDF form — a benefits application, a permit, a school registration. The PDF is untagged, so their screen reader reads it as a wall of unordered text, or as nothing at all. They cannot fill it in.
Today Iris solves half of that. It converts the PDF into accessible HTML they can actually read. But the agency wants its form back, on its own paper, with the boxes filled. An HTML transcript is not a submission.
This feature closes the loop:
- Upload the PDF to Iris. (works today)
- Get accessible HTML back. (works today)
- Fill in the fields — in the browser app, or through the API. (new)
- Download a tagged PDF: the original document, with your answers in it, readable by a screen reader, and pixel-for-pixel the same page otherwise. (new)
Step 4 is the deliverable. Steps 3 and 4 are the work.
3. What "done" looks like
A maintainer can run this and get a file they would submit to a government agency:
curl -X POST http://localhost:8080/v1/sessions -F "images=@form.pdf"
# ... poll until ready_for_review, read the HTML, decide what to enter ...
curl -X POST http://localhost:8080/v1/sessions/$ID/pdf \
-H 'Content-Type: application/json' \
-d '{"values": {"applicant.name": "Ada Lovelace", "applicant.consent": true}}'
curl -o form_tagged.pdf http://localhost:8080/v1/sessions/$ID/pdf
And all five of these are true of form_tagged.pdf:
- It is tagged. It has a structure tree whose reading order is the reading order Iris worked out, with headings as headings, tables as tables, lists as lists, and images with alt text.
- It carries the data.
applicant.name shows "Ada Lovelace" and the consent box is ticked.
- Nothing else moved. Rendered at 150 DPI and compared to the original page by page, every pixel outside the filled fields is identical. This is checked by the tool itself, not asserted.
- It was produced on this machine. No network call was made. No account was needed.
- It says what it could not do. A machine-readable report lists every page, every field, and every thing the source file made impossible.
4. Non-goals
- Not a PDF beautifier. We do not re-flow, re-typeset, re-render, or "clean up" anything.
- Not a PDF writer. We never build a new PDF from the HTML. See §7.1 for why that is the wrong idea and not merely a different one.
- Not a remediation suite. We do not fix a source file's non-embedded fonts, broken ToUnicode tables or missing colour contrast. We report them.
- Not for sessions that uploaded images. No source PDF, no tagged PDF. Clear error, no fallback. This will only be triggered if the user is looking at html of a pdf.
- Not a service. The new component is a library and a command. It listens on no port.
5. Constraints that cannot be traded
These come from Iris and apply to every line of this work.
- Open source only. Every dependency has an OSI-approved licence and a public source repository. No third-party API, no freemium tier, no "free for non-commercial", no account, no key.
- AGPL-3.0-or-later. The new repository is AGPLv3, same as Iris. Every dependency must be compatible with distributing it under AGPLv3.
- One machine. A laptop, a Mac Mini, a self-hosted box. No AWS, no GCP, no Azure, no hosted \database, no object store.
- No network at runtime. The tagger makes zero outbound connections. This is testable, and §15 requires the test.
- Concise plain language in the docs. [CONTRIBUTING.md § Documentation (https://github.com/EqualifyEverything/equalify-iris/blob/main/CONTRIBUTING.md#documentation) states the rules, and they cover everything this work writes.
- Plain language is used throughout documentation and coding that isn't overly verbose. Simple. To the point. Clear.
6. Decision: a new repository
Build this as a new repository — equalify-iris-pdf — that ships a library and a command-line tool, plus a small, listed set of changes inside equalify-iris.
Why not a feature inside Iris
Iris's first stated constraint is "content only — no CSS, no visual fidelity, no pixel-perfect layout". Tagging a PDF in place is nothing but coordinates and visual fidelity. It is the opposite discipline, with an opposite test strategy: Iris's tests mock a model and assert on prose, and this tool's tests take fixture PDFs and assert on bytes and pixels. Iris's CI would grow a PDF corpus, a rasterizer comparison and eventually a Java validator, none of which any existing test needs.
Why not a service
A second service breaks "one machine, no vendor lock-in" — another port, another config file, another gate, another thing to deploy. Iris already shells out to a local binary (pdftoppm,pdfinfo, pdftohtml) and degrades cleanly when it is missing. iris-pdf is the fourth one.
That pattern already exists in the codebase, an operator already installs poppler, and running the work in a child process keeps Iris's single event loop free — which matters, because tagging a 25-page document is seconds of CPU and Iris serves every request on one thread.
What lives where
| Repository |
Holds |
Knows about |
equalify-iris-pdf (new) |
Everything that touches a PDF's bytes: the structure tree, content-stream marking, form filling, pixel verification, the report |
The shape of Iris's HTML. Nothing else about Iris — no config, no database, no HTTP, no model |
equalify-iris |
Keeping the source PDF, reading the form fields out of it, telling the page agent about them, the endpoints, the browser UI |
That iris-pdf exists and may not be installed |
The boundary is a pure function: source PDF + per-page HTML + values → tagged PDF + report. No
state, no clock, no network, no randomness. That is what makes it testable, and it is why this split
is worth two repositories instead of one.
7. How the tagging works
Read this section before writing any code. The method is the whole design.
7.0 What a tagged PDF is
A gloss, because the rest of this document assumes it.
A PDF page is a list of drawing instructions — "set this font", "draw these glyphs here", "paint this image". Nothing in that list says which glyphs are a heading and which are a footnote, and nothing says what order a human reads them in. A screen reader given only that list has to guess, and it guesses from geometry, which is why an untagged two-column page is read straight across the columns.
A tagged PDF adds a second, parallel structure — the structure tree — that says: this run of glyphs is an H1, this one is a P inside a TD inside a Table, they are read in this order, and this image is a Figure whose alt text is "Organisation chart". The tree hangs off the document catalog as /StructTreeRoot. It points into the page's drawing instructions using marked content: the instructions get wrapped in BDC/EMC operators carrying a number (an MCID), and a structure element names the page and the MCID. Anything on the page that is decoration rather than content —a running head, a page number, a rule — is wrapped as an artifact instead, which tells a screen reader to skip it.
BDC, EMC and BMC change nothing about rendering. They are bookkeeping. That is what makes this
whole feature possible: you can add a full structure tree to a page without moving a single pixel.
7.1 Why we do not re-render from the HTML
The obvious idea — take Iris's HTML, run it through an HTML-to-PDF renderer, ship that — is wrong, and it is worth being explicit about because it is what everyone suggests first.
Iris's HTML is content-only by design. It has no CSS, no columns, no page geometry, no logos, no signature rules, no boxes. A PDF rendered from it is a different document that happens to contain the same words. Hand that to the agency and they reject it: it is not their form. The requirement is "no other visible changes", and a re-render changes every visible thing there is.
So the original PDF is the substrate. The HTML is the map.
7.2 The method: artifact-and-overlay
For each page, in order:
- Mark the whole existing content stream as an artifact. Prepend
/Artifact BMC to the page's content and append EMC. No parsing: a valid page's content stream is already balanced with respect to q/Q and BT/ET, so a wrapper around all of it is properly nested by construction. If /Contents is an array of streams, prepend to the first and append to the last — the array is concatenated before interpretation.
- Get the page's words and where they sit.
- If the page has a text layer, take it from mupdf's structured text: every character with its origin, its quad, its font and its size.
- If the page has no text layer (a scan), run Tesseract to get word boxes. The OCR text is used only for positions. See §7.4.
- Align those words to Iris's HTML for that page. §7.5. The result is: for every element in the HTML, the list of word boxes on the page that element's text occupies.
- Append a new content stream to the page holding an invisible text layer: text render mode 3 (
3 Tr), Iris's text, positioned and scaled to sit exactly over the word boxes from step 3. It draws nothing. It is selectable, searchable and readable by assistive technology.
- Mark that new stream up as you write it. You author it, so you know exactly where every element begins and ends: emit
/P <</MCID 7>> BDC … EMC around each run as you go. No content stream analysis is needed anywhere in this method.
- Build the structure tree from Iris's HTML using the map in §8, pointing each element at the MCIDs you just wrote.
- Handle the non-text parts. Images become
/Figure elements with /Alt from the HTML's alt text, referencing the image by a marked-content id written into the overlay stream at the image's position. Link annotations get /Link elements. Form widgets get /Form elements. §8, §9.
- Set the document-level requirements. §8.3.
Step 1 plus step 4 is the trick. Because the original content is an artifact and the accessible content is something we wrote, we never have to understand, parse or modify anyone else's drawing instructions. That removes the single hardest and most fragile part of building a tagger.
7.3 The cost of this method, stated plainly
On a page that already had a text layer, the words now exist twice: once in the original content
(marked as an artifact) and once in the overlay (marked as content).
- A conforming consumer sees them once. Acrobat, NVDA, JAWS and VoiceOver read the structure tre and skip artifacts. That is what "artifact" means.
- A naive text extractor sees them twice.
pdftotext, a plain grep-the-PDF script, and some simple in-browser viewers concatenate every text operator regardless of marking. Copying a paragraph in such a viewer can yield it doubled.
This is a real defect and it is the reason §17 (in-place tagging) exists. It is accepted for v1 because the alternative — parsing, decoding and rewriting arbitrary content streams — is the part of this project most likely to produce a broken PDF, and shipping a working tagger for scanned documents first is worth more than shipping nothing.
v1 must print this caveat in the report for every page where a text layer already existed, as warning code duplicate_text_layer. Do not bury it.
7.4 Scanned pages
A scanned page has no text layer. It is one image. Iris's own corpus is mostly these, so this path is not an edge case — it is the main one.
- Word boxes come from Tesseract (
tesseract <img> - tsv), which gives one row per word with its bounding box and confidence.
- The text placed in the overlay is always Iris's, never Tesseract's. Tesseract supplies geometry; the vision model supplies the words. Where they disagree, Iris wins, because Iris is what the human reviewed and corrected.
- Render the page for OCR at 300 DPI, not the 150 DPI Iris uses for the vision model. OCR word boxes get meaningfully better and the image is thrown away afterwards.
- If Tesseract is not installed and a page has no text layer, that page fails with code
no_text_positions. Do not fall back to a whole-page /Figure with the transcript in /Alt: a multi-kilobyte /Alt string is one unnavigable blob and several readers truncate it.
--ocr off skips OCR entirely and fails such pages the same way. --ocr required fails the run if Tesseract is missing at all. --ocr auto (default) uses it when a page needs it and it is there.
7.5 Aligning HTML text to word boxes
This is the one genuinely fiddly algorithm. Specify it as its own module with its own tests.
Input: the page's word boxes in the page's own order, and the page's HTML fragment.
Output: for each text node in the HTML, the boxes it occupies; and for each box, the element that claimed it or null.
Method:
- Flatten the HTML fragment to a list of text runs, each carrying a path to its element. Reuse the thinking in
src/pipeline/flatten.ts but not the code — this needs element identity, which the screen-reader view deliberately throws away.
- Normalise both sides the same way: lower-case, strip accents for comparison only, collapse whitespace, drop soft hyphens, join a word the source broke at a line end (Iris already does this in
src/pipeline/hyphens.ts — the same rule applies here in reverse).
- Align the two token sequences with a standard sequence alignment (Needleman–Wunsch over words, or an anchor-and-recurse diff). Word-level, not character-level: the sequences are hundreds of tokens long, not tens of thousands, so an O(n·m) alignment is cheap and exact.
- Reading order will differ between the two sides, and that is the point — Iris fixed the column order and the PDF's operator order did not. So align on content, and never assume the two orders match. A two-column page must produce a correct alignment; there is a fixture for it.
What the alignment produces, and what each outcome means:
| Outcome |
Meaning |
Action |
| Matched |
The word is in both |
Tag it as the element says |
| In HTML, not on the page |
Iris added scaffolding — a <caption> it generated, an [not legible] marker, alt text |
Emit it in the overlay at the position of its neighbours, tagged. It is content |
| On the page, not in HTML, and inside the top or bottom 10% of the page or matching the page's own number |
Page furniture. Iris strips these deliberately (src/pipeline/markers.ts) |
Already an artifact from step 1. Nothing to do. Count it in the report |
| On the page, not in HTML, anywhere else |
Iris lost content |
Warning unmatched_text with the page, the text and the box. Emit it in the overlay as a bare /P so it is not silently dropped |
That last row matters. The easy bug here is to treat every unmatched word as decoration, which
quietly deletes from the accessible document exactly the words the model missed. Unmatched text
inside the body of the page is a reported failure, never an artifact.
8. Mapping Iris's HTML to PDF structure
8.1 The element map
Implement as one table in one file. Anything not in the table maps to P and raises warning
unmapped_element naming the tag, so the gap shows up in a report instead of silently disappearing.
| Iris HTML |
PDF structure type |
Notes |
| document root |
Document |
One per PDF, the tree's only child of /StructTreeRoot |
<h1>…<h6> |
H1…H6 |
Iris lints heading order with axe-core before delivering (src/pipeline/lint.ts), so take the level as given |
<p> |
P |
|
<ul>, <ol> |
L |
/ListNumbering from the list type |
<li> |
LI |
The bullet or number becomes Lbl; the rest becomes LBody |
<dl> |
L |
<dt> → Lbl, <dd> → LBody, each pair in one LI |
<table> |
Table |
<caption> → Caption. A table Iris joined across a page break (src/pipeline/tables.ts) stays one Table whose rows name two different pages. That is legal and correct |
<thead>, <tbody>, <tfoot> |
THead, TBody, TFoot |
|
<tr> |
TR |
|
<th> |
TH |
Carry scope to /Scope (Row, Column, Both). Give every TH an /ID and every TD a /Headers array |
<td> |
TD |
colspan/rowspan → /ColSpan//RowSpan |
<figure> |
Figure |
<figcaption> → Caption, as a sibling inside the Figure |
<img> |
Figure |
alt → /Alt. alt="" → /Artifact instead, no structure element |
<a href> |
Link |
§8.2 |
<blockquote> |
BlockQuote |
|
<code>, <pre> |
Code |
|
<form> |
Form per control, not per form |
PDF has no container form type. The <fieldset>/<legend> becomes a Sect with /T from the legend |
<input>, <select>, <textarea> |
Form |
§9 |
<label> |
— |
Not an element of its own. Its text becomes the widget's /TU and the Form element's /Alt |
<hr role="doc-pagebreak"> |
— |
Not emitted. It is Iris's page separator, and a PDF already has pages |
<nav>, <aside>, <section> |
Sect |
aria-label → /T |
<sup> footnote reference |
Reference |
Target note → Note with an /ID |
lang attribute anywhere |
/Lang on that element |
Only where it differs from the enclosing element |
8.2 Links
Iris already knows a PDF's links: src/util/pdf.ts extracts them and src/pipeline/links.ts feeds
them to the page agent and checks they came back. Use that.
A Link structure element's /K array holds the MCIDs of the link's text and an object
reference to the annotation:
<< /Type /StructElem /S /Link /P … /Pg …
/K [ 12 << /Type /OBJR /Obj 47 0 R >> ] >>
Set /Contents on the annotation to the link's purpose (the HTML anchor text) — that is the
annotation's accessible name and untagged PDFs almost never have it. Give the annotation a
/StructParent pointing back through the number tree.
Internal links (#fragment) that Iris generated for footnotes and cross-references have no PDF
annotation behind them. Emit the Reference/Note pair from the table above and skip the
annotation. Do not invent a /GoTo annotation — that adds a clickable region the original page did
not have, which is a visible change on hover and a behavioural change on click.
8.3 Document-level requirements
All of these, every time:
/MarkInfo << /Marked true >> in the catalog.
/StructTreeRoot with /K, /ParentTree, /ParentTreeNextKey.
/StructParents on every page dict, /StructParent on every annotation, and a /ParentTree
number tree that resolves both. Page entries are arrays indexed by MCID; annotation entries are a
single reference.
/Lang on the catalog, from the HTML root's lang. Iris derives that carefully
(bodyLang in src/pipeline/assembly.ts) and refuses to guess; if it refused, take the
--lang argument, and if that is absent, fail with no_document_language. A wrong /Lang makes a
screen reader read the document in the wrong accent, which is worse than being asked.
/ViewerPreferences << /DisplayDocTitle true >>.
- A title, in both the document info dictionary and XMP
dc:title. Use Iris's <title>, which
already mirrors the uploaded filename (src/util/outputNames.ts).
- XMP metadata declaring PDF/UA-1:
pdfuaid:part = 1.
- Every glyph the overlay draws comes from a font embedded in the output with a correct
/ToUnicode CMap. Verify mupdf writes one; if it does not, write it (the format is small — a
begincmap/beginbfchar stream). Pick the font by the document's script and embed a subset. A
character with no glyph in the chosen font is warning missing_glyph, listing the characters — do
not substitute and do not drop.
8.4 PDF/UA conformance: what we promise
We promise a tagged PDF. We do not promise a PDF/UA-conformant PDF, because the source file can make that impossible and we are not allowed to change how the page looks.
Two examples. If the source embeds no font, PDF/UA fails and embedding one would change the typeface. If the source's existing text has a broken /ToUnicode table, PDF/UA fails and fixing it means re-encoding text we have marked as an artifact.
So the contract is: every PDF/UA-1 clause we can satisfy without changing a pixel, we satisfy. Every clause the source makes unreachable is named in the report, with which clause and why. That is a stronger promise than a conformance badge, because it is checkable and it never hides anything.
9. Forms
9.1 Field identity: how an HTML input maps to a PDF field
This is the crux of the feature and it is solved by copying a pattern Iris already has.
Iris extracts a PDF's link targets at upload time and hands them to the page agent as ground truth, because a rasterized page cannot show where a link points. A rasterized page cannot show a field's name either. So do exactly the same thing with form fields:
- At upload, read the AcroForm (the PDF's interactive-form dictionary) and write
sessions/<id>/fields.json, keyed by the page's processing order — the same shape and the same reason as the existing links.json.
- Add a "Form fields on this page" section to the page agent's prompt, listing each field's fully qualified name, type, options and whether it is required.
- Tell the agent to put the field's name in the
name attribute of the <input> it emits for that field, exactly as given.
- Check deterministically that it did, the way
missingLinks checks links. A field name in the ground truth that appears in no name attribute in that page's HTML is a fidelity problem, fed back to the correction pass with the source image.
Why the model rather than a string substitution: deciding which transcribed label belongs to a field rectangle is the same judgement the extraction is. The existing links code makes this argument in full at the top of src/pipeline/links.ts — read it before you disagree with it.
Shape for radio groups and checkbox groups: name is the group's fully qualified name and value is that option's export value.
9.2 The values contract
Values are a flat JSON object keyed by fully qualified field name. Types are fixed by field type, not by the caller:
| Field type |
JSON value |
Rules |
| Text |
string |
Respect /MaxLen; a longer string is an error, never a silent truncation |
| Checkbox |
boolean |
The tool resolves the on-state name by reading the widget's /AP /N keys. Callers never send "Yes", "On" or "1" |
| Radio group |
string |
Must be one of the widget's states. Anything else is an error naming the states |
| Combo / list box |
string, or array when multi-select |
Must be in /Opt unless the field is editable |
| Push button |
— |
Not settable. Error field_not_settable |
| Signature |
— |
Not settable. Warning signature_field_skipped |
| Read-only field |
— |
Skipped, counted in the report as skippedReadOnly |
Set values through mupdf's widget API (setTextValue, setChoiceValue, toggle), which regenerates the appearance stream. Do not set /NeedAppearances true and hope: several viewers ignore it, and a form whose values are invisible in Preview is a failed submission.
Every filled widget also gets:
/TU set to the label text from the HTML — that is the accessible name a screen reader announces.
- A
Form structure element containing an /OBJR reference to the widget, placed in the structure
tree where the <input> sits in the HTML, so the field is read in the right place.
/StructParent wired into the parent tree.
/TU is worth doing even when no value is set. A blank tagged form that announces its fields
correctly is already a large improvement on a blank untagged one.
9.3 Flattening
--flatten replaces the interactive fields with their drawn appearance and removes the widgets. Off by default. Some agencies require a flattened submission and some require a live form; neither is our call. When flattening, the Form structure elements become P elements carrying the same text, so the values stay readable.
9.4 Forms with no AcroForm
A scanned form has ruled lines and boxes but no fields. There is nothing to set, so entered values have nowhere to go without drawing them onto the page — and deciding where is guesswork.
v1 refuses. If values are supplied and the PDF has no matching field, fail with no_acroform_field naming each value that had nowhere to go. Do not guess a position.
§17 describes the v2 feature that handles this properly.
10. The two guarantees, and how they are enforced
Both run inside the tool, before it writes anything. Both are on by default. Both fail the run rather than warn.
10.1 Pixels
Render every page of the input and of the output with the same rasterizer (mupdf) at the same DPI (150 by default, --verify-dpi to change) and compare.
- Outside the rectangles of fields whose values changed: byte-identical pixels, tolerance zero.
- Inside those rectangles: no constraint. That is where the data is.
- On failure: name the page, the count of differing pixels, and the bounding box of the difference. Write the two renderings next to the output path when
--verify-dump is set, because the first thing anyone will want is to look at them.
This is what turns "no other visible changes" from a promise into a test. It also catches the whole class of bugs where an unbalanced marked-content operator corrupts the graphics state.
10.2 Text
Extract the text of the input and of the output. Every word in the input must still be in the output. Words may be added — the overlay adds Iris's text and the fields add values — but nothing may be lost. On failure, name the page and the missing text.
Cheap, and it catches a content stream damaged in a way that happens to still render.
11. What we refuse, and what we say
Each refusal is a distinct exit reason with a distinct code. None of them is a crash and none is a silent degradation.
| Situation |
Code |
Behaviour |
| Encrypted, no password supplied |
encrypted |
Refuse. --password accepts one |
| Encrypted with an owner password restricting modification |
permissions_denied |
Refuse. Do not circumvent it |
| Carries a digital signature |
signed |
Refuse by default; --allow-signed proceeds and the report says the signature is invalidated, because any change invalidates it |
| Dynamic XFA form |
xfa |
Refuse. The visible form is generated at open time and is not in the page content |
| No source PDF (session uploaded images) |
no_source_pdf |
Refuse |
| More than one PDF in the session |
multiple_sources |
Refuse in v1. §17 |
| A page has no text layer and no OCR available |
no_text_positions |
Fail that page, name it, continue only if --partial is set |
| Pixel check failed |
pixels_changed |
Refuse to write the output |
| Text check failed |
text_lost |
Refuse to write the output |
| More than 25 pages |
too_many_pages |
Refuse. Matches Iris's existing MAX_PDF_PAGES |
Save with an incremental update (mupdf saveToBuffer with the incremental option). The original
bytes stay in the file untouched and the changes are appended, so the change is auditable and nothing
in the original is re-compressed or re-encoded. Note the size cost in the report.
12. Dependencies and their licences
| What |
Licence |
Why |
Runtime or build |
mupdf (mupdf on npm, 1.28.x) |
AGPL-3.0-or-later |
The PDF engine. Full low-level object access (newDictionary, addObject, addStream, getTrailer), typed form widgets with appearance regeneration, per-character text positions, and a rasterizer for the pixel check. WASM, so no native build step |
Runtime |
Tesseract (tesseract-ocr) |
Apache-2.0 |
Word boxes on scanned pages. Invoked as a subprocess, like poppler |
Runtime, optional |
| veraPDF |
GPLv3+ / MPLv2 |
The PDF/UA validator, for the conformance report and CI |
Build and CI, optional |
| poppler |
GPL-2.0-or-later |
Already an Iris dependency. Not needed by the new tool |
Iris only |
On mupdf's licence. It is AGPL-3.0-or-later, which is the same licence as Iris and therefore fine
here. It does mean neither Iris nor this tool can be combined into proprietary software — which is
already true of Iris, and is the point of choosing AGPL. Artifex also sells commercial MuPDF
licences; nothing in this plan needs one.
Fallback if mupdf is rejected. @cantoo/pdf-lib (MIT, actively maintained) for object-level work
plus pdfjs-dist (Apache-2.0) for text positions plus poppler for rendering. It is a permissive
stack and three dependencies instead of one, and pdf-lib has no form-appearance engine, so every
filled field's appearance stream would have to be drawn by hand. Choose it only if the AGPL of a
dependency is a real blocker for a real user. Note that the original pdf-lib (1.17.1, last released
2022) is not maintained; do not use it.
13. Repository A: equalify-iris-pdf
13.1 Layout
src/
index.ts # the library API: tag(), fields()
cli.ts # the iris-pdf command
pdf/
document.ts # open, save incrementally, refusals from §11
struct.ts # StructTreeRoot, StructElem, ParentTree, MCID allocation
content.ts # artifact-wrap an existing stream; write the overlay stream
fonts.ts # pick, subset, embed; write ToUnicode
widgets.ts # AcroForm inventory, typed value setting, /TU, /OBJR
metadata.ts # XMP, /Lang, /ViewerPreferences, title
html/
parse.ts # Iris's HTML -> an element tree with text runs
map.ts # the §8.1 table, and nothing else
align/
words.ts # normalize + tokenize both sides
align.ts # the sequence alignment
classify.ts # matched / added / furniture / lost (the §7.5 table)
ocr/
tesseract.ts # subprocess, TSV parsing, "not installed" as a value not a throw
verify/
pixels.ts # §10.1
text.ts # §10.2
pdfua.ts # veraPDF, optional
report.ts # the report type, and the only place it is built
test/
fixtures/ # the corpus, §15
*.test.ts
Node 24, TypeScript run directly through Node's type stripping, node --test. Same toolchain as Iris, for the same reason: one runtime to install.
13.2 The command
iris-pdf tag --pdf <in.pdf> --pages <pages.json> [--values <values.json>] --out <out.pdf>
[--report <report.json>] [--lang <bcp47>] [--title <text>]
[--ocr auto|off|required] [--verify pixels,text|off] [--verify-dpi 150]
[--flatten] [--password <pw>] [--allow-signed] [--partial] [--strict]
iris-pdf fields --pdf <in.pdf> [--json]
# the AcroForm inventory: name, type, page, rect, options, required, readonly, maxlen.
# This is what Iris calls at upload time. It is why Iris needs no PDF library of its own.
iris-pdf check --pdf <in.pdf>
# veraPDF against PDF/UA-1, if veraPDF is installed. Exits 0 if it is not, saying so.
pages.json, the map from PDF page to Iris HTML:
{ "pages": [ { "sourcePage": 1, "html": "<h1>Application</h1>…" } ] }
sourcePage is 1-based and refers to the PDF's own page number. Iris builds this file; see §14.2 for why the page number has to be carried explicitly and cannot be counted out of the HTML.
Exit codes: 0 success, 1 refused (§11), 2 verification failed, 3 bad arguments or unreadable input. Every non-zero exit prints one line naming the code, and the full detail goes to the report.
13.3 The report
One JSON object. It is the feature's honesty surface, so it is a first-class output, not a log.
{
"tool": "iris-pdf 0.1.0",
"source": { "pages": 4, "encrypted": false, "signed": false,
"acroform": true, "xfa": false, "hadTextLayer": [1,2,3,4] },
"pages": [ { "page": 1, "textSource": "pdf-text", "words": 412,
"matched": 401, "addedFromHtml": 3, "furniture": 8,
"lost": 0, "mcids": 214 } ],
"structure": { "elements": 431,
"byType": { "P": 220, "H2": 12, "Table": 2, "Figure": 3,
"Link": 9, "Form": 22 } },
"form": { "fields": 22, "set": 18, "skippedReadOnly": 2, "unresolved": [] },
"verification": { "pixels": "identical-outside-fields", "differingPixels": 0,
"textPreserved": true },
"pdfua": { "checked": true, "profile": "PDF/UA-1", "passed": false,
"failures": [ { "clause": "7.21.4.2", "detail": "font Helvetica not embedded",
"inherited": true } ] },
"warnings": [ { "code": "duplicate_text_layer", "page": 1 } ]
}
"inherited": true means the source file caused it and we could not fix it without changing a pixel.
That flag is what makes §8.4's promise checkable.
14. Repository B: changes inside equalify-iris
Six changes. Each is a separate PR.
14.1 Keep the source PDF
Today Iris throws the uploaded PDF away. src/routes/sessions.ts rasterizes it, writes the PNGs
into input/, and the original buffer goes out of scope. Nothing can be tagged later because nothing
was kept.
- Add
paths.sessionSource(id) → sessions/<id>/source/, and write each uploaded PDF there as
<uploadIndex>__<sanitized name>.pdf.
- Add
paths.sessionPageMap(id) → sessions/<id>/pages.json, mapping each page's processing order
to its source file and the page number inside it.
- Storage cost: a PDF is almost always smaller than the PNGs Iris already keeps for it. Note it in
config.example.yaml under storage anyway.
14.2 The page map is not optional
Do not try to recover page numbers from the delivered HTML later. Two reasons, both load-bearing:
- A page the extractor accepted as blank emits no page-break marker
(src/pipeline/extraction.ts), so counting markers undercounts pages.
- A marker's
aria-label carries the page's printed number, which is not its position — a
document numbered i, ii, 1, 2 is ordinary.
The per-page fragments in sessions/<id>/fragments/final.json carry order, which is what everything else in Iris counts by. pages.json maps order to sourcePage. Build pages.json fo iris-pdf from those two.
14.3 Form fields as ground truth
New file src/pipeline/fields.ts, written as a sibling of src/pipeline/links.ts. Read that file
first; this one mirrors its structure, its prompt-section approach and its verification.
- At upload, call
iris-pdf fields and write sessions/<id>/fields.json, keyed by page order.
pageFieldContext(fields) returns the prompt section, the fields shown and the count dropped,
exactly like pageLinkContext. Cap it the way links are capped, and log the truncation.
missingFields(html, fields) returns the fields whose name reached no name attribute. Feed the
result into the fidelity check in src/pipeline/extraction.ts next to missingLinks.
- Add the field rules to
agents/page.md. The prompt already tells the model to render a fill-in
block as a <form> with <label>/<input> and to transcribe a filled field as
<input readonly value="…"> — this adds "and put the field's name in name, exactly as listed".
- If
iris-pdf is not installed, fields.json is simply absent. The page agent gets the prompt it
gets today and the run is unaffected. Same degradation as poppler missing for links.
14.4 Endpoints
| Method & path |
Purpose |
GET /v1/sessions/{id}/fields |
The form fields found in the source PDF: name, type, page, label, options, required, readonly, maxlen. What a client needs to build a fill UI without parsing HTML |
POST /v1/sessions/{id}/pdf |
Body {"values": {…}}, optional — a document with no form still gets tagged. Enqueues the job, 202 with its state |
GET /v1/sessions/{id}/pdf |
The tagged PDF. 409 while it is not ready, with the state. Content-Disposition: attachment; filename="<base>_tagged.pdf" |
GET /v1/sessions/{id}/pdf/report |
The §13.3 report |
- Gated by
server.api_token like the rest of /v1/sessions.
- Runs on the existing run queue (
src/util/queue.ts, defaults.max_concurrent_runs). That
queue exists to bound what the machine is doing, and this is the machine doing something.
- The child process gets a timeout and its stderr goes to the run log. It writes the report to
--report; Iris reads it from there and serves it unchanged.
GET /v1/sessions/{id} gains pdf — null, or {status, updated_at}.
GET /v1/limits gains tagged_pdf: true|false, so a client can hide the button on a deployment
without the tool installed.
- Update docs/API.md in the same PR. That is the rule in
README § Working on Iris.
14.5 Config
tagged_pdf:
# Off unless the iris-pdf command is available. Blank means "find it on PATH".
enabled: true
command: iris-pdf
# Refuse to write an output whose pages do not render identically to the source
# outside the fields that were filled. Turning this off is not recommended and
# the report says it was off.
verify: true
# Word boxes for pages with no text layer. auto = use Tesseract when a page needs
# it and it is installed; required = fail the run if it is missing; off = never.
ocr: auto
# Validate against PDF/UA-1 with veraPDF and include the result in the report.
# Off by default: it needs a Java runtime.
pdfua_check: false
# How long one document may take before the child process is killed.
timeout_seconds: 300
14.6 The browser app
public/demo.html gains a third action next to "Download HTML":
- Render the fields from
GET /v1/sessions/{id}/fields as a real HTML form, with the label Iris
transcribed, the right control for the type, and required where the PDF says so.
- The form must itself be accessible. That is the whole point of this product and the demo page
is already tested for it (test/demo-a11y.test.ts). Extend that test to cover the new form.
- "Download tagged PDF" posts the values and polls.
- Show the report's warnings in plain language. A user who learns their form came back with two
fields unset needs to learn it here, not from the agency.
The delivered HTML document does not change. It stays a content-only document with no script and
no submit target. Filling happens in the app or through the API, never by turning the deliverable
into an application.
15. The test corpus
Without fixtures this project cannot be reviewed, so build the corpus first. Every fixture is a small
PDF committed to the repository, with a licence that permits redistribution — a US federal form
(public domain), a document the project generates itself, or an openly licensed sample. No fixture
contains real personal data.
| Fixture |
Exercises |
text-simple.pdf |
Born-digital, one column, headings and paragraphs. The happy path |
text-two-column.pdf |
Reading order differs between the PDF's operator order and Iris's HTML. The alignment test that matters |
scan-300dpi.pdf |
Image-only. The OCR path |
scan-skewed.pdf |
Image-only, slightly rotated. Word boxes that do not sit on a grid |
form-acroform.pdf |
Text fields, checkboxes, a radio group, a combo box. The filling path |
form-flat.pdf |
A scanned form with ruled lines and no fields. Must refuse cleanly in v1 |
table-across-pages.pdf |
A table Iris joins across a page break, which must stay one Table |
links.pdf |
Link annotations, including one whose text wraps across two lines |
mixed.pdf |
Page 1 born-digital, page 2 scanned. Per-page method selection |
signed.pdf |
Must refuse unless --allow-signed |
encrypted.pdf |
Must refuse without --password |
cjk.pdf |
A non-Latin script. Font embedding and /ToUnicode |
blank-page.pdf |
A page Iris declares blank. No marker, no structure, no crash |
Each fixture ships with the Iris HTML for it, captured once and committed, so the tagger's tests need
no model and no network.
One test asserts the no-network rule directly: run the full corpus with outbound sockets stubbed
to throw, and fail if anything tries to open one. It is the cheapest possible guard on the constraint
that matters most to the people deploying this.
16. Build order
Each milestone ends with something that runs and something that is checked. Do not start the next one
until the previous one's exit criteria hold.
M1 — Skeleton and refusals. The repository, the CLI, iris-pdf fields, and every refusal in §11.
Exit: iris-pdf fields --pdf form-acroform.pdf prints the correct inventory; each refusal fixture
exits with its code and its one-line message.
M2 — The structure writer. pdf/struct.ts, pdf/content.ts, pdf/metadata.ts. Given a
hand-written mapping, produce a tagged PDF. No HTML, no alignment.
Exit: a fixture comes back with a valid /StructTreeRoot, a resolving /ParentTree, and pixels
identical to the input. Assert on the object graph, not on a byte hash.
M3 — The pixel and text gates. §10, wired into the CLI and on by default.
Exit: a deliberately corrupted content stream is caught and the output is not written.
M4 — HTML mapping and alignment. html/, align/. The §8.1 table and the §7.5 algorithm.
Exit: text-simple.pdf and text-two-column.pdf produce a structure tree whose reading order
matches the HTML, verified by walking the tree and comparing to the HTML's text order. The two-column
fixture is the one that proves it.
M5 — Scanned pages. ocr/, and the per-page method choice.
Exit: scan-300dpi.pdf produces selectable, tagged text carrying Iris's wording, not Tesseract's;
mixed.pdf uses a different method on each page; with Tesseract uninstalled the scanned page fails
with no_text_positions and the born-digital page still succeeds.
M6 — Forms. pdf/widgets.ts, the §9.2 contract, /TU, Form elements, --flatten.
Exit: form-acroform.pdf plus a values file comes back with the values visible, the fields
announced by name, and pixels identical outside the filled rectangles.
M7 — Iris integration. §14.1 to §14.4. Source PDF kept, page map, fields ground truth, endpoints.
Exit: ./test/e2e.sh covers the full lifecycle — upload a PDF, convert, read fields, post values,
download a tagged PDF — against mocks, with no credentials.
M8 — The app and the docs. §14.6, docs/API.md, docs/design-notes.md, config.example.yaml,
the README's dependency line and further-reading table.
Exit: a person can do the whole thing at http://localhost:8080/ with no API call; the demo
accessibility test passes with the new form; this PRD is deleted.
M9 — Conformance. veraPDF in CI over the corpus, inherited classification, the pdfua block of
the report.
Exit: every fixture's failures are either zero or marked inherited with a named clause.
M1–M6 are equalify-iris-pdf. M7–M8 are equalify-iris. M9 is both. M1–M3 and M4 can proceed in
parallel once the report type in M1 is fixed.
17. Deliberately deferred
Named so they are not re-discovered as gaps.
- In-place tagging (v2). Removes §7.3's duplicate text layer by marking the original text
operators instead of overlaying new ones. Needs a content-stream tokenizer and a text-state tracker.
The technique that avoids font metrics entirely: track only the explicit positioning operators
(Tm, Td, TD, T*, TL), take the first text-showing operator's origin from them, and let
mupdf's per-character origins supply the advance for each subsequent operator on the line. Do this
only once v1 is in use and the corpus is real.
- Filling a flat form (v2). Create real AcroForm fields at detected blanks, then fill them. The
rule that makes it safe: a value may only be drawn where the original page has no ink, which the
§10.1 pixel machinery can check directly. Refuse anything else.
- Multiple PDFs in one session (v2). One tagged PDF per source, selected by
GET /v1/sessions/{id}/pdf?source=N.
- Building a PDF from uploaded images (v3). Images become pages, overlay carries the text. Falls
out of the v1 machinery nearly for free, but it invents a document rather than preserving one, so
it is a separate decision.
- PDF/UA-2. Wait until veraPDF's PDF/UA-2 profile and reader support are both settled.
18. Privacy, and why it is in this document
Entered form values are the most sensitive data Iris will ever hold. A benefits application has a name, an address, a date of birth, sometimes a national identity number. Today Iris holds documents people chose to upload; after this change it holds what they typed into them.
Iris has no sign-in and no session isolation by design: GET /v1/sessions lists the deployment's
sessions, and a session id is all it takes to read one (README). That is a defensible trade for a document conversion service. It is a different trade once the session holds a filled form.
So this work carries five requirements, and they are not optional:
- Values never reach the run log. Log that a field was set and its name. Never its value.
- Values never reach GitHub. The contribution path files agent suggestions upstream under the deployment's own account. Nothing from
values.json may enter an issue body, an agent draft or a regression fixture. Add a test that asserts it.
- Values never reach a model provider. The tagger makes no model calls at all. But a feedback re-run re-extracts from the source images, and a page image of a form the user filled in the PDF already went to the provider today. State that plainly in the docs rather than implying more privacy than exists.
POST /v1/sessions/{id}/close deletes values.json and the tagged PDF. The HTML and the fixtures are what close is for; the personal data is not.
- The docs say to gate the deployment. Any deployment accepting form data sets
server.api_token. Say it in config.example.yaml next to tagged_pdf.enabled, where an operator turning this on will read it.
19. Open questions
Three, each with a default so nothing blocks on an answer.
- Does Iris store entered values at all, or only pass them through? Storing them lets a user come back and re-download. Not storing them means requirement 4 above is free. Default: do not store them. The request body is used and dropped; a re-download needs the values again. A maintainer can overrule this.
- Should
/v1/sessions/{id}/fields exist, or should clients parse the HTML? The endpoint is more work and a second place field metadata lives. Default: build it. Asking every client to parse a document to find its form controls is how field identity gets guessed wrong.
- Is
iris-pdf published to npm, or vendored? Publishing is convenient; vendoring keeps the "one machine, nothing hosted" story absolute. Default: publish, and document a vendored install too. Iris already tolerates it being absent, so neither choice can break a deployment.
Status: proposal. Nothing in this repository is built.
This is a plan of work, written to be handed to a person or an AI agent and acted on. Everything in it should be up for debate, then acted on according to the best possible result within the context of the requirements stated in what "done" looks like.
Close this issue when the work lands. The design record afterwards is the git history, the
issues each decision cites, design-notes.md, API.md and the code. A requirements document that outlives its build goes stale and starts lying; Iris's last one was retired for exactly that reason. The final PR in this plan deletes the mirrored file and moves each surviving decision into the doc whose job it is.
1. What we are building, in one sentence
Give Iris a second output: the same PDF the user uploaded, now tagged for accessibility, carrying any form data they entered, and visually identical to the original in every other respect.
2. Who this is for
A person using a screen reader receives a PDF form — a benefits application, a permit, a school registration. The PDF is untagged, so their screen reader reads it as a wall of unordered text, or as nothing at all. They cannot fill it in.
Today Iris solves half of that. It converts the PDF into accessible HTML they can actually read. But the agency wants its form back, on its own paper, with the boxes filled. An HTML transcript is not a submission.
This feature closes the loop:
Step 4 is the deliverable. Steps 3 and 4 are the work.
3. What "done" looks like
A maintainer can run this and get a file they would submit to a government agency:
And all five of these are true of
form_tagged.pdf:applicant.nameshows "Ada Lovelace" and the consent box is ticked.4. Non-goals
5. Constraints that cannot be traded
These come from Iris and apply to every line of this work.
6. Decision: a new repository
Build this as a new repository —
equalify-iris-pdf— that ships a library and a command-line tool, plus a small, listed set of changes insideequalify-iris.Why not a feature inside Iris
Iris's first stated constraint is "content only — no CSS, no visual fidelity, no pixel-perfect layout". Tagging a PDF in place is nothing but coordinates and visual fidelity. It is the opposite discipline, with an opposite test strategy: Iris's tests mock a model and assert on prose, and this tool's tests take fixture PDFs and assert on bytes and pixels. Iris's CI would grow a PDF corpus, a rasterizer comparison and eventually a Java validator, none of which any existing test needs.
Why not a service
A second service breaks "one machine, no vendor lock-in" — another port, another config file, another gate, another thing to deploy. Iris already shells out to a local binary (
pdftoppm,pdfinfo,pdftohtml) and degrades cleanly when it is missing.iris-pdfis the fourth one.That pattern already exists in the codebase, an operator already installs poppler, and running the work in a child process keeps Iris's single event loop free — which matters, because tagging a 25-page document is seconds of CPU and Iris serves every request on one thread.
What lives where
equalify-iris-pdf(new)equalify-irisiris-pdfexists and may not be installedThe boundary is a pure function: source PDF + per-page HTML + values → tagged PDF + report. No
state, no clock, no network, no randomness. That is what makes it testable, and it is why this split
is worth two repositories instead of one.
7. How the tagging works
Read this section before writing any code. The method is the whole design.
7.0 What a tagged PDF is
A gloss, because the rest of this document assumes it.
A PDF page is a list of drawing instructions — "set this font", "draw these glyphs here", "paint this image". Nothing in that list says which glyphs are a heading and which are a footnote, and nothing says what order a human reads them in. A screen reader given only that list has to guess, and it guesses from geometry, which is why an untagged two-column page is read straight across the columns.
A tagged PDF adds a second, parallel structure — the structure tree — that says: this run of glyphs is an
H1, this one is aPinside aTDinside aTable, they are read in this order, and this image is aFigurewhose alt text is "Organisation chart". The tree hangs off the document catalog as/StructTreeRoot. It points into the page's drawing instructions using marked content: the instructions get wrapped inBDC/EMCoperators carrying a number (an MCID), and a structure element names the page and the MCID. Anything on the page that is decoration rather than content —a running head, a page number, a rule — is wrapped as an artifact instead, which tells a screen reader to skip it.BDC,EMCandBMCchange nothing about rendering. They are bookkeeping. That is what makes thiswhole feature possible: you can add a full structure tree to a page without moving a single pixel.
7.1 Why we do not re-render from the HTML
The obvious idea — take Iris's HTML, run it through an HTML-to-PDF renderer, ship that — is wrong, and it is worth being explicit about because it is what everyone suggests first.
Iris's HTML is content-only by design. It has no CSS, no columns, no page geometry, no logos, no signature rules, no boxes. A PDF rendered from it is a different document that happens to contain the same words. Hand that to the agency and they reject it: it is not their form. The requirement is "no other visible changes", and a re-render changes every visible thing there is.
So the original PDF is the substrate. The HTML is the map.
7.2 The method: artifact-and-overlay
For each page, in order:
/Artifact BMCto the page's content and appendEMC. No parsing: a valid page's content stream is already balanced with respect toq/QandBT/ET, so a wrapper around all of it is properly nested by construction. If/Contentsis an array of streams, prepend to the first and append to the last — the array is concatenated before interpretation.3 Tr), Iris's text, positioned and scaled to sit exactly over the word boxes from step 3. It draws nothing. It is selectable, searchable and readable by assistive technology./P <</MCID 7>> BDC … EMCaround each run as you go. No content stream analysis is needed anywhere in this method./Figureelements with/Altfrom the HTML's alt text, referencing the image by a marked-content id written into the overlay stream at the image's position. Link annotations get/Linkelements. Form widgets get/Formelements. §8, §9.Step 1 plus step 4 is the trick. Because the original content is an artifact and the accessible content is something we wrote, we never have to understand, parse or modify anyone else's drawing instructions. That removes the single hardest and most fragile part of building a tagger.
7.3 The cost of this method, stated plainly
On a page that already had a text layer, the words now exist twice: once in the original content
(marked as an artifact) and once in the overlay (marked as content).
pdftotext, a plaingrep-the-PDF script, and some simple in-browser viewers concatenate every text operator regardless of marking. Copying a paragraph in such a viewer can yield it doubled.This is a real defect and it is the reason §17 (in-place tagging) exists. It is accepted for v1 because the alternative — parsing, decoding and rewriting arbitrary content streams — is the part of this project most likely to produce a broken PDF, and shipping a working tagger for scanned documents first is worth more than shipping nothing.
v1 must print this caveat in the report for every page where a text layer already existed, as warning code
duplicate_text_layer. Do not bury it.7.4 Scanned pages
A scanned page has no text layer. It is one image. Iris's own corpus is mostly these, so this path is not an edge case — it is the main one.
tesseract <img> - tsv), which gives one row per word with its bounding box and confidence.no_text_positions. Do not fall back to a whole-page/Figurewith the transcript in/Alt: a multi-kilobyte/Altstring is one unnavigable blob and several readers truncate it.--ocr offskips OCR entirely and fails such pages the same way.--ocr requiredfails the run if Tesseract is missing at all.--ocr auto(default) uses it when a page needs it and it is there.7.5 Aligning HTML text to word boxes
This is the one genuinely fiddly algorithm. Specify it as its own module with its own tests.
Input: the page's word boxes in the page's own order, and the page's HTML fragment.
Output: for each text node in the HTML, the boxes it occupies; and for each box, the element that claimed it or
null.Method:
src/pipeline/flatten.tsbut not the code — this needs element identity, which the screen-reader view deliberately throws away.src/pipeline/hyphens.ts— the same rule applies here in reverse).What the alignment produces, and what each outcome means:
<caption>it generated, an[not legible]marker, alt textsrc/pipeline/markers.ts)unmatched_textwith the page, the text and the box. Emit it in the overlay as a bare/Pso it is not silently droppedThat last row matters. The easy bug here is to treat every unmatched word as decoration, which
quietly deletes from the accessible document exactly the words the model missed. Unmatched text
inside the body of the page is a reported failure, never an artifact.
8. Mapping Iris's HTML to PDF structure
8.1 The element map
Implement as one table in one file. Anything not in the table maps to
Pand raises warningunmapped_elementnaming the tag, so the gap shows up in a report instead of silently disappearing.Document/StructTreeRoot<h1>…<h6>H1…H6src/pipeline/lint.ts), so take the level as given<p>P<ul>,<ol>L/ListNumberingfrom the list type<li>LILbl; the rest becomesLBody<dl>L<dt>→Lbl,<dd>→LBody, each pair in oneLI<table>Table<caption>→Caption. A table Iris joined across a page break (src/pipeline/tables.ts) stays oneTablewhose rows name two different pages. That is legal and correct<thead>,<tbody>,<tfoot>THead,TBody,TFoot<tr>TR<th>THscopeto/Scope(Row,Column,Both). Give everyTHan/IDand everyTDa/Headersarray<td>TDcolspan/rowspan→/ColSpan//RowSpan<figure>Figure<figcaption>→Caption, as a sibling inside theFigure<img>Figurealt→/Alt.alt=""→/Artifactinstead, no structure element<a href>Link<blockquote>BlockQuote<code>,<pre>Code<form>Formper control, not per form<fieldset>/<legend>becomes aSectwith/Tfrom the legend<input>,<select>,<textarea>Form<label>/TUand theFormelement's/Alt<hr role="doc-pagebreak"><nav>,<aside>,<section>Sectaria-label→/T<sup>footnote referenceReferenceNotewith an/IDlangattribute anywhere/Langon that element8.2 Links
Iris already knows a PDF's links:
src/util/pdf.tsextracts them andsrc/pipeline/links.tsfeedsthem to the page agent and checks they came back. Use that.
A
Linkstructure element's/Karray holds the MCIDs of the link's text and an objectreference to the annotation:
Set
/Contentson the annotation to the link's purpose (the HTML anchor text) — that is theannotation's accessible name and untagged PDFs almost never have it. Give the annotation a
/StructParentpointing back through the number tree.Internal links (
#fragment) that Iris generated for footnotes and cross-references have no PDFannotation behind them. Emit the
Reference/Notepair from the table above and skip theannotation. Do not invent a
/GoToannotation — that adds a clickable region the original page didnot have, which is a visible change on hover and a behavioural change on click.
8.3 Document-level requirements
All of these, every time:
/MarkInfo << /Marked true >>in the catalog./StructTreeRootwith/K,/ParentTree,/ParentTreeNextKey./StructParentson every page dict,/StructParenton every annotation, and a/ParentTreenumber tree that resolves both. Page entries are arrays indexed by MCID; annotation entries are a
single reference.
/Langon the catalog, from the HTML root'slang. Iris derives that carefully(
bodyLanginsrc/pipeline/assembly.ts) and refuses to guess; if it refused, take the--langargument, and if that is absent, fail withno_document_language. A wrong/Langmakes ascreen reader read the document in the wrong accent, which is worse than being asked.
/ViewerPreferences << /DisplayDocTitle true >>.dc:title. Use Iris's<title>, whichalready mirrors the uploaded filename (
src/util/outputNames.ts).pdfuaid:part= 1./ToUnicodeCMap. Verify mupdf writes one; if it does not, write it (the format is small — abegincmap/beginbfcharstream). Pick the font by the document's script and embed a subset. Acharacter with no glyph in the chosen font is warning
missing_glyph, listing the characters — donot substitute and do not drop.
8.4 PDF/UA conformance: what we promise
We promise a tagged PDF. We do not promise a PDF/UA-conformant PDF, because the source file can make that impossible and we are not allowed to change how the page looks.
Two examples. If the source embeds no font, PDF/UA fails and embedding one would change the typeface. If the source's existing text has a broken
/ToUnicodetable, PDF/UA fails and fixing it means re-encoding text we have marked as an artifact.So the contract is: every PDF/UA-1 clause we can satisfy without changing a pixel, we satisfy. Every clause the source makes unreachable is named in the report, with which clause and why. That is a stronger promise than a conformance badge, because it is checkable and it never hides anything.
9. Forms
9.1 Field identity: how an HTML input maps to a PDF field
This is the crux of the feature and it is solved by copying a pattern Iris already has.
Iris extracts a PDF's link targets at upload time and hands them to the page agent as ground truth, because a rasterized page cannot show where a link points. A rasterized page cannot show a field's name either. So do exactly the same thing with form fields:
sessions/<id>/fields.json, keyed by the page's processing order — the same shape and the same reason as the existinglinks.json.nameattribute of the<input>it emits for that field, exactly as given.missingLinkschecks links. A field name in the ground truth that appears in nonameattribute in that page's HTML is a fidelity problem, fed back to the correction pass with the source image.Why the model rather than a string substitution: deciding which transcribed label belongs to a field rectangle is the same judgement the extraction is. The existing links code makes this argument in full at the top of
src/pipeline/links.ts— read it before you disagree with it.Shape for radio groups and checkbox groups:
nameis the group's fully qualified name andvalueis that option's export value.9.2 The values contract
Values are a flat JSON object keyed by fully qualified field name. Types are fixed by field type, not by the caller:
/MaxLen; a longer string is an error, never a silent truncation/AP /Nkeys. Callers never send"Yes","On"or"1"/Optunless the field is editablefield_not_settablesignature_field_skippedskippedReadOnlySet values through mupdf's widget API (
setTextValue,setChoiceValue,toggle), which regenerates the appearance stream. Do not set/NeedAppearances trueand hope: several viewers ignore it, and a form whose values are invisible in Preview is a failed submission.Every filled widget also gets:
/TUset to the label text from the HTML — that is the accessible name a screen reader announces.Formstructure element containing an/OBJRreference to the widget, placed in the structuretree where the
<input>sits in the HTML, so the field is read in the right place./StructParentwired into the parent tree./TUis worth doing even when no value is set. A blank tagged form that announces its fieldscorrectly is already a large improvement on a blank untagged one.
9.3 Flattening
--flattenreplaces the interactive fields with their drawn appearance and removes the widgets. Off by default. Some agencies require a flattened submission and some require a live form; neither is our call. When flattening, theFormstructure elements becomePelements carrying the same text, so the values stay readable.9.4 Forms with no AcroForm
A scanned form has ruled lines and boxes but no fields. There is nothing to set, so entered values have nowhere to go without drawing them onto the page — and deciding where is guesswork.
v1 refuses. If values are supplied and the PDF has no matching field, fail with
no_acroform_fieldnaming each value that had nowhere to go. Do not guess a position.§17 describes the v2 feature that handles this properly.
10. The two guarantees, and how they are enforced
Both run inside the tool, before it writes anything. Both are on by default. Both fail the run rather than warn.
10.1 Pixels
Render every page of the input and of the output with the same rasterizer (mupdf) at the same DPI (150 by default,
--verify-dpito change) and compare.--verify-dumpis set, because the first thing anyone will want is to look at them.This is what turns "no other visible changes" from a promise into a test. It also catches the whole class of bugs where an unbalanced marked-content operator corrupts the graphics state.
10.2 Text
Extract the text of the input and of the output. Every word in the input must still be in the output. Words may be added — the overlay adds Iris's text and the fields add values — but nothing may be lost. On failure, name the page and the missing text.
Cheap, and it catches a content stream damaged in a way that happens to still render.
11. What we refuse, and what we say
Each refusal is a distinct exit reason with a distinct code. None of them is a crash and none is a silent degradation.
encrypted--passwordaccepts onepermissions_deniedsigned--allow-signedproceeds and the report says the signature is invalidated, because any change invalidates itxfano_source_pdfmultiple_sourcesno_text_positions--partialis setpixels_changedtext_losttoo_many_pagesMAX_PDF_PAGESSave with an incremental update (mupdf
saveToBufferwith the incremental option). The originalbytes stay in the file untouched and the changes are appended, so the change is auditable and nothing
in the original is re-compressed or re-encoded. Note the size cost in the report.
12. Dependencies and their licences
mupdfon npm, 1.28.x)newDictionary,addObject,addStream,getTrailer), typed form widgets with appearance regeneration, per-character text positions, and a rasterizer for the pixel check. WASM, so no native build steptesseract-ocr)On mupdf's licence. It is AGPL-3.0-or-later, which is the same licence as Iris and therefore fine
here. It does mean neither Iris nor this tool can be combined into proprietary software — which is
already true of Iris, and is the point of choosing AGPL. Artifex also sells commercial MuPDF
licences; nothing in this plan needs one.
Fallback if mupdf is rejected.
@cantoo/pdf-lib(MIT, actively maintained) for object-level workplus
pdfjs-dist(Apache-2.0) for text positions plus poppler for rendering. It is a permissivestack and three dependencies instead of one, and pdf-lib has no form-appearance engine, so every
filled field's appearance stream would have to be drawn by hand. Choose it only if the AGPL of a
dependency is a real blocker for a real user. Note that the original
pdf-lib(1.17.1, last released2022) is not maintained; do not use it.
13. Repository A:
equalify-iris-pdf13.1 Layout
Node 24, TypeScript run directly through Node's type stripping,
node --test. Same toolchain as Iris, for the same reason: one runtime to install.13.2 The command
pages.json, the map from PDF page to Iris HTML:{ "pages": [ { "sourcePage": 1, "html": "<h1>Application</h1>…" } ] }sourcePageis 1-based and refers to the PDF's own page number. Iris builds this file; see §14.2 for why the page number has to be carried explicitly and cannot be counted out of the HTML.Exit codes:
0success,1refused (§11),2verification failed,3bad arguments or unreadable input. Every non-zero exit prints one line naming the code, and the full detail goes to the report.13.3 The report
One JSON object. It is the feature's honesty surface, so it is a first-class output, not a log.
{ "tool": "iris-pdf 0.1.0", "source": { "pages": 4, "encrypted": false, "signed": false, "acroform": true, "xfa": false, "hadTextLayer": [1,2,3,4] }, "pages": [ { "page": 1, "textSource": "pdf-text", "words": 412, "matched": 401, "addedFromHtml": 3, "furniture": 8, "lost": 0, "mcids": 214 } ], "structure": { "elements": 431, "byType": { "P": 220, "H2": 12, "Table": 2, "Figure": 3, "Link": 9, "Form": 22 } }, "form": { "fields": 22, "set": 18, "skippedReadOnly": 2, "unresolved": [] }, "verification": { "pixels": "identical-outside-fields", "differingPixels": 0, "textPreserved": true }, "pdfua": { "checked": true, "profile": "PDF/UA-1", "passed": false, "failures": [ { "clause": "7.21.4.2", "detail": "font Helvetica not embedded", "inherited": true } ] }, "warnings": [ { "code": "duplicate_text_layer", "page": 1 } ] }"inherited": truemeans the source file caused it and we could not fix it without changing a pixel.That flag is what makes §8.4's promise checkable.
14. Repository B: changes inside
equalify-irisSix changes. Each is a separate PR.
14.1 Keep the source PDF
Today Iris throws the uploaded PDF away.
src/routes/sessions.tsrasterizes it, writes the PNGsinto
input/, and the original buffer goes out of scope. Nothing can be tagged later because nothingwas kept.
paths.sessionSource(id)→sessions/<id>/source/, and write each uploaded PDF there as<uploadIndex>__<sanitized name>.pdf.paths.sessionPageMap(id)→sessions/<id>/pages.json, mapping each page's processing orderto its source file and the page number inside it.
config.example.yamlunderstorageanyway.14.2 The page map is not optional
Do not try to recover page numbers from the delivered HTML later. Two reasons, both load-bearing:
(
src/pipeline/extraction.ts), so counting markers undercounts pages.aria-labelcarries the page's printed number, which is not its position — adocument numbered i, ii, 1, 2 is ordinary.
The per-page fragments in
sessions/<id>/fragments/final.jsoncarryorder, which is what everything else in Iris counts by.pages.jsonmapsordertosourcePage. Buildpages.jsonfoiris-pdffrom those two.14.3 Form fields as ground truth
New file
src/pipeline/fields.ts, written as a sibling ofsrc/pipeline/links.ts. Read that filefirst; this one mirrors its structure, its prompt-section approach and its verification.
iris-pdf fieldsand writesessions/<id>/fields.json, keyed by page order.pageFieldContext(fields)returns the prompt section, the fields shown and the count dropped,exactly like
pageLinkContext. Cap it the way links are capped, and log the truncation.missingFields(html, fields)returns the fields whose name reached nonameattribute. Feed theresult into the fidelity check in
src/pipeline/extraction.tsnext tomissingLinks.agents/page.md. The prompt already tells the model to render a fill-inblock as a
<form>with<label>/<input>and to transcribe a filled field as<input readonly value="…">— this adds "and put the field's name inname, exactly as listed".iris-pdfis not installed,fields.jsonis simply absent. The page agent gets the prompt itgets today and the run is unaffected. Same degradation as poppler missing for links.
14.4 Endpoints
GET /v1/sessions/{id}/fieldsPOST /v1/sessions/{id}/pdf{"values": {…}}, optional — a document with no form still gets tagged. Enqueues the job,202with its stateGET /v1/sessions/{id}/pdf409while it is not ready, with the state.Content-Disposition: attachment; filename="<base>_tagged.pdf"GET /v1/sessions/{id}/pdf/reportserver.api_tokenlike the rest of/v1/sessions.src/util/queue.ts,defaults.max_concurrent_runs). Thatqueue exists to bound what the machine is doing, and this is the machine doing something.
--report; Iris reads it from there and serves it unchanged.GET /v1/sessions/{id}gainspdf—null, or{status, updated_at}.GET /v1/limitsgainstagged_pdf: true|false, so a client can hide the button on a deploymentwithout the tool installed.
README § Working on Iris.
14.5 Config
14.6 The browser app
public/demo.htmlgains a third action next to "Download HTML":GET /v1/sessions/{id}/fieldsas a real HTML form, with the label Iristranscribed, the right control for the type, and
requiredwhere the PDF says so.is already tested for it (
test/demo-a11y.test.ts). Extend that test to cover the new form.fields unset needs to learn it here, not from the agency.
The delivered HTML document does not change. It stays a content-only document with no script and
no submit target. Filling happens in the app or through the API, never by turning the deliverable
into an application.
15. The test corpus
Without fixtures this project cannot be reviewed, so build the corpus first. Every fixture is a small
PDF committed to the repository, with a licence that permits redistribution — a US federal form
(public domain), a document the project generates itself, or an openly licensed sample. No fixture
contains real personal data.
text-simple.pdftext-two-column.pdfscan-300dpi.pdfscan-skewed.pdfform-acroform.pdfform-flat.pdftable-across-pages.pdfTablelinks.pdfmixed.pdfsigned.pdf--allow-signedencrypted.pdf--passwordcjk.pdf/ToUnicodeblank-page.pdfEach fixture ships with the Iris HTML for it, captured once and committed, so the tagger's tests need
no model and no network.
One test asserts the no-network rule directly: run the full corpus with outbound sockets stubbed
to throw, and fail if anything tries to open one. It is the cheapest possible guard on the constraint
that matters most to the people deploying this.
16. Build order
Each milestone ends with something that runs and something that is checked. Do not start the next one
until the previous one's exit criteria hold.
M1 — Skeleton and refusals. The repository, the CLI,
iris-pdf fields, and every refusal in §11.Exit:
iris-pdf fields --pdf form-acroform.pdfprints the correct inventory; each refusal fixtureexits with its code and its one-line message.
M2 — The structure writer.
pdf/struct.ts,pdf/content.ts,pdf/metadata.ts. Given ahand-written mapping, produce a tagged PDF. No HTML, no alignment.
Exit: a fixture comes back with a valid
/StructTreeRoot, a resolving/ParentTree, and pixelsidentical to the input. Assert on the object graph, not on a byte hash.
M3 — The pixel and text gates. §10, wired into the CLI and on by default.
Exit: a deliberately corrupted content stream is caught and the output is not written.
M4 — HTML mapping and alignment.
html/,align/. The §8.1 table and the §7.5 algorithm.Exit:
text-simple.pdfandtext-two-column.pdfproduce a structure tree whose reading ordermatches the HTML, verified by walking the tree and comparing to the HTML's text order. The two-column
fixture is the one that proves it.
M5 — Scanned pages.
ocr/, and the per-page method choice.Exit:
scan-300dpi.pdfproduces selectable, tagged text carrying Iris's wording, not Tesseract's;mixed.pdfuses a different method on each page; with Tesseract uninstalled the scanned page failswith
no_text_positionsand the born-digital page still succeeds.M6 — Forms.
pdf/widgets.ts, the §9.2 contract,/TU,Formelements,--flatten.Exit:
form-acroform.pdfplus a values file comes back with the values visible, the fieldsannounced by name, and pixels identical outside the filled rectangles.
M7 — Iris integration. §14.1 to §14.4. Source PDF kept, page map, fields ground truth, endpoints.
Exit:
./test/e2e.shcovers the full lifecycle — upload a PDF, convert, read fields, post values,download a tagged PDF — against mocks, with no credentials.
M8 — The app and the docs. §14.6,
docs/API.md,docs/design-notes.md,config.example.yaml,the README's dependency line and further-reading table.
Exit: a person can do the whole thing at
http://localhost:8080/with no API call; the demoaccessibility test passes with the new form; this PRD is deleted.
M9 — Conformance. veraPDF in CI over the corpus,
inheritedclassification, thepdfuablock ofthe report.
Exit: every fixture's failures are either zero or marked inherited with a named clause.
M1–M6 are
equalify-iris-pdf. M7–M8 areequalify-iris. M9 is both. M1–M3 and M4 can proceed inparallel once the report type in M1 is fixed.
17. Deliberately deferred
Named so they are not re-discovered as gaps.
operators instead of overlaying new ones. Needs a content-stream tokenizer and a text-state tracker.
The technique that avoids font metrics entirely: track only the explicit positioning operators
(
Tm,Td,TD,T*,TL), take the first text-showing operator's origin from them, and letmupdf's per-character origins supply the advance for each subsequent operator on the line. Do this
only once v1 is in use and the corpus is real.
rule that makes it safe: a value may only be drawn where the original page has no ink, which the
§10.1 pixel machinery can check directly. Refuse anything else.
GET /v1/sessions/{id}/pdf?source=N.out of the v1 machinery nearly for free, but it invents a document rather than preserving one, so
it is a separate decision.
18. Privacy, and why it is in this document
Entered form values are the most sensitive data Iris will ever hold. A benefits application has a name, an address, a date of birth, sometimes a national identity number. Today Iris holds documents people chose to upload; after this change it holds what they typed into them.
Iris has no sign-in and no session isolation by design:
GET /v1/sessionslists the deployment'ssessions, and a session id is all it takes to read one (README). That is a defensible trade for a document conversion service. It is a different trade once the session holds a filled form.
So this work carries five requirements, and they are not optional:
values.jsonmay enter an issue body, an agent draft or a regression fixture. Add a test that asserts it.POST /v1/sessions/{id}/closedeletesvalues.jsonand the tagged PDF. The HTML and the fixtures are what close is for; the personal data is not.server.api_token. Say it inconfig.example.yamlnext totagged_pdf.enabled, where an operator turning this on will read it.19. Open questions
Three, each with a default so nothing blocks on an answer.
/v1/sessions/{id}/fieldsexist, or should clients parse the HTML? The endpoint is more work and a second place field metadata lives. Default: build it. Asking every client to parse a document to find its form controls is how field identity gets guessed wrong.iris-pdfpublished to npm, or vendored? Publishing is convenient; vendoring keeps the "one machine, nothing hosted" story absolute. Default: publish, and document a vendored install too. Iris already tolerates it being absent, so neither choice can break a deployment.