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.
- Windows 11 (or Windows 10 1809+), x64 or ARM64 PC.
- Nothing else — the package is self-contained.
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.
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.
- 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.
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-clientinstalled; without it the status line says so. - Signatures you type are drawn in whatever script face the machine has.
fonts-urw-base35supplies 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).
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.
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.
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.
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."
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 pageextractbecause their fields sit in a/Parenthierarchy — 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.
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.
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.
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.
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.
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/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:connectedDebugAndroidTestA 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.
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.
- 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 oldq 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_extractdeflates 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. - Undo is offered a moment before it will be obeyed.
ViewerViewModel.undogoes throughlaunchEdit, which drops the request silently whileeditingBlocked(editsInFlight > 0) — by design (#145: a tap while one runs is ignored, never queued). The button greys out ontoolsDisabledinstead, which deliberately leaveseditsInFlightout so quick page work does not make the toolbar flicker. An operation's effect lands insidelaunchEdit's block and itseditsInFlight--in thefinallyafter 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 throughclickUndo()/clickRedo(), which wait oneditingBlocked— 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 coverspageRewriteDeciding, and a test that asks the view model rather than the screen is the right habit regardless.) - A one-shot status cannot be asserted on.
ViewerViewModel.statusMessageused 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 onstatusSerialinstead.
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.
#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 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.
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.
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-signeddocuments 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:
BuildLinesscales its split threshold off the rawTfoperand, 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.
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).
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 3uk-hmrcrows 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).
- 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%%EOFin the last 4 KiB; sizes 4,194,304 and 19,710 bytes), andmegapdf-cli extracton 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. - 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. - Public markdown extracted 935 against 937: the same two documents, same cause.
- 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 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.
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.
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 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.
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-searchpasses 16 of 16; CoreScanSearchtests pass 24 of 24; Android unit tests andScanRecognitionTestpass on the phone. - iOS
ScanSearchTestspass 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.
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.
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.
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.
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.
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.
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).
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.)
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.
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.
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.
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.
| 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) |
| 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 |
| 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) |
| 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 |
| 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 |
| 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 |
| 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 |
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.
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.
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.