Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ jobs:
node-version-file: .nvmrc
cache: npm
- run: sudo apt-get update && sudo apt-get install -y tesseract-ocr
# veraPDF checks every PDF/UA-1 claim the tests make (test/pdfua.test.ts).
- run: /home/linuxbrew/.linuxbrew/bin/brew install verapdf && echo /home/linuxbrew/.linuxbrew/bin >> "$GITHUB_PATH"
- run: npm ci
- run: npm run typecheck
- run: npm test
env:
IRIS_REQUIRE_VERAPDF: 1
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ iris-pdf check --pdf out.pdf # runs veraPDF's PDF/UA-1 check, if instal
| `--password` | Open an encrypted PDF. The output keeps its encryption. |
| `--allow-signed` | Tag a signed PDF. This breaks the signature, and the report says so. |
| `--partial` | Leave a page untagged, instead of failing, when it has no way to place text. |
| `--strict` | Fail on any warning that means content went untagged or unmatched. |
| `--strict` | Fail on any warning that means content went untagged or unmatched, or that the file was `repaired`. |

### pages.json

Expand All @@ -65,7 +65,7 @@ Text fields take strings, checkboxes `true`/`false`, radio groups and lists one
1. The page's original drawing is kept byte for byte and marked as an artifact.
2. Iris's words are matched to the words on the page (from the text layer, or from Tesseract on a scan).
3. An invisible text layer is added with Iris's words at those positions, tagged with the structure from the HTML: headings, lists, tables with their headers, links, figures with alt text, form fields.
4. The file is saved incrementally: the original bytes are the start of the output.
4. The file is saved incrementally: the original bytes are the start of the output. A damaged file is instead rewritten from mupdf's repair of it, with warning `repaired`.

Then two checks run, and if either fails nothing is written (exit 2):

Expand All @@ -74,16 +74,16 @@ Then two checks run, and if either fails nothing is written (exit 2):

## The report

`--report` writes JSON: per page, where the text came from and how many words matched; the structure written; fields set and skipped; the check results; and every warning. Warnings name what could not be done, for example `unmatched_text` (page text missing from the HTML, kept as a paragraph), `missing_alt`, `field_not_in_html`, `unmatched_link`, `duplicate_text_layer`, `page_not_in_html` and `page_not_tagged` (the page is left as it was; a blank page needs no HTML and is not warned), `no_title`, `alignment_incomplete` (the page and the HTML differ too much to match every word in time; the rest is kept as unmatched text).
`--report` writes JSON: per page, where the text came from and how many words matched; the structure written; fields set and skipped; the check results; and every warning. Warnings name what could not be done, for example `unmatched_text` (page text missing from the HTML, kept as a paragraph), `missing_alt`, `field_not_in_html`, `unmatched_link`, `duplicate_text_layer`, `page_not_in_html` and `page_not_tagged` (the page is left as it was; a blank page needs no HTML and is not warned), `no_title`, `font_not_embedded` (a source font has no embedded program, which PDF/UA-1 requires; the source drawing is not changed), `source_marked_content` (the page drawing has marked-content ids left from an earlier tag tree), `alignment_incomplete` (the page and the HTML differ too much to match every word in time; the rest is kept as unmatched text).

The output declares PDF/UA-1 only when it has a title and every page is tagged.
The output declares PDF/UA-1 only when it has a title, every page is tagged, every source font is embedded, and no page drawing has leftover marked content. The tests check each such claim with veraPDF.

## Refusals and exit codes

| Exit | When |
|---|---|
| 0 | Done. |
| 1 | Refused: `encrypted` (no or wrong password), `permissions_denied`, `damaged`, `too_many_pages` (over 25), `too_many_words` (over 4000 on a page), `already_tagged`, `xfa` (dynamic form), `signed`, `no_acroform_field`, `no_text_positions`, `strict`. |
| 1 | Refused: `encrypted` (no or wrong password), `permissions_denied`, `too_many_pages` (over 25), `too_many_words` (over 4000 on a page), `already_tagged`, `xfa` (dynamic form), `signed`, `no_acroform_field`, `no_text_positions`, `strict`. |
| 2 | A check failed: `pixels_changed`, `text_lost`. |
| 3 | Bad input: `unreadable`, `bad_pages`, `no_document_language`, `bad_value`, `field_not_settable`, `bad_arguments`. |

Expand All @@ -97,7 +97,7 @@ Form values are personal data. They are never printed, logged, or put in the rep

- **The text exists twice** on a page that already had a text layer: the original, now an artifact, and ours. Screen readers use ours. Plain copy-and-paste tools may show the text doubled. The report warns `duplicate_text_layer`.
- A table that continues onto the next page is tagged as two tables.
- `check` needs veraPDF installed. Checking the whole corpus in CI is not done yet.
- `check` needs veraPDF installed.
- A form with no fields (a flat form) cannot be filled.

## License
Expand Down
25 changes: 11 additions & 14 deletions src/html/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ type Ctx = {
notes: Set<string>; // ids that internal links point at
label?: string; // text of an enclosing <label>
legend?: string; // text of an enclosing <fieldset>'s legend
inFigure?: boolean;
warn: (w: Warning) => void;
};

Expand Down Expand Up @@ -94,19 +93,11 @@ function build(e: Elem | string, parent: Node, ctx: Ctx) {
return;
}
case "img": {
if (ctx.inFigure) return;
if (e.attrs.alt === undefined) return ctx.warn({ code: "missing_alt", detail: e.attrs.src ?? "img" });
if (e.attrs.alt === "") return; // decorative: the original drawing is already an artifact
add("Figure").alt = e.attrs.alt;
return;
}
case "figure": {
const fig = add("Figure");
let alt: string | undefined;
walk(e, (d) => { if (d.tag === "img" && d.attrs.alt) alt ??= d.attrs.alt; });
if (alt) fig.alt = alt;
return kids(fig, { ...inner, inFigure: true });
}
case "a": {
// An internal link is a Reference; the Link inside it owns the annotation.
const internal = e.attrs.href.startsWith("#");
Expand Down Expand Up @@ -159,17 +150,23 @@ function listItem(e: Elem, parent: Node, label: string, ctx: Ctx) {
const li: Node = { type: "LI", kids: [] };
parent.kids.push(li);
if (label) li.kids.push({ type: "Lbl", kids: [{ words: [word(label)] }] });
const body: Node = { type: "LBody", kids: [] };
let body: Node = { type: "LBody", kids: [] };
li.kids.push(body);
if (e.attrs.id && ctx.notes.has(e.attrs.id)) target(body, e.attrs.id, ctx);
// A footnote list item: LI may hold only Lbl and LBody, so the Note goes inside the LBody.
if (e.attrs.id && ctx.notes.has(e.attrs.id)) {
const note: Node = { type: "P", kids: [] };
body.kids.push(note);
target(note, e.attrs.id, ctx);
body = note;
}
for (const k of e.kids) build(k, body, ctx);
}

// The target of an internal link gets an /ID. A paragraph or list body is a
// footnote, so it becomes a Note; anything else (a heading, say) keeps its type.
// The target of an internal link gets an /ID. A paragraph is a footnote, so
// it becomes a Note; anything else (a heading, say) keeps its type.
function target(n: Node, id: string, ctx: Ctx) {
n.id = `${ctx.prefix}${id}`;
if (n.type === "P" || n.type === "LBody") n.type = "Note";
if (n.type === "P") n.type = "Note";
}

const scope = (s: string) => ({ row: "Row", rowgroup: "Row", col: "Column", colgroup: "Column" })[s.toLowerCase()] ?? "Both";
Expand Down
4 changes: 2 additions & 2 deletions src/html/map.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// The spec's §8.1 table: which PDF structure type each HTML element becomes.
// Special cases (li, dl, figure, img, a, input, label) are handled where the
// Special cases (li, dl, img, a, input, label) are handled where the
// tree is built, in tag.ts; this file only names the types.

export const STRUCT: Record<string, string> = {
Expand All @@ -8,7 +8,7 @@ export const STRUCT: Record<string, string> = {
ul: "L", ol: "L", dl: "L", li: "LI", dt: "Lbl", dd: "LBody",
table: "Table", caption: "Caption", thead: "THead", tbody: "TBody", tfoot: "TFoot",
tr: "TR", th: "TH", td: "TD",
figure: "Figure", figcaption: "Caption", img: "Figure",
figure: "Div", figcaption: "Caption", img: "Figure",
a: "Link",
blockquote: "BlockQuote", code: "Code", pre: "Code",
input: "Form", select: "Form", textarea: "Form",
Expand Down
85 changes: 81 additions & 4 deletions src/pdf/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,79 @@ export function artifactStreams(doc: mupdf.PDFDocument, page: mupdf.PDFObject):
return [doc.addStream("/Artifact BMC q\n", {}), ...streams, doc.addStream("\nQ EMC\n", {})];
}

// Pages (1-based) whose own drawing has marked-content ids, left from a tag
// tree since removed. Inside our artifact they are tagged content in an
// artifact, which PDF/UA-1 forbids (7.1). A stream that cannot be read, or
// forms nested past a depth of 32, count as marked. Tiling patterns and annotation appearances are searched too.
export function pagesWithMcids(doc: mupdf.PDFDocument): number[] {
const out: number[] = [];
for (let i = 0; i < doc.countPages(); i++) {
const page = doc.findPage(i), seen = new Set<number>();
const marked = (...s: mupdf.PDFObject[]) => {
try {
return /\/MCID\b/.test(withoutStrings(s.map((x) => x.readStream().asString()).join("\n")));
} catch {
return true;
}
};
const forms = (res: mupdf.PDFObject, depth: number): boolean => {
let found = false;
if (depth > 32) return true;
if (!res.isDictionary()) return false;
// A named property list (/Tag /Name BDC) keeps its /MCID in the resources.
res.get("Properties").forEach((p) => { if (p.isDictionary() && !p.get("MCID").isNull()) found = true; });
const drawn = (x: mupdf.PDFObject) => x.isStream() && (x.get("Subtype").asName() === "Form" || x.get("PatternType").asNumber() === 1);
[res.get("XObject"), res.get("Pattern")].forEach((d) => d.forEach((x) => {
if (found || !drawn(x)) return;
if (x.isIndirect()) {
if (seen.has(x.asIndirect())) return;
seen.add(x.asIndirect());
}
found = marked(x) || forms(x.get("Resources"), depth + 1);
}));
return found;
};
const contents = page.get("Contents"), streams: mupdf.PDFObject[] = [];
if (contents.isArray()) contents.forEach((s) => { if (s.isStream()) streams.push(s); });
else if (contents.isStream()) streams.push(contents);
// Annotation appearances draw too.
let appearance = false;
// /AP holds /N, /R, /D, each a stream or a dictionary of streams: two levels.
const ap = (a: mupdf.PDFObject, depth: number) => {
if (appearance) return;
if (a.isStream()) appearance = marked(a) || forms(a.get("Resources"), 1);
else if (a.isDictionary() && depth < 2) a.forEach((x) => ap(x, depth + 1));
};
page.get("Annots").forEach((a) => { if (a.isDictionary()) ap(a.get("AP"), 0); });
if ((streams.length && marked(...streams)) || forms(page.getInheritable("Resources"), 0) || appearance) out.push(i + 1);
}
return out;
}

// A content stream with its string literals, comments and inline-image data
// removed, so their bytes are not read as operators. An unclosed string leaves
// the stream as it was.
export function withoutStrings(s: string): string {
let out = "", depth = 0;
const space = (c: string | undefined) => c === undefined || /[\0\t\n\f\r ]/.test(c);
for (let i = 0; i < s.length; i++) {
const c = s[i];
if (depth) {
if (c === "\\") i++;
else if (c === "(") depth++;
else if (c === ")") depth--;
} else if (c === "(") depth = 1;
else if (c === "%") while (i + 1 < s.length && !/[\n\r]/.test(s[i + 1])) i++;
else if (c === "I" && s[i + 1] === "D" && space(s[i - 1]) && space(s[i + 2])) {
const end = s.slice(i + 3).search(/[\0\t\n\f\r ]EI(?![^\0\t\n\f\r ])/);
if (end < 0) return s;
out += " ";
i += 3 + end + 2;
} else out += c;
}
return depth ? s : out;
}

// True if the page's own content paints nothing (annotations aside).
export function drawsNothing(page: mupdf.PDFPage): boolean {
let drew = false;
Expand Down Expand Up @@ -54,10 +127,14 @@ export class Overlay {
// One word, stretched to fill its box. A space after it, if the HTML had
// one, lets extractors see the word break.
word(text: string, box: Box, baseline: number, size: number, space = true) {
const chars = [...text], glyphs = chars.map((c) => this.fonts.glyph(c));
this.text += chars.filter((_, k) => glyphs[k].gid).join("") + " "; // a missing glyph is reported, not checked
const natural = glyphs.reduce((w, g) => w + g.advance, 0) * size;
const h = natural > 0 ? Math.min(10, Math.max(0.1, (box[2] - box[0]) / natural)) : 1;
// A character no font has is reported, and left out: a .notdef glyph has no Unicode (PDF/UA-1 7.21.7).
const chars = [...text].filter((c) => this.fonts.glyph(c).gid), glyphs = chars.map((c) => this.fonts.glyph(c));
if (!chars.length) return;
this.text += chars.join("") + " ";
const unit = glyphs.reduce((w, g) => w + g.advance, 0), wide = box[2] - box[0];
// Squeezed to under half its width, extractors merge a word's repeated letters. Shrink the text instead.
if (unit * size > 0 && wide / (unit * size) < 0.5) size = Math.max(0.5, (2 * wide) / unit);
const h = unit * size > 0 ? Math.min(10, Math.max(0.1, wide / (unit * size))) : 1;
const m = mupdf.Matrix.concat([h, 0, 0, -1, box[0], baseline], this.toUser);
// Consecutive glyphs from the same font share one Tj.
const runs: { font: number; hex: string }[] = [];
Expand Down
14 changes: 8 additions & 6 deletions src/pdf/document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type Source = {
signed: boolean;
acroform: boolean;
xfa: boolean;
repaired: boolean; // damaged: saved as a full rewrite, not an update
warnings: Warning[];
};

Expand Down Expand Up @@ -37,16 +38,17 @@ export function openPdf(bytes: Uint8Array, opts: OpenOptions = {}): Source {
forEachField(doc, (field) => {
if (inherited(field, "FT")?.asName() === "Sig" && inherited(field, "V")) signed = true;
});
const source = { doc, encrypted, signed, acroform: !acroform.isNull(), xfa, warnings };
const repaired = doc.wasRepaired() || !doc.canBeSavedIncrementally();
const source = { doc, encrypted, signed, acroform: !acroform.isNull(), xfa, repaired, warnings };
if (opts.readOnly) return source;

// An owner password can forbid changes. We do not work around it.
if (!doc.hasPermission("edit")) {
throw new IrisPdfError("permissions_denied", "The PDF's owner does not permit changes to it.");
}
if (doc.wasRepaired() || !doc.canBeSavedIncrementally()) {
throw new IrisPdfError("damaged", "The PDF is damaged, so it cannot be updated without rewriting it.");
}
// A damaged file cannot be updated in place. It is rewritten from what mupdf
// repaired, which is also what the checks render as the original.
if (source.repaired) warnings.push({ code: "repaired", detail: "The PDF was damaged; the output is a rewritten copy, not an update of the original bytes." });
const pages = doc.countPages();
if (pages > MAX_PAGES) {
throw new IrisPdfError("too_many_pages", `The PDF has ${pages} pages; the limit is ${MAX_PAGES}.`);
Expand Down Expand Up @@ -90,7 +92,7 @@ export function inherited(field: mupdf.PDFObject, key: string): mupdf.PDFObject
return null;
}

export function save(doc: mupdf.PDFDocument): Uint8Array {
export function save(doc: mupdf.PDFDocument, rewrite = false): Uint8Array {
// A copy: the buffer lives in mupdf's memory, which can move.
return doc.saveToBuffer("incremental,compress").asUint8Array().slice();
return doc.saveToBuffer(rewrite ? "compress,encrypt=keep" : "incremental,compress").asUint8Array().slice();
}
49 changes: 49 additions & 0 deletions src/pdf/fonts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ export class FontSet {
return { font: 0, gid: 0, advance: 0.5 };
}

// Advance of a string at size 1.
width(text: string) {
return [...text].reduce((w, c) => w + this.glyph(c).advance, 0);
}

resourceName(font: number) {
return `IrisF${font}`;
}
Expand All @@ -37,9 +42,53 @@ export class FontSet {
for (const i of this.used) refs[this.resourceName(i)] = tmp.addFont(this.fonts[i]);
for (const s of streams) tmp.insertPage(-1, tmp.addPage([0, 0, 1, 1], 0, { Font: refs }, s));
tmp.subsetFonts();
// Codes are glyph ids. PDF/UA-1 (7.21.3.2) wants a TrueType CIDFont to say so.
for (const ref of Object.values(refs)) {
const cid = ref.get("DescendantFonts").get(0);
if (cid.get("Subtype").asName() === "CIDFontType2" && cid.get("CIDToGIDMap").isNull()) cid.put("CIDToGIDMap", tmp.newName("Identity"));
}
const out: Record<string, mupdf.PDFObject> = {};
const graft = doc.newGraftMap();
for (const [name, ref] of Object.entries(refs)) out[name] = graft.graftObject(ref);
return out;
}
}

// Base names of the source's fonts with no embedded program (PDF/UA-1 7.21.4.1).
// Looks in every page, form XObject, tiling pattern and annotation appearance. Nesting past a depth of 32 is not checked, and counts as unembedded.
export function unembeddedFonts(doc: mupdf.PDFDocument): string[] {
const out = new Set<string>(), seen = new Set<number>(), dr = doc.getTrailer().get("Root", "AcroForm", "DR");
const once = (o: mupdf.PDFObject) => {
if (!o.isIndirect()) return true;
if (seen.has(o.asIndirect())) return false;
seen.add(o.asIndirect());
return true;
};
const resources = (res: mupdf.PDFObject, depth: number) => {
if (depth > 32) return void out.add("(nested too deeply to check)");
if (!res.isDictionary() || !once(res)) return;
res.get("Font").forEach((f) => { if (f.isDictionary() && once(f)) font(f, depth); });
res.get("XObject").forEach((x) => { if (x.isStream() && x.get("Subtype").asName() === "Form" && once(x)) resources(x.get("Resources"), depth + 1); });
res.get("Pattern").forEach((p) => { if (p.isStream() && once(p)) resources(p.get("Resources"), depth + 1); }); // tiling patterns draw too
};
const font = (f: mupdf.PDFObject, depth: number) => {
const type = f.get("Subtype").asName();
if (type === "Type3") return resources(f.get("Resources"), depth + 1);
const kids = f.get("DescendantFonts");
const base = type !== "Type0" ? f : kids.isArray() ? kids.get(0) : kids;
const d = base.isDictionary() ? base.get("FontDescriptor") : base;
if (!d.isDictionary() || !["FontFile", "FontFile2", "FontFile3"].some((k) => d.get(k).isStream())) out.add(f.get("BaseFont").asName() || "(unnamed)");
};
// /AP holds /N, /R, /D, each a stream or a dictionary of streams: two levels.
const appearance = (ap: mupdf.PDFObject, depth = 0) => {
// An appearance with no resources of its own takes the form's (/DR).
if (ap.isStream()) return resources(ap.get("Resources").isDictionary() ? ap.get("Resources") : dr, 0);
if (ap.isDictionary() && depth < 2) ap.forEach((x) => appearance(x, depth + 1));
};
for (let i = 0; i < doc.countPages(); i++) {
const page = doc.findPage(i);
resources(page.getInheritable("Resources"), 0);
page.get("Annots").forEach((a) => { if (a.isDictionary()) appearance(a.get("AP")); });
}
return [...out];
}
Loading
Loading