diff --git a/README.md b/README.md index e4510d0..266e9ce 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,10 @@ key inline** — this section is the decisions an operator has to make, not the for a single Caddy/nginx). Without it every caller presents as the proxy's address and shares one bucket; the log warns when it sees an `X-Forwarded-For` while this is unset. Anything Iris cannot interpret warns and trusts nothing rather than failing startup. +- **Tagged PDFs — optional, off by default.** Set `tagged_pdf.command` to the `iris-pdf` command from + [equalify-iris-pdf](https://github.com/EqualifyEverything/equalify-iris-pdf), and a session made from + one PDF can return that PDF tagged, with its form filled in. The demo page shows this only when it is + on. Iris runs without it. ### One GitHub identity, and no sign-in @@ -222,6 +226,8 @@ in the other direction: it has its own shared secret and 404s unless you set it. | `POST /v1/sessions` | Create a session, upload images and/or PDFs (`multipart/form-data`) | | `GET /v1/sessions/{id}` | Poll status | | `GET /v1/sessions/{id}/output` | Fetch the HTML when ready | +| `GET /v1/sessions/{id}/fields` | The uploaded PDF's form fields (only with tagged PDFs on) | +| `POST /v1/sessions/{id}/pdf` | The uploaded PDF back, tagged, with its form filled in (only with tagged PDFs on) | | `POST /v1/sessions/{id}/feedback` | Submit feedback, trigger a re-run | | `POST /v1/sessions/{id}/close` | Finalize the session and clean tmp | | `GET /v1/sessions/{id}/logs` | Fetch the run log (ndjson) | diff --git a/config.example.yaml b/config.example.yaml index 1cc26fc..45f8256 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -93,6 +93,20 @@ storage: # alternative for multi-instance deployments (not implemented in v1). database: ./data/iris.sqlite +# Optional: give back the uploaded PDF, tagged for accessibility, with form fields filled +# in. Needs the `iris-pdf` command from +# https://github.com/EqualifyEverything/equalify-iris-pdf, which is not part of Iris. +# Blank (the default) turns it off, and Iris works as it does without it. +# +# With it on, a session made from one PDF keeps that PDF, and the demo page offers +# a form for the PDF's fields and "Download tagged PDF". Form values are personal data: +# they are used for one request, and Iris stores neither them nor the filled PDF. +tagged_pdf: + # The command to run, e.g. `iris-pdf` if it is on PATH. + command: ${IRIS_PDF_COMMAND} + # How long one PDF may take before the command is stopped. + timeout_seconds: 300 + # Iris has ONE GitHub identity: the token below. The server holds it, callers never # see it, and nobody signs in. Every session is owned by that account and every issue # Iris files is filed under it. diff --git a/docs/API.md b/docs/API.md index 9075623..7e8835a 100644 --- a/docs/API.md +++ b/docs/API.md @@ -712,7 +712,8 @@ curl -s "$BASE/limits" | jq "upload_per_minute": 12, "max_upload_memory_mb": 256, "window_seconds": 60 - } + }, + "tagged_pdf": false } ``` @@ -740,7 +741,8 @@ are streaming: the byte total is checked before the body is read (or counted as request declares no length), while the part count is refused during parsing, once a part past `max_files` appears. `rate_limits` is how often you may ask ([Rate limits](#rate-limits-how-often-you-may-ask)), and is `null` on a deployment that does not -limit requests in the app — which means "not limiting", not "unknown". +limit requests in the app — which means "not limiting", not "unknown". `tagged_pdf` says whether +this deployment can [return a tagged PDF](#get-a-tagged-pdf-optional). A PDF's **links survive**, which rasterizing alone would not manage: a link is an annotation over the page rather than something drawn on it, so the page image carries the link text and @@ -956,6 +958,55 @@ those references 404 until a consumer supplies the files. What a screen-reader u browser will show broken images, and one that rewrites the `src`s has the log and the fragment to match them against. +## Get a tagged PDF (optional) + +A deployment that runs [equalify-iris-pdf](https://github.com/EqualifyEverything/equalify-iris-pdf) +can give you the original PDF back with tags, built from the HTML. The feature is off unless +`tagged_pdf.command` is set in the config. `GET /v1/limits` says whether it is on. + +It works only for a session made from **one PDF**. Iris keeps that PDF so it can tag it. Sessions made +from images, or from several files, get `409 no_source_pdf`. + +**Read the PDF's form fields:** + +```bash +curl -s -H "$AUTH" "$BASE/sessions/$SID/fields" | jq '.fields[0]' +``` +```json +{ "name": "applicant.name", "type": "text", "page": 1, "options": [], "required": true, + "readonly": false, "maxlen": 40, "editable": false, "multiSelect": false } +``` + +`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 +refuses, or `503 busy`, the same as below. + +**Get the tagged PDF:** + +```bash +curl -s -X POST -H "$AUTH" "$BASE/sessions/$SID/pdf" \ + -H 'content-type: application/json' \ + -d '{"values":{"applicant.name":"Jane Doe","applicant.consent":true}}' > tagged.json +jq -r .pdf tagged.json | base64 --decode > tagged.pdf +``` + +`values` is optional. Its keys are field names. A text field takes a string, a checkbox takes `true` +or `false`, and a multi-select list takes an array. Fields you leave out keep what the PDF had. +**The values are used for this one request and are never stored or logged.** The run log records +only which field names were given. + +The answer is `{"filename", "pdf", "report"}`: the file name to save as, the PDF in base64, and the +tagger's report. `report.warnings` lists what the tagger could not do cleanly, such as a form field +it could not find in the HTML. The demo page turns each warning into a plain sentence. + +The session must have finished (`ready_for_review` or `closed`), or you get `409 invalid_state`. The +PDF is tagged from the first pass's HTML for each page, before review. A page that failed extraction is +left out, and the report lists it as `page_not_in_html`. Errors from the tagger keep +its code: `400` for a bad value, `422` for a PDF it refuses (for example `encrypted`), and `504 +timeout` after `tagged_pdf.timeout_seconds` (300 by default). Two tagger runs at once, counting +both `/fields` and `/pdf`, is the limit, so a third gets `503 busy` with `Retry-After`. A deployment without the feature answers +`404 tagged_pdf_unavailable`. + ## Submit feedback (re-run) Triggers a new run within the same session, with the feedback injected as a top-level @@ -1000,8 +1051,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 **118** event types and every one of them has a section -below — **113** sections, because a few cover a pair of events that are only read together. So a +**The index is the whole log.** `src/` emits **120** event types and every one of them has a section +below — **114** sections, because a few cover a pair of 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. @@ -1125,6 +1176,7 @@ emits fails it too. | [`agent_issue_failed`](#agent_issue_failed) | A suggestion did not get filed, and which of the two calls failed | | [`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 | | [`calibrate_call_failed`](#calibrate_call_failed) | One calibration verifier call threw — a tool's line, never a run's | ### `run_queued` / `run_dequeued` @@ -4278,6 +4330,13 @@ which failure it was, and this `error` is written to match: with `blank: 0` it i provider error, standing for all of them because that is the diagnosis; otherwise it counts the source pages reported blank, which an empty document could not. +### `tagged_pdf` / `tagged_pdf_failed` + +A [tagged PDF](#get-a-tagged-pdf-optional) was made, or the tagger refused. `ms` is how long it took. +`tagged_pdf` lists `fields_given`, the names of the fields that were filled in. The values are +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. + ### `calibrate_call_failed` One verifier call in the calibration harness threw: `image` is the page, `defect` the seeded defect the diff --git a/public/demo.html b/public/demo.html index 339d143..09ffd24 100644 --- a/public/demo.html +++ b/public/demo.html @@ -49,6 +49,15 @@ border: 1px solid var(--border); border-radius: 8px; background: var(--bg); color: var(--fg); } textarea { min-height: 5rem; } + select { + font: inherit; max-width: 32rem; padding: .5rem .65rem; + border: 1px solid var(--border); border-radius: 8px; background: var(--bg); color: var(--fg); + } + h3 { font-size: 1.1rem; margin: 1.5rem 0 .5rem; } + fieldset { border: 1px solid var(--border); border-radius: 8px; margin: 1rem 0; padding: .25rem 1rem 1rem; } + legend { font-weight: 600; padding: 0 .35rem; } + .choice { display: flex; gap: .5rem; align-items: center; margin: .5rem 0; } + .choice label { display: inline; margin: 0; font-weight: 400; } button { font: inherit; font-weight: 600; cursor: pointer; border: 1px solid transparent; border-radius: 8px; padding: .6rem 1.1rem; background: var(--accent); color: var(--accent-fg); @@ -157,6 +166,25 @@
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.
+ + +