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
49 changes: 46 additions & 3 deletions .github/workflows/code-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,14 +69,18 @@ jobs:
HEAD_SHA=$(echo "$META" | jq -r .head.sha)
BASE_REF=$(echo "$META" | jq -r .base.ref)
BASE_SHA=$(echo "$META" | jq -r .base.sha)
HEAD_REPO=$(echo "$META" | jq -r .head.repo.full_name)
git fetch origin "pull/$PR_NUMBER/head:pr-$PR_NUMBER"
git checkout "pr-$PR_NUMBER"
else
PR_NUMBER="${{ github.event.pull_request.number }}"
HEAD_SHA="${{ github.event.pull_request.head.sha }}"
BASE_REF="${{ github.event.pull_request.base.ref }}"
BASE_SHA="${{ github.event.pull_request.base.sha }}"
HEAD_REPO="${{ github.repository }}" # the job condition admits only same-repo PRs
fi
# A fork's code must not run in this job: it holds id-token and the Bedrock role.
if [ "$HEAD_REPO" = "${{ github.repository }}" ]; then echo "fork=false" >> "$GITHUB_OUTPUT"; else echo "fork=true" >> "$GITHUB_OUTPUT"; fi
if gh pr diff "$PR_NUMBER" --name-only | grep -qx '.github/workflows/code-review.yml'; then
echo "changed_workflow=code-review.yml" >> "$GITHUB_OUTPUT"
# Single-quoted: the escaped open-expression on the `github.event.*` line
Expand Down Expand Up @@ -160,6 +164,26 @@ jobs:
gh pr comment "$PR_NUMBER" --body-file "$BODY"
fi

- uses: actions/setup-node@v7
if: steps.guard.outputs.already_reviewed != 'true'
with:
# Must match .nvmrc: the sources are .ts, run directly by Node's type stripping.
node-version-file: .nvmrc
cache: npm

# For the OCR tests. Failure-tolerant like every step before the model: a
# step that exits non-zero here would skip the fallback and verify steps.
# Without Tesseract the OCR tests skip themselves; the warning says so.
- name: Install Tesseract (OCR tests)
if: steps.guard.outputs.already_reviewed != 'true'
continue-on-error: true
run: |
if sudo apt-get update && sudo apt-get install -y tesseract-ocr; then
echo "tesseract installed"
else
echo "::warning::tesseract-ocr failed to install; the OCR tests will skip. The review still runs."
fi

- name: Build review context
if: steps.guard.outputs.already_reviewed != 'true'
# Every check records pass/fail/skip into the context rather than failing the
Expand All @@ -172,6 +196,7 @@ jobs:
HEAD_SHA: ${{ steps.ctx.outputs.head_sha }}
BASE_REF: ${{ steps.ctx.outputs.base_ref }}
BASE_SHA: ${{ steps.ctx.outputs.base_sha }}
FORK: ${{ steps.ctx.outputs.fork }}
run: |
set -euo pipefail
OUT=/tmp/review-context.md
Expand Down Expand Up @@ -257,12 +282,30 @@ jobs:
fi
{ echo '```'; echo; echo "Result: **$SCRIPT_LINT**"; } >> "$OUT"

if [ "$FORK" = true ]; then
# Running a fork's install scripts or tests here would hand them this job's credentials.
{ echo; echo "## Install, typecheck, unit tests"; echo; echo "Skipped: the head is a fork. See the Test workflow run on this PR."; } >> "$OUT"
INSTALL=skip TYPECHECK=skip UNIT=skip
else
{ echo; echo "## Install — \`npm ci\`"; echo '```'; } >> "$OUT"
if npm ci 2>&1 | tail -40 >> "$OUT"; then INSTALL=pass; else INSTALL=fail; fi
{ echo '```'; echo; echo "Result: **$INSTALL**"; } >> "$OUT"

{ echo; echo "## Typecheck — \`npm run typecheck\`"; echo '```'; } >> "$OUT"
if npm run typecheck 2>&1 | tail -80 >> "$OUT"; then TYPECHECK=pass; else TYPECHECK=fail; fi
{ echo '```'; echo; echo "Result: **$TYPECHECK**"; } >> "$OUT"

{ echo; echo "## Unit tests — \`npm test\`"; echo '```'; } >> "$OUT"
if npm test 2>&1 | tail -120 >> "$OUT"; then UNIT=pass; else UNIT=fail; fi
{ echo '```'; echo; echo "Result: **$UNIT**"; } >> "$OUT"
fi

# Also written to its own file so the fallback step can cat it without a pipe.
SUMMARY=$(printf '## Check summary\n- workflow lint (`actionlint`): %s\n- workflow scripts (`shellcheck`): %s\n' \
"$WFLINT" "$SCRIPT_LINT")
SUMMARY=$(printf '## Check summary\n- workflow lint (`actionlint`): %s\n- workflow scripts (`shellcheck`): %s\n- npm ci: %s\n- typecheck: %s\n- unit (`npm test`): %s\n' \
"$WFLINT" "$SCRIPT_LINT" "$INSTALL" "$TYPECHECK" "$UNIT")
{ echo; echo "$SUMMARY"; } >> "$OUT"
echo "$SUMMARY" | tee -a "$GITHUB_STEP_SUMMARY" > /tmp/check-summary.md
if [ "$WFLINT" = fail ] || [ "$SCRIPT_LINT" = fail ]; then
if [ "$WFLINT" = fail ] || [ "$SCRIPT_LINT" = fail ] || { [ "$FORK" != true ] && [ "$INSTALL$TYPECHECK$UNIT" != passpasspass ]; }; then
echo "::warning::One or more checks failed — the review continues so Claude reports it."
fi

Expand Down
24 changes: 24 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: Test

on:
push:
branches: [main]
pull_request:

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version-file: .nvmrc
cache: npm
- run: sudo apt-get update && sudo apt-get install -y tesseract-ocr
- run: npm ci
- run: npm run typecheck
- run: npm test
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules/
scratch/
.claude/worktrees/
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
24
11 changes: 11 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@ Uses accessible HTML to tag and update a PDF. It is a sibling of [equalify-iris]

The **Iris PDF Maintainer** (a Claude agent) is this repo's primary maintainer. Other agents, human or automated, work alongside it. These include the PR reviewer in `.github/workflows/code-review.yml`. Their output is input to the maintainer, not a final decision.

## Working here

Several agents work from this folder at once, so each piece of work gets its own git worktree:

- Before you touch any file, start a new worktree (Claude Code: `EnterWorktree`). It lives under `.claude/worktrees/<name>` on its own branch.
- Give subagents that edit files their own worktree too (`isolation: "worktree"`). The subagent commits on its branch and you merge that branch into yours; otherwise its work is lost.
- Never edit in the main checkout; keep it on `main`. (CI checks out there too; this rule is for local work.)
- Worktrees sit in an ignored folder inside the main checkout. Clean the main checkout with `git clean -fdx`, never `-ffdx`: the double `f` deletes other agents' worktrees.
- `scratch/` is ignored, for local experiments.
- Work lands through a branch and a pull request.

## Standards

- Everything here serves building and maintaining this library.
Expand Down
103 changes: 103 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,105 @@
# equalify-iris-pdf

Use accessible HTML to tag and update a PDF.

[Iris](https://github.com/EqualifyEverything/equalify-iris) turns page images into accessible HTML. This tool takes that HTML and the original PDF, and gives back **the same PDF, tagged**: a structure tree a screen reader can follow, with form fields filled in if you give it values. The page looks exactly as it did.

It runs offline. It makes no network or model calls.

## Install

Node 24 or later. Tesseract 5 is optional; it is needed only for scanned pages.

```sh
git clone https://github.com/EqualifyEverything/equalify-iris-pdf && cd equalify-iris-pdf
npm ci
npm test
npm link # puts iris-pdf on your PATH
```

The sources are TypeScript, run directly by Node. Node does not do that inside `node_modules`, so use a clone (as above), not `npm install` from another project.

## Use

```sh
iris-pdf tag --pdf in.pdf --pages pages.json --out out.pdf --report report.json
iris-pdf fields --pdf in.pdf [--json]
iris-pdf check --pdf out.pdf # runs veraPDF's PDF/UA-1 check, if installed
```

`tag` options:

| Option | Meaning |
|---|---|
| `--values values.json` | Fill form fields (below). |
| `--lang`, `--title` | Used when `pages.json` has none. A language is required. |
| `--ocr auto\|off\|required` | Use Tesseract for pages with no text layer. Default `auto`. |
| `--verify pixels,text\|off` | The checks below. On by default. `--verify-dpi` sets the render resolution (36–600, default 150). |
| `--flatten` | Draw the field values into the page and remove the fields. |
| `--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. |

### pages.json

Iris's HTML, one entry per source page:

```json
{ "lang": "en", "title": "Parking permit",
"pages": [{ "sourcePage": 1, "html": "<h1>Parking Permit</h1><p>…</p>" }] }
```

### values.json

Field name to value. Names are the ones `iris-pdf fields` prints.

```json
{ "applicant.name": "Ada Lovelace", "applicant.consent": true, "contact": "phone", "state": "WI" }
```

Text fields take strings, checkboxes `true`/`false`, radio groups and lists one of their options. A wrong type, an unknown option or a value over the field's length limit stops the run before anything is written. Read-only fields are skipped and counted. Unchecking always writes `/Off`, whatever the source used for "off".

## How it works

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.

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

- **Pixels.** Every page renders the same as before, except inside fields whose values changed.
- **Text.** Every word the source had is still there, and every character of the added layer can be read back.

## 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).

The output declares PDF/UA-1 only when it has a title and every page is tagged.

## 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`. |
| 2 | A check failed: `pixels_changed`, `text_lost`. |
| 3 | Bad input: `unreadable`, `bad_pages`, `no_document_language`, `bad_value`, `field_not_settable`, `bad_arguments`. |

Errors print one line: `iris-pdf: <code>: <message>`.

## Privacy

Form values are personal data. They are never printed, logged, or put in the report or an error message; only field names are.

## Known limits

- **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.
- A form with no fields (a flat form) cannot be filled.

## License

[AGPL-3.0-or-later](LICENSE).
Loading
Loading