Skip to content

Latest commit

 

History

History
2948 lines (2474 loc) · 209 KB

TESTING.md

File metadata and controls

2948 lines (2474 loc) · 209 KB

Testing MegaPDF

Thanks for testing! MegaPDF is a deliberately simple PDF editor: it does four things — edit text, check boxes, place signatures, save — and tries to do them with zero learning curve. If you ever feel lost or surprised, that itself is a bug worth reporting.

Requirements

  • Windows 11 (or Windows 10 1809+), x64 or ARM64 PC.
  • Nothing else — the package is self-contained.

Install (one time, ~1 minute)

Get Mega PDF from the Microsoft Store: https://apps.microsoft.com/detail/9PF4TRRH4M76 — the Store installs it, signs it, and keeps it up to date. It appears in the Start menu.

If you installed an earlier test build from a zip (Setup.exe), uninstall that first — it is a different package and cannot update itself into the Store version.

To uninstall completely: Settings → Apps → Mega PDF → Uninstall. Note: uninstalling removes your signature library — signatures are stored locally, per Windows user, and never leave your computer.

What to test

Open any PDF (Open button, drag-and-drop onto the window, or right-click a PDF → Open with → MegaPDF).

Tabs — open a second PDF any of those ways and it opens as a tab beside the first, in the same window: the Open dialog takes several files at once, and a double-click in File Explorer while MegaPDF is running lands in the window you already have rather than starting another copy — as a new tab, or on the tab that already shows that file. Each tab keeps its own undo history, find, zoom and unsaved dot; the toolbar acts on the tab you are looking at. Ctrl+W closes a tab (Close tab and New window are in the More menu; Ctrl+Shift+N opens another window). Close the window with unsaved changes in two tabs and you should be asked about each; answer Cancel on the second and the first must still be there, edits and all. Kill the app with three tabs open and the next launch should offer to restore each of them, into its own tab.

Moving a tab to another window (#711, Windows) — drag a tab down off the strip and it opens in a window of its own where you let go; drag it onto another MegaPDF window's strip and it moves there. Right-click a tab (or Shift+F10 on it) for Move tab to new window, which is greyed out when it is the window's only tab. Make an unsaved edit and zoom in first: the edit, Undo, the zoom, the scroll position and any open find must all come with the tab, both windows must draw, and the window a tab leaves must close if it was the last one there. A real drag cannot be automated, so this is by hand; --screenshot-state tab-move covers everything after the pointer lets go.

Tabs on the Mac and Linux (#711) — on the Mac every document is a window of its own, and macOS decides whether they open as tabs, the way it does for Preview: System Settings ▸ Desktop & Dock ▸ Prefer tabs when opening documents. Set to Always, open three PDFs and you get one window with three tabs in the system's own tab bar (there is no MegaPDF tab strip); set to In Full Screen (the default) or Never, you get three windows, and Window ▸ Merge All Windows makes them tabs. Try it under both settings. Drag a tab out of the bar, or choose Window ▸ Move Tab to New Window, and it becomes a window of its own with its unsaved changes and undo intact; Window ▸ Merge All Windows puts them back. ⌘W closes the tab you are on, ⇧⌘W closes every tab in the window (asking about any with unsaved changes first). Opening a file that is already open selects its tab instead of opening it twice. On Linux the in-window tab strip stays: right-click a tab (or Shift+F10 on it) for Move Tab to New Window, or drag the tab well clear of the strip and let go. The window it leaves closes if that was its last tab, and a file opened from the file manager goes to the window you used last, or to the one that already has it.

Reading mode — the View menu, or Ctrl+H, takes the toolbars and the side pane off the screen and leaves the page. A small floating bar carries the page number and the way back; click the page number to jump to one directly. Esc (or the bar's own button) returns to the normal view. Settings (⚙) has page colours — Normal, Sepia for a long read, Night for a dark room (night inverts pictures too, which is deliberate) — and Open documents in reading mode, which should make every document you open afterward start this way.

Pages — the Pages button opens a pane of page thumbnails. Rotate a page that was scanned sideways, delete one, drag pages into a different order, insert a blank page, insert another PDF's pages, or save the pages you picked out as a file of their own. Each of those is one step in the undo history — Ctrl+Z should put the document back exactly as it was, one operation at a time.

Save As → Markdown — Save As offers Markdown document beside PDF document. Pick it and you get the document's text as a .md file: headings, paragraphs, lists, and the values you filled in. It is an export, not a save: the busy strip says Exporting…, the confirmation says Exported, the PDF is untouched, and if the document had unsaved changes the dot stays and closing still asks. A scanned page has no text to give and is written as a one-line note. Open the .md in any text editor and check it reads like the document.

Text — click any text and type. Enter or clicking elsewhere applies; Esc cancels. Clearing all the text deletes it. Ctrl+Z / Ctrl+Y undo and redo everything. If you see a note about fonts, that's expected on some documents — check the result still looks reasonable.

Checkboxes — click a checkbox to check/uncheck it. This works both on real form checkboxes and on plain drawn squares. The mark style (✗ / ✓ / ■) is in Settings (⚙).

Form fields — click a fillable field and type.

Signatures — the ✒ Signatures button: add one by typing, drawing, or importing a photo/scan (white background is removed automatically). Then click the signature, click the page to place it. Click a placed signature to select it: drag to move, drag the round corner handle to resize, ✕ or Delete key to remove.

What this places is an electronic signature — a picture of a name. It is not a digital signature, the cryptographic kind backed by a certificate, and nothing MegaPDF says around this feature may imply otherwise (#602). If a string you meet while testing suggests that placing a signature made the document verifiable, tamper-evident, certified, legally binding or secure, that is a bug worth filing. A document that already has a digital signature — open one of the genuinely signed PDFs — signed-approval.pdf and signed-certified.pdf, written by python3 tools/gen_signature_fixtures.py <outdir> — and press Ctrl+S. MegaPDF must warn at that point, not on open, and must say digital signature rather than just signature: "This document has a digital signature", or "This document is certified against changes" when the signature certifies the document. Save a copy is the prominent choice and leaves the original untouched; Overwrite the original is the deliberate, secondary one. After Save a copy, a quiet line says the original's digital signature doesn't carry over. Nothing is ever refused, and nothing here is about the signatures you place yourself (#481, #602).

Whiteout — the Whiteout button, then drag across anything (text, images, even a scan) to cover it with white. Click a whiteout to select it; ✕ or Delete removes it. Whiteout covers; it does not remove. What is underneath is still in the file, and another app can copy or search it. That is what Redact is for, and the tooltip says so.

Redact — the Redact button, beside Whiteout, then drag across what you want gone, or drag across text to mark the words. The marked areas show translucent with an outline, so you can still read what you are about to remove. Click a mark to select it: drag it to move it, drag a corner to resize it (a mark is an area, so a corner moves its edges on its own rather than keeping a shape), ✕ or Delete removes it, and Ctrl+Z undoes it. Marking, moving and removing leave the document clean — no unsaved dot, because a mark is not in the file. Clear all marks in the More menu drops every mark on every page at once, and one Ctrl+Z puts them all back. Nothing is removed until you save.

Ctrl+S or Save As then asks, and offers Save as a copy ("…-redacted.pdf") as the default — redaction cannot be undone once saved. Afterwards a short summary says what went: "1 area redacted: 13 characters".

The thing worth testing is the promise. Open the saved copy in Edge or Acrobat, press Ctrl+F and search for a word you redacted: it should not be there. Select the black box and copy: nothing should come out. Try it on a scan too — the pixels inside the box are overwritten in the picture itself, not covered.

Sometimes it will refuse, say "Nothing was removed", and tell you why. That is deliberate: if MegaPDF cannot take some of what you marked apart safely, it removes nothing rather than leaving some of it behind. A refusal leaves your marks in place and your file untouched — if you hit one, the document is worth reporting.

Add text — the Add text button, then click anywhere (including on top of a whiteout), type, press Enter. The new text behaves like any other text afterwards: click it to edit, clear it to delete.

Find — press Ctrl+F and a small find bar appears at the top right. Type and it searches the whole document as you go (capitals don't matter): every match on the page lights up, the current one stands out from the rest, and the box next to the field reads "3 of 17" — or "No results". Enter goes to the next match and Shift+Enter the previous (while the find box still has focus; the arrow buttons always work), and both wrap around from the end of the document back to the beginning. Esc (or the ✕) closes the bar and clears the highlights. Try it on something long, and try a phrase that runs across two lines.

About — the ⚙ Settings flyout ends with an About section: the version number, a link to the source on GitHub, and Third-party notices — the licences of the open-source pieces MegaPDF is built on. Check the notices dialog opens and scrolls.

Print — the Print button (or Ctrl+P) opens the normal Windows print dialog with a preview; pick a printer and print. What you see, including your edits, is what prints.

Print on iPhone, iPad and Android (#675) — More ▸ Print opens the system's own print sheet (AirPrint on iOS, the Android print service). By hand, on a device with a printer or a PDF "printer" such as Save as PDF: (1) make an edit and do not save, print, and check the edit is on the output — what prints is the document on screen, not the file on disk; (2) on a five-page document choose pages 2–3 and check exactly two sheets come out; (3) open owner-only.pdf and check Print is dimmed, because its print bit is clear; (4) on an iPad check the sheet appears as a popover from the More button and does not crash; (5) on Android, a document that allows printing but not copying prints in full, and picking a range says it cannot print part (it needs the copy permission to extract the pages); (6) iOS, unverified: a document opened with only its user password prints from an encrypted copy, which AirPrint may not be able to read — report what you see.

Shrink for email — the Shrink button saves a smaller copy of the current file (pictures reduced to email quality; the original is never touched). Try it on a photo-heavy or scanned PDF and check the size difference — and that the copy still looks fine.

Saving — Ctrl+S overwrites; Save As makes a copy. Close with unsaved changes and you should always be asked. After saving, open the file in Edge or Adobe to confirm your edits look right there too.

Sturdiness — kill MegaPDF from Task Manager mid-editing; on next launch it should offer to restore your unsaved changes. Zoom with the toolbar − / + or Ctrl+mouse-wheel. Scroll through a long document; pages should appear as you reach them.

Known limitations (not bugs)

  • Text in scanned/photographed PDFs can't be edited (there's no OCR — by design), and Ctrl+F can't find it either: to the app, a scan is a picture, not words.
  • Find is plain text only — no wildcards, no "match case" or "whole word" options, and no replace. On a very long document the count appears when the whole sweep finishes, not progressively.
  • Editing text sometimes substitutes a similar font, with a notice — expected when the document doesn't embed a complete font.
  • Password-protected PDFs show an error instead of a password prompt.
  • Redact refuses rather than half-finishes. Some documents draw part of a page from a shared block MegaPDF cannot take apart safely; marking inside one gets "Nothing was removed" and an explanation. Removing nothing is the deliberate choice: a file that looks redacted and is not would be worse than no feature.
  • Redact removes whole letters. Drag across half a word and the whole letter goes: you cannot remove half a glyph, and leaving half behind would leave it recoverable. On text set at an angle this reaches a little further than the box you drew.
  • A document that does not allow changes cannot be redacted — the button is disabled, same as Whiteout and Add text.

Linux

The Linux app ships as a .deb, a signed APT repository and a tarball — https://electricrv.ca/megapdf/linux/ has the three ways in — and building it yourself is in the README. Everything above applies once it is open, tabs included: a second megapdf file.pdf, or a file manager's Open With while the app is running, lands in the running window as a tab, and Ctrl+Page Up / Ctrl+Page Down move between tabs. megapdf-cli extract file.pdf is on your PATH beside it (below). Two differences worth knowing:

  • Print opens MegaPDF's own small dialog — which printer, how many copies — rather than the system print panel, and then hands the job to CUPS. It needs cups-client installed; without it the status line says so.
  • Signatures you type are drawn in whatever script face the machine has. fonts-urw-base35 supplies one on most desktops; with none, they come out in the body font in italic, which is legible and does not look like a signature.

The app's own diagnostics answer most "is this my machine or the app" questions, and none of them needs a document:

megapdf --desktop-check     # session, fonts, file dialogs, where saves are staged
megapdf --language-check    # which language it would run in, and why
megapdf --print-check       # the CUPS route
megapdf --self-test <dir>   # fill, check, sign, save, reopen — no window needed

Two known differences from Windows and the Mac, both filed: a save of a very large document fails if /tmp is a tmpfs, as it is on Fedora (#193), and the app has no third-party notices file yet (#194).

Releasing (for contributors, not testers)

The manual pass above is also the release gate: before a build goes to any store it is installed and clicked through on a real machine per platform, because CI cannot click and every 2.1.1 show-stopper was found that way after CI was green. The order, the gates and the commands are in docs/RELEASING.md.

Automated coverage (for contributors, not testers)

What is covered, and what is not: docs/qa/test-matrix.md. Every feature against every platform, with each pairing saying whether a test in CI drives that platform's own path, only the shared engine is tested, nothing is, or the feature is not there — and the gap list first, because that is the part worth reading. It is generated from tests/matrix/coverage.toml, and the qa-matrix workflow resolves every claim in it against the tree on every push: a named entry point or test that stops existing fails CI rather than quietly going stale here. It answers "is anything looking at this?", never "does this work", and it does not stand in for the by-hand pass in docs/RELEASING.md §2.3 (#330).

Search is covered by engine-level tests on all three platforms, so a parity break shows up in CI rather than in your hands: tests/MegaPDF.Core.Tests/PdfiumEngineTests.cs (five tests — case-insensitive matching with a plausible rect, every occurrence in reading order, a match spanning text-object boundaries, no-match, empty term), ios/MegaPDFTests/SearchTests.swift, and android/engine/src/androidTest/java/com/megapdf/engine/TextSearchTest.kt — the last two run against the same shared fixture PDFs. The About screen's notices formatting is covered by android/app/src/test/java/com/megapdf/android/NoticeParagraphsTest.kt. None of this replaces the manual pass above: nothing automated checks how the find bar feels.

Redaction (#173) is checked by hunting for what it removed rather than by asking the engine whether it removed it. tools/leakcheck searches a saved file for the removed strings in PDFium's text extraction, in every stream qpdf --qdf --decode-level=all writes out, in the raw bytes as ASCII, UTF-16 and PDF hex digits, in /Info, XMP, the outline, every annotation and every link address, and in the pixels inside the areas. The core test suite runs those searches over the fixtures tools/gen_redaction_fixtures.py writes, the Mac app's --self-test runs the whole flow through the view model the window binds to, and android/engine/src/androidTest/java/com/megapdf/engine/RedactionTest.kt runs it on a device. tools/stress/redaction-battery.sh runs the same searches across a whole corpus, where the requirement is 0 leaks, 0 crashes, 0 hangs and no render change outside the areas.

Large files (#147, #148, #267, #270) are checked in two tiers, because the defects only show at sizes no repository can hold. test_xref_entries_are_findable() runs on every push: it saves the ordinary fixtures in both of PDFium's cross-reference forms and insists every in-use entry is a non-negative offset that lands on the N 0 obj it names, and that the cross-reference stream is a conforming object — /Type /XRef, closed with endobj, an entry for itself, and a /Length that is the length of the stream. test_large_file_xref() and test_large_xref_stream_opens_from_its_table() make the same demands of files past 2 and 4 GiB, and run only where those files exist:

python3 tools/gen_large_fixtures.py ~/large --only huge-2_5gb,huge-4_5gb   # ~4 min, 7.5 GB
python3 tools/make_xref_stream.py ~/large/huge-4_5gb.pdf ~/large/huge-4_5gb-xrefstream.pdf
MEGAPDF_LARGE_FIXTURES=~/large MEGAPDF_LARGE_SCRATCH=/scratch ./megapdf_core_tests ...

Each case skips itself if its fixture is not there, so the 2.68 GB file alone is a useful run.

Both skip with a printed line when MEGAPDF_LARGE_FIXTURES is unset, and each case skips itself when the scratch directory has less room than the copy it is about to write — a full save is the size of its source and an incremental one is twice that. The saved copies are deleted unless MEGAPDF_LARGE_KEEP is set. They are not in CI: generating a 4.5 GB fixture and writing a 9.7 GB copy is not something to do on every push.

Document structure (#142, #353) — headings, paragraphs, lists, fields, furniture, reading order over columns — is megapdf_structure_load()'s own contract, exercised by golden block dumps (core/tests/expected/structure/*.blocks) over the fixtures tools/gen_structure_fixtures.py builds (columns.pdf, furniture.pdf, lists.pdf, headings.pdf, xobject-text.pdf, scan.pdf, mixed.pdf) plus the existing demo/forms/ formtext/userunit fixtures and the #98 schematic, and validated against the corpus by tools/stress/structure-battery.sh (megapdf_structure_check driving contract 9 the way tools/leakcheck drives redaction) — see that script's own header comment for the four measures and their gates.

The tagged path (#358) reads a page's structure tree instead of inferring, when the tree passes the trust rule (at least 90% of the page's characters inside a known-typed element or an /Artifact, and no marked content referenced twice; otherwise the page falls back to the heuristics with its confidence capped at 80). tagged.pdf (two pages: H1/H2, a two-MCID paragraph, a nested list, a /RoleMap-mapped heading, a /THead table, a /Figure with /Alt, a /Link, and a paragraph drawn above its heading but tagged after it) must come out with source = TAGGED on both pages, its table as a pipe table in Markdown and the tree's order; tagged-wrong.pdf (the same page with a tree covering 58% of it, scrambled) must fall back. Both are dumped with and without MEGAPDF_STRUCTURE_HEURISTIC_ONLY (--heuristic), test_structure_tagged() asserts every kind directly, and test_structure_tagged_mutations() runs 300 same-length byte mutations of the tree's own syntax through the default path under the Linux leg's ASan. tools/stress/trust_threshold.py <battery-check.log> bins the battery's per-tagged-page coverage and each order's agreement with pdftotext to justify (or move) the 90% threshold; the battery summary reports how many tagged pages the tree served and how many the rule rejected.

tiny-font-size.pdf (#382) pins the body size at the visible caption's 12 pt when most of a page's characters report a near-zero Tf (an invisible OCR layer scaled through Tm), the shape that used to round it to 0 and make every line a heading; structure_check bodysizediag is the corpus measurement behind the 1 pt floor.

Which corpus gates what (#434)

There are two corpora, and they answer different questions.

private public
where GPD-DAVE, k2, k3 only anywhere — CI, a cloud sandbox, a laptop
what 4,337 of the owner's real documents 1,497 fetched from a committed manifest
how already on disk tools/stress/public-corpus/fetch.sh
population real producers, real typography, valid files conformance fixtures, engine test suites, deliberately broken files, 186 real IRS/USCIS forms, 33 genuinely GPO-signed documents, 108 real UK OGL forms, and 7 very large documents
depth the deeper battery: real-world shapes nothing synthetic reproduces the reproducible one: anyone can run it and get the same documents

Neither replaces the other. The private corpus stays the deeper gate and stays local; the public corpus is what makes the gates runnable by someone who is not the owner.

They are different populations and they give different numbers. That is the point, not a problem. A public-corpus figure is never a substitute for the private one in a release decision, and a gate must never be retuned to make the public corpus go green — doing that would quietly weaken the gate for the corpus it was actually calibrated on (#354, #363). Record the two separately.

tools/stress/public-corpus/fetch.sh                  # → ~/megapdf-public-corpus, ~2 min
bash tools/stress/pages-battery.sh    <cli> ~/megapdf-public-corpus <out> --jobs 3
tools/stress/markdown-battery.sh      <cli> $(command -v cmark) ~/megapdf-public-corpus <out>
tools/stress/structure-battery.sh     <structure_check> ~/megapdf-public-corpus <out> --reference --cli <cli>

#525, 2026-09-30: the 280 wiki-* rows are now reachable from the Anthropic cloud sandbox, and reproducible again. Two separate problems, both fixed the same way. Wikipedia's PDF export re-renders on demand, so those rows' pinned hashes used to drift without the article changing (first found 2026-09-28, re-verifying this batch) — and, independently of that, *.wikipedia.org is one of the hosts the sandbox's egress proxy refuses outright (with irs.gov, uscis.gov, assets.publishing.service.gov.uk and govinfo.gov — see the Sixth run below). Both are fixed by mirroring the 280 documents once to a GitHub release on this repository, fetched the way tools/fetch-pdfium-linux.sh fetches PDFium (see tools/stress/public-corpus/README.md, "Non-Latin scripts: hosted, not fetched live"). A cloud-sandbox run after this change reaches 1,443 of 1,777 rows — the 1,163 git-sourced rows the Sixth run measured, plus these 280 — leaving 334 (irs/uscis/uk-*/govinfo-signed/ govinfo-large) still behind hosts the sandbox cannot reach. The Sixth run's own 1,163 figure is left as written below: it is what that run, on that manifest revision, actually measured, not a number to retrofit.

Corpus staging on k3 (#470)

k3 (kdocker3) is where batteries run repeatedly, so all three corpora are staged there permanently instead of being fetched or copied per run:

corpus path on k3 size refreshed by
private ~/pdf-test 4,337 documents never re-fetched — it is the owner's own machine copy
private (Canadian forms) ~/pdf-test-ca 135 documents same
private (UN) ~/pdf-test-un 90 documents same
public ~/pdf-public 1,494 documents, 4.3 GB (#609, 2026-10-01) tools/stress/public-corpus/fetch.sh ~/pdf-public
private (vendor manuals) ~/pdf-test-vendor 28 documents, 77 MB (2026-10-22) never re-fetched; third-party copyright, internal test use only, never committed. manifest.tsv holds a sha256 per file

Point any battery at ~/pdf-public exactly as at ~/megapdf-public-corpus above — it is the same corpus, just not re-downloaded:

tools/stress/public-corpus/fetch.sh ~/pdf-public                  # after a manifest change: skips what it already has
tools/stress/public-corpus/fetch.sh --verify-only ~/pdf-public    # proves it byte-for-byte, no network at all
tools/stress/structure-battery.sh <structure_check> ~/pdf-public <out> --reference --cli <cli>

~/pdf-public is chmod'd read-only the same way ~/pdf-test is (dr-xr-sr-x directories, r--r--r-- files), so an ordinary battery run cannot write into it, move a file, or repair a mismatch by accident — the same protection the private corpus has had all along.

What is staged there, and what is not (#609). Until 2026-10-01 this mount held 1,381 of the manifest's 1,777 rows and every battery summary reported the figure as "the public corpus" with nothing saying what it was 1,381 of. It now holds 1,494 rows: the 108 UK OGL forms and the 7 very large govinfo-large documents were staged on Dave's decision (#609) — 112 of the 115 landed. The remaining 283 rows are stated exclusions, not a silent hole: the 280 wiki-* non-Latin rows are excluded by decision, and 3 uk-hmrc rows are excluded because HMRC has made them permanently unfetchable (#625 — P53_0622.pdf and P55_2025.pdf were retired for an online-only claim service, and VAT2.pdf's asset URL now redirects to guidance instead of serving a PDF; re-pinning the manifest to "whatever HMRC serves now" isn't available because nothing at those URLs is a PDF to pin to). Both decisions are recorded the same way, in EXCLUDED-FROM-THIS-CORPUS.tsv inside the corpus directory (~/pdf-public on k3), where tools/stress/corpus_coverage.sh reads it, so every battery summary prints each exclusion by name instead of leaving a silent hole — see tools/stress/public-corpus/README.md for the exact lines. One further row is on disk but outside every number any battery prints: the qpdf fixture whose extension is .Pdf, which find -name '*.pdf' does not match — long footnoted as "assumed / not verified", now counted and named in the coverage block. Staging those 115 documents costs about 25 minutes and 4.2 GB, nearly all of it the two multi-gigabyte rows, so it is a thing to do once per machine and leave alone.

The corpus arithmetic (#625), all 1,777 manifest rows: 1,493 reachable (staged and matched by a battery's own *.pdf walk) + 1 staged-but-unreached (the .Pdf case-mismatch fixture) + 280 stated exclusion (wiki-*, non-Latin, deferred by decision) + 3 stated exclusion (uk-hmrc, #625, permanently unfetchable) + 0 unexplained = 1,777.

Scratch is disposable; corpora persist. Every agent brief says "leave the machine as you found it" and "clean up after yourself" — that means your own build directories, containers and pulled images, named for your issue (~/megapdf-<n>*, megapdf-<n>* containers/images). It does not mean deleting ~/pdf-test, ~/pdf-test-ca or ~/pdf-public: those are shared, permanent fixtures the next agent would otherwise have to rebuild or re-download, and #470 exists precisely because three agents re-downloaded the public corpus on 2026-09-27 rather than leaving it staged. If a task's brief does not name one of these three paths as scratch, it is not scratch.

First full run, 2026-09-27 (1,036 documents visited, 1,533 pages — see the #434 PR for the per-category breakdown). Superseded by the run below, kept for history:

measure public corpus gate
structure: aggregate token F1 0.998590 >= 0.998 pass
structure: F1 through megapdf-cli 0.998915 >= 0.998 pass
structure: order agreement tau (median) 1.000 >= 0.9 pass
structure: crashes / hangs 0 / 0 0 / 0 pass
structure: CLI bad exit codes 2 0 fail — #443
markdown: cmark parse failures 0 0 pass
markdown: crashes / hangs 0 / 0 0 / 0 pass
markdown: bad exit codes 2 0 fail — #443
pages: crashes / hangs 0 / 0 0 / 0 pass
pages: qpdf failures, count mismatches, other refusals 6 / 3 / 5 0 / 0 / 0 fail — #445, all harness or population

That run's own text noted what it was missing: real US federal fillable forms. #434 calls those the highest-value category, and irs.gov, uscis.gov and govinfo.gov were all unreachable from the cloud sandbox that first built the corpus. Its 237 form documents were synthetic single-feature fixtures, exercising field syntax but not the deep /Parent hierarchies a real IRS form carries — "a green run here is not evidence that real government forms work."

Second run, 2026-09-27: 136 real IRS forms + 50 real USCIS forms added

Its own gate numbers are superseded by the third run below, which measured the identical manifest (byte-identical, #455) against a moved main — kept here for the corpus population history (the federal-forms extension) and because the fidelity-gate failure it recorded is exactly what #453 and #455 are about.

irs.gov and uscis.gov are reachable from an ordinary machine (kdocker3) even though they are not reachable from Anthropic's cloud sandbox — see tools/stress/public-corpus/README.md, "Network reality", for the measured statuses and the curl-vs-urllib TLS-fingerprint wrinkle this uncovered. The corpus is now 1,349 documents, 140.2 MB; classify() also had a real bug fixed alongside this (README.md and build-manifest.py have the detail — raw-byte scanning missed /Widget inside a compressed object stream, which every modern government PDF uses), which is why the form category grew beyond just the new federal rows.

tools/stress/public-corpus/build-manifest.py --add-source irs      # from a machine that can reach it
tools/stress/public-corpus/build-manifest.py --add-source uscis
tools/stress/public-corpus/fetch.sh                                # → ~/megapdf-public-corpus
bash tools/stress/pages-battery.sh    <cli> ~/megapdf-public-corpus <out> --jobs 3
tools/stress/markdown-battery.sh      <cli> $(command -v cmark) ~/megapdf-public-corpus <out>
tools/stress/structure-battery.sh     <structure_check> ~/megapdf-public-corpus <out> --reference --cli <cli>

1,348 documents visited (1,349 minus one qpdf fixture with a case-mismatched .Pdf extension that find -name '*.pdf' still does not match, per #445's "assumed / not verified" note — cosmetic, unchanged by this run):

measure public corpus gate
structure: aggregate token F1 0.996178 >= 0.998 fail — #453, not the federal forms (below)
structure: F1 through megapdf-cli 0.996217 >= 0.998 fail — #453, same cause
structure: order agreement tau (median, 1,393 tagged pages) 0.955 >= 0.9 pass
structure: poppler agreement tau (median, informational) 0.998 — —
structure: crashes / hangs 0 / 0 0 / 0 pass
structure: CLI bad exit codes 2 0 fail — #443, same two pdfium fixtures as before
structure: pages F1 < 0.9 23 (was 5) informational → #453 (18 pages), #444 (rest)
markdown: cmark parse failures 0 0 pass
markdown: crashes / hangs 0 / 0 0 / 0 pass
markdown: bad exit codes 2 0 fail — #443, same two documents
pages: crashes / hangs 0 / 0 0 / 0 pass
pages: qpdf failures, count mismatches, other refusals 6 / 3 / 5 0 / 0 / 0 fail — #445, all harness or population
pages: extract refused (field /Parent hierarchy) 209 (was 72) informational expected (#174) — see per-category table below

Per-category breakdown for form (structure-battery measure 1, pages-battery extract):

form source documents structure F1 extract refused-fields
irs (real IRS forms) 136 0.998613 122 (90%)
uscis (real USCIS forms) 50 0.999991 14 (28%; 35/50 already permission-restricted before the field check)
veraPDF fixtures 213 0.998879 4
pdfium fixtures 74 0.998749 7
qpdf fixtures 76 0.780229 61

The real federal forms individually clear the fidelity gate (0.998613 and 0.999991, both

= 0.998) — they are not why the aggregate structure gate is red. That is qpdf's own overlay/annotation-copy fixtures over-counting tokens 2-2.7x against PDFium on two documents, filed as #453, unrelated to this extension's federal-forms content. What the real forms do confirm, at a scale no synthetic fixture showed: 90% of real IRS forms refuse a page extract because their fields sit in a /Parent hierarchy — exactly #174's documented, currently-correct refusal (MEGAPDF_ERR_FIELDS, exit 9), now measured against Form 1040 itself and most of its schedules rather than a hand-built fixture. Filed as #452 for whoever scopes #174's extract/import field-hierarchy behavior next.

#442 (the structure battery can stall forever on pdftotext) reproduced during this run too: one qpdf fixture (shared-unnamed-field.pdf) hung pdftotext for the run's duration and had to be killed by hand (an external watchdog, not a code change) to let the battery continue. That document's measure 3 (poppler agreement, informational) is missing as a result; every other measure above is unaffected.

Read #445 before treating a red pages battery on the public corpus as an engine defect: on this run, as before, every qpdf-failure/count-mismatch/other-refusal was the harness or the population, not the engine.

Third run, 2026-09-28: proving the k3 staging (#470)

Structure battery only, run against ~/pdf-public on kdocker3 rather than a fresh ~/megapdf-public-corpus fetch, to prove the staged copy is a drop-in replacement — same manifest revision as the second run above (main at 8b09807, manifest unchanged since d182085), engine built fresh from that same commit:

tools/stress/public-corpus/fetch.sh --verify-only ~/pdf-public   # offline: 1,349 verified, 0 missing, 0 mismatched
tools/stress/structure-battery.sh <structure_check> ~/pdf-public <out> --reference --cli <cli>

1,348 documents visited (the same case-mismatched .Pdf qpdf fixture noted above is still skipped by find -name '*.pdf'):

measure public corpus (staged) gate
structure: aggregate token F1 0.999167 >= 0.998 pass
structure: F1 through megapdf-cli 0.999206 >= 0.998 pass
structure: order agreement tau (median, 1,396 tagged pages) 0.956 >= 0.9 pass
structure: poppler agreement tau (median, informational) 0.998 — —
structure: crashes / hangs 0 / 0 0 / 0 pass
structure: CLI bad exit codes 0 0 pass

Every gate is green on the engine as of 8b09807 — #443 and #453's CLI-exit-code and aggregate-F1 failures from the second run above are both resolved by now (unrelated to #470; recorded here only because this run would otherwise look inconsistent with the history above). The point of this run is narrower than the numbers: a battery pointed at the permanently staged ~/pdf-public produces a normal, gate-passing report, end to end, with zero documents fetched over the network — the staging in "Corpus staging on k3" above is a transparent substitute for ~/megapdf-public-corpus, not a different corpus.

Third addition, 2026-09-28: 33 genuinely-signed documents (#471 part 1)

Not a battery run — a targeted measurement, per the task brief, of what megapdf_save()'s full rewrite does to a digital signature already on a document. 33 documents from www.govinfo.gov (GPO Federal Register, Public Law, Congressional Record, CFR and Statutes at Large PDFs), every one independently verified Signature is Valid with poppler's pdfsig before any MegaPDF code touched it. The corpus is now 1,382 documents, 298.1 MB; the new signed category and the govinfo-signed source are documented in tools/stress/public-corpus/README.md, "The signed category".

tools/stress/public-corpus/build-manifest.py --add-source govinfo-signed  # from a machine that can reach www.govinfo.gov
tools/stress/public-corpus/fetch.sh --category signed                    # → ~/megapdf-public-corpus/govinfo-signed

Result: the digital signature does not survive a MegaPDF save, with or without an edit.

stage result (33/33)
before any MegaPDF processing Signature is Valid
after megapdf_save(), no edit at all Digest Mismatch
after megapdf_save() following one edit Digest Mismatch

The two after-save rows are identical: saving invalidates the digital signature regardless of whether the content changed, because FPDF_SaveAsCopy re-serialises the whole file and carries the original /ByteRange bounds over unchanged into a file whose length is now different. Filed as #476 — a behaviour finding per the task brief, core/ untouched, no gate adjusted. See the README section for the full detail (including the specific byte-count evidence) and the harness used.

Fourth addition, 2026-09-28: 280 non-Latin-script documents, per-script battery (#471 part 2)

core/megapdf_structure.cpp's BuildWords, BuildLines and the XY-cut reading order assume left-to-right, horizontal text (#444). The corpus had essentially no Arabic, Hebrew, Han, Devanagari or Thai text to measure that assumption against. 280 rows were added, 40 real Wikipedia articles per script (wiki-ar/he/zh/ja/ko/hi/th sources, titles pinned in tools/stress/public-corpus/nonlatin_wiki_titles.py); the corpus is now 1,662 documents, 407.4 MB. See tools/stress/public-corpus/README.md, "Non-Latin scripts", for sourcing, licence and a reproducibility caveat found while verifying the batch (Wikipedia's on-demand PDF export is not always byte-stable minutes apart).

tools/stress/public-corpus/build-manifest.py --add-source nonlatin-wiki
tools/stress/structure-battery.sh <structure_check> <script-dir> <out> --reference --cli <cli>

All three batteries (structure, Markdown, pages) run per script, on a per-script corpus directory rather than the pooled corpus, so the breakdown below is exact rather than inferred from the source column after the fact:

script docs pages structure F1 (internal / CLI) pages F1 < 0.9 order tau (tree / poppler) crashes / hangs
Arabic 40 84 0.982495 / 0.985033 3 0.823 / 0.964 0 / 0
Hebrew 40 118 0.998457 / 1.000000 5 0.863 / 0.957 0 / 0
Han (Chinese) 40 74 0.984626 / 0.997121 1 0.942 / 0.988 0 / 0
Japanese (horizontal) 40 123 0.997126 / 0.999669 0 0.956 / 0.993 0 / 0
Korean 40 119 0.998822 / 1.000000 1 0.942 / 1.000 0 / 0
Devanagari (Hindi) 40 89 0.998433 / 0.999060 1 0.799 / 0.996 0 / 0
Thai 40 174 0.988943 / 0.999758 0 0.974 / 0.987 0 / 0
Japanese (vertical, bonus — not in manifest.tsv, see README) 18 39 0.512555 / 0.512555 34 0.134 / 0.182 0 / 0

Markdown and pages batteries: 0 crashes, 0 hangs, 0 cmark parse failures, 0 qpdf failures, 0 page-count mismatches on every script, including the vertical-Japanese bonus sample.

As expected, and as instructed, the gate was not tuned to fit these numbers. Three of seven real, horizontal, non-Latin scripts (Arabic, Han, Thai) fail the corpus-wide >= 0.998 structure fidelity gate outright at just 40 documents each; the rest (Hebrew, Devanagari, Korean, and — marginally, on the internal measure only — Japanese-horizontal) sit within 0.003 of it, against the general corpus's comfortable 0.998590–0.998915. Every script under-segments slightly relative to PDFium's own tokenisation (engine/PDFium token ratio 0.976–0.999, worst on Han and Thai); vertical Japanese under-segments drastically (ratio 0.72) — the same family of bug #444 found, but the mirror image of its example (there the engine over-produced tokens 10:2 against PDFium; here it under-produces). Reading-order self-agreement (tree tau) is also markedly weaker for Arabic and Devanagari (0.80–0.86) than for the CJK/Thai group (0.94–0.97) even though token fidelity is close to passing for both — the RTL/Indic order risk docs/reading-mode-plan.md §7 named but had no population to measure. Filed as #482 (vertical Japanese, the #444-shaped failure), #483 (the horizontal-script fidelity-gate shortfall) and #484 (Arabic/Devanagari reading-order self-disagreement); core/megapdf_structure.cpp was not touched, since another session is working there for #453.

What #482 turned out to be: the reference is wrong, and the real defect is order (2026-09-28)

The vertical-Japanese row above measures PDFium, not the engine. Measure 1 scores the engine's tokens against PDFium's own FPDFText_GetText, and this sample is the population where that reference stops being trustworthy. The sample is generated by tools/stress/public-corpus/gen-ja-vertical.py from ja.wikipedia extracts, so the exact text and the exact reading order are known in advance; scoring all three extractors against that source text instead of against each other settles which of them is wrong:

against the generated source text (18 docs, 39 pages) CJK chars returned ...in source order Latin/digit token F1
MegaPDF (the shipped, tagged path) 18,704 / 18,704 18,704 (1.0000) 0.9863
PDFium FPDFText_GetText (measure 1's reference) 18,704 / 18,704 16,440 (0.8790) 0.5177
poppler pdftotext -layout (measure 3's reference) 18,704 / 18,704 1,586 (0.0848) 1.0000

PDFium's 0.5177 is measure 1's 0.51 aggregate almost to the digit: it fragments the Latin and numeric runs that vertical Japanese sets rotated inside its columns, returning 1,560 tokens where the source has 1,125, and it also mis-orders 12% of the CJK. The engine returns the source's text exactly, in the source's order. It is not under-segmenting — it is the only one of the three that gets this population right, and the measure marks it down for it. (poppler's 0.085 is not a defect either: -layout is asked to reproduce the physical page, and a vertical page read physically is row-major across the columns.)

The real defect the ground truth did expose is in reading order, and only on untagged pages. Forced down the heuristic path (--heuristic, what a vertical PDF with no structure tree gets), the engine returned every character but only 24.4% of them in source order: #451 taught BuildWords to keep a vertical column whole, but BuildLines above it still clustered words on a shared vertical centre and sorted them by left edge, so a tb-rl page was read left-to-right — every column in exactly the wrong order, with nothing lost, which is why a token-multiset measure cannot see it. Fixed by extending #472's layout frame to detect vertical writing from the origin step between characters rather than from the glyph matrix (ReadsVertically, core/megapdf_structure.cpp), with vertical-tbrl.pdf as the regression fixture. Before and after, same sample, same binary:

vertical Japanese (18 docs, 39 pages) before after
heuristic-path CJK order fidelity vs the source text 0.2441 0.9225
tagged-path (shipped) CJK order fidelity vs the source text 1.0000 1.0000
measure 2: order agreement tau (tree, median) 0.154 0.976
measure 1: aggregate token F1 0.522677 0.522677
crashes / hangs 0 / 0 0 / 0

Measure 1 does not move, and should not: the engine's tokens were already right and the gate is measuring the reference's fragmentation. The 0.998 gate was not touched, and the vertical-Japanese sample is still below it — that number stays red until measure 1 has a reference that can read a vertical page, which is its own question and not one to settle by moving a gate.

Nothing else moves either. The frame test was run over every page of every corpus on hand before the change was measured, to size what it could possibly touch: 0 pages of 20,499 in the public corpus outside one fixture set, 0 of 3,812 UN pages and 0 of 781 pages across all seven horizontal non-Latin scripts are seen as vertical, against 38 of the 39 vertical pages. The seven horizontal scripts re-measured byte-identically on every column of the table above, before and after.

Fifth run, 2026-09-28: 108 UK OGL forms + 7 large govinfo documents (#471 parts 3-4)

Built on main including #452/#463's field-hierarchy relaxation, #457's XFA work, #470's k3 staging, and #471 part 1's signed category — see the #471 parts-3/4 PR for the exact commit and manifest revision this was actually run against. The "whole corpus" row below is a snapshot of 1,489 documents (pre-#471-part-2's non-Latin rows, which landed on main while this run was already underway) — it does not include the 280 wiki-* rows the run directly above added. The UK-specific and large-specific findings that follow it do not depend on total corpus composition (they are computed only from their own rows), so they stand regardless; the aggregate figure is reported for context, not as this PR's own authoritative "whole corpus" number — see the PR for a number against the true final manifest if one was re-run. The corpus's final size, including every population added by this point, is 1,777 documents, ~4.40 GB (dominated by the seven large rows at ~4.25 GB) — see tools/stress/public-corpus/README.md, "UK government forms" and "Very large documents", for how the UK/large sets were chosen and licensed.

tools/stress/public-corpus/build-manifest.py --add-source uk-hmrc
tools/stress/public-corpus/build-manifest.py --add-source uk-homeoffice
tools/stress/public-corpus/build-manifest.py --add-source uk-dwp
tools/stress/public-corpus/build-manifest.py --add-source govinfo-large
tools/stress/public-corpus/fetch.sh                                # → ~/megapdf-public-corpus
bash tools/stress/pages-battery.sh    <cli> ~/megapdf-public-corpus <out> --jobs 3
tools/stress/markdown-battery.sh      <cli> $(command -v cmark) ~/megapdf-public-corpus <out>
tools/stress/structure-battery.sh     <structure_check> ~/megapdf-public-corpus <out> --reference --cli <cli>

1,489 documents visited by find -name '*.pdf' for the non-large batteries (see the snapshot caveat above), minus the same case-mismatched qpdf fixture #445 already noted, minus the fact that large rows were battery-tested separately, below, rather than mixed into the same run given their size:

measure whole corpus gate
structure: aggregate token F1 (internal API) 0.998390 >= 0.998 pass — up from #453's 0.996178 now that #453/#463 have landed
structure: F1 through megapdf-cli 0.999412 >= 0.998 pass
structure: order agreement tau (median, 2,403 tagged pages) 0.945 >= 0.9 pass
structure: poppler agreement tau (median, informational) 0.987 — —
structure: crashes / hangs 0 / 0 0 / 0 pass
structure: CLI bad exit codes 0 0 pass — the two pdfium fixtures #443 named no longer misfire
structure: pages F1 < 0.9 3 informational down from 23
structure: poppler (pdftotext) timeouts 1 informational (#442) bounded, did not stall the run
markdown: cmark parse failures 0 0 pass
markdown: crashes / hangs / bad exit codes 0 / 0 / 0 0 / 0 / 0 pass
pages: crashes / hangs 0 / 0 0 / 0 pass

Per-source breakdown, UK rows (structure-battery measure 1, pages-battery extract):

source documents structure F1 (internal) structure F1 (cli) extract refused-fields
uk-hmrc 62 0.999535 0.999535 0
uk-homeoffice 22 0.991581 (below gate) 0.999733 0
uk-dwp 24 0.999958 0.999984 0

Zero field-/Parent-hierarchy refusals across all 108 UK forms — including CT600A/B/C/J, SA800 and SA900, the deepest hierarchies in the set. This is the evidence #471 part 3 asked for: #463's relaxation holds on a second jurisdiction's forms, not just the US federal ones it was built and measured against (compare the second run above: 90% of real IRS forms refused extract before #463).

uk-homeoffice's internal-API F1 (0.991581) is below the 0.998 gate even though the whole corpus passes and uk-homeoffice's own CLI-measured F1 (0.999733) does not — filed as #479, a fidelity gap concentrated in the Home Office nationality-form family specifically (not furniture volume, page count, or anything else common to every uk-homeoffice document). Filed as the finding per #471's "never fix, never gate-adjust" instruction. #480 records the addition and its overall results.

large category (7 govinfo Federal Register documents, 48.4 MB - 2.07 GB), tested separately given their size (--jobs 1):

measure large category gate
pages: crashes / hangs 0 / 0 (981s total, 28 operations) 0 / 0 pass
pages: qpdf timeouts 0 0 pass
structure: crashes / hangs 0 / 0 (283s total) 0 / 0 pass
structure: aggregate token F1 (internal API) 0.989043 >= 0.998 fail — see #488
structure: F1 through megapdf-cli 0.989950 >= 0.998 fail — same cause
structure: poppler (pdftotext) timeouts 0 informational (#442) pass — completed even on the 2.07 GB document
markdown: crashes / hangs / bad exit codes 0 / 0 / 0 (45s total) 0 / 0 / 0 pass

Wall time / peak RSS per document (via megapdf-cli extract, outside the batteries, as a baseline for a future regression to compare against): see tools/stress/public-corpus/README.md, "Very large documents" — from 1.42s/290MB (48.4 MB document) to 14.75s/2.40GB (the 2.07 GB document), scaling roughly linearly with size, no sign of a blow-up at the top of the range.

The structure gate fails on this population — filed as #488: these are largely pre-1995 OCR'd scans (see README, "Very large documents"), and 0.989 is well within the range other scan-heavy or non-standard populations have measured at elsewhere in this corpus. Not fixed or gate-adjusted here, per #471's own instruction — a gate moving on a new population is the information wanted.

Battery timeouts and the large category: the current defaults were sufficient for every document tested, with real margin. Across all three batteries and all 7 documents up to 2.07 GB, not one operation hit its timeout — pages-battery.sh's 300s (28 rotate/delete/ move/extract operations, 981s total, none individually timed out), structure-battery.sh's 120s (283s total across internal-API, pdftotext --reference, and CLI calls per document; poppler itself finished the 2.07 GB document inside 120s, where #442 found it can hang indefinitely on a much smaller, adversarial fixture), and markdown-battery.sh's 120s (45s total for all 7). No change requested for these sizes — but this is 7 documents topping out at 2.07 GB; a document meaningfully larger, or a slower host, could still reach the existing limits, so this is worth re-checking rather than assumed permanently settled if the category ever grows. Not changed here either way — tools/stress/*-battery.sh was out of bounds for #442/#445 and stays out of bounds here.

Android app UI tests (#346)

android/app/src/androidTest/ drives the Android app's own screen on an emulator — the menu rows, gestures, toolbar and pickers that the engine's tests and the core's parity tests never touch, and that until #346 only a store capture had ever exercised. They are Compose UI tests against the real MainActivity and its real view model and engine, finding things by the labels the accessibility work gave them (#328, #347), so a label that goes missing fails a test rather than a TalkBack pass. The fixture is the app's own demo agreement, handed over as a content:// uri through a FileProvider — the way another app hands a PDF over (#376). (The app's own provider serves it, from a folder inside its share grant: the test APK has a uid of its own, so a provider of its own would be out of the app's reach.)

test what it drives
EditingTest a tap ticks a drawn checkbox (Undo lights, the title gets its dot), Save writes it to the file; Add text → tap → type → Add puts a box where the tap landed, and tapping it again brings up its ✎ / ✕ chrome
RedactionMarkLifecycleTest the ⋮ Redact row arms the tool and says so (#328); a drag along a line marks it; tap selects, drag moves, ✕ at its centre removes (#347); Undo takes back removal, move and placement one step each, Redo puts the placement back; Clear all marks is one step (#329)
FileCommandsTest Export as Markdown asks for a text/markdown document named .md and writes real Markdown without touching the document (#409); Save a copy asks for a PDF, writes one that becomes the document; Share asks Save / Share without saving / Cancel when dirty, and Share without saving hands the chooser a content:// grant on a copy (#378). The pickers and the chooser are answered by Espresso-Intents, so the test sees exactly what the app asked the system for
OpenWithTest an ACTION_VIEW with a content:// uri opens the document cold; a second one while the first has unsaved changes asks Save / Discard / Cancel, and Discard replaces it; a file:// uri opens too (#376)
PinchZoomTest two fingers apart widen the page past the screen; a double tap fits it again (#336)

They run in android-ci.yml's instrumented-test job after the engine's tests, on the same API 30 emulator, a pixel_5 frame (:app:connectedDebugAndroidTest). Locally, with an emulator or a device attached:

cd android && ./gradlew :app:connectedDebugAndroidTest

A red test stays in the tree under @Ignore naming its issue, so the fix takes the annotation off rather than writing the test: undoAfterARemovalTakesBackTheMoveBeforeIt is #429, found by the harness's first run.

What they cannot reach: the system picker's own sheet (DocumentsUI is stubbed, not driven), the OS share sheet, TalkBack itself, and how any of it feels — the manual pass in this file still stands.

Three timing traps, and the numbers that settle them (#611)

Main went red for most of a day on PageToolsProgressTest, and none of it was a product defect: it was three races the tests were losing, all of one family — a check that samples something the app is in the middle of changing. They are written down here because each was diagnosed from the test's name twice before anybody measured anything.

  1. An operation has to outlast the busy strip's 0.5 s show threshold (BusyState.SHOW_AFTER_MS) before Stop is offered at all, and these fixtures were sized by eye rather than by measurement. Measured on the hosted CI emulator: extract over the old q Q-padded 800 x 100 KB fixture took 373 ms — under the threshold, so most runs had nothing to press Stop on. The 80 MB document was a mirage: megapdf_pages_extract deflates what it copies and a no-op pair compresses to nothing, so the real work was a 332 KB write. With incompressible padding the same shape takes 1,893 ms. The search sweep was the same story at 2,000 pages (679 ms, 1.4x the threshold, 3 failures in 24) and is 8,000 pages now (1,904 ms). Extract runs at ~42 MB/s of output and the sweep at ~0.24 ms a page, both near-linear, so bytes and pages are the only dials: re-measure before shrinking either count. Combine's own import of 5,000 pages is only 149 ms; what keeps its token alive long enough is re-reading 5,002 page sizes afterwards, not the import.
  2. Undo is offered a moment before it will be obeyed. ViewerViewModel.undo goes through launchEdit, which drops the request silently while editingBlocked (editsInFlight > 0) — by design (#145: a tap while one runs is ignored, never queued). The button greys out on toolsDisabled instead, which deliberately leaves editsInFlight out so quick page work does not make the toolbar flicker. An operation's effect lands inside launchEdit's block and its editsInFlight-- in the finally after it, so a test pressing Undo as soon as the previous change shows up is pressing into that gap. Every test presses Undo and Redo through clickUndo() / clickRedo(), which wait on editingBlocked — the question the view model actually answers. Do not click the button directly. (The product asymmetry this worked around is itself fixed now, see below, so the wait is belt and braces — but it is also what covers pageRewriteDeciding, and a test that asks the view model rather than the screen is the right habit regardless.)
  3. A one-shot status cannot be asserted on. ViewerViewModel.statusMessage used to be erased by the toast that showed it, so "did the app say Stopped.?" was a race against a recomposition the test cannot see. It is kept now, and the toast de-duplicates on statusSerial instead.

Two habits came out of it. A wait that can time out uses waitUntilOrExplain, so the report says what the app was doing rather than only a line number — that is what finally told all three apart. And .github/scripts/run-instrumented-tests.sh runs adb root before starting the logcat capture: adb root restarts adbd and tears down every connection it was serving, which is why four of the six original #611 failures uploaded a 0-byte instrumented-logcat.txt — #545's diagnostics were missing exactly when needed.

The asymmetry behind trap 2 is fixed, and what the flicker actually costs

#611 left the product half of trap 2 alone and flagged it: the toolbar genuinely offered an Undo the view model would discard, for the few hundred milliseconds after an edit too quick to show a spinner. Dave reversed the #145 trade-off for 2.2 — a person who taps Undo and sees nothing happen reports it as lost work, which is worse than a blink — so there is now one predicate instead of two. ViewerViewModel.toolsDisabled is gone; editingBlocked is both what the actions refuse on and what the controls grey out on, and ToolbarGateTest holds the invariant: a command the toolbar offers is one the view model will act on. It fails 3 runs in 3 without the fix and passes 3 in 3 with it.

Two things worth keeping from that change. The swap was safe to make in one step because the old condition was a strict subset of the new one — every busy.page spinner is started inside a launchEdit/launchPageEdit block, so page.isVisible implies editsInFlight > 0 — which means unifying them could only ever disable something earlier, never offer something new. And the blink #145 traded this away for was finally measured, 64 times over 16 full-suite runs on the CI emulator:

edit blocked, min–median–max
checkbox tap 5.5 – 7.2 – 7.7 ms
rotate one page 0 – 21.6 – 118.8 ms
undo a checkbox 0 – 21.9 – 119.6 ms
undo a rotation 0 – 30.5 – 122.8 ms

A frame is 16.7 ms, so a checkbox tap's grey-out does not survive to be drawn at all, and the worst case seen is about seven frames. Every single measurement showed one blocked period or none — never two — so the toolbar dims once per edit and cannot strobe. Anything long enough to be properly visible was already disabling the toolbar before this change, via the page spinner.

That is the whole justification for overriding #145, and it is worth being explicit about why. #145 did not weigh a 7 ms grey-out against a discarded tap and choose the tap; it reasoned about "a flicker on every checkbox" without ever timing one, and traded away a real correctness property for a cost that turns out not to exist at 60 Hz. The structural half of the measurement is what settles it rather than taste: one blocked period per edit, never two means there is no mechanism by which the toolbar can strobe, whatever the hardware — a slower phone makes the single dim longer, not repeated. Opinion could have argued either way about whether a blink is worse than losing a tap; the numbers mean nobody has to.

This is the same failure mode as trap 1 above, in the same file, found the same week: a constant or a trade-off chosen by reasoning about a quantity instead of measuring it, and wrong by enough to matter. The extract fixture was sized for a write that was assumed to take "a real amount of wall time" and took 373 ms; this flicker was avoided as if it were visible and it is 7 ms. When a decision here turns on a duration, measure the duration.

Measuring it needed three attempts, which is worth knowing before anyone tries to watch a Compose state over time again: a thread calling runOnMainSync in a loop starves the looper and times its own test out, and a Runnable reposting itself on the looper stops Espresso ever seeing the app idle (AppNotIdleException). What works is a passive Snapshot.registerApplyObserver, which timestamps every point the value can change and is invisible to the idling policy.

megapdf-cli (#142, #355, #357)

megapdf-cli extract <file.pdf> is a small, self-contained native binary — no .NET runtime, no Store package — that turns a PDF's text into plain text (or, with --format md, CommonMark Markdown) from a shell: PowerShell, cmd, bash, whatever the machine has. It is built as part of the shared engine core, not as part of any app:

cmake -S core -B core/build/linux-x64 -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build core/build/linux-x64 --target megapdf_cli
core/build/linux-x64/megapdf-cli extract report.pdf > report.txt

(On Windows, -A x64 in place of -G Ninja, and the output is megapdf-cli.exe under the build's Release\ folder; on macOS, the same as Linux. Building the core needs the pinned PDFium prebuilt — tools/fetch-pdfium-linux.sh / tools/fetch-pdfium-mac.sh, or tools/pdfium/install-release.sh on Windows — fetched first, same as MEGAPDF_CORE_TESTS.)

--help lists every option (--format txt|md, --pages, --out, --password-file/--password-stdin, --keep-lines, --page-marker/--no-page-breaks, --keep-furniture, --all-fields/--no-fields, --heuristic, --strict, --quiet). There is deliberately no --password flag — the password is a file's first line or stdin's first line, never a command-line argument, matching the rest of the repo's own practice (tools/gen_security_fixtures.sh, MEGAPDF_STRESS_UNLOCK_LIST). Exit codes are part of the contract, not an afterthought:

exit meaning
0 text written
1 usage error (a bad option, a bad --pages range, both password options given)
2 the file could not be opened (missing, unreadable, not a PDF), or opened but a requested page in it could not be read (#443: a document PDFium's own page tree calls valid enough to open but not enough to load every page it counts)
3 a password is required, or the one given is wrong
4 the document uses a security handler this build cannot open
5 none of the requested pages had a text layer
6 --strict was given and at least one requested page had no text layer
7 the output could not be written (--out's file, or stdout)
130 interrupted (Ctrl+C)

A page with no text layer never silences the run — MegaPDF does not do OCR, and says so once on stderr, plus one page N: no text layer line per such page (--quiet silences these two notes; errors always print regardless). core-tests.yml runs the built binary against the structure fixtures above on all three OSes and diffs its output against the same .txt/.md goldens the internal API test compares against, plus an informational, Linux-only token comparison against pdftotext. tools/stress/structure-battery.sh --cli <megapdf-cli path> re-runs the corpus battery's token-fidelity measure through the real binary (--page-marker, not the default form feed, because a real document's text can itself contain a stray form-feed character from a bad font mapping) rather than only through the internal API call.

--format md (#357) renders the same contract-9 blocks as CommonMark: #-headings (capped at level 6), unwrapped paragraphs with bold/italic/monospace spans, -/N.-prefixed list items (a lettered or roman marker becomes 1. <marker> text, since CommonMark has no lettered lists), form fields as GitHub task-list items or **name:** value, and a page with no text layer as *[Page N has no text layer]*. It infers nothing itself — every block came from the same structure load the plain-text writer uses, so the two formats can never disagree about what a heading is. core_tests.cpp additionally renders each golden .md fixture through an independent CommonMark implementation (cmark, apt install cmark on Linux) and asserts its heading and list-item counts match contract 9's own block counts — proof that escaping never breaks a real block out of its own markup, skipped rather than failed when cmark is not on PATH. tools/stress/markdown-battery.sh <megapdf-cli> <cmark> <corpus> <out-dir> is the corpus-scale counterpart: every document's --format md output parses cleanly through cmark, gating on crashes, hangs and parse failures (never on heading accuracy — there is no corpus-scale ground truth for that; #357's own hand-checked sample of 30 documents is where that bar is judged, by a person, off --dump output on the corpus machine).

Where the binary comes from, per platform (#142, #356). Not a separate build: on every platform megapdf-cli is the megapdf_cli CMake target, built alongside libmegapdf_core by the same core build every app already needed.

platform where it ships
Linux tools/build-linux-app.sh builds it and copies it into the app tree's bin/, beside libmegapdf_core.so and libpdfium.so. From there: the tarball and install.sh (~/.local/bin/megapdf-cli, a symlink into ~/.local/lib/megapdf), the .deb (/usr/bin/megapdf-cli, a symlink into /opt/MegaPDF), the Flatpak (flatpak run --command=megapdf-cli ca.electricrv.MegaPDF file.pdf), and the snap (snap run megapdf.cli extract file.pdf). tools/Linux-Packaging.md has the exact invocations and the packaging changes behind each.
Windows megapdf-cli.exe, x64 and arm64, in megapdf-cli-windows-<arch>-<version>.zip, attached to a draft GitHub release by .github/workflows/windows-cli-release.yml on a windows-cli-v* tag, alongside megapdf_core.dll, pdfium.dll, LICENSE and THIRD-PARTY-NOTICES.txt. Unsigned — MegaPDF carries no Authenticode certificate; the only Windows signing that exists is the Store's own re-signing of the MSIX on ingestion, which cannot sign a loose .exe. The release notes say so. Not in the MSIX Store package — a Store app cannot put a binary on PATH.
macOS megapdf-cli, a universal (arm64 + x86_64) binary, in megapdf-cli-macos-universal-<version>.zip, attached to a draft GitHub release by the cli-release/cli-release-publish jobs in .github/workflows/macos-app.yml on a macos-cli-v* tag. Signed with the app's Developer ID and notarized the way notarize-macos-app.sh notarizes the bundle, but not stapled — Apple's stapler only attaches to a bundle, .pkg or .dmg, not a loose-file zip, so Gatekeeper checks the ticket online on first run instead. Not in the Mac App Store package, for the same reason as Windows.

The zips are a separate release artifact from the app itself, on their own tag series (windows-cli-v*, macos-cli-v*, alongside linux-v*, android-v*, ios-v*) — nothing about the MSIX or the Mac App Store submission changes.

Verifying the archives needs Windows/macOS hardware this repository's CI does not have outside the tag build itself — download, unzip and run on a clean Windows 11 machine and a clean Mac (Gatekeeper's verdict on the unstapled zip in particular) is a manual check.

Sixth run, 2026-09-28: the sample pinned, baseline tied to a named revision (#455)

The first baseline recorded against a manifest revision rather than a date, which is what #455 asks for: a gate number without the manifest it was measured on cannot tell you whether it moved because the engine changed or because the corpus reshuffled.

Measured before #519, so the internal-API figures below read very slightly low. That fix (#479/#495) taught the fidelity measure to count a list item's marker, which the engine keeps in its own field and the CLI's writer puts back when it renders — so every alphanumeric list marker had been counting as a token the engine lost. The correction is small and always in the same direction: on a 300-document sample the internal aggregate moved from 0.971414 to 0.971436, matching the CLI leg exactly. Compare a later run's internal-API numbers against a re-measurement, not against these, and note it the way #455 asks: a gate number names both the manifest it was measured on and the tool that measured it.

manifest revision: sha256:d1c084b1cedb7583dfbef03e3b8fb59c9eac55c39b1dbed0fdd132cde7dd079e
1,777 rows  (363 form, 300 tagged, 250 report, 150 malformed, 100 scan, 0 large,
             614 opt-in: 136 irs, 50 uscis, 108 uk-*, 33 govinfo-signed, 7 govinfo-large,
             280 wiki-*)

Measured over 1,163 of those 1,777 rows. Only raw.githubusercontent.com is reachable from Anthropic's cloud sandbox; the 614 opt-in rows sit behind hosts that refuse the proxy's CONNECT outright (irs.gov, uscis.gov, assets.publishing.service.gov.uk, govinfo.gov, *.wikipedia.org — the same block #434 recorded). This is therefore the baseline for the git-sourced subset of that revision, not the whole corpus; a full-corpus number needs a machine that can reach the other five hosts.

Run against a clean tree holding only this revision's rows. That matters and is itself a #455 point: the fetch directory on this machine still held 101 documents selected by earlier manifest revisions, and a battery walks the directory, not the manifest — so measuring it as-is would have reported 1,264 documents against a 1,163-row manifest and reproduced the exact ambiguity this issue exists to remove. fetch.sh --verify-only over the subset: verified 1163, missing 0, mismatched 0. Built against the pinned 33-patch PDFium (pdfium-7934-megapdf-2b3b415e86b1).

measure this run gate
structure: aggregate token F1 0.999450 >= 0.998 pass
structure: F1 through megapdf-cli 0.999670 >= 0.998 pass
structure: crashes / hangs 0 / 0 0 / 0 pass
structure: CLI bad exit codes 0 0 pass
structure: order agreement tau, median 1.000 >= 0.9 pass
structure: pages F1 < 0.9 2 informational
markdown: cmark parse failures 0 0 pass
markdown: crashes / hangs / bad exits 0 / 0 / 0 0 / 0 / 0 pass
pages: crashes / hangs 0 / 0 0 / 0 pass
pages: qpdf failures / count mismatches / write failures 0 / 0 / 0 0 / 0 / 0 pass
pages: refused, other 5 0 fail — #445

1,162 documents seen by the structure and markdown batteries rather than 1,163: their find is case-sensitive -name '*.pdf' and one qpdf fixture is split-exp-04.Pdf. Cosmetic, noted so the counts reconcile.

The one red gate is the same one TESTING.md already tells you how to read. All five refused, other come from two documents — pdfium/testing/resources/bug_216.pdf and document_aactions.pdf — refusing rotate/move/extract with exit 9, the pair #453 identified and #445 filed. #450 since split out the buckets that were miscounted (usage error, refused, damaged input, qpdf, carried damage), and those now read 2 / 0 / 3 and are correctly not gated; what is left is #445's still-open point that a page the engine cannot load is a third legitimate refusal the contract does not list. Not re-filed, and no threshold touched.

One poppler timeout (pdftotext bounded at 120s by #442, on qpdf's shared-unnamed-field.pdf); that document is simply absent from measure 3's counts and does not touch the fidelity gate.

Seventh run, 2026-09-28: all three batteries over all four corpora, main at 88117b7

The first measurement of every corpus on hand against one commit, and the first since the three changes that could have moved these numbers: #521 (the layout frame now detects vertical writing), #519 (the fidelity measure now counts a list item's marker) and #452/#463 (PDFium at 33 patches, release pdfium-7934-megapdf-2b3b415e86b1). Run on kdocker3 in a container, structure-battery.sh --reference --cli, then markdown-battery.sh, then pages-battery.sh.

Zero crashes and zero hangs, in every battery, on every corpus. 5,763 documents.

Nothing was silently narrowed, which is the first thing to check in a run this size: each battery's own sweep visited exactly the document count on disk.

corpus on disk visited opened the rest
private 4,158 4,158 4,084 12 encrypted, 62 unreadable format
public 1,381 1,381 1,368 5 encrypted, 8 unreadable format
Canadian 134 134 134 —
UN 90 90 90 —

Structure, against F1 >= 0.998 and order agreement tau >= 0.9:

corpus token F1, internal token F1, CLI tau median CLI bad exits
private 0.998880 pass 0.998880 pass 0.935 pass 0
public 0.971952 fail 0.971952 fail 0.956 pass 0
Canadian 0.999907 pass 0.999907 pass 0.959 pass 0
UN 0.996348 fail 0.996348 fail 0.967 pass 0

Markdown: every gate passes on all four corpora. Pages: all gates pass except public's five refused, other, which are the same two documents recorded above, tracked as #445's open point. The private corpus now shows 0 refused-fields, down from 33, which is #452/#463 landing.

Both failures are already-open issues, measured again rather than newly found. Neither is an artifact of #519's correction, whose effect is about 0.00002.

  • Public resolves to one category. The 33 govinfo-signed documents carry 96% of the corpus's tokens and score 0.9707; every other category present scores 0.999 to 1.000. #498 already recorded 0.970684 for exactly that population.
  • UN resolves to one document. No document scores below 0.96, and a single 190-page one at 0.9662 carries the whole shortfall. Excluding it, the corpus reads 0.99960. That document is #496's, root-caused since as #524: BuildLines scales its split threshold off the raw Tf operand, so a producer that puts its scale in the text matrix gets one block per word.

Read the private corpus's 0.998880 as a first figure, not a comparison: no private baseline is committed, by design, because the corpus is personal.

Eighth run, 2026-09-30: page tools (#174) corpus battery, pages-battery.sh, main at 5483095

The outstanding item #174 itself named: three platforms now ship a page-tools interface (Android #554, Mac/Linux #556, the shared .NET layer Windows builds on), the engine side (#430, contract 10) and rotation-aware coordinates (#439) had landed, but no pass had run the corpus through rotate/delete/move/extract since #430's engine-only pass over the private corpus alone. This run adds the other three corpora and measures against main at 5483095, on top of PDFium at 33 patches (pdfium-7934-megapdf-2b3b415e86b1), the drawn-em font-size fix and the vertical-writing frame (#521) the seventh run already covered. Run on kdocker3 in a container, all four corpora through tools/stress/pages-battery.sh (the private and public corpora concurrently, --jobs 8 / --jobs 6 on 16 cores; Canadian and UN sequentially first, --jobs 8).

Zero crashes and zero hangs, on every operation, on every corpus. 5,763 documents — the same population size as the seventh run. As in that run, nothing was silently narrowed:

corpus on disk visited opened the rest
private 4,158 4,158 4,084 12 encrypted, 62 unreadable format
public 1,381 1,381 1,374 4 encrypted, 3 unreadable format
Canadian 134 134 134 —
UN 90 90 90 —

Gates (0 crashes, 0 hangs, 0 qpdf failures, 0 page-count mismatches, 0 write failures, 0 refusals outside the two the contract documents — a security-forbidden operation, exit 8, and the field-/Parent-hierarchy refusal, exit 9 with that message):

corpus crashes/hangs QPDF FAILED COUNT MISMATCH WRITE FAILED REFUSED, other
private 0/0 0 0 0 0 pass
public 0/0 0 0 0 5 fail — #445
Canadian 0/0 0 0 0 0 pass
UN 0/0 0 0 0 0 pass

Public's failure is the confirmation the task brief said to expect, not a new finding. The five refused, other (2 rotate, 1 move, 2 extract) resolve to exactly two documents (checked by their path-hash in the log, not their names) — the same pair #445 already tracks as pages the engine cannot load. Not re-filed, no threshold moved.

The field-hierarchy refusal (refused-fields, the other named-in-advance result) did not reproduce at the rate expected. extract's field-/Parent-hierarchy refusal came back 0 on every operation, on every one of the four corpora — not the roughly 0.8% quoted going in. This is not an instrument or engine defect: the sixth and seventh runs already recorded the private corpus at 0 refused-fields, "down from 33," once #452/#463's relaxation landed, and this run's corpus composition (document counts identical to the seventh run on every corpus) is consistent with that fix simply still holding rather than a new regression to chase. The ~0.8% figure appears to predate #452/#463 landing; flagged here rather than restated as confirmed, per the instruction to check the instrument before concluding.

Permission-restricted documents (exit 8, the security itself forbidding the operation — expected, not gated) were common on the Canadian and public corpora specifically: Canadian rotate 52/134, delete/move 46/134 (plus 51 single-page documents skipped), extract 67/134 — the Canada Revenue Agency's fillable-form family carries assembly restrictions; public corpus restricted ran 36–47 per operation out of 1,374 opened. Private and UN corpora carried far fewer (private 37–51 of 4,084; UN 0 of 90).

Coverage gap worth stating plainly: this run, like #430's, only exercises rotate, delete, move and extract. megapdf-cli pages --help lists two more contract-10 operations, --blank (insert) and --import (pages from another document), that pages-battery.sh never calls. #174's own scope names insert and import alongside the other four; they remain untested at corpus scale — the harness would need extending to drive them (--import in particular needs a second document per call) before that half of #174's ask is answered. No issue filed for this here since it is a coverage gap in the harness rather than a battery result, but it should not be read as answered by this run.

No new issue was filed from this run: the one failing gate is #445, already open and explicitly not to be re-filed; nothing else was red.

Ninth run, 2026-09-30: page tools (#174, #567) — insert and import added, pages-battery.sh, main at 7786c7f

The eighth run's own coverage-gap note is what this run closes: pages-battery.sh drove rotate, delete, move and extract but never --blank (insert) or --import, the other two operations megapdf-cli pages --help and #174's own scope both name. #567 extended the harness to drive both, on the same gates as the rest, and this is that run — the same four corpora, on main at 7786c7f (unchanged since the eighth run), PDFium still at 33 patches.

Blank appends one page after the document's last, sized to match it (pdfinfo's own "Page size", already rotation-adjusted — the same rule DocumentViewModel.Pages.cs's BlankPageSize uses on the desktop apps) rather than the CLI's own Letter default, so the corpus's mixed and unusual page sizes are what actually gets exercised.

Import is the operation #567 named as mattering most, and the one this run has the most to say about. Two calls per document, both population-wide, neither a sub-sample of the corpus:

  • importself — every page of the document, imported into a fresh copy of itself, appended at the end. Expected page count 2n, always known.
  • importpair — every page of a second, different document imported the same way: the document standing half the corpus's length further along the same sorted file listing, wrapping around. One pairing per document (N calls, not N² — a full pairwise sweep was considered and rejected as quadratically more expensive for a return this run's numbers don't show it earning; see the pairing-rule discussion below).

Every document that opens is visited by both — importself is exhaustive by construction, and importpair's fixed offset means every document appears exactly once as an import target and (for an even-sized corpus) very close to exactly once as another document's import source. Neither call's page-count expectation, or damaged/clean classification, is taken from the primary document alone: the other document in each call is independently qpdf --check'd and qpdf --show-npages'd, and damage or an unknown count on either side is handled the same way #445 already handles it for the primary document (folded into the expected-count skip and the damaged-input carve-out, never blamed on the side that was actually clean).

Population — nothing quietly narrowed. 5,763 documents, the same size as the seventh and eighth runs:

corpus on disk visited opened the rest
private 4,158 4,158 4,084 12 encrypted, 62 unreadable format
public 1,381 1,381 1,374 4 encrypted, 3 unreadable format
Canadian 134 134 134 —
UN 90 90 90 —

Identical to the eighth run's own table on every corpus — the two runs are the same population, extended with two more operations, not a different sample.

Gates (0 crashes, 0 hangs, 0 qpdf failures, 0 page-count mismatches, 0 write failures, 0 refusals outside the two the contract documents — a security-forbidden operation, exit 8, and the field-/Parent-hierarchy refusal, exit 9 with that message, now reachable from import as well as extract):

corpus crashes/hangs QPDF FAILED COUNT MISMATCH WRITE FAILED REFUSED, other
private 0/0 0 0 0 0 pass
public 0/0 0 0 0 9 fail — #445, extended
Canadian 0/0 0 0 0 0 pass
UN 0/0 0 0 0 0 pass

Private, Canadian and UN are clean across all seven operations, blank and both imports included. Public's own gate is red for the same reason the eighth run's was.

Public's failure is #445, confirmed rather than rediscovered, and now visible through two more operations. The same two documents #445 already tracks — pages the engine cannot load, not a pages-battery defect — account for every one of the nine REFUSED, other instances: rotate (2), move (1) and extract (2) exactly as the eighth run counted them, plus, now, importself (2: each of the two documents refusing to import a copy of its own unloadable pages into itself) and importpair (2: two different, otherwise-clean documents whose fixed partner happens to be one of the two #445 documents, so importing pages from it fails the same way). Four distinct documents in total (checked by path-hash, not by name) — the original pair, plus the two documents that happen to sit opposite them in the pairing. Not re-filed, no threshold moved.

A second, smaller and genuinely new observation, filed as #574 rather than folded into #445. Fixing an accounting gap this run's own per-op breakdown had — protected / unsupported security / did not open were previously summed only at the top level (the primary document failing before any operation ran), never per operation — surfaced two more public-corpus documents where qpdf reports a page count fine but megapdf-cli's own open fails outright on every operation run against them, rotate and extract included. This is a different failure shape from #445 (which opens fine and fails on specific pages) and was sitting in every prior run's own log, uncounted in the printed table. Not gated (an open failure was never part of this battery's crash/hang/refusal gate, in this run or any before it), 2 of 1,381 public documents, not seen on the other three corpora. #574 has the detail; #567's PR carries the reporting fix that found it.

The genuine finding, and the reason #567 was filed the way it was: the field-hierarchy refusal is not gone — extract on this PDFium (33 patches) simply cannot reach it any more, and import can. The eighth run (and the seventh) measured extract's own refusal at zero on every corpus, against roughly 0.8% expected, and flagged rather than confirmed the gap: core/megapdf_core.h's own comment on the patched copy explains why — extracting pages into a brand-new document has no pre-existing field name for the collision check to trip over, so past patch 33 extract structurally cannot produce MEGAPDF_ERR_FIELDS at all. Importing pages into an existing document still can, and importself guarantees the collision (every field name in the copy already exists, verbatim, in the target), so it measures the population that actually carries hierarchical form fields, not a sampled fraction of it:

corpus importself refused-fields rate importpair refused-fields rate
private 37 / 4,084 0.91% 0 / 4,084 0%
public 212 / 1,374 15.4% 0 / 1,374 0%
Canadian 5 / 134 3.7% 4 / 134 3.0%
UN 0 / 90 0% 0 / 90 0%

The private corpus's own rate — 0.91% — lands right where the eighth run said it expected extract's to be (roughly 0.8%), on the same corpus composition (identical document counts since the seventh run). That is exactly the confirmation #567 asked for: the zero the last two runs measured was the absence of a working probe, not the absence of the problem, and the problem is still there, at essentially the rate it was always expected at. Public's much higher rate (15.4%) is not a surprise once the corpus is considered rather than compared directly to private's: the public corpus is disproportionately government fillable forms, and an earlier run over this same corpus already found 90% of real IRS forms alone hit this exact hierarchy before #452/#463 relaxed it (see the second public-corpus run, above) — importself measuring a corpus-wide 15.4% where forms are a large minority of 1,374 documents is consistent with that, not a contradiction of it.

Cross-document import (importpair) almost never collides, and that is itself informative, not a null result. A field-name clash needs the same top-level name on both sides, and two arbitrary documents essentially never share one — except on the Canadian corpus, where four documents do (3.0%, against zero on private and public despite far larger populations). The Canadian corpus is entirely Canada Revenue Agency fillable forms sharing a generated field-naming convention across different forms, exactly the condition that makes a collision between two different documents plausible. This is the shape a self-import can never produce (there is only one document in it) and the reason the pairing rule keeps a real cross-document case in the run at all, rather than relying on self-import alone.

The pairing rule, stated as the issue asked: every document once as a target, every document once as a source, no full pairwise sweep. importself is exhaustive over the corpus by construction. importpair pairs document i with the document standing at i + half the (limited) file count, wrapping around — a fixed, population-wide sample rather than a sub-sample of it, and, for an even-sized listing, a perfect one-to-one matching (every document is some other document's partner exactly once). A full O(n²) sweep was considered and rejected: it is quadratically more expensive against a corpus where this run's own numbers (0% collision rate on two of the four corpora, and the Canadian corpus's real collisions already found by the cheap version) do not show it buying more than a fixed offset already reaches. The next person extending this should read that as "no evidence a denser sample would find more," not as "a denser sample was ruled out on principle" — the corpus composition could change that.

Blank is clean everywhere. Zero REFUSED, other, zero count mismatches, zero crashes or hangs on any corpus. dims-unknown (the page pdfinfo could not size, so the harness declined to guess rather than sending a request that was never really formed) was 0 on private, Canadian and UN, and 2 on public — one of them the #574 document that needs a password qpdf does not, the other a clean document pdfinfo simply could not report a size for. Neither is gated.

No issue was re-filed for #445 (confirmed, not rediscovered). #574 is new, small, not gated and not #567's own finding, but disclosed rather than folded silently into this run's numbers. With insert and import now both run at corpus scale, #174 has exercised its full contract-10 surface: rotate, delete, move, extract, blank and import all ran clean except for #445's already-known, already-excused pair (and #574's two, also unrelated to #567).

Tenth run, 2026-10-01: all three batteries, all four corpora, main at c3cc720 (#537)

A #537 release-candidate condition and a regression check, not an investigation: a day's worth of engine changes had each been tested on its own but never together at corpus scale -- #578 rewrote lifetime rules across 47 entry points behind the dead-handle contract, #595 normalises a masked hyphen in the structure path, #587 landed two new contract functions, and #532 changed how line splitting scales off the drawn em rather than the raw Tf operand. Run on kdocker3 in a container (megapdf:base, PDFium still at 33 patches, pdfium-7934-megapdf-2b3b415e86b1), all four corpora through structure-battery.sh --reference --cli, then markdown-battery.sh, then pages-battery.sh (#567's full seven-operation shape: rotate, delete, move, extract, blank, importself, importpair) -- the same battery order the seventh, eighth and ninth runs used, each stage run with all four corpora concurrent (pages kept the eighth/ninth run's own split: private+public together at --jobs 8/--jobs 6, then Canadian+UN together at --jobs 8). Total wall time for all three batteries, all four corpora: about 30 minutes -- far short of "hours," worth noting for planning the next one.

Zero crashes and zero hangs, in every battery, on every corpus. 5,763 documents, nothing silently narrowed:

corpus on disk visited opened (structure/markdown) the rest
private 4,158 4,158 4,084 12 encrypted, 62 unreadable format
public 1,381 1,381 1,368 5 encrypted, 8 unreadable format
Canadian 134 134 134 —
UN 90 90 90 —

Identical to the seventh run's own table on every corpus. pages-battery.sh opens through a different path and so counts differently, as every prior run has noted: private 4,084 opened (12 protected, 62 did-not-open, matching the table above exactly), public 1,374 opened (4 protected, 3 did-not-open, plus 5 of those 1,374 separately flagged input already damaged -- failing their own qpdf --check before megapdf-cli ever touched them, a #445-point-4 case, not a smaller opened count), Canadian and UN both 134/90 with nothing flagged.

Structure, against F1 >= 0.998 and order agreement tau >= 0.9:

corpus token F1, internal token F1, CLI tau median CLI bad exits
private 0.998997 0.998997 0.951 0 pass
public 0.974441 0.974441 0.977 0 fail — #498, confirmed
Canadian 0.999932 0.999932 0.994 0 pass
UN 0.999136 0.999136 0.967 0 pass — moved, see below

Markdown: every gate passes on all four corpora (0 crashes, 0 hangs, 0 other bad exit codes, 0 cmark parse failures, 0 cmark timeouts).

Pages (0 crashes/hangs, 0 qpdf failures, 0 count mismatches, 0 write failures, 0 refusals outside the contract's own two: a security-forbidden operation, exit 8, and the field-/Parent- hierarchy refusal, exit 9 with that message, reachable from both extract and import):

corpus crashes/hangs QPDF FAILED COUNT MISMATCH WRITE FAILED REFUSED, other
private 0/0 0 0 0 0 pass
public 0/0 0 0 0 9 fail — #445, confirmed
Canadian 0/0 0 0 0 0 pass
UN 0/0 0 0 0 0 pass

Field-/Parent-hierarchy refusal, through import (the probe #567 established; extract structurally cannot reach it on this PDFium, per the ninth run):

corpus importself refused-fields rate importpair refused-fields rate
private 37 / 4,084 0.91% 0 / 4,084 0%
public 212 / 1,374 15.43% 0 / 1,374 0%
Canadian 5 / 134 3.7% 4 / 134 3.0%
UN 0 / 90 0% 0 / 90 0%

Every one of these numbers matches the ninth run exactly, document-for-document count, not just in aggregate -- this is the instrument confirming the same known state, not coincidence.

Public's structure failure resolves to the same category #498 already named, confirmed rather than rediscovered. The 33 govinfo-signed documents carry 95.6% of the corpus's tokens (the brief's "96%" holds) and score 0.973305 on their own (#498 recorded 0.970684); the remaining documents score 0.999206. Public's overall figure (0.974441) is up from the seventh run's 0.971952 by almost exactly the same margin the signed category itself moved by (+0.0026 signed, +0.0025 overall) -- one population moving, not two. Not fixed or gate-adjusted, per #471's own instruction. A plausible contributor is #595's masked-hyphen normalisation (fewer spurious token splits would read as a small, broad fidelity gain of exactly this shape), but that is offered as a plausible direction, not confirmed root cause -- nothing here isolates it from #532 or #587.

Public's nine REFUSED, other are #445's known pair, confirmed exactly, not re-filed. This run used pages-battery.sh's full seven-operation shape (#567), the same as the ninth run, not the five-operation shape the eighth run and the task brief's own "known results" both describe -- so nine, not five, is the right number to expect here. Resolved by path-hash (never by name) to the same shape the ninth run found: the same two primary documents refuse rotate/move/ extract/importself on their own unloadable pages (7 instances between them), and two unrelated, otherwise-clean documents refuse importpair only because their fixed partner happens to be one of those two (2 instances) -- four distinct documents, nine refusals, matching the ninth run's own accounting of this exact pair down to the operation.

UN's gate moved from fail to pass, and it is #524 fixed, not a new result. The seventh run measured UN at 0.996348 (fail); this run measures 0.999136 (pass). Re-deriving each document's own F1 from the battery log's per-document token counts (fid_a/fid_b/fid_match, keyed by the same path-hash id the log already uses) finds the previously-worst document -- the 190-page one the seventh run's own text named as #524's and #496's -- improved from F1 0.9662 to 0.9935; it is no longer even the corpus's worst (an unrelated 148-page document is, at 0.9853, nowhere near failing the gate by itself). Excluding that formerly-worst document, UN reads 0.999295, itself slightly above the seventh run's own exclusion figure of 0.99960 -- a small, general improvement on top of the specific one. This is not a mystery: git log shows PR #532 (wip/524-drawn-em-font-size, merge commit 6e67296) landed after the seventh run's commit (88117b7) and is present at this run's c3cc720; its own fix commit's message is structure: lock the size-in-Tf/size-in-Tm invariant, and retire #496's caveat (#524). The brief's own "known results to confirm" section still named UN as failing at ~0.9963 because that is what the last full-corpus measurement (the seventh run) found, before #532 landed -- this run is simply the first full-corpus measurement taken after the fix, and it confirms the fix rather than finding a new problem. Worth Dave's attention as a candidate to close #524, not done here.

Large documents (#488) are not present in this run's public corpus and nothing here can move or reconcile against the other agent's figure. This corpus's public-corpus mount is the 1,381-document, seven-category state (govinfo-signed, irs, nonlatin, pdfium, qpdf, uscis, verapdf) that predates the large/govinfo-large addition TESTING.md records separately; there is no large population mounted here to measure. Noted so the absence is not mistaken for a silent pass on #488's own figure.

Dead-handle contract rewrite (#578, 47 entry points): no lifetime regression at corpus scale. Zero crashes and zero hangs across all three batteries, all four corpora, 5,763 documents, every pages-battery.sh operation including blank and both import variants -- the broadest exercise of entry-point lifetimes this repository's battery has driven since #578 landed.

#587's two new contract functions are not directly exercised by name in any of these three batteries (no battery script calls either one), so this run speaks to the absence of a corpus-wide regression elsewhere after they landed, not to their own correctness -- that remains whatever #587's own tests already cover.

No issue filed from this run: the one genuinely new finding (#524, via #532) is a fix confirmed, not a regression, and everything else measured is #445 or #498 confirmed exactly rather than rediscovered.

Eleventh run, 2026-10-01: all three batteries, all four corpora, over the staged corpus, wip/609-stage-and-measure at 993ba98 (#609, #612)

The first run over a public corpus that says what it is a count of, and the first over the two populations that were added to the manifest because they found defects. Dave's decision on #609 was to stage the missing documents rather than redefine the condition, so this run is the measurement after staging: #612 fixed the fetch, 112 of the 115 missing documents were staged on k3's ~/pdf-public, and all three batteries ran over the result. Run on kdocker3 in a container (megapdf:base, PDFium still at 33 patches, pdfium-7934-megapdf-2b3b415e86b1), all four corpora through structure-battery.sh --reference --cli, then markdown-battery.sh, then pages-battery.sh (#567's full seven-operation shape), in the tenth run's own order and concurrency: structure and markdown with all four corpora at once, pages as private+public (--jobs 8/--jobs 6) then Canadian+UN (--jobs 8). About 55 minutes for everything, against the tenth run's 30 -- the whole of the difference is the seven large documents.

Zero crashes and zero hangs, in every battery, on every corpus. 5,875 documents:

corpus on disk visited opened (structure/markdown) the rest
private 4,158 4,158 4,084 12 encrypted, 62 unreadable format
public 1,493 1,493 1,480 5 encrypted, 8 unreadable format
Canadian 134 134 134 —
UN 90 90 90 —

Private, Canadian and UN are identical to the tenth run on every line. Public is +112 documents and +112 opened: every one of the 112 newly staged documents opened, on both paths, with nothing newly encrypted, unreadable, crashing or hanging. pages-battery.sh counts differently as always: private 4,084 opened (12 protected, 62 did-not-open), public 1,486 opened (4 protected, 3 did-not-open, 5 separately flagged input already damaged), Canadian and UN 134/90 with nothing flagged.

What the corpus actually was, printed by the run itself (#609's cheap half, and the part that stops this recurring). Every battery summary now ends with a coverage block from tools/stress/corpus_coverage.sh. Public's, verbatim in substance:

manifest rows 1,777
rows present on disk 1,494
rows the *.pdf walk reaches — the population every number below is over 1,493
staged but not reached (.Pdf, qpdf) 1
stated exclusion: wiki-* non-Latin, deferred by Dave decision 280
not accounted for: uk-hmrc, source re-served different bytes (#625) 3

Coverage 1,493 of 1,777 (84.0%), PARTIAL -- against the tenth run's unstated 1,381 of 1,777 (77.7%). The three corpora with no manifest print one line saying so rather than nothing, because an absent line reads as "nobody checked". Nothing in the block gates: a partial corpus is a fact about the machine, not a regression in the code under test.

Structure, against F1 >= 0.998 and order agreement tau >= 0.9:

corpus token F1, internal token F1, CLI tau median CLI bad exits
private 0.998997 0.998997 0.951 0 pass
public 0.980019 0.980019 0.978 0 fail — #498, confirmed
Canadian 0.999932 0.999932 0.994 0 pass
UN 0.999136 0.999136 0.967 0 pass

Markdown: every gate passes on all four corpora (0 crashes, 0 hangs, 0 other bad exit codes, 0 cmark parse failures, 0 cmark timeouts), public now over 1,493 documents rather than 1,381.

Pages (0 crashes/hangs, 0 qpdf failures, 0 qpdf timeouts, 0 count mismatches, 0 write failures, 0 refusals outside the contract's own two):

corpus crashes/hangs QPDF FAILED COUNT MISMATCH WRITE FAILED REFUSED, other
private 0/0 0 0 0 0 pass
public 0/0 0 0 0 9 fail — #445, confirmed
Canadian 0/0 0 0 0 0 pass
UN 0/0 0 0 0 0 pass

Public's structure failure is #498 unchanged, and the aggregate's move from 0.974441 to 0.980019 is the staging, attributed to the token. Resolved by manifest source from the battery log's own per-document fid_match/fid_a/fid_b:

source docs tokens token F1
govinfo-signed 33 10,041,571 0.973305
govinfo-large 7 5,331,327 0.989955
irs 136 266,159 0.998664
uscis 50 172,827 0.999991
uk-dwp 24 95,980 0.999984
uk-homeoffice 22 89,862 0.999900
uk-hmrc 59 82,508 0.999558
verapdf 860 10,400 0.999567
qpdf 174 6,053 1.000000
pdfium 115 1,730 0.999133

govinfo-signed measures 0.973305 -- the tenth run's figure to six decimal places, on the same 33 documents, which is #498 confirmed and not rediscovered. Restricting to the tenth run's own population (dropping govinfo-large and the three uk-* sources) reproduces 0.974441 exactly, over 1,368 documents and 10,498,740 tokens -- the tenth run's public figure, token for token. So the aggregate did not move because anything changed in the engine; it moved because 5.33 M tokens scoring 0.989955 joined a population of 10.5 M scoring 0.974441. govinfo-large measures 0.989955, which is #488's own recorded figure to six decimal places: that category is understood (97.1% of its deficit is line-wrap hyphens that never join, because line building merges lines across the column gutter), #488 is out of the milestone with the bar for a future attempt written down, and nothing was attempted here. Public still fails the gate, by less, for exactly the reason it failed before.

The UK forms had never been measured, and this is what they produced. All three sources pass the fidelity gate with room to spare -- 0.999984, 0.999900, 0.999558, the best scores of any real-document source in the corpus and comfortably above irs (0.998664). They contributed zero crashes, hangs, qpdf failures, count mismatches, write failures and zero REFUSED, other. The one thing they did produce is field-hierarchy refusals, below. #479 (the divergence between the internal path and the command line that this population originally found) does not reappear: internal and CLI F1 are identical to six decimals on every corpus, public included.

The seven large documents are clean on every operation. 7 seen, 7 opened, zero refusals of any kind, zero timeouts -- pages-battery.sh's 300s bound was not approached by any operation on a 2.07 GB document, which is what tools/stress/public-corpus/README.md, "Very large documents", already recorded from a separate run and is now true in a battery that measured them alongside everything else.

Public's nine REFUSED, other are #445's known pair, confirmed exactly. Resolved by manifest source rather than by name: pdfium 7 (rotate 2, move 1, extract 2, importself 2 -- the two primary documents whose own pages the engine cannot load) and verapdf 2 (importpair only -- two otherwise-clean documents whose fixed pairing partner happens to be one of those two). Four distinct documents, nine refusals, the ninth and tenth runs' accounting down to the operation. None of the 112 newly staged documents is among them.

Field-/Parent-hierarchy refusal, through import only -- and the UK forms moved it, as #609 predicted they would:

corpus importself refused-fields rate importpair rate
private 37 / 4,084 0.91% 0 / 4,084 0%
public 230 / 1,486 15.48% 0 / 1,486 0%
Canadian 5 / 134 3.7% 4 / 134 3.0%
UN 0 / 90 0% 0 / 90 0%

Private (0.91%), Canadian (5 and 4) and UN (0) are the tenth run's numbers exactly. Public rose from 212 to 230, and all 18 of the increase are UK forms: uk-dwp 11 of 24 (45.8%), uk-homeoffice 5 of 22 (22.7%), uk-hmrc 2 of 59 (3.4%). The seven large documents produced none. The rate order is worth noting rather than burying: DWP's 45.8% is the second-highest of any source in the corpus after IRS's 100% (136 of 136), and these are numbered-question benefit forms -- the shape #609 said was "exactly the population that exposed the list-marker bug in the measure". It is contract behaviour (exit 9, a refusal rather than a corruption, since #452/#463) and so not a gate failure and not re-filed, but somebody should decide whether a one-in-two refusal rate on real UK benefit forms is acceptable product behaviour, which is a question for Dave and not for this run. extract-side refusals remain zero on all four corpora, which is the ninth run's finding re-confirmed over a 112-document-larger population: past PDFium patch 33, extracting pages into a brand-new document has no pre-existing field name for the collision check to trip over, so extract structurally cannot reach this refusal and import can. The zero is the contract, not a dead probe.

fetch.sh was fixed first, because #609 could not be done without it (#612). --max-time 300 aborted the two multi-gigabyte rows at around 875 MB on four attempts out of four, so the command the manifest's README names could not fetch the manifest's own corpus. Replaced with a low-speed abort and resumption. Proved on the real rows, not only in the test: the 1.57 GB row was carried past 1.53 GB, the run was killed there deliberately, and the re-run resumed from exactly that offset and transferred only the remaining 34,122,108 bytes before its manifest sha256 verified; the 2.07 GB row's first single curl invocation ran unbroken past 1.07 GB over about ten minutes -- more than three times the old wall clock, at a size the old wall clock could never have reached. All 7 landed and verified. 112 of the 115 missing documents staged; the other three are #625, three uk-hmrc rows whose source now serves different bytes, each refused on its own by the rule that a pinned hash is never re-fetched over (#525).

One issue filed: #625. Everything else is confirmation. #498 is confirmed to six decimals on the same 33 documents; #445 is confirmed to the operation on the same four; #488's figure is confirmed to six decimals and explicitly not attempted; #567's refusal measure is confirmed on private, Canadian and UN and extended on public; #479 does not reappear. The release-candidate condition in #537 that a full corpus battery be green still cannot be claimed -- but for the first time the reason is written in the run's own output rather than inferred from a count nobody could interpret.

Twelfth run, 2026-10-01: pre-flight for 2.2.0, all three batteries, all four corpora, main at c108240

Not the §2.2 release gate. The 2.2.0 version bump and two feature PRs were still landing at the time of this run, so this is not a run against a release commit and the checklist issue should not cite it as the gate. It is an early-warning pass, asked for so a regression would be found now rather than after the release commit exists; the gate run against the actual release commit is separate and still to come.

Run on kdocker3, not kdocker2. The task brief named kdocker2, but the three non-private corpora (~/pdf-public, ~/pdf-test-ca, ~/pdf-test-un) are staged on kdocker3, not kdocker2 -- kdocker2 holds only the private corpus mirror and has no /data/megapdf-work role in corpus batteries at all (CLAUDE.md's own machine table: kdocker3 is "general builds, corpus batteries"; kdocker2 is Android only). This looks like the same k2/k3 mix-up noted elsewhere. Run in megapdf:base (PDFium still at 33 patches, pdfium-7934-megapdf-2b3b415e86b1, the same image the tenth and eleventh runs used), built fresh from origin/main at c108240 (megapdf-cli --version reports 2.1.1, confirming the version bump has not landed). Scratch workspace and container removed afterward; the four corpora were only read.

Compared directly against the eleventh run (993ba98 -> 77 commits -> c108240), not just against the gates, per the brief: every structure, markdown and pages number below is the eleventh run's own number, to six decimal places on every F1 and exactly on every count. 77 commits landed on main between the two runs (iOS whiteout/signature tools, the Windows/ Android screenshot slots, the scroll-position indicator, the NDK pin, the French review, this file's own agent-guide), none of which touched anything this battery measures.

Zero crashes and zero hangs, in every battery, on every corpus. 5,875 documents, identical to the eleventh run on every corpus:

corpus on disk visited opened (structure/markdown) the rest
private 4,158 4,158 4,084 12 encrypted, 62 unreadable format
public 1,493 1,493 1,480 5 encrypted, 8 unreadable format
Canadian 134 134 134 —
UN 90 90 90 —

Structure, against F1 >= 0.998 and order agreement tau >= 0.9 -- matches the eleventh run to six decimal places on every corpus:

corpus token F1, internal token F1, CLI tau median CLI bad exits
private 0.998997 0.998997 0.951 0 pass
public 0.980019 0.980019 0.978 0 fail — #498, confirmed, unmoved
Canadian 0.999932 0.999932 0.994 0 pass
UN 0.999136 0.999136 0.967 0 pass

Markdown: every gate passes on all four corpora (0 crashes, 0 hangs, 0 other bad exit codes, 0 cmark parse failures, 0 cmark timeouts) -- private 4,158 visited/2,982 extracted/ 1,102 textless/12 password-gated; public 1,493/937/543/5; Canadian 134/134/0/0; UN 90/83/7/0. All four match the eleventh run's own figures exactly.

Pages (0 crashes/hangs, 0 qpdf failures, 0 count mismatches, 0 write failures, 0 refusals outside the contract's own two), matching the eleventh run exactly on every cell:

corpus crashes/hangs QPDF FAILED COUNT MISMATCH WRITE FAILED REFUSED, other
private 0/0 0 0 0 0 pass
public 0/0 0 0 0 9 fail — #445, confirmed, unmoved
Canadian 0/0 0 0 0 0 pass
UN 0/0 0 0 0 0 pass

Public's opened count (the pages path counts differently, as every prior run notes): 1,486 opened (4 protected, 3 did-not-open, 5 of those separately flagged input already damaged). Private: 4,084 opened (12 protected, 62 did-not-open). Canadian/UN: 134/90, nothing flagged. The nine public REFUSED, other are #445's known pair (pdfium 7: rotate 2, move 1, extract 2, importself 2; verapdf 2: importpair only) -- the same four documents, nine refusals, the ninth/tenth/eleventh runs' own accounting.

Field-/Parent-hierarchy refusal, through import only:

corpus importself refused-fields rate importpair refused-fields rate
private 37 / 4,084 0.91% 0 / 4,084 0%
public 230 / 1,486 15.48% 0 / 1,486 0%
Canadian 5 / 134 3.7% 4 / 134 3.0%
UN 0 / 90 0% 0 / 90 0%

Every one of these numbers is the eleventh run's own, unchanged.

Coverage arithmetic reconciles exactly as stated in the brief, and matches what the eleventh run's own block plus #625 together predict: 1,493 reachable + 1 staged-but-unreached (the .Pdf case-mismatch qpdf fixture) + 283 stated exclusion (280 wiki-*, deferred by Dave decision, plus 3 uk-hmrc rows resolved as permanently unfetchable by #625) + 0 unexplained = 1,777 manifest rows. corpus_coverage.sh's own block (printed identically by the structure, markdown and pages summaries) lists every exclusion by name; nothing is silently absent.

Nothing moved since the eleventh run. Every gate verdict, every F1 to six decimals, every crash/hang/refusal count and the coverage arithmetic are identical to the eleventh run across all three batteries and all four corpora, over 77 intervening commits. The two known, tracked failures (#498 on the public structure gate, #445's nine REFUSED, other on the public pages battery) are confirmed unchanged and are not new findings. No issue filed from this run. This is a pre-flight only: docs/RELEASING.md §2.2's gate run still has to happen against the actual release commit once the version bump and the two in-flight feature PRs land.

Thirteenth run, 2026-10-02: the §2.2 release gate for 2.2.0, all three batteries, all four corpora, main at 983fcdf

This is the gate, not another pre-flight. The release commit is main at 983fcdf (merge of #646, wip/version-2-2-0 — "Version 2.2.0 everywhere"), the commit Dave has called the 2.2.0 release candidate. megapdf-cli --version in the built tree reports megapdf-cli 2.2.0, confirming the bump is live on the commit under test.

Run on kdocker3, not kdocker2, same as the twelfth run and for the same reason. docs/RELEASING.md §2.2 still names kdocker2; CLAUDE.md's own machine table still says kdocker3 is "general builds, corpus batteries" and kdocker2 is Android-only with no /data/megapdf-work role in a corpus battery at all. ~/pdf-public, ~/pdf-test-ca and ~/pdf-test-un exist only on kdocker3. Noted here rather than silently switched, as the twelfth run's own entry did; the runbook's §2.2 heading and body text could be corrected to say kdocker3 in one small edit, but that is a documentation fix for its own change, not a side effect of a gate run, so it is named here and left alone.

Built fresh in a throwaway megapdf:base container (Ubuntu 24.04, build-essential/cmake/ ninja/qpdf/poppler-utils/cmark) from a clean clone of 983fcdf, repo and all four corpora mounted read-only except the repo's own build tree: tools/fetch-pdfium-linux.sh pulled the same pinned release the eleventh and twelfth runs used (pdfium-7934-megapdf-2b3b415e86b1, 33 patches, confirmed by the configure log), then cmake -S core -B core/build/linux-x64 -G Ninja -DCMAKE_BUILD_TYPE=Release -DMEGAPDF_CORE_TESTS=ON -DMEGAPDF_FIXTURES_DIR=... and cmake --build ... --target megapdf_cli megapdf_structure_check. All four corpora read 1,493/134/90/4,158 documents on disk, matching the eleventh/twelfth runs exactly before a single document was opened. Container and workspace removed afterward (the build tree was root-owned inside the container; a throwaway alpine container did the final rm -rf after docker rm left files behind); the corpora were only read.

tools/stress/compare_runs.py does not apply to this battery shape, and the git diff between the two commits confirms why a bit-for-bit match was the only sane expectation. compare_runs.py reads a MegaPDF.Stress run --out .../edits directory's results.jsonl (the edit-battery harness); structure-battery.sh, markdown-battery.sh and pages-battery.sh each write their own plain-text .log, as they have since the seventh run established "all three batteries" as structure/markdown/pages, not structure/markdown/edits. So the comparison against the twelfth run below is done the way the twelfth run compared itself against the eleventh: number for number, against this file's own recorded figures. git diff --stat c108240 983fcdf touches 69 files over 23 commits (the signature-strip parity #641, the iOS cold-start deadline #599/#643, the scroll-position indicator, Fable's French review, the MS Store certification notes #647, and the version bump itself) and exactly one line inside core/ or libs/pdfium/: core/cli/megapdf_cli.cpp's version string, "megapdf-cli 2.1.1" to "megapdf-cli 2.2.0". tools/stress/ itself is byte-identical between the two commits. Nothing in the diff touches engine behaviour, so an exact match on every figure below is the expected result, not a coincidence to be explained.

Zero crashes and zero hangs, in every battery, on every corpus. 5,875 documents, identical to the eleventh and twelfth runs on every corpus:

corpus on disk visited opened (structure/markdown) the rest
private 4,158 4,158 4,084 12 encrypted, 62 unreadable format
public 1,493 1,493 1,480 5 encrypted, 8 unreadable format
Canadian 134 134 134 —
UN 90 90 90 —

Structure, against F1 >= 0.998 and order agreement tau >= 0.9 — matches the twelfth run to six decimal places on every corpus:

corpus token F1, internal token F1, CLI tau median CLI bad exits
private 0.998997 0.998997 0.951 0 pass
public 0.980019 0.980019 0.978 0 fail — #498, confirmed, unmoved
Canadian 0.999932 0.999932 0.994 0 pass
UN 0.999136 0.999136 0.967 0 pass

Markdown: every gate passes on all four corpora (0 crashes, 0 hangs, 0 other bad exit codes, 0 cmark parse failures, 0 cmark timeouts) — private 4,158 visited/2,982 extracted/ 1,102 textless/12 password-gated; public 1,493/937/543/5; Canadian 134/134/0/0; UN 90/83/7/0. All four match the eleventh and twelfth runs' own figures exactly.

Pages (0 crashes/hangs, 0 qpdf failures, 0 count mismatches, 0 write failures, 0 refusals outside the contract's own two), matching the twelfth run exactly on every cell:

corpus crashes/hangs QPDF FAILED COUNT MISMATCH WRITE FAILED REFUSED, other
private 0/0 0 0 0 0 pass
public 0/0 0 0 0 9 fail — #445, confirmed, unmoved
Canadian 0/0 0 0 0 0 pass
UN 0/0 0 0 0 0 pass

Public's opened count (the pages path counts differently, as every prior run notes): 1,486 opened (4 protected, 3 did-not-open, 5 of those separately flagged input already damaged). Private: 4,084 opened (12 protected, 62 did-not-open). Canadian/UN: 134/90, nothing flagged. The nine public REFUSED, other are #445's known pair (pdfium 7: rotate 2, move 1, extract 2, importself 2; verapdf 2: importpair only) — the same four documents, nine refusals, the ninth/tenth/eleventh/twelfth runs' own accounting.

Field-/Parent-hierarchy refusal, through import only:

corpus importself refused-fields rate importpair refused-fields rate
private 37 / 4,084 0.91% 0 / 4,084 0%
public 230 / 1,486 15.48% 0 / 1,486 0%
Canadian 5 / 134 3.7% 4 / 134 3.0%
UN 0 / 90 0% 0 / 90 0%

Every one of these numbers is the eleventh and twelfth runs' own, unchanged.

Coverage arithmetic reconciles exactly as before, printed identically by all three battery summaries: 1,493 reachable + 1 staged-but-unreached (the .Pdf case-mismatch qpdf fixture)

  • 283 stated exclusion (280 wiki-*, deferred by Dave decision, plus 3 uk-hmrc rows resolved as permanently unfetchable by #625) + 0 unexplained = 1,777 manifest rows. corpus_coverage.sh's own block lists every exclusion by name; nothing is silently absent.

Gates, per docs/RELEASING.md §2.2 and this task's own checklist — pass on every corpus except the two already-tracked, out-of-milestone exceptions, unmoved from the twelfth run: structure F1 >= 0.998 (private/Canadian/UN pass; public fails at 0.980019, #498); order tau

= 0.9 (all four pass); zero crashes (pass, all batteries, all corpora); zero hangs (pass, all batteries, all corpora); zero out-of-contract refusals (private/Canadian/UN pass; public has the same nine #445 REFUSED, other, none outside the known pair); the markdown battery's own checks (pass on all four corpora); the pages battery's own checks other than the refusal count above (0 qpdf failures, 0 count mismatches, 0 write failures on all four corpora).

Nothing moved since the twelfth run. Every gate verdict, every F1 to six decimal places, every crash/hang/refusal count and the coverage arithmetic are identical to the twelfth run (itself identical to the eleventh), across all three batteries and all four corpora, over the 23 commits that separate c108240 from 983fcdf — which the core/libs/pdfium diff above shows touched no engine code but one version string. The two known, tracked failures (#498 on the public structure gate, #445's nine REFUSED, other on the public pages battery) are confirmed unchanged for the fourth consecutive full-corpus run and are not new findings. No issue filed from this run; no regression found. This is the §2.2 release gate against the actual 2.2.0 release commit, and it is green except for the two conditions #498 and #445 already carry outside milestone 2.2.

Fourteenth run, 2026-10-13: #671's image notes, structure + markdown batteries, private corpus, wip/671-image-notes

Scope: not a release gate. A targeted run for one change — #671, which makes the text and Markdown writers describe a picture instead of dropping it. Two batteries over one corpus (/mnt/pdf-test on GPD-DAVE, the same private corpus ~/pdf-test names on k3), sampled at --limit 400 of 4,158 documents, not all four corpora in full: the question this run answers is narrow and it is a local verification of one PR, not the §2.2 arithmetic. Built in the worktree against the committed libs/pdfium/linux-x64 prebuilt (32 patches, per the configure log), -DCMAKE_BUILD_TYPE=Release.

What was at risk, and why a battery was the right instrument. tools/structure-check strips the writers' synthesised notes before tokenizing, so that a statement about a picture is never counted as invented page text. #671 both reworded the PAGE_IMAGE note and added an [Image: ...] line, so the strip rules had to change with it. Measured, with the pre-change oracle built from HEAD against the post-change output: cli_fid_a rises from 41 to 50 tokens on tiny-font-size.pdf (F1 ≈ 0.90) and from 0 to 11 on scan.pdf (F1 = 0). The gate really would have failed on essentially every document containing an image, so this is the measurement, not the worry.

Structure battery — 401 documents, 2,852 pages, 939 tagged, 225 textless, 82s:

measure result gate
aggregate F1 (internal API) 0.998540 >= 0.998 — pass
aggregate F1 (through megapdf-cli) 0.998540 >= 0.998 — pass
order tau median (835 tagged pages) 0.966 >= 0.9 — pass
crashes / hangs 0 / 0 0 — pass
CLI bad exit codes 0 0 — pass
pages F1 < 0.9 3 not a gate

The two F1 numbers being equal to six decimal places is the result, not a coincidence worth glossing: the internal measure reads megapdf_block_string() and never sees a synthesised note, while the CLI measure reads the bytes the writer actually produced. Equality means #671's new notes contributed exactly zero tokens to the fidelity measure — which is the whole property the strip rules exist to maintain, stated as a number rather than as an assertion that the code looks right.

Markdown battery — 400 documents, 30s: 330 extracted (exit 0), 70 no-text-on-any-page (exit 5), 0 crashes, 0 hangs, 0 cmark parse failures, 0 cmark timeouts, 0 out-of-contract exit codes. All gates pass. The 70 exit-5 documents matter beyond the count: megapdf-cli derives that exit code by scanning its own written output for the PAGE_IMAGE note, so before the matching fix those 70 would have come back exit 0 with no stderr note at all. Found by running the binary — nothing in the test suite or the type system catches it.

Not run, and why: the public, Canadian and UN corpora, and the pages and redaction batteries. #671 touches only the two writers, the figure/page-image block fields and the fidelity oracle's strip rules; the pages and redaction batteries exercise neither, and the per-corpus structure/markdown verdicts are the §2.2 release gate's arithmetic rather than this change's. A full four-corpus run belongs to the next release gate, where the #498 public-corpus condition is also tracked.

#677 re-run, 2026-10-17 (#680) — the same two batteries, same corpus and sample (--limit 400, /mnt/pdf-test), on main after #677 added the page size to the scanned-page note and megapdf_block_image() gained DPI, bit depth and colour space. Built from a scratch build directory against the committed libs/pdfium/linux-x64 prebuilt, -DCMAKE_BUILD_TYPE=Release.

battery result gate
structure, 401 documents, 2,852 pages, 83s: aggregate F1 (internal) 0.998540 >= 0.998 — pass
structure: aggregate F1 (through megapdf-cli) 0.998540 >= 0.998 — pass
structure: order tau median (835 tagged pages) 0.966 >= 0.9 — pass
structure: crashes / hangs / CLI bad exit codes 0 / 0 / 0 0 — pass
markdown, 400 documents, 32s: extracted / no text (exit 5) 330 / 70 —
markdown: crashes / hangs / bad exit codes / cmark failures / timeouts 0 / 0 / 0 / 0 / 0 0 — pass

Every number equals the #671 run above to the last digit, which is the result: appending , 21.6 × 27.9 cm inside the note's own brackets added no tokens to the fidelity measure and moved no document between exit 0 and exit 5. The 120-document sample that gave F1 0.997947 on both sides of the change was a sample property, not a regression. Also added: core_tests.cpp now asserts bits_per_pixel == 1 on the generated CCITT Group 4 and JBIG2 fixtures (checked live by flipping the expected value to 2 and watching the suite fail).

Fifteenth run, 2026-10-19: the §2.2 release gate for 2.3.0, all three batteries, all four corpora, wip/release-2.3.0 at 8838a6c

Result: not green. One real regression, found by this gate, and it is a show-stopper candidate for Dave's call: the markdown and text writers got 27 to 31 times slower on two very large public-corpus documents, and both now exceed the batteries' 120 s hang bound. Everything else matches the thirteenth run or moves by a named change. Checklist issue #696, PR #697.

The commit under test is 8838a6cdf1f6eac0f876b8e1863c175751a69b45 ("2.3.0 copy"), fetched from GitHub; megapdf-cli --version in the built tree reports megapdf-cli 2.3.0. Run on kdocker3 (see the thirteenth run for why not kdocker2), built fresh in a throwaway megapdf:base container with tools/fetch-pdfium-linux.sh (the same pinned pdfium-7934-megapdf-2b3b415e86b1, 33 patches per the configure log, so the PDFium binary is the thirteenth run's) and the thirteenth run's cmake line, targets megapdf_cli megapdf_structure_check. The four corpora were mounted read-only. Disk counts are 4,158 / 1,493 / 134 / 90, identical to the thirteenth run.

Unlike 2.2.0, the core changed. git diff --stat 983fcdf 8838a6c -- core libs tools/stress is 21 files, +1,361/-56, all under core/ (libs/ and tools/stress/ are untouched). The commits touching core: aef9df4 (#664 symbol-font and repeated-letter bullets, #665 a cut-off download is exit 10, not exit 2), f1c7c73 (2.2.1 version), 44e77b0 (#671 image notes in the text and Markdown writers), 25c74e3 (#677 part 1, page size in the scanned-page note), 584cdd2 (#677 part 2, megapdf_block_image() reports DPI, bit depth, colour space), 3f302ed and c5dff89 (#669, letter-bullet residue), fe41c50 (2.3.0 version). Two commits in the range (3f93d8f #680, 3b408b8 #682) touch only tests and Android. The battery scripts are unchanged, so the gates below are the thirteenth run's gates.

Zero crashes in every battery on every corpus (the two private documents the pages and structure scripts count as bad exits are #665's new exit 10, explained below). Hangs: zero except the two public documents below. Documents visited, 5,875, as before.

corpus on disk visited opened (structure/markdown) the rest
private 4,158 4,158 4,084 12 encrypted, 62 unreadable (2 of them now exit 10, see below)
public 1,493 1,493 1,478 (1,480 in the thirteenth run) 5 encrypted, 8 unreadable, 2 hung
Canadian 134 134 134 —
UN 90 90 90 —

Structure (F1 >= 0.998, tau >= 0.9):

corpus token F1, internal token F1, CLI tau median CLI bad exits vs 13th
private 0.998997 0.998997 0.951 2 F1, tau identical F1/tau pass; bad-exit gate reads 2, see exit 10
public 0.976157 0.976157 0.978 0 13th: 0.980019 fail: the 0.980019 #498 figure plus 2 hangs, see below
Canadian 0.999932 0.999932 0.994 0 identical pass
UN 0.999136 0.999136 0.967 0 identical pass

Markdown (0 crashes, 0 other bad exits except as noted, 0 cmark failures, 0 cmark timeouts):

corpus visited extracted textless (exit 5) password-gated hangs other bad exit cmark failures/timeouts
private 4,158 2,982 1,102 12 0 2 0/0
public 1,493 935 (13th: 937) 543 5 2 0 0/0
Canadian 134 134 0 0 0 0 0/0
UN 90 83 7 0 0 0 0/0

Pages (0 qpdf failures, 0 count mismatches, 0 write failures on any corpus, 0 hangs):

corpus crashes/hangs QPDF FAILED COUNT MISMATCH WRITE FAILED REFUSED, other
private 2/0 (see exit 10) 0 0 0 0 the 2 are #665
public 0/0 0 0 0 9 the known #445, unmoved (rotate 2, move 1, extract 2, importself 2, importpair 2: the thirteenth run's own split)
Canadian 0/0 0 0 0 0 pass
UN 0/0 0 0 0 0 pass

Field-/Parent refusals are the thirteenth run's, to the document: importself private 37, public 230, Canadian 5, UN 0; importpair private 0, public 0, Canadian 4, UN 0. Public's opened count is 1,486 (4 protected, 3 did not open, 5 flagged input already damaged), as before. Private: 12 protected, 60 did not open (62 in the thirteenth run; the other 2 are the exit 10 pair).

Deltas against the thirteenth run, each with its cause

  1. Two private documents exit 10 (structure CLI bad exits 2, markdown other bad exit 2, pages "crashed or hung before the page count was known: 2"). Explained by #665 (aef9df4). Verified directly, not inferred: both are truncated downloads (header %PDF-, no %%EOF in the last 4 KiB; sizes 4,194,304 and 19,710 bytes), and megapdf-cli extract on each prints "the file looks incomplete: ... it may be a download that stopped early" and exits 10. They were two of the 62 unreadable documents in every earlier run, exit 2. This is the new behaviour working, but the batteries' own valid-exit lists ("0/2/3/4/5, never 1/6/7/130" in the structure and markdown scripts, the pages script's crash classifier) predate exit 10, so the three scripts score a correct result as a gate failure. Nothing was changed here; the scripts need exit 10 accepted as a "cannot open" code before the next gate, or this reads red every time.
  2. Public structure F1 0.976157 against 0.980019, opened 1,478 against 1,480, 2 hangs. This is the regression below, not an accuracy change. Re-run with the hang bound lifted to 1,500 s (TIMEOUT=1500, same binary, public only, 3,941 s): 1,480 opened, 0 hung, F1 0.980019 internal and CLI, exactly the thirteenth run and #498, tau median 0.978, 0 bad exits. So the F1 shift is entirely the two documents missing from the aggregate.
  3. Public markdown extracted 935 against 937: the same two documents, same cause.
  4. Everything else is identical to the thirteenth run to the last digit: private F1 0.998997 / tau 0.951, Canadian 0.999932 / 0.994, UN 0.999136 / 0.967, markdown private 2,982 / 1,102 / 12, Canadian 134 / 0, UN 83 / 7, public textless 543, every pages cell. That the symbol-bullet recognition (#664/#669; aef9df4 reports 130 private documents gaining 1,688 list items) moved no F1 digit is consistent with it re-classifying blocks rather than adding or removing tokens, and that #671/#677's notes moved no textless count is the property the fourteenth run measured (a page carrying only a picture note still exits 5). Neither is an unexplained change; both are expected no-moves.

The regression: a 27x to 31x slowdown on very large documents, not an intended change

The two documents (hashes a20c1b39c91c, 1,566,046,588 bytes, 1,710 pages; 6cc48f6729ac, 2,066,415,812 bytes, 2,006 pages; both public corpus) time out in the structure CLI measure and the markdown battery at the 120 s bound. Timed directly with megapdf-cli extract <file> --format md --quiet, an otherwise idle host, one run each:

build a20c1b39c91c 6cc48f6729ac
2.2.0 (983fcdf) 14 s 15 s
2.3.0 (8838a6c) 382 s (text format also 382 s) 462 s (text 459 s)

Both finish with exit 0 and correct output (the lifted-bound structure run measures them at 225 and 232 ms/page and the thirteenth run's F1), so this is slow, not hung or wrong. Bisected on the first document, 100 s cap, one run per commit, each built in the same container image:

commit result
aef9df4 (#664/#665) 13 s
44e77b0 (#671) 14 s
25c74e3 (#677 part 1) 13 s
584cdd2 (#677 part 2) cut off at 100 s
3f302ed, c5dff89 cut off at 100 s

The commit that introduced it is 584cdd2, "#677 part 2: megapdf_block_image() reports PDFium's DPI, bit depth and colour space". Its message says "No written output changes", which is true of the output; it does not say the call is free. The diff adds a call to FPDFImageObj_GetImageMetadata(obj, page, &meta) per image. The likely mechanism, an inference from the call and not measured, is that PDFium loads or decodes the image to answer it, which on documents of 1.5 to 2 GB made of large scans costs minutes over the document. Nothing here profiled it. The 120 s bound is the batteries' own; whether 6 to 8 minutes to extract a 2 GB document is acceptable for 2.3.0, or whether the metadata should be fetched lazily or only for the page-image note, is Dave's decision. Not fixed in this run.

Not run

The redaction battery (not part of the thirteenth run's set). The markdown battery with the hang bound lifted (only structure was re-run that way, so the two documents' Markdown output was timed and exit-checked but not passed through cmark here). The bisect timed the first document only, one run per commit; the second document was timed on the two endpoints only.

Coverage arithmetic is the thirteenth run's: 1,493 reachable + 1 staged-but-unreached + 283 stated exclusion + 0 unexplained = 1,777 manifest rows, printed identically by all three summaries.

Gates: F1 >= 0.998 (private/Canadian/UN pass; public 0.976157 as run, 0.980019 with the bound lifted, #498); tau >= 0.9 (all pass); zero crashes (pass); zero hangs (fail: 2, public, the regression above); zero cmark failures/timeouts (pass); zero qpdf failures, count mismatches and write failures (pass); zero out-of-contract refusals (private/Canadian/UN pass; public has the same nine #445 refusals). The exit 10 reading of 2 on private is a correct result that the scripts' stale exit lists score as a failure (delta 1). Raw summaries are gitignored under tests/Private/stress/release-2.3.0-kdocker3/ on GPD-DAVE.

The re-run after the fix, 2026-10-20: structure and markdown, all four corpora, main at 80dfb5d

Both findings above are fixed, and the gate is green.

  • The slowdown: #704 makes the structure pass describe JPEG 2000 and JBIG2 images without FPDFImageObj_GetImageMetadata. The DPI is now computed from the pixel size and the bounding rect, PDFium's own formula. For these two codecs, bit depth and colour space stay unknown, except JBIG2, which is always 1 bit.
  • The exit 10 pair: the three batteries now count #665's exit 10 as a file that does not open.

Build: 80dfb5d, the 2.3.0 release commit. It merges #700, #702, #703 and #704; only #704 touches core/. It was built in megapdf:base on kdocker3 with the same pinned PDFium, and megapdf-cli --version printed megapdf-cli 2.3.0.

The two JPEG 2000 documents: both exported as Markdown with exit 0, in 29 s for the pair. 2.2.0 took 14 s + 15 s, and 8838a6c took 382 s + 462 s. Only the pair's total was recorded: the per-file timer needed bc, which the image does not have.

corpus F1 (internal = CLI) τ median opened CLI bad exits markdown extracted crashes / hangs / bad exits / cmark failures / cmark timeouts
private 0.998997 0.951 4,084 0 2,982 0 / 0 / 0 / 0 / 0
public 0.980019 0.978 1,480 0 937 0 / 0 / 0 / 0 / 0
Canadian 0.999932 0.994 134 0 134 0 / 0 / 0 / 0 / 0
UN 0.999136 0.967 90 0 83 0 / 0 / 0 / 0 / 0
  • Matches 2.2.0: every figure is identical to the thirteenth run, the 2.2.0 gate.
  • Public: its F1 of 0.980019 is #498, unmoved.
  • Durations: the whole public markdown battery took 192 s, and private took 104 s.
  • Not re-run: the pages battery. No page-tools code changed, and its two exit-10 "crashed before the page count" documents are the incomplete pair, which the script now counts as did-not-open.
  • No raw logs kept: a failed copy was followed in the same command by the workspace cleanup, so these re-run logs were not kept. The figures here were read from them before deletion.

Find on scanned pages (#676), 2026-10-17

Find reads a page that has no text layer of its own, through the OS recogniser, and searches what it read. In memory only: never written to the document or to disk, dropped on close. iOS (Vision), Android (ML Kit, bundled model), Windows (Windows.Media.Ocr) and macOS (Vision, from the Avalonia app) are built. Linux has no recogniser and is unchanged by design: Find on a scan there behaves exactly as it always did, with no dead affordance.

Phase 0, what it cost (small, non-random samples of about 30 textless pages from the private corpus, the first by sorted path; read the percentiles as indicative). Counts and milliseconds only; no file name or recognised text was kept.

platform recogniser recognise p50 per page notes
iOS / macOS (Mac mini M4, load ~5) Vision .accurate 134 ms at 100 dpi, 154 ms at 300 flat across dpi; .fast reads 61-92% of the characters, so not used; cold 539 ms
Windows (GPD-DAVE) Windows.Media.Ocr 169 ms at 100 dpi, 931 ms at 300 scales with pixels; throws past 10,000 px a side (4 of 31 pages at 300 dpi); cold 312-377 ms
Android (Pixel 4 XL, a 2019 phone, load ~3.4) ML Kit, bundled model 190 ms at 100-150 dpi, 284 ms at 300 cold 193 ms; about 13-16 MB per device (an estimate, no release bundle built); a ceiling for current hardware

Chosen: iOS recognises at 150 dpi, accurate, language detected per page. A 300-page scan is about 45 s on the mini. Android is decided to use the bundled model (Dave, 2026-10-17).

Automated, iOS (OcrTextTests, ScanSearchTests): the matcher and the cache following moves, deletes and inserts; the model reading a scan once and then searching from the cache; a page with a text layer never read; nothing kept after close; a text box added over a scan found as ordinary text and the scan found as read text, once each; and the real Vision recogniser reading drawn pages (English, and French with accents) and a blank sheet skipped. With the cache shift removed, the move and delete tests fail; with the duplicate filter removed, the text-box test fails.

Android (ScanSearchTest on the JVM, ScanRecognitionTest on a device): the same logic as iOS, in a plain ScanSearcher class so it is tested on the JVM with the recogniser scripted (18 tests; with the cache shift removed the move and delete tests fail). The real recogniser reading drawn English and French pages is an instrumented test. Size, measured: an unsigned universal release APK (four ABIs, R8) is 60.6 MB with ML Kit against 17.3 MB without, +43.3 MB; a per-device bundle was not built, so the 13-16 MB per device is still an estimate. Not yet run on a phone with the app itself (the Pixel 4 XL needed its USB-debugging authorisation again).

Windows (ScanSearchTests in Core.Tests; --screenshot-state scan-search): the shared logic lives in MegaPDF.Core (ScanSearch.cs) so Avalonia can reuse it, and the recogniser is WindowsOcrRecogniser, built from the person's profile languages (it answers null, and Find behaves as before, when no OCR language is installed). Core tests: 24, including the real engine (an image-only page has no text of its own, an image under an invisible OCR layer does, a text box added over a scan is not a text layer, the render is 150 dpi and capped by the recogniser's 10,000 px limit, a blank sheet never reaches the recogniser). That last group found a real defect: GetTextRuns() does not fill in TextBoxId, so boxes were being counted as text until they were subtracted by object index. The self-test state drives the real window in two parts: scripted (runs anywhere: the searcher built with the document, the cache following a page move, the dashed highlight, the note under the find bar) and a genuine scan (the fixture's own first page as pixels with no text layer, read by the real recogniser): "fixture" was found 0.3 pt from where the engine's own search finds it on the original; it prints SKIP and passes on a machine with no OCR language. 16 checks pass locally. The existing find, progress, pages and reading states and the whole Core suite (496) still pass.

macOS (MegaPDF.Avalonia, --self-test, section "Find on scanned pages (#676)"): the shared logic is the same MegaPDF.Core code Windows uses; the recogniser is MacVisionRecogniser, which calls Vision's VNRecognizeTextRequest (accurate, language detected per page) through hand-written objc_msgSend declarations in the style of MacPrinter, so nothing new is shipped. A word's box is read through the two corner points of boundingBoxForRange:error: rather than its CGRect, which comes back through objc_msgSend_stret on Intel; the Intel path was not run (the Mac mini is Apple silicon). The self-test has a scripted half that runs anywhere (including Linux CI, which has no recogniser) and a genuine-scan half that is skipped where there is none. On the Mac mini the whole self-test passes, and a real scan of demo.pdf (the fixture written to say "rental" three times) gives back 3 hits, as the original does, the first 1.4 pt from where the engine finds it on the original; fixture.pdf gives 1 hit 3.6 pt away. A window screenshot shows the dashed edges on the three hits, "1 of 3" and the note on its own row under the find bar.

By hand (not automated; a screenshot of -screenshot scan-search on the simulator shows it):

  • Open a scanned PDF, Find a word on it: the strip counts pages and Stop works; hits have a dashed edge and the note under the find bar appears while a read hit is current.
  • Search again: no strip, the result is immediate.
  • Move or delete a page, search again: the hits are on the pages the words are on.
  • Add a text box over a scanned page and search a word in both: two hits, not three.
  • Apply a redaction on a scanned page, search the redacted word: no hit.
  • A French scan: accented words are found.
  • Linux and the desktops are unchanged until their phases land.

Find on three real scans (2026-10-18)

The three true scans in the phone corpus (a one-page map, a seven-page restaurant menu, a three-page product poster; no text layer on any page) searched through Find with the real recogniser on two platforms, against an independent reference: Tesseract 5.3 at 300 dpi, counted the same way (a term inside a word counts). Counts only. The Mac figures come from a throwaway runner over the real view model (Vision, accurate level); the phone ran the debug APK from CI for da17941 (ML Kit, bundled model), read off the find bar.

scan term Tesseract macOS Vision Android ML Kit
map, 1 page thule 4 6 6
bomber 2 2 not run
canada 1 1 not run
greenland (letters set on a diagonal) 0 0 not run
menu, 7 pages chef 7 7 7
calamari 4 4 not run
hummus, lentil, shrimp 2, 4, 3 2, 4, 3 not run
poster, 3 pages wi-fi 29 29 24
garager 25 28 not run
camera, account 4, 5 4, 5 not run

Vision's per-page counts match Tesseract's on every term of the menu, term for term. On the map, both recognisers find more "Thule" than Tesseract: I counted at least five by eye in the rendered page, so the extra hits are probably real, but I did not match each one to a place. Text set on a diagonal ("GREENLAND") is found by none. The one shortfall is Android on the poster: 24 of 29 "wi-fi". The text there is very small, and Android reads a page at 150 dpi capped at 4096 px on the long side (the cap turned out not to be the cause: see the follow-up below). Timing on the Mac mini: one page 0.6 s, seven pages 3.1 s, three poster pages 2.8 s, including rendering.

Follow-up the same day: every term on every platform, and the render resolution. The phone figures above were read off the find bar one term per scan; this ran every term. Android: a throwaway instrumented test on the Pixel 4 XL running the shipped read path (render, blank check, ML Kit, OcrPage.matches) at several dpi; at 150 dpi it reproduces the app's own 6, 7 and 24, so it is the same path. Windows: a throwaway hook in the scan-search self-test state on GPD-DAVE (Windows.Media.Ocr, the real view model and SearchAsync), with ScanReader.Dpi made overridable for the run. Neither probe was committed.

scan term by eye Vision 150 ML Kit 150 / 200 / 300 / 400 Windows 150 / 200 / 300
map thule 6 6 6 / 6 / 6 / 6 4 / 6 / 6
greenland (diagonal) 1 0 1 at every dpi 0 / 0 / 0
bomber, canada 2, 1 2, 1 at every dpi 2, 1 at every dpi
menu hummus, calamari, lentil, chef, shrimp 2, 4, 4, 7, 3 same at every dpi same, except lentil 3 at 300
poster wi-fi 29 24 / 29 / 29 / 28 20 / 29 / 29
garager 28 28 / 28 / 28 / 29 18 / 26 / 25
camera, account 4, 5 4, 5 at every dpi 3, 4 / 4, 5 / 4, 5

Three corrections to the paragraph above. The 4096 px cap was not the cause: the poster's pages are US Letter, which is 1275 x 1650 px at 150 dpi, nowhere near the cap. The cause is the resolution: at 200 dpi both Android and Windows find every "wi-fi" Vision and Tesseract find, and Windows recovers the map's two missing "Thule", while the menu, whose type is larger, is the same at every resolution. Diagonal text is not missed by everyone: ML Kit finds the one diagonal "GREENLAND" on the map at every resolution; Vision, Windows and Tesseract do not. On the map, the six "Thule" Vision and ML Kit report are all on the page by eye (title, two labels, three in the caption), so Tesseract's 4 was the undercount. The cost of 200 dpi: the whole poster read in 2.9 s at 150 and 3.9 s at 200 on the phone, and in 0.96 s and 0.99 s on Windows; the seven-page menu in 4.4 s and 4.6 s on the phone and 1.2 s and 1.7 s on Windows. A 200 dpi Letter page is a 15 MB bitmap. Whether the shipped 150 should become 200, on which platforms, is a decision for #676 and was not made here; Vision at 150 already matched on these three, and iOS was not run.

Adopted: 200 dpi on every platform (Dave, 2026-10-18), in the shared ScanReader.Dpi (Windows and macOS), Android's OCR_DPI and iOS's ocrDPI. Checked after the change:

  • Android app on the Pixel 4 XL: the find bar reads "1 of 29" for "wi-fi" on the poster, where it read 24.
  • macOS: Vision at 200 gives the same as at 150 on the map and menu; on the poster, "wi-fi" 30 and "garager" 29, one more of each than at 150, with the extra hits not checked against the page.
  • Self-tests and unit tests: the Mac self-test passes (754 checks, with every fixture generator); Windows scan-search passes 16 of 16; Core ScanSearch tests pass 24 of 24; Android unit tests and ScanRecognitionTest pass on the phone.
  • iOS ScanSearchTests pass in the simulator. The search-through-the-model test's wait was raised from 15 s to 60 s: a cold first Vision read in a fresh simulator measured 15-20 s at 200 (8.4 s at 150), against 2.3 s warm at either.

iOS on the three real scans (2026-10-19). A throwaway XCTest (not committed) opened each scan through the real ViewerModel in a fresh iPhone 16 simulator (iOS 26.5) on the Mac mini, at the shipped 200 dpi with Vision, and counted searchMatches for every term above, every hit fromScan:

scan terms iOS Vision 200 macOS Vision 200
map thule, greenland (diagonal), bomber, canada 6, 0, 2, 1 6, 0, 2, 1
menu hummus, calamari, lentil, chef, shrimp 2, 4, 4, 7, 3 2, 4, 4, 7, 3
poster wi-fi, garager, camera, account 30, 28, 4, 5 30, 29, 4, 5

The same as macOS term for term, except one "garager" fewer on the poster (28, which is what Vision gave on the Mac at 150; the extra Mac hit was never checked against the page). As on the Mac, the diagonal "GREENLAND" is not read. The first search on each document, which reads every page, took 16.6 s for the one-page map (the cold Vision load), 28.2 s for the seven-page menu and 9.0 s for the three-page poster; every later search 0.2 s. A simulator on Apple silicon is not a phone: no iPhone has run this.

Hosted wait (2026-10-19). testASearchHitsBothTheTextBoxAddedOverAScanAndTheScan timed out at its 60 s wait in hosted run 39265423233; in four passing hosted runs it took 9.5-31.8 s, and in the failing run the French Vision test, which has no deadline, took 63.5 s and passed, so the runner was slow, not hung. The wait is now 180 s: it guards against a hang, not the read time.

Known limits. A phrase split across two recognised lines is not found (the engine's own search finds those); a highlight is a whole word, not a slice of it; a drawing-only page is read (only a uniform blank sheet is skipped); reading is on demand inside Find, not a background pass.

Vendor manuals corpus, first run (2026-10-23)

Dave's 27 CNC vendor manuals (Carbide 3D, PocketNC, Roland, Shaper, Stepcraft, Tormach, from the CaseMaker research behind #664 and #665) plus R02P032 Installation.pdf are staged read-only at ~/pdf-test-vendor on kdocker3: 28 documents (921 pages in the 26 that open). None was in any of the other four corpora. They are third-party copyright, so the folder is never committed or redistributed, and batteries are pointed at it exactly as at the others. Two Stepcraft files are truncated at 1,048,576 bytes on purpose (#665) and must stay that way.

Run on kdocker3 at fcff3df in a throwaway megapdf:base container built from a clean clone (megapdf-cli 2.3.0, PDFium at 33 patches), structure-battery.sh --reference --cli, markdown-battery.sh and pages-battery.sh, corpus mounted read-only, then removed. Disk count 28, visited 28 in every battery. Zero crashes, zero hangs, zero bad exit codes in all three.

battery result
structure 26 opened, 2 unreadable. Token F1 0.997394 against the 0.998 gate (fail), the same through megapdf-cli. Order tau median 0.986 on 197 tagged pages; poppler agreement tau median 0.995 on 909 pages
markdown 26 extracted, 2 unreadable, 0 cmark failures, 0 cmark timeouts
pages rotate, delete, move, blank, import-self and import-pair 0 qpdf failures, 0 count mismatches, 0 write failures; extract 26 ok

The two unreadable documents are the two truncated Stepcraft manuals. megapdf-cli extract on each prints "the file looks incomplete: 1.0 MB present, the PDF's own index says 2.4 MB" (7.0 MB for the other) and exits 10, so #665's behaviour holds on the real files it came from. Two Roland MDX-50 guides carry owner restrictions; the pages battery reports them as restricted and refuses the edits.

The structure gate fails on this corpus: five documents (stepcraft/manual-uccnc-quickstart 0.9848, roland/MDX-50_InstallGuide_EN 0.9888, roland/MDX-50_users-manual_apac 0.9896, roland/MDX-50_UserManual_EN 0.9934, roland/3d-tech-brief 0.9957); the other 21 that opened are at or above 0.9981. The cause is not diagnosed (#781). Whether this corpus gates a release or is reported like the public corpus's known figure is Dave's call; it is registered but not made a gate. A new corpus has no earlier run, so nothing here is a regression.

After #781's cross-block hyphen join (2026-10-23, see "Cross-block wrap hyphens (#781)" below): F1 0.998199, internal API and megapdf-cli the same to the last digit; unmatched words 912 to 630. The cause #781 found was stranded wrap hyphens (845 of the 912 unmatched words); the join takes 282 of the unmatched words back, from the 336 the diagnosis put in class (a) (the continuation is the first word of the very next block). The other 509 (class (b): columns or cells interleaved into one block) are untouched, so the corpus passes by about 0.0002, roughly 35 words. Whether it gates a release is still Dave's call.

Phone downloads corpus, first run (2026-10-18)

Dave offered the PDFs on the Pixel 4 XL's Downloads folder for private testing. 58 files; 37 were staged read-only as Phone-Downloads-2026-10-18 in the private corpus (P:, /mnt/pdf-test), and 21 were left out and named in that folder's EXCLUDED-FROM-THIS-CORPUS.tsv (18 look personal, financial or medical from their names and producers, 3 have names that say nothing and were not opened). Nothing was read for content: counts and producers only. The folder is not part of ~/pdf-test on k3, so no earlier baseline changes. A run copies the 37 to a scratch directory.

Run on kdocker3 at dde8379 in a throwaway megapdf:base container (PDFium at 33 patches), structure-battery.sh --reference --cli, markdown-battery.sh, pages-battery.sh --jobs 4 and redaction-battery.sh, then removed. The batteries' *.pdf walk skips one file whose extension is .PDF (a one-page true scan); it was copied under a lower-case name and run alone, so all 37 were covered.

battery 36 files the 37th (a true scan, no text)
structure 36 opened, 0 crashed, 0 hung, aggregate F1 0.999409 (gate 0.998), tau median 0.933 on 228 tagged pages, 717 pages, 81 textless opened, textless, F1 n/a
markdown 34 extracted, 2 with no text on any page, 0 cmark failures no text on any page
pages 0 crashes, 0 hangs, 0 mismatches, 0 qpdf failures, 0 other refusals; the two encrypted files (owner restrictions, open with no password) came back as the documented "restricted" 7 of 7 operations ok
redaction 21 redacted, 15 refused (8 shared-form, 5 image, 1 layout-guard), 0 leaks, 0 changed outside, 0 crashed, 0 hung redacted, 0 leaks

Zero crashes and zero hangs in every battery. What this does not show: no battery reads a scanned page's recognised text (Find on scans, #676, has its own checks), and these files are one phone's Downloads folder, not a sample.

Letter bullets confirmed across the document (#669), 2026-10-19

Run on kdocker3 at bcc8179 plus the change, in a throwaway megapdf:base container (PDFium at 33 patches), structure-battery.sh --reference --cli and markdown-battery.sh over all four corpora, before (bcc8179's own core/megapdf_structure.cpp) and after, the two builds run side by side. A per-document pass then ran megapdf-cli extract --format md from both builds over every *.pdf in the four corpora and compared list-item counts per document by path hash. Corpus file names are not recorded here. Container, build trees, the downloaded manual and all scratch removed afterward.

The rule. The per-page threshold of 3 is unchanged. A letter-valued marker that fails it on a page is accepted there only if the document has confirmed the same signature elsewhere: the same code point, in the same named face, at the same indent (within one em), on at least 2 other pages that each pass the per-page test. The marker's face must also be a different typeface family from the text after it (FaceFamily strips the subset tag and style words, so "Arial-BoldMT" beside "ArialMT" is one family). Two earlier shapes of the rule were measured and rejected on the corpora: letter-and-face alone turned a letter-spaced "S u m m a r y :" into "- u m m a r y :" (thousands of items in one public document), and "distinct name" alone turned a tax form's bold line labels ("b", "c", "d") and its multiplication "x" into bullets (two further private documents).

The reported manual (Tormach PathPilot User Guide for Lathes, UM10751, public; the copy at tormach.com is version 1220A, 226 pages, not the 257 the issue counts, so the counts differ from the issue's table and the two are not directly comparable): literal l items 46 on 29 pages before, 1 on 1 page after. Its bullets are Wingdings-Regular glyph l, one signature, confirmed on 111 pages. Page 22 (one item) is now a list item. The one that remains is page 44, where two items run together on one line after a heading; only a line's first word is ever read as a marker, so that is the separate inline-items gap the issue names, not this rule.

Per-document list-item counts, 5,875 documents:

corpus documents exit codes changed markdown differs list-item count changed items gained items lost
private 4,158 0 4 3 21 0
public 1,493 0 2 2 3 0
Canadian 134 0 0 0 0 0
UN 90 0 0 0 0 0

Every changed document was read: one public and one private document draw their bullets with a symbol face mapped to l (a line gained its bullet); one private document is a CV whose bullets are n in a symbol face (a line was split into a bullet plus its text; this also appears twice because the corpus holds the file twice); one public document uses D in a symbol face; one private document gains three Wingdings3 arrow items and its other items nest one level deeper as a result (indentation only; the depth logic seeing a larger marker set, as #664's own note recorded for 10 documents). No document without a symbol-face bullet changed. No document lost an item.

Gates, identical before and after on every cell: zero crashes everywhere. Structure F1 (CLI): private 0.998997, public 0.976157, Canadian 0.999932, UN 0.999136, tau median 0.951 / 0.978 / 0.994 / 0.967. Markdown: 0 cmark failures, extracted counts 2,982 / 935 / 134 / 83. The public F1 gate fails before and after (#498); the figure is lower than the 0.980019 recorded for the 2.2.0 candidate, and identically so before and after this change, so it is a property of the corpus as staged now, not of this change. Two public documents (2.1 GB and 1.5 GB) exceed the 120 s bound in both builds; run alone with a 400 s bound, the 1.5 GB one finished in 375 s (before) and 372 s (after) and the 2.1 GB one was cut off at 400 s by both, so they are slow, not changed by this. Two private files exit 10 (an incomplete download, #665) in both. none of that moved.

Not verified here: the other four platforms were not run (the change is in the shared core only); the UI was not looked at.

Mid-line letter markers (#669, second shape), 2026-10-19

Run on kdocker3 in a throwaway megapdf:base container, on top of #689's tree (3f302ed), the two builds side by side, the same per-document comparison and the same batteries as the #689 section above (all four corpora, structure --reference --cli and markdown). Counts only. Everything, including the downloaded manual, removed afterward.

The premise was wrong, and what the issue calls a run-on line is not one. The issue describes items "run together on one line" ("l Starting the program at a tool change. l Starting the program with ..."). Dumping the words of that page shows two separate lines, each beginning with the Wingdings l at the same x (93.1 pt). The Markdown joined them only because neither line was classed a list item, so the paragraph logic folded the second into the first. #689's borrow required the marker's x to match a confirming page's cluster within one em; this page's items sit one nesting level deeper than any other page uses (a nested list on a page of its own), so they missed. The indent is not part of the signature after all, and #689's rule stating that it was is the error this corrects.

What was built and measured, and what was dropped. A line-splitter, as specified (cut a line at a mid-line marker carrying a confirmed signature, face distinct, standing off by the glyph gap), was built and run over all four corpora and the manual. It fired on 0 real run-on lines. Its one effect in 5,875 documents was a false split in one private document: a bold X signature mark mid-line ("... signature (if applicable) X Date ...") became a bullet because that X already passes the signature test (the face is distinct from the text beside it and the glyph repeats on other pages). A rule with no instances to fix and one wrong effect was not shipped. What ships is the indent change.

The reported manual (UM10751, version 1220A, 226 pages): literal l items 46 on 29 pages before #689, 1 after #689, 0 after this. Page 44's two items are now two list items.

Per-document list-item counts against #689's tree, 5,875 documents: exit codes changed 0, items lost 0. One public document changed, +3 items (a symbol-face b marker whose items sit at an indent the confirming pages do not use, read: three real bullets). The private, Canadian and UN corpora did not change at all.

Gates, identical before and after on every cell, zero crashes: structure F1 private 0.998997, public 0.976157 (fails, #498, unchanged), Canadian 0.999932, UN 0.999136; tau median 0.951 / 0.978 / 0.994 / 0.967; markdown 0 cmark failures, extracted 2,982 / 935 / 134 / 83. The same two public files exceed the markdown battery's 120 s bound and the same two private files exit 10, as in the #689 section.

Not verified: only Linux; the other four platforms not run. Page 44's items come out at nesting level 3 (the page has three indents), rendered with four spaces of indent after a heading and no parent item; that is the existing depth logic and was not changed here.

Text selection costs (#710), 2026-10-20

Contract 11's calls (megapdf_selectable_*, core/megapdf_core.h), timed because a drag calls them on every pointer move. A Release build of the core on kdocker3, in a throwaway megapdf:base container, from wip/710-core-selection. A scratch harness (not committed) loaded every page's selection snapshot, up to the first 20-60 pages of each document, and then ran the queries 2,000-20,000 times at random points on each of the five densest pages it found. No document is named here.

corpus sample pages load median load p99 load max
public: govinfo-signed, irs, uscis, uk-hmrc (40 pages each) 2,574 2.29 ms 10.71 ms 14.20 ms
public: govinfo-large, uk-dwp, uk-homeoffice, qpdf, pdfium (60 each) 2,057 0.61 ms 6.71 ms 16.02 ms
private (20 each) 3,953 0.54 ms 4.69 ms 12.07 ms

On the fifteen densest pages (8,582-15,070 characters): the load 6.4-13.2 ms, against 2.0-7.8 ms for contract 2's megapdf_text_load of the same page and 7.1-18.4 ms for a one-page megapdf_structure_load (the load is the same reading of the page that contract 9 does); caret_at and word_at 0.22-0.69 us per call; a drag move (caret_at, then rects from the page's first character to the new caret, every line of the page in the worst case) 3.2-5.6 us; a whole-page text 19-39 us. The #98 schematic (in the repo) loads in 8.9 ms and answers caret_at in 0.66 us.

So the per-move cost is three to four orders of magnitude under a 16.7 ms frame, and the load is the call to keep off a per-move path. Not measured: a phone. The load there will be slower by the phone's factor; the contract says to load once per page, off the UI thread if needed.

Automation launches leave the person's data alone (#717), 2026-10-21

Every automation launch on both desktops (the Windows --screenshot/--screenshot-state/ --engine-check/--hold/... launches, and every Avalonia self-test, screenshot, capture and diagnostic entry point, including --open-check and --window-tabs-check) now keeps its settings, recents, signatures and recovery journal in a fresh megapdf-userdata-* folder under the temporary folder, deleted at exit, or in the folder MEGAPDF_USER_DATA names. At exit it checks that the folder it used was not the real one and that the real one is byte-identical (SHA-256 of every file) to what it was at launch, and the launch fails if either is not so.

where what ran real data folder other traces
GPD-DAVE, wip/717-isolated-user-data all 20 CI self-test states and --engine-check, under the UI lock byte-identical, 9 entries, hashes, sizes and mtimes, before and after; also against a copy taken before the first run Explorer Recent .lnk and AutomaticDestinations/CustomDestinations: unchanged. Clipboard sequence number 306 before and after. 0 isolation folders left in %TEMP%
kdocker3, origin/main (51ed986) Linux --self-test against a stand-in user folder recent.json rewritten
kdocker3, the branch --self-test, --screenshot with and without a state, --render-check, --install-kind unchanged, every one 0 isolation folders left
kdocker3, the branch, MEGAPDF_USER_DATA = the real folder (what every launch did before) --self-test, --screenshot-state find the self-test rewrote recent.json the guard failed both, exit 1: the data folder in use is the real one
Mac mini, origin/main --self-test recent.json rewritten (restored from a copy afterwards)
Mac mini, the branch --self-test, --screenshot-state find, --window-tabs-check, --render-check unchanged, every one Saved Application State, Preferences and LaunchServices recent documents unchanged; pasteboard changeCount 14 before and after

The guard's own failure cases (a recents row, a rewritten settings file, a new journal, a removed signature index, a real folder that appears, the real folder in use) are tests/MegaPDF.Core.Tests/AutomationUserDataTests.cs, which also holds a journal open through the end of the run, as a state's document does, and checks the run's folder still goes.

Windows select-copy no longer writes the real clipboard at all: in an automation launch Copy keeps the package it would have handed to Clipboard.SetContent and the state reads it there. Saving and restoring every format is not possible faithfully (clipboard history and the cloud clipboard keep each write; delay-rendered formats and file lists do not round-trip). What this does not exercise is the final SetContent/Flush call itself. The Avalonia self-test runs on Avalonia's headless platform, whose clipboard is in memory, so it never reached the real one.

The QA scripts (tools/windows-qa, tools/screenshots-windows) start with tools/windows-qa/qa-safety.ps1's watch, which copies %LOCALAPPDATA%\MegaPDF and the package's data folder to D:\megapdf-qa\backup-<timestamp>\ and compares them when PowerShell exits, and install and remove packages only through it: a Store-signed package is never removed. Its own checks were run on GPD-DAVE against stand-in folders and stand-in package objects (no package was installed or removed).

The huge-image cache check counts bytes, not milliseconds (#698), 2026-10-21

test_huge_image_render_cache (core/tests/core_tests.cpp) asserted that a cache hit rendered at least 4x faster than the decode. That failed on two hosted runners at once (Windows missed by 2 ms: 28 x 4 = 112 against 110), and the ratio has a floor, because a hit still resamples and composites the kept copy. It now opens its fixture with megapdf_open_file and asserts on megapdf_testing_bytes_read(), the bytes PDFium read through the core's file callback during each render. PDFium patch 0024 keeps the image's 1.2 MB stream in the file, so a render that decodes reads it all and one drawn from the page's image cache reads none. Threshold 64 KiB (0024's own boundary). Timings are printed, never asserted. No PDFium change.

The printed line, PDFium patches 33 (pdfium-7934-megapdf-2b3b415e86b1), Debug build, whole core suite green on both:

machine first again (2nd/3rd) larger zoom out zoom in again ms (same order)
kdocker3, Ubuntu 24.04 container, ASan 1,200,001 0 / 0 1,200,001 0 0 63, 2/1, 128, 8, 21
Mac mini, Apple silicon, no ASan 1,200,001 0 / 0 1,200,001 0 0 37, 1/1, 69, 5, 12

So the cache is not missing on the Mac (the hosted macOS run's 61 ms had raised that question): every hit read 0 bytes. Negative control, a local edit never committed: render second through a second Page of the same document (each page has its own image cache). On both machines "rendering the page again does not decode the image again" failed with 1200001, 0 bytes read and nothing else in the test failed on the Mac. (The kdocker3 negative run was in a container without the fixtures directory, so it also printed 135 unrelated "opens" failures; the positive run there, with fixtures, was green.)

Wrap hyphens on a block's first line (#496), 2026-10-22

One UN document scored 0.9935 in its Spanish edition against 0.9996 to 1.0000 in its five siblings (#496, after #532 had already taken it from 0.9662). The cause is not Spanish and not the page count. A numbered paragraph, or a paragraph whose first line is indented, was cut after its first line by GatherOneBlock, so a wrap hyphen ending that line sat at a block boundary. BuildPieces joins a hyphen only to the next line of the same block; measure 1's reference side (JoinLineWrapHyphens) joins across blocks. One word became two engine tokens and one reference token. Spanish wraps with hyphens far more often than the other editions, which is the whole of why it alone crossed the gate.

Machine and build. kdocker3, a throwaway megapdf:base container, the corpora mounted read-only, a scratch directory under /home/claude removed afterwards. Two builds of the same tree, same pinned PDFium (33 patches), same structure_check: base is main at 244849d, fix is main plus this branch's three commits. Structure battery with --reference --cli, and the Markdown battery, over all four corpora, base and fix running side by side.

Step 1, re-baseline. The base build reproduces the 2026-10-20 re-run exactly on every corpus (table below, "base" columns), so #710's 184 lines in megapdf_structure.cpp did not move the baseline. For the document: Spanish 0.993529 (84,516 matched of 85,250 engine and 84,883 reference tokens), the five siblings 1.000000 (Arabic), 0.999626 (Chinese), 0.999742 (English), 0.999933 (French), 1.000000 (Russian). 20.1 content blocks per page (not the 85,544 blocks of the Tf/Tm bug, so #532 has not regressed). Spanish engine tokens exceed the reference's by 367 and exactly 734 engine tokens have no match: the two-for-one shape of a split word.

Steps 2 and 3, structure_check hyphendiag. New diagnostic mode (numbers only; --dump writes token pairs to a PRIVATE-marked directory). Self-check on generated one-page documents: bb_hyphen=1 on each of the three fixtures of the new unit test, bb_hyphen=0 on tm-scaled-size.pdf. On the document's Spanish edition:

block-boundary wrap hyphens (A ends in a hyphen, B begins lowercase) count share of 367
LIST_ITEM > PARAGRAPH, B's left = A's left (continuation under the number) 179 48.8%
LIST_ITEM > PARAGRAPH, B 2.5 em left of A (indented number, continuation at the margin) 144 39.2%
PARAGRAPH > PARAGRAPH, B 2.5 em left of A (indented first line) 44 12.0%

Every one has A a single line, B below A and the two overlapping horizontally, so none is a column or page break (H1 prime) and none has a heading as B. fid_if_joined is 1.000000 for the Spanish edition with split, merge and other all 0: the 367 are the whole gap, none of it justified text (H2), one font (H3) or the literal-hyphen fallback (H4). English has 13 such hyphens (0.999742 to 0.999979 if joined) and French 4 (0.999933 to 1.000000).

Steps 4 and 5, three commits, each measured on its own (UN corpus, structure battery):

build Spanish English French UN aggregate
base 0.993529 0.999742 0.999933 0.999136
+ a list item also continues at its number's left edge; a paragraph's second line may sit up to 4 em left of an indented first line 0.997457 1.000000 1.000000 0.999555
+ the same allowance for an indented list item's second line 1.000000 1.000000 1.000000 0.999801

Arabic, Chinese and Russian do not move. Content blocks per page for the Spanish edition: 20.1 (base), 16.4, 14.1.

The design's 4a (the marker's own left edge) and 4b (the indented first line) are the first commit and together explain 223 of 367 (60.8%), which meets the design's "at least half" rule but reaches only 0.997457, short of the 0.998 gate. The other 144 are the indented-number shape the design's own H1 row anticipates (LIST_ITEM > PARAGRAPH, ldelta_em above 1) but its 4a text does not cover; commit three is the same first-line allowance applied to the number. It is kept as a separate commit so it can be dropped.

kFirstLineIndentMaxEm is 4.0, the design's default. The design's rule for setting it (the largest PARAGRAPH > PARAGRAPH bucket, 2.5, rounded up to a whole em) would give 3.0, but the design's own test fixture indents the first line 36 pt at 10 pt, 3.6 em, so 3.0 would fail it. The document's real indent is 2.4 em and fits either.

Tests. test_structure_wrap_hyphen_across_indent (core/tests/core_tests.cpp), three generated fixtures. Without the change the first two fail four checks (3 and 2 blocks, the hyphen kept); against the first commit alone the third fails two (3 blocks, the hyphen kept); with the branch the whole core suite is green, test_structure_goldens included, so no golden moved. The design's first fixture put the text at x=108 behind a number at x=72, a 22 pt gap that the line split (kLineSplitFontSizeFactor, 2 em, 20 pt) cuts into two lines before any block rule sees it; the fixture here puts the text at x=100.

The gate, four corpora, base against fix (--reference --cli; F1 identical through megapdf-cli):

corpus F1 base F1 fix tau (vs tree) pages F1 < 0.9 crashes / hangs / bad CLI exits Markdown extracted / cmark failures
private 0.998997 0.999004 0.951 = 0.951 33 = 33 0 / 0 / 0 2,982 = 2,982 / 0
public 0.980019 0.980576 0.978 = 0.978 53 to 52 0 / 0 / 0 937 = 937 / 0
Canadian 0.999932 0.999932 0.994 = 0.994 0 = 0 0 / 0 / 0 134 = 134 / 0
UN 0.999136 0.999801 0.967 = 0.967 8 = 8 0 / 0 / 0 83 = 83 / 0

Nothing fell on any measure the gate reads. The public corpus still fails the F1 gate for its known reason (#498); it moved up, not down. Opened counts and the Markdown output counts are identical to the 2026-10-20 re-run.

One informational number did move, and is stated here rather than left to be found. Measure 3, agreement with pdftotext -layout, is not gated. Its median fell by 0.001 on the UN corpus (0.999 to 0.998) and on the public corpus (0.990 to 0.989); private (0.992) and Canadian (0.996) did not move. The likely cause, not separately measured, is a list item now reading as one block where it was two, which changes the token order a little against poppler's. UN: 92 of 3,752 pages changed, 85 of them down, the largest by 0.022. Public: 3,964 of 22,942 pages, 3,681 down, the largest by 0.031. Private: 16 of 17,379 pages. No page crosses 0.9 in either direction (the count of pages under 0.9 is identical on every corpus). The order against the document's own structure tree moved on 4 pages in all.

Not run. The pages and redaction batteries: they do not read block grouping. Hosted CI, and the Windows, Mac, iOS and Android builds: the change is in core/, which every platform links, but only the Linux build was exercised here.

Spanish wrap hyphens before an accented letter (compa- / ñía) are never joined by the engine or the reference, because both test the next letter against ASCII lowercase. The measure cannot see it, so it does not explain any of this; widening one side alone would move the gate for an unrelated reason. Left for its own issue, as the design says.

Shrink-for-email survey, baseline (#6), 2026-10-21

The measurement step of #6, taken before any policy change: what megapdf_shrink_images does to the corpora today, and what the candidate bilevel / grey / transparency rules would do instead. tools/shrink-survey/ (shrink_survey.cpp built with -DMEGAPDF_SHRINK_SURVEY=ON, run.sh, summarise.py). Aggregates only; the per-image output lives outside the repo and holds path hashes, never a name. kdocker3, megapdf:base container with libjpeg-turbo8-dev, corpora mounted read-only, 6 jobs, 300 s per document, PDFium patches 33, origin/main 244849d plus this tool. No product code changed.

This is not quite the design's tool. The design reads each image's outcome from a decision hook inside megapdf_shrink_images_cancellable; neither exists until #593's core step lands, and this step must not touch that engine. So the baseline decision is computed in the tool from the published facts (megapdf_images_load, then the 150 dpi / 1.2 / 100 KB / 8 KB / 0.9 rules), and the engine's own megapdf_shrink_images runs on the same document as a cross-check: the two replaced counts agree on every one of the 4,084 + 1,482 documents (0 disagreements). Sizes are libjpeg-turbo's; WIC and ImageIO differ at the same nominal quality, so a gate read here is indicative for Windows and iOS, not exact. Image bytes are summed per placement (one shared image drawn twice counts twice, as shrink sees it), so they can exceed the file's size.

private (~/pdf-test) public (~/pdf-public)
documents visited 4,158 of 4,337 on disk (same population as every other battery: 179 files are named .PDF and find -name '*.pdf' does not see them) 1,493 of 1,777 manifest rows (the staged 1,494, less one .Pdf; 283 stated exclusions printed by the run)
result 4,084 ok, 62 unreadable, 12 encrypted 1,482 ok, 5 encrypted, 4 unreadable, 2 timeouts at 300 s
images / documents holding any 177,076 / 3,214 11,041 / 251
inside form XObjects 4,234 images, 61.1 MB 40 images, 0.2 MB
stored image bytes 2,554.2 MB 670.2 MB
documents with at least one image replaced today 1,485 (36.4%) 107 (7.2%)
image bytes saved by today's rules 1,647.3 MB over 5,774 images 59.3 MB over 567 images
time per document, median / p90 / max 28 / 518 / 19,752 ms 3 / 145 / 228,252 ms
megapdf_shrink_images alone, median / p90 / max 5 / 202 / 10,378 ms 0 / 16 / 73,620 ms

Run time was 178 s (private) and 316 s (public). The public max (228 s) is a document that finished just inside the 300 s bound. The two timeouts are the two largest staged documents, 1.6 GB and 2.1 GB; the survey's own work (a walk that decodes bit depth, a render and five encodes per candidate, a rewrite, the shrink, a second rewrite) is what ran past the bound, so a timeout here is not evidence about shrink alone. A re-run of those two at a 3,000 s bound is reported in the last paragraph.

Today's rules by kind, one row per kind. "Not rendered" means the loop skips the image without rendering it (under 8 KB, or right-sized and under 100 KB, or under 8 px). "In forms" images are never reached, so they appear in the image count and in neither outcome column.

Private:

kind images stored MB replaced kept small JPEG saves under 10%, kept in forms saved MB
colour DCT 3,376 1,492.1 3,023 0 353 0 1,072.0
grey DCT 1,302 496.4 1,081 0 221 0 315.4
colour Flate/none/other 1,139 240.0 1,004 0 135 0 218.0
not rendered 126,675 142.3 0 125,093 0 1,582 0.0
bilevel CCITT 22,753 88.9 293 20,711 827 922 4.4
colour JPX 129 30.0 22 0 107 0 12.9
grey Flate/none/other 403 30.0 304 0 99 0 20.9
bilevel Flate/none/other 20,630 25.3 24 18,529 373 1,704 0.3
grey JPX 21 5.5 1 0 20 0 3.0
bilevel JBIG2 648 3.7 22 498 102 26 0.4

Public:

kind images stored MB replaced kept small JPEG saves under 10%, kept in forms saved MB
colour DCT 1,494 357.9 160 0 1,334 0 23.0
colour JPX 1,031 218.8 11 0 1,020 0 4.6
bilevel CCITT 638 47.7 187 100 351 0 20.7
grey DCT 253 26.9 123 0 130 0 6.8
not rendered 7,510 13.0 0 7,470 0 40 0.0
colour Flate/none/other 18 3.0 17 0 1 0 2.8
grey Flate/none/other 80 2.2 69 0 11 0 1.4
bilevel Flate/none/other 13 0.6 0 9 4 0 0.0
bilevel JBIG2 4 0.0 0 4 0 0 0.0

In the public corpus CCITT bilevel is 7.1% of all image bytes (conformance fixtures among them), and today's loop replaces 187 of those images for 20.7 MB, 35% of that corpus's savings. In the private corpus "bilevel" images are mostly strips and stencil masks: 20,711 of 22,753 CCITT images are under the size gates.

The five gates, read (the design's thresholds):

gate number private public outcome
G1 median rgb_q80 / rgb_q75 on grey candidates (p90; bytes-weighted) 1.098 (1.121; 1.108), 1,417 images, 519 MB 1.047 (1.085; 1.048), 329 images under 1.25 in both: 0.80 ships. At 0.80 the 10% rule keeps 7 more private images and none public
G2 median grey_q75 / rgb_q75 0.943 (0.968; 0.955) 0.947 (0.967; 0.964) not under 0.85: no single-channel follow-up
G3 bilevel Flate/none/other bytes, share of all image bytes 1.0% (CCITT 3.5%, JBIG2 0.1%) 0.1% (CCITT 7.1%) under 5%: no G4 encoder follow-up
G4 stored bytes inside form XObjects, share 2.4% 0.0% under 5%: no follow-up
G5 images tripping R5 with no /SMask, /Mask, /ImageMask 130 of 1,236: 617 /SMask, 87 /Mask, 391 /ImageMask, 11 inline (not resolvable by dictionary), 130 none 24 of 29: 2 /SMask, 3 /ImageMask, 24 none tripped, and the remedy does not apply: see below

G5. Every R5 trip was checked against its image dictionary (pikepdf; 1,236 + 29, not a sample of 10), matching each to its image by page and drawing order and confirming the width and height. The design's remedy is to widen the excluded border to 2 px and re-run. That was done at 2 px and at 4 px: private trips 1,236 -> 1,224 -> 1,205, public 29 -> 29 -> 29. The 130 do not move, so the ring is not their cause. They are almost entirely transparent (alpha 0, black underneath; median 2.8% of pixels, p90 68%, up to 98.7%) and sit in 56 documents. Every one of the 93 pages involved carries a clip (91 a path clip, 2 a text-clip render mode); the one dissected (a 600 x 1000 photograph on a page with text render mode 7) rendered 356 opaque pixels of 150,000. The rendered bitmap applies the object's clip, and the clip is not an image mask. R5 as written (pixel alpha, any source) keeps these images, which is the safe direction; the images with alpha off the ring carry 71.6 MB (4.3%) of the private corpus's savings and 3.0 MB (5.1%) of the public's. Today's loop encodes such an image's RGB with the alpha thrown away (transparent pixels are black); what that does to the page was not looked at here.

What leaving bilevel alone gives up: private 339 bilevel images of 5,774 replaced today, 5.11 MB of 1,647 MB (0.3%), and in 38 of 1,485 documents the bilevel images are the only ones replaced; public 187 images, 20.7 MB (35.0%), in no document alone. The private "scanned letters" are mostly not bilevel: the grey bucket (DCT grey 1,302 images, 496 MB; Flate grey 400, 30 MB) is where scans live.

D7, "never larger", measured at the file. A plain megapdf_save rewrite of the unshrunk document is already as large as or larger than the original in 1,246 of 4,084 private documents (30.5%) and 586 of 1,482 public (39.5%); public median change among rewritten documents +29%. After shrink, 1,021 private documents (231 with at least one image replaced) and 588 public (66 replaced) are not smaller than the original; in the private corpus 87 of the 231 replaced-and-larger documents have a rewrite that is smaller than the original, so shrink itself made them bigger. For those 87, 70 (80%) draw one image object more than once, against 20 of 150 (13%) of documents that shrank normally. That is consistent with megapdf_shrink_images replacing each placement separately, so a shared image becomes N JPEGs; it was shown by association, not by inspecting the output. The whole private corpus goes 3,240 MB -> 1,827 MB; the public corpus goes 977 MB -> 1,007 MB (up, from rewrite growth on small fixtures).

The two timed-out documents, alone at a 3,000 s bound. The 1.6 GB one is a 1,710-page JPEG 2000 scan (the export #696 measured). It finished in 2,727 s with the other workload on the machine (one core, 1.4 GB peak): every one of its 1,710 images was replaced, the file went from 1,566 MB to 590 MB (62% smaller, a plain rewrite alone is 1,581 MB), and megapdf_shrink_images by itself took 2,391 s, about 40 minutes, with no progress callback or way to stop it. The 2.1 GB one was stopped by the run's owner after about 14 minutes, not at the bound, so it has no number.

Not done here, by design: the after-run (needs Phase 2), per-image timing on the phones, and anything on Windows or iOS encoders. Rerun with tools/shrink-survey/run.sh <megapdf_shrink_survey> <corpus> <out> --jobs 6 then summarise.py <out>/survey.jsonl --md; build cmake -S core -B build -DMEGAPDF_SHRINK_SURVEY=ON.

--window-tabs-check exits non-zero when an assertion fails (#711), 2026-10-22

The 2026-10-21 note on #711 said one --window-tabs-check run printed [FAIL] lines and exited 0. Measured on the Mac mini: it can. Avalonia's default ShutdownMode is OnLastWindowClose, and the check closes windows itself (Close Window, the closing step of the run). When an earlier assertion failed, the check's own close steps took the wrong path and closed the last window, so the process ended with exit 0 before Finish ran: [FAIL] lines, no ::error::window-tabs-check: N check(s) failed line. The fix sets ShutdownMode.OnExplicitShutdown in the --window-tabs-check block of App.axaml.cs, so only desktop.Shutdown(code) ends the process.

All runs: Mac mini, scratch clone of origin/main (a3111b4) with the one-file change below as stated, built with tools/build-macos-app.sh osx-arm64, output redirected to a file (no pipe).

change files given exit what the run printed
none fixture.pdf forms.pdf (two) 1 [FAIL] three PDFs were given, ::error::...1 check(s) failed
none, plus a deliberate Check(..., false) before the final foreach (scratch only) fixture.pdf forms.pdf demo.pdf 0 two [FAIL] lines (B has no editable text at the click point in forms.pdf), the run stopped after "undo after the round trip", no summary line, the deliberate failure never ran
none, the same deliberate failure demo.pdf fixture.pdf forms.pdf 1 all 30 usual lines, the deliberate [FAIL], 1 check(s) failed
OnExplicitShutdown fixture.pdf forms.pdf 1 same as the first row
OnExplicitShutdown, deliberate failure fixture.pdf forms.pdf demo.pdf 1 six [FAIL] lines including the deliberate one, 6 check(s) failed (the run now reaches Finish)
OnExplicitShutdown, deliberate failure demo.pdf fixture.pdf forms.pdf 1 the deliberate [FAIL], 1 check(s) failed
OnExplicitShutdown, no deliberate failure demo.pdf fixture.pdf forms.pdf 0 30 [PASS], window-tabs-check: PASS, no hang, pgrep MegaPDF empty

The argument order matters: the check clicks body text at (78, 186) on its second file and edits there, so fixture.pdf forms.pdf demo.pdf (the order #711's remaining-work list gives) fails two checks on its own; demo.pdf fixture.pdf forms.pdf is the order that passes on a clean build.

Wave 2: real-input, VoiceOver and hosted-CI runs (#710, #711, #614), 2026-10-22

One day's verification of select-and-copy (#710), tab tear-out (#711) and the standing iOS runner (#614) on the real machines: GPD-DAVE, the Mac mini, WSLg, the kdocker2/3 builders and the hosted macOS runners. Each row is one run; the issues hold the per-step detail. What the day found and fixed: the Windows word double-click (#774) and text-box double-click (#775) never received a DoubleTapped (the two taps hit different elements), fixed in #777; VoiceOver never spoke the app's live-region announcements on the Mac (#778), fixed in #779 by posting AppKit's own announcement request; --window-tabs-check could exit 0 after [FAIL] (#767). Still open and unmilestoned: #770, #771, #773, #776.

#710 desktop items 1 and 2: the real routes and the real double-click (before the fixes)

date machine build what result
2026-10-22 GPD-DAVE (2560x1600, 150 %), elevated launch, person's launch (MEGAPDF_USER_DATA scratch) a3111b4, Release win-x64 unpackaged #710 desktop item 1: real input (mouse_event with real move events + SendKeys) on selection.pdf and owner-only.pdf: drag + Ctrl+C, right-click then mouse-click Copy (TextCopyItem by UI Automation), Ctrl+A + Ctrl+C, no-copy document all PASS: clipboard equals the expected text with \r\n (6-line drag; 16-line whole document); owner-only.pdf: clipboard unchanged, notice shown, Copy disabled with reason. Note: a drag whose moves are SetCursorPos warps (the shape of Drag in tools/windows-qa/common.ps1) starts no selection; use MOUSEEVENTF_MOVE|ABSOLUTE
2026-10-22 GPD-DAVE, same a3111b4 #710 desktop item 2: real double-click on "charlie" and "bravo" (gaps 20-350 ms), single click on a body line, added text box double-click FAIL: the word double-click opens the line editor and selects nothing at every gap (#774); single click opens the editor (PASS); first double-click on an unselected added text box only selects it, the second opens the editor (#775)
2026-10-22 Mac mini (macOS 26, 2560x1440 @1x), person's launch (MEGAPDF_USER_DATA scratch), fresh clone ~/mp-710 a3111b4, tools/build-macos-app.sh osx-arm64 #710 desktop item 1: cliclick drag, Cmd+C, Edit > Copy and Cmd+A, Cmd+C through System Events, right-click > Copy, no-copy document all PASS: pbpaste equals the expected text with \n (104-byte drag, 257-byte whole document); owner-only.pdf: Edit > Copy disabled, sentinel unchanged by Cmd+C and the menu, popup shows Copy greyed + reason. Note: cliclick kp:return and kp:esc did not reach the app; System Events key code 36 / 53 did
2026-10-22 Mac mini, same a3111b4 #710 desktop item 2: cliclick dc: on "charlie", single click press/release, added text box double-click (also CGEvents at 120 and 300 ms) word double-click PASS (charlie, no editor); single click opens on release (PASS); added text box: first double-click only selects, second opens the editor (#775)
2026-10-22 GPD-DAVE WSLg, pointer driven from the Windows side, text read from the Windows clipboard a3111b4, MegaPDF-linux-x64 from ci.yml run 39792746643 #710 desktop items 1 and 2: drag + Ctrl+C, right-click > Copy, Ctrl+A + Ctrl+C, no-copy document, double-click, single click, added text box PASS: the three copies (the clipboard mirror carried the text with \n), owner-only.pdf Ctrl+C unchanged with the notice in the status bar, word double-click (charlie), single click opens on release. NOT SHOWN: the right-click menu on owner-only.pdf (#776, app or WSLg not told apart). FAIL: added text box first double-click only selects (#775)

#710 desktop items 3, 4 and 5: announcement, auto-scroll while held, Mac highlight colour

date machine build what result
2026-10-22 GPD-DAVE (2560x1600, 150 %), person's launch (MEGAPDF_USER_DATA scratch), Windows UI lock held e709ac7, Release win-x64 unpackaged #710 desktop item 3 (Windows, listener half): real input (mouse_event with real move events + SendKeys) on selection.pdf while a UI Automation notification listener (IUIAutomation5.AddNotificationEventHandler, desktop root, subtree; a throwaway C# tool) logged UIA_NotificationEventId. Narrator NOT run (Dave's) the app raises, kind 2 / processing 1 / activity page-focus: Selected: Left two bravo and more Left three charlie Left four delta Left five echo Right… after the drag, Copied after Ctrl+C, Selected: Selection Columns Left one alpha Left two bravo and more Left three charlie Left… after Ctrl+A. Word double-click: no notification (#774 opened the line editor). Proves what the app raises, not what Narrator speaks
2026-10-22 Mac mini (macOS 26.7.1, 2560x1440 @1x), person's launch, VoiceOver on with the caption panel (SCREnableCaptionPanel), mini lock held e709ac7, tools/build-macos-app.sh osx-arm64 #710 desktop item 3 (Mac): real drag, double-click, Cmd+A, Cmd+C; caption panel frames captured around each; the app's accessibility tree read after each FAIL (#778): the tree holds Selected: ... / Selected: charlie / Copied, VoiceOver speaks none of them (panel showed only Select All, Copy, Left three charlie, text); the older reading-mode live region is silent the same way; libAvaloniaNative.dylib has no NSAccessibilityPostNotification symbol. VoiceOver prefs removed afterwards; osascript to VoiceOver raised a TCC prompt, answered Don't Allow
2026-10-22 GPD-DAVE, same e709ac7 #710 desktop item 4 (Windows): window 1278x889 (not maximised), press on page 1, drag to 40 px below the window, still for 2.6 s with CopyFromScreen shots only, release, Ctrl+C PASS: pages scrolled during the hold to "Page 2 of 2"; clipboard ran through the last page 2 line (14 lines, \r\n); the listener's Selected: notification came 4 ms after the release
2026-10-22 Mac mini, same e709ac7 #710 desktop item 4 (Mac): window 1200x540 points (not maximised), cliclick dd:, 12 dm:, w:2500, du:; shots mid-hold; Cmd+C PASS: scrolled to "Page 2 of 2" during the hold, page 2 text highlighted mid-hold, clipboard 14 lines with \n
2026-10-22 GPD-DAVE WSLg, pointer from the Windows side, text read from the Windows clipboard e709ac7, MegaPDF-linux-x64 from ci.yml run 39832937133 #710 desktop item 4 (Linux): the window as it opened (fills the screen), press on page 1, drag to 40 px below the page area (inside the status bar), still 2.6 s, release, Ctrl+C PASS: scrolled to "Page 2 of 2", page 2 highlighted mid-hold, clipboard 14 lines with \n. A resize of the WSLg window from outside left its content clipped (not a product measurement)
2026-10-22 Mac mini, same e709ac7 #710 desktop item 5: defaults write -g AppleHighlightColor "1.000000 0.874510 0.701961 Orange", launch, real drag, screencapture, sample the pixels between glyphs; delete the default, relaunch, repeat PASS: orange (254, 241, 229) with the override, accent blue (226, 240, 255) without; defaults read absent before, after the delete, and at the end

#710 item 2 after the fixes (#774, #775; PR #777)

date machine build what result
2026-10-22 GPD-DAVE (150 %), Windows UI lock 857ae61 (PR #777) Windows --screenshot-state select-copy and the states that touch DocumentView text/selection select-copy 64/64 (was 40; +24 for the second-click decision and the text box); whiteout-text 28/28, fill 17/17, form-flow 29/29, redact 19/19, reading exit 0. Fail-first: with the fix switched off select-copy is 56 passed, 8 failed (the new word and text-box checks). Hosted Windows UI self-test green on the same head
2026-10-22 kdocker3, megapdf:dotnet container under Xvfb 857ae61 Linux --self-test (whole), Avalonia headless CheckTextSelection with real click-count-2 double-clicks, Core tests 936 PASS, exit 0; Core tests 574/574. Fail-first: without TryEditTextBoxAt the box's "second click opens the editor" and "with its own text" checks fail, everything else passes
2026-10-22 Mac mini (macOS 26), fresh clone 857ae61 macOS --self-test 925 PASS, exit 0
2026-10-22 GPD-DAVE (win-x64), mini (cliclick), WSLg (CI MegaPDF-linux-x64), person's launches 857ae61 #710 desktop item 2 re-verified with real input: word double-click copy, text-box double-click, single click PASS on all three (clipboard charlie, no editor; text box editor opens on the first double-click; single click opens the line editor). Before (a3111b4/e709ac7): Windows FAIL (#774), all three FAIL on the box (#775)

#778: Mac VoiceOver speaks the announcements (PR #779)

date machine build what result
2026-10-22 Mac mini (macOS 26.7.1, arm64), VoiceOver caption panel origin/main 186d958 drag, double-click, ⌘A, ⌘C, ⇧⌘R with real cliclick input fail-first: no Selected:, Copied or Reading mode on caption; only VoiceOver's own focus and menu speech
2026-10-22 Mac mini, VoiceOver PR #779 (6a712ce's Announce path) the same five actions Selected: Left two bravo…, Selected: charlie, Selected: Selection Columns…, Copied, Reading mode on / off; ⌘A+⌘C 60 ms apart ends on Copied; 4 of 5 double-clicks announced
2026-10-22 Mac mini 6a712ce --self-test 934 PASS, 0 FAIL, exit 0
2026-10-22 kdocker3 6a712ce Core tests; Linux --self-test under Xvfb 574/0; 944 PASS, 0 FAIL
2026-10-22 kdocker3 6a712ce with the NativeAnnouncer?.Invoke line commented out Linux --self-test 6 FAIL (the #778 hand-over checks), exit 1

#710 iOS: adjustable handles, Command-C on the iPad, no-copy path (PR #772)

date machine build what result
2026-10-22 Mac mini, iPhone 17 Pro + iPad Pro 13" simulators, iOS 26.5, build 32ac588 #710 iOS: SelectCopyUITests (VoiceOver-adjustable handle step, Command-C on iPad, no-copy path); FormTextUITests, BodyTextEditUITests regression; unit suite 366 iPhone 4 pass 1 skip, iPad 5/5; regression 7/7 + 3/3; unit 366/0; fail-first mutations each red

#711 items 2 and 4: Windows at other scales, Linux under WSLg

date machine build what result
2026-10-22 GPD-DAVE (2560x1600; scale 150 %, 100 %, restored to 200 %), elevated launches a3111b4 (origin/main), Release win-x64 unpackaged #711 item 2: --screenshot-state tab-move fixture.pdf exit 0, 70 PASS / 0 FAIL at 150 % and 100 % (70/0 at 200 %, exit not captured); closes by itself passed on all three
2026-10-22 GPD-DAVE, same a3111b4 #711 item 2: real SendInput drags (tear-out 250 px with an unsaved edit, drop onto another strip, Escape mid-drag, Ctrl+Z in the new window, click on a line in the moved document) at 150 % and 100 % (and 200 % baseline) all as designed: edit, undo and tab moved with it; Escape cancelled with no stray window; editor opened on the clicked line (not one off); pages sharp (by eye). Item ticked
2026-10-22 GPD-DAVE WSLg (Weston/XWayland, 200 %) a3111b4, MegaPDF-linux-x64 from ci.yml run 39792746643 #711 item 4: real Windows-pointer drag 200 px down with an unsaved edit; Menu key / right click then Move Tab to New Window; Ctrl+Z new window made with the edit; Undone. after Ctrl+Z; but window placed at X (10,10) not at the release point, Shift+F10 did not open the menu (Menu key did), WSLg repaint/scale broke after the new window (first window unresponsive); item left open, #773

#711 items 3 and 6: a real tab drag and VoiceOver on the Mac

date machine build what result
2026-10-22 Mac mini, scratch clone ~/mp-711 (removed) origin/main a3111b4 + #767 one-liner item 8, --window-tabs-check, no change, design order, deliberate failure exit 0 with [FAIL] lines and no summary; fixed by #767 (exit 1)
2026-10-22 Mac mini same item 3, real cliclick drag: Merge All Windows, unsaved edit, tab dragged 250 pt out, ⌘Z in the new window new window holds the dirty edit, undo works, both windows draw (393 to 5,190 distinct colours per region), AppKit's preview is window-shaped
2026-10-22 Mac mini same, "Prefer tabs: Always" set and deleted item 3 step 5: tab dragged onto another window's tab bar (windows not overlapping) joined: right window 3 tabs, left 2; three earlier tries onto a covered tab bar did not merge
2026-10-22 Mac mini same item 3 step 6: ⌘W Don't Save, ⌘Q with a dirty tab prompt each time, Don't Save closes, app quits on the last window or on ⌘Q
2026-10-22 Mac mini same item 3, defects found #770 (one 95 % CPU wedge in the tab drag loop, not reproduced in two repeats), #771 (Window menu lacks the tab commands after a fresh launch)
2026-10-22 Mac mini, VoiceOver (caption panel) same item 6: Move Tab to New Window via System Events no new-window caption (Finder, same invocation, equally silent)
2026-10-22 Mac mini, VoiceOver same item 6: Move Tab to New Window with real clicks MegaPDF, has new, window
2026-10-22 Mac mini, VoiceOver same item 6: real drag of a tab out Now in, forms.pdf — MegaPDF, window, Open, button, group

#614: hosted iOS CI beside the standing runner

See tools/mac-mini.md, "The standing runner against hosted". Three dispatched hosted runs (runner=hosted): 39818409951 green in 55 min 51 s, 39845022875 red in 53 min 33 s on one iPad reading-mode UI test, 39868868578 green in 47 min 59 s. The mini's median job is 15 min 46 s with a median queue of 15 minutes.

Cross-block wrap hyphens (#781), 2026-10-23

PR #783, branch wip/781-hyphen-join. A wrap hyphen at the end of a block whose continuation is the first word of the NEXT block (the paragraph builder cut the block at that line) was emitted as two words; the fidelity reference joins them, so each costs 3 unmatched words. The fix moves that one word into the prefix's block with the hyphen stripped (JoinHyphenAcrossBlocks, core/megapdf_structure.cpp). Fix 2 of the plan (per-leaf median pitch) was not needed and was not built; fix 3 (class (b), interleaved columns) was out of scope.

Run on kdocker3, main at 4f7f4a5 and the branch built side by side in throwaway megapdf:base containers (the pinned PDFium, megapdf-cli 2.3.0), the three batteries over all five corpora, corpora read-only, then removed. Disk counts 4,158 / 1,493 / 134 / 90 / 28; every battery visited every document. Zero crashes, zero hangs, zero bad CLI exits in every cell, baseline and branch.

corpus 2.3.0 gate main here branch unmatched, main to branch documents better / worse
vendor 0.997394 (first run) 0.997394 0.998199 912 to 630 6 / 0
private 0.998997 0.999004 0.999069 10,804 to 10,105 74 / 0
Canadian 0.999932 0.999932 0.999938 33 to 30 1 / 0
UN 0.999136 0.999801 0.999801 351 to 351 0 / 0
public 0.976157 (2 hangs) 0.980576 0.981815 621,389 to 581,492 46 / 0

Internal API and megapdf-cli agree to the last digit in every cell. Why main differs from the 2.3.0 column: #496 (UN segmentation, d3f3093 and eccb737) landed after 2.3.0, and #704 removed the two public hangs; the branch is compared with main on the same machine, not with the 2.3.0 digits. Gated tau medians (tagged pages) are identical to three places: private 0.951, public 0.978, Canadian 0.994, UN 0.967, vendor 0.986. The informational poppler-agreement tau median moved on public only, 0.989 to 0.988 (private 0.992, vendor 0.995, Canadian 0.996, UN 0.998 unchanged): of 22,942 public pages 3,496 went down and 336 went up, none by 0.05 or more, all inside the 46 documents that changed. The likely mechanism, not measured, is a joined word that no longer pairs with the two halves pdftotext -layout never joins.

Markdown is identical to the baseline in every cell (private 2,982 extracted / 1,102 textless / 12 password-gated, public 937 / 543 / 5, Canadian 134, UN 83 / 7, vendor 26; 0 cmark failures and 0 cmark timeouts). Pages: 0 qpdf failures, 0 count mismatches, 0 write failures and 0 crashes or hangs in every cell; public's 9 "REFUSED, other" are the known #445, as in the 2.3.0 gate; the private and public summaries differ from the baseline's only in elapsed seconds.

What changed in the output. Documents whose dumped text changed: vendor 6, private 74, Canadian 1, public 46, UN 0. Joins counted by diffing the two dumps line by line (counts only; no document text was read into any record): vendor 86, private 233, Canadian 1, public 11,402 (the public count is dominated by a few very large documents; the diff script could not align 4 of them, so treat it as a lower bound). No document got worse in unmatched words.

The first version did harm and was changed. Without the "drawn next" condition (the continuation must be the character PDFium drew right after the hyphen) the join took the first lowercase word of whatever block came next in reading order. Private: F1 0.999057, 74 documents better and 30 worse (a wrong word glued on, +1 unmatched word each) while the aggregate still rose. Vendor 0.998124 with 135 joins against 86 now. With the condition: 0 worse everywhere. A second version joined a soft hyphen before any character; one private document of digits with soft hyphens lost a match, so soft hyphens now need the lowercase test too.

Compound words. A wrap-flagged hyphen in a real compound ("well-" then "known") is joined, as BuildPieces already joins it inside a block and as the reference joins it; no more conservative rule exists without leaving the reference. Shape on private, 233 joins: 98 have the joined word standing unhyphenated elsewhere in the same document, 46 have the hyphenated form elsewhere (a probable compound split at a line end), 89 have neither. A literal U+002D that PDFium did not flag is not joined across blocks (the in-block join does join it). A continuation that starts with an accented letter is not joined (#728/#756: unchanged, neither fixed nor worsened).

Tests. test_structure_cross_block_hyphen, five checks (join; a block that was only the continuation is absorbed; uppercase continuation left alone; a lowercase block that is not drawn next left alone; a mid-line compound hyphen untouched). The first two fail on unmodified main, verified by building the test file against the main engine. No golden changed.

Not verified. Windows, macOS, iOS, Android and the .NET apps beyond what hosted CI says; the tagged path (#358), the furniture and leftover builders, which this does not touch; the redaction battery; rendered Markdown beyond cmark parsing it. The pages battery cannot reach this change (it edits pages, not text) and was run for the record only, with --jobs 4 instead of the sequential default to fit the session. Scratch (dumps of every document's text) lived under ~/megapdf-781fix-* on kdocker3 and was deleted.

Reporting

For each issue: what you clicked, what you expected, what happened, and the PDF (or a screenshot) if you can share it. "This felt confusing" is a valid report.