diff --git a/docs/API.md b/docs/API.md
index d80c57d0..62c8e601 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -987,7 +987,15 @@ curl -s -H "$AUTH" "$BASE/sessions/$SID/fields" | jq '.fields[0]'
```
`type` is one of `text`, `checkbox`, `radio`, `combobox`, `listbox`, `button` or `signature`. A PDF with
-no form gives `{"fields": []}`. It can answer `404`, `409 no_source_pdf`, `422` for a PDF the tagger
+no form gives `{"fields": []}`.
+
+A flat form, such as a scan, has no fields until it is tagged. If its HTML has form controls, Iris
+tags it once and lists the fields it made, each with `"created": true`. `POST /pdf` fills them by
+those names. This needs iris-pdf with
+[equalify-iris-pdf#16](https://github.com/EqualifyEverything/equalify-iris-pdf/pull/16). An older
+one makes no fields, so the list is empty.
+
+It can answer `404`, `409 no_source_pdf`, `422` for a PDF the tagger
refuses, or `503 busy`, the same as below.
**Get the tagged PDF:**
@@ -1063,8 +1071,8 @@ The events worth grepping for have a section each below, and the index is a link
the index when you have a `type` off a log line and want to know what it means; read a section when
you want to know what the field it names is for and what it costs.
-**The index is the whole log.** `src/` emits **125** event types and every one of them has a section
-below — **117** sections, because a few cover two or three events that are only read together. So a
+**The index is the whole log.** `src/` emits **127** event types and every one of them has a section
+below — **118** sections, because a few cover two or three events that are only read together. So a
`type` you cannot find here is not one the index skipped: it is a misread line, or a name `src/` no
longer emits.
@@ -1191,6 +1199,7 @@ emits fails it too.
| [`contribution_failed`](#contribution_failed) | The filing step threw, **after** `run_complete` |
| [`run_failed`](#run_failed) | The run threw, so there is **no document** |
| [`tagged_pdf` / `tagged_pdf_failed`](#tagged_pdf--tagged_pdf_failed) | A tagged PDF was made, or could not be |
+| [`tagged_pdf_fields` / `tagged_pdf_fields_failed`](#tagged_pdf_fields--tagged_pdf_fields_failed) | A flat form was tagged to list its fields, or could not be |
| [`form_fields` / `page_fields` / `page_fields_missing`](#form_fields--page_fields--page_fields_missing) | The PDF's form field names were read, shown to a page, or not used |
| [`calibrate_call_failed`](#calibrate_call_failed) | One calibration verifier call threw — a tool's line, never a run's |
@@ -4383,6 +4392,13 @@ A [tagged PDF](#get-a-tagged-pdf-optional) was made, or the tagger refused. `ms`
never logged. `tagged_pdf_failed` has the tagger's `code` and `error`. An `internal_error`'s
message is left out, because it may quote a value.
+### `tagged_pdf_fields` / `tagged_pdf_fields_failed`
+
+`GET /fields` tagged a flat form to list the fields it gets. `ms` is how long it took.
+`tagged_pdf_fields` has `created`, how many fields were made. `tagged_pdf_fields_failed` has the
+tagger's `code`. A made list is reused until the extracted pages change. A failure is reused for a
+minute after it ends, so a failing form writes at most one line a minute while it is asked.
+
### `form_fields` / `page_fields` / `page_fields_missing`
With tagged PDFs on, the upload reads the PDF's form fields, and the page agent is asked to name
diff --git a/public/demo.html b/public/demo.html
index a4b197e8..8e405717 100644
--- a/public/demo.html
+++ b/public/demo.html
@@ -167,8 +167,8 @@
Your accessible HTML
+ the upload was one PDF. The fields come from that PDF, or for a flat form from
+ tagging it (GET .../fields), so the form is built when the result is shown. -->
Get your PDF back, tagged
The same PDF you uploaded, tagged so a screen reader can read it in order. The pages look the same. The tags come from Iris's first reading of each page, before review, so the HTML above is the more accurate version.
@@ -440,6 +440,8 @@
About
function renderFields(doc, list, fields) {
const el = (tag, props = {}) => Object.assign(doc.createElement(tag), props);
const req = (f) => (f.required ? ' (required on the form)' : '');
+ // A field Iris made for a flat form is named like `home-address-2`. Read it as words.
+ const label = (f) => (f.created ? f.name.replace(/[-_]+/g, ' ').replace(/^./, (c) => c.toUpperCase()) : f.name) + req(f);
let n = 0;
for (const f of fields) {
if (f.readonly) continue;
@@ -447,7 +449,7 @@
box.append(input);
} else if (f.type === 'radio' || f.type === 'checkbox') {
box = el('fieldset');
- box.append(el('legend', { textContent: f.name + req(f) }));
+ box.append(el('legend', { textContent: label(f) }));
// A chosen radio cannot be unchosen, so "leave it" is a choice of its own. A
// checkbox is asked the same way: the page cannot show whether the PDF has it
// checked, so an unticked box would not say "leave it" or "untick it".
@@ -474,7 +476,7 @@
About
});
} else if (f.type === 'combobox' || f.type === 'listbox') {
box = el('div');
- box.append(el('label', { htmlFor: id, textContent: f.name + req(f) }));
+ box.append(el('label', { htmlFor: id, textContent: label(f) }));
const select = el('select', { id, multiple: f.multiSelect });
if (!f.multiSelect) select.append(el('option', { value: '', textContent: 'Leave as it is' }));
for (const o of f.options) select.append(el('option', { value: o, textContent: o }));
diff --git a/src/routes/sessions.ts b/src/routes/sessions.ts
index 527db36f..d767508a 100644
--- a/src/routes/sessions.ts
+++ b/src/routes/sessions.ts
@@ -1,7 +1,7 @@
import { Router } from "express";
import type { Request, Response, NextFunction } from "express";
import multer from "multer";
-import { writeFileSync, readFileSync, existsSync, rmSync } from "node:fs";
+import { writeFileSync, readFileSync, existsSync, rmSync, statSync } from "node:fs";
import { extname, join } from "node:path";
import { ulid } from "ulid";
import type { IrisConfig } from "../config.ts";
@@ -42,7 +42,7 @@ import {
} from "../providers/imageLimits.ts";
import { imageDimensions } from "../util/imageSize.ts";
import { usableFieldName } from "../pipeline/fields.ts";
-import { readFields, tagPdf, taggedPdfCommand, tagTimeoutSeconds, TaggedPdfError, type PdfField } from "../util/taggedPdf.ts";
+import { createdFields, readFields, tagPdf, taggedPdfCommand, tagTimeoutSeconds, TaggedPdfError, type PdfField, type TagInput } from "../util/taggedPdf.ts";
// 50 MB is a memory bound, not the image limit. It stays well above what an image may
// be (see imageLimits.ts) because a PDF legitimately is: 25 pages of scans is a large
@@ -145,6 +145,13 @@ function ownedSession(store: Store, id: string, userId: number): SessionRecord |
return s;
}
+// Set `key` as the newest entry, and drop the oldest past `max`. A Map keeps insertion order.
+export function remember(map: Map, key: K, value: V, max: number): void {
+ map.delete(key);
+ map.set(key, value);
+ while (map.size > max) map.delete(map.keys().next().value!);
+}
+
// Keep the upload for a tagged PDF later, when this deployment makes them and the upload
// was one PDF on its own. Then the PDF's page N is the session's page N.
export function keepSourcePdf(
@@ -602,6 +609,71 @@ export function sessionsRouter(cfg: IrisConfig, store: Store): Router {
return true;
}
+ function readyToTag(s: SessionRecord): boolean {
+ return (s.status === "ready_for_review" || s.status === "closed") && existsSync(paths.sessionFinalFragments(s.session_id));
+ }
+ // The download's name, and the pages as extracted, for the tagger. Order is the PDF's page
+ // number, because the session is one PDF. A page that failed extraction holds only its
+ // `@page-failed` note, so it is left out and the tagger reports it untagged.
+ function tagInput(s: SessionRecord): { base: string; input: TagInput } {
+ const finalPath = paths.sessionFinalFragments(s.session_id);
+ const base = existsSync(paths.sessionSourceName(s.session_id))
+ ? readFileSync(paths.sessionSourceName(s.session_id), "utf8").trim() || "document"
+ : "document";
+ const { fragments = [] } = JSON.parse(readFileSync(finalPath, "utf8")) as { fragments?: Fragment[] };
+ const outPath = paths.sessionOutput(s.session_id);
+ const output = existsSync(outPath) ? readFileSync(outPath, "utf8") : "";
+ return {
+ base,
+ input: {
+ lang: output.match(/]*\blang="([^"]+)"/i)?.[1],
+ title: base,
+ pages: [...fragments]
+ .filter((f) => !f.innerHtml.trimStart().startsWith(""), frag(1, "