= {
+ name: [[75, 62, 290, 76]], email: [[75, 86, 290, 102]], news: [[20, 116, 28, 124]],
+ card: [[80, 139, 88, 147], [130, 139, 138, 147]],
+ book1: [[20, 184, 155, 210]], due1: [[155, 184, 290, 210]], book2: [[20, 210, 155, 236]], due2: [[155, 210, 290, 236]],
+};
+const grow = (b: Box, d: number): Box => [b[0] - d, b[1] - d, b[2] + d, b[3] + d];
+const within = (a: Box, b: Box) => a[0] >= b[0] && a[1] >= b[1] && a[2] <= b[2] && a[3] <= b[3];
+
+for (const [name, scan] of [["form-lines", false], ["form-scan", true], ["form-scan-skewed", true]] as const) {
+ test(`${name}: a flat form gets a field on each blank the HTML names`, { skip: scan && noOcr }, () => {
+ const { doc, report } = tagFixture(name);
+ assert.deepEqual(report.form.created.map((c) => [c.name, c.type, c.page]), [
+ ["name", "text", 1], ["email", "text", 1], ["news", "checkbox", 1], ["card", "radio", 1],
+ ["book1", "text", 1], ["due1", "text", 1], ["book2", "text", 1], ["due2", "text", 1],
+ ]);
+ assert.deepEqual(report.warnings.filter((w) => w.code === "field_not_placed"), [{ code: "field_not_placed", page: 1, detail: "phone" }]);
+ for (const [field, ws] of widgets(doc)) {
+ const want = blanks[field];
+ assert.equal(ws.length, want.length, field);
+ ws.forEach((w, k) => {
+ const b = w.getBounds() as Box, c: Box = [(b[0] + b[2]) / 2, (b[1] + b[3]) / 2, (b[0] + b[2]) / 2, (b[1] + b[3]) / 2];
+ // A scan's blanks move a little, and a skewed one's more; drawn ones do not.
+ assert.ok(scan ? within(c, grow(want[k], 5)) : within(b, grow(want[k], 1.5)), `${field} at ${b.map((v) => v.toFixed(1))}`);
+ });
+ }
+ assert.equal(find(structTree(doc), "Form").length, 9); // 8 fields, the radio group has 2 widgets
+ assert.equal(report.verification.differingPixels, 0);
+ });
+}
+
+test("a flat form's made fields are filled and named for a screen reader", () => {
+ const { doc, report } = tagFixture("form-lines", { values: { name: "Ada Lovelace", card: "child", news: true, book1: "Emma" } });
+ const w = widgets(doc);
+ assert.equal(w.get("name")![0].getValue(), "Ada Lovelace");
+ assert.equal(w.get("book1")![0].getValue(), "Emma");
+ assert.deepEqual(w.get("card")!.map(state), ["Off", "child"]);
+ assert.notEqual(state(w.get("news")![0]), "Off");
+ assert.equal(report.form.set, 4);
+ assert.equal(w.get("name")![0].getLabel(), "Full name", "a field in a fieldset is named by its own label");
+ assert.equal(w.get("card")![0].getLabel(), "Card type:");
+ assert.equal(w.get("book2")![0].getLabel(), "Book");
+ assert.equal(report.verification.pixels, "identical-outside-fields");
+ assert.throws(() => tag(readFixture("form-lines.pdf"), pagesOf("form-lines"), { values: { phone: "555" } }), { code: "no_acroform_field" });
+});
+
+test("a scan whose words are placed approximately gets no fields", () => {
+ const dir = mkdtempSync(join(tmpdir(), "iris-pdf-")), out = join(dir, "o.pdf"), json = join(dir, "o.json");
+ const cli = new URL("../src/cli.ts", import.meta.url).pathname;
+ const r = spawnSync(process.execPath, [cli, "tag", "--pdf", fixture("form-scan.pdf"), "--pages", fixture("form-scan.pages.json"), "--out", out, "--report", json], { encoding: "utf8", env: { PATH: "" } });
+ assert.equal(r.status, 0, r.stderr);
+ const report = JSON.parse(readFileSync(json, "utf8"));
+ assert.deepEqual(report.form.created, []);
+ assert.equal(report.warnings.filter((w: { code: string }) => w.code === "field_not_placed").length, 10); // every control, a radio button each
+ assert.equal(new mupdf.PDFDocument(readFileSync(out)).loadPage(0).getWidgets().length, 0);
+});
+
+test("a flat form's made fields keep their tags when the output is tagged again", () => {
+ // A check box with a value, a dotted name and a repeated one: each made field's name or state differs from the HTML.
+ const html = pagesOf("form-lines").pages[0].html.replace('name="news"', 'name="news" value="yes"').replace('name="name"', 'name="you.name"').replace('name="due2"', 'name="due1"');
+ const pages = { ...pagesOf("form-lines"), pages: [{ sourcePage: 1, html }] };
+ const first = newReport(), out = tag(readFixture("form-lines.pdf"), pages, { values: { news: true } }, first);
+ assert.deepEqual(first.form.created.map((c) => c.name), ["you-name", "email", "news", "card", "book1", "due1", "book2", "due1-2"]);
+ const again = newReport(), doc = new mupdf.PDFDocument(tag(out, pages, {}, again));
+ assert.deepEqual(again.warnings.filter((w) => w.code.startsWith("field_")).map((w) => w.code), ["field_not_in_pdf"]); // phone
+ assert.equal(find(structTree(doc), "Form").length, 9);
+ assert.equal(state(widgets(doc).get("news")![0]), "yes");
+});
diff --git a/test/html.test.ts b/test/html.test.ts
index 516f934..2cef8a8 100644
--- a/test/html.test.ts
+++ b/test/html.test.ts
@@ -1,6 +1,6 @@
import { test } from "node:test";
import assert from "node:assert/strict";
-import { buildPage, isRun, wordsInOrder, type Node } from "../src/html/build.ts";
+import { buildPage, isRun, wordsInOrder, type FormRef, type Node } from "../src/html/build.ts";
import type { Warning } from "../src/report.ts";
function build(html: string) {
@@ -61,18 +61,44 @@ test("form controls take their label from for=, an enclosing label, or aria-labe
'Contact Email ' +
'Name ',
);
- const forms: Node[] = [];
- const walk = (n: Node) => { if (n.type === "Form") forms.push(n); n.kids.forEach((k) => isRun(k) || walk(k)); };
- walk(top);
- assert.deepEqual(forms.map((f) => f.form), [
- { name: "contact", label: "Email", group: "Contact", value: "email" },
- { name: "name", label: "Name", group: undefined },
- { name: "state", label: "State", group: undefined },
+ const forms = formsOf(top).map(({ name, label, group, value, type }) => ({ name, label, group, value, type }));
+ assert.deepEqual(forms, [
+ { name: "contact", label: "Email", group: "Contact", value: "email", type: "radio" },
+ { name: "name", label: "Name", group: undefined, value: undefined, type: "text" },
+ { name: "state", label: "State", group: undefined, value: undefined, type: "text" },
+ { name: "", label: "", group: undefined, value: undefined, type: "text" }, // reported when it is tagged
]);
assert.equal((top.kids[0] as Node).title, "Contact");
- assert.deepEqual(warnings.map((w) => w.code), ["field_without_name"]);
+ assert.deepEqual(warnings, []);
+});
+
+test("form controls keep their label's words, a radio group's question, and a list's options", () => {
+ const { top } = build(
+ 'Do you own a home? Yes No
' +
+ 'Your city: Ames Boone ',
+ );
+ const [yes, no, city, notes] = formsOf(top);
+ assert.deepEqual([yes.group, no.group], ["Do you own a home?", "Do you own a home?"]);
+ assert.deepEqual(no.labelWords.map((w) => w.text), ["No"]);
+ assert.deepEqual(city.labelWords.map((w) => w.text), ["Your", "city:"]);
+ assert.deepEqual([city.type, city.options], ["combobox", ["a", "Boone"]]);
+ assert.equal(notes.type, "multiline");
});
+test("header ids are unique across a page's tables", () => {
+ const { top } = build("");
+ const ids: string[] = [], walk = (n: Node) => { if (n.id) ids.push(n.id); n.kids.forEach((k) => isRun(k) || walk(k)); };
+ walk(top);
+ assert.deepEqual(ids, ["p1-th1", "p1-th2"]);
+});
+
+function formsOf(top: Node) {
+ const forms: FormRef[] = [];
+ const walk = (n: Node) => { if (n.form) forms.push(n.form); n.kids.forEach((k) => isRun(k) || walk(k)); };
+ walk(top);
+ return forms;
+}
+
test("tables: header ids, scope and each cell's headers", () => {
const { top } = build(
"