The bulk of this release is an audit of contributions merged since v0.3.77,
plus what a four-engine reference panel found when the result was measured
against it. Two of the fixes are security-relevant: AES-256 encryption wrote
files nothing could decrypt, and an incremental update dropped /Encrypt
from the trailer while appending its objects in plaintext. Five more were
aborts on valid files, which panic = "abort" turns into a dead host
process rather than a catchable error.
Added
- Structured diagnostics are readable from every binding built on the C ABI, and from WASM — extraction records what it could not do (an unreadable font, a page with no text layer, a dropped glyph) as
Warningvalues carrying a category, a page number and a message. Until now nothing outside Rust could see them: there was no C entry point, so all eighteen bindings were blind to every diagnostic the library produced.pdf_document_structured_warningsandpdf_document_take_structured_warningsreturn them as JSON, andstructuredWarnings/takeStructuredWarningsreturn them as objects from WASM. The two accessors differ in whether they drain: reading is non-destructive, taking clears (#1190). - A memory budget for rendering, so a page cannot demand more than the host has —
RenderOptions::max_output_pixels(defaultDEFAULT_MAX_OUTPUT_PIXELS, 16 megapixels) bounds the output raster, and over budget the scale is reduced to fit rather than the render failing: a caller who asked for an image of a huge page wants the image. Nothing in a PDF boundspage_box × scale— Table 30 defines/MediaBoxwith no ceiling — and Annex C.1 puts staying inside available memory on the reader, so the bound has to be the reader's. Because the budget also sets the footprint an image is decoded for, it bounds the decode and not merely the buffer: on the pathological page below, peak memory falls to 799 MB at 4 megapixels. Lower it on mobile and WASM, raise it for print; 16 megapixels clears a 4K display and A4 at 300 dpi, and no page in a 2007-document corpus reaches half of it (#1244). - A
page_bbox()accessor on text elements, reachable from the C ABI and the bindings built on it — for a run drawn under a rotated text matrix it reports the run's rectangle mapped into the page's displayed frame. Two limits are worth stating plainly, because the accessor is narrower than it sounds. It is the identity when the run's ownrotation_degreesis zero, so a landscape page stored portrait with/Rotate 90and ordinary horizontal text — the commonest rotated-page shape — still reports pre-/Rotatespace. And WASM cannot reach it: it is a method rather than a serialized field, and the two fields it derives from are#[serde(skip)]. Internal consumers such asextract_text_in_rectstill select on the uncorrected rectangle, so feeding a reported rect back does not yet round-trip on a rotated page (#1056, #1057).
Changed
- Two dependencies that nothing imported were removed, taking 43 packages out of the tree —
imageprocandtokenizerswere declared optional, wired into theocrandmlfeatures, and referenced by zero lines of Rust anywhere in the workspace. Both also sat on thecargo-shearignore list, which is why the unused-dependency gate never reported them: that list exists for crates used behind adep:feature gate (cms,rsa,x509-parsergenuinely are), and these two had outlived it. Removing them drops 43 packages including theesaxx-rs/onig-sys/spm_precompiledC/C++ chain,nalgebra, andab_glyph. Both are also gone from the ignore list, so the gate can police them from now on.ocrandmlbuild unchanged; there is no behavioural difference, because nothing was calling them. ttf-parseris now reached only by our own font parsing — withimageprocgone, theimageproc→ab_glyph→owned_ttf_parser→ttf-parserchain leaves the tree, andfontdb0.24 had already dropped its own dependency on it. That leaves no third-party consumer at all, so the planned migration ontoskrifa/read-fonts(both already present viasubsetterandharfrust) no longer has to wait on anyone else.- Every dependency across every language was refreshed against its registry, and two of the bumps needed code — the audit queried crates.io, npm, PyPI, NuGet, RubyGems, Maven Central, Clojars, Hex, pub.dev, Packagist, CRAN and the GitHub API for the current release of every declared dependency in all eighteen bindings, then applied what was safe to apply. Two Rust upgrades were breaking at the source level rather than merely at the version number:
taffy0.14 retypedmin_size/max_sizeasLengthPercentageAuto(onlysizeandflex_basisstill takeDimension), andquick-xml0.42 moved element names and attribute values from bytes tostrand madexml11_content()return theCowdirectly instead of aResult. The remaining Rust bumps —brotli9,office_oxide0.1.9,ort2.0.0-rc.13,regex1.13,uuid1.26,smallvec1.16,bytes1.12,crc32fast1.5 — are source-compatible. Four were held back deliberately, each for a reason the version number does not show:tractstays on 0.22 because 0.23 pullsdyn-eq(MPL-2.0) into the graph, which the licence policy rejects, and raises the ML features' rustc floor to 1.91;aesstays on 0.9.2 because 0.9.3 raised its own floor above this crate's 1.88;simplecovstays on 0.22 because 1.x requires Ruby 3.2 while the gem supports 3.1; and the Dart package is already at the newest versions its declared SDK floor allows. Java moves to JUnit 5.14.4, AssertJ 3.27.7, SLF4J 2.0.19 and current Maven plugins; Kotlin to 2.4.10; Scala to the 3.3.8 LTS; Clojure to cljfmt 0.16.5 and tools.build 0.10.14; .NET to Test SDK 18.9.0 and xunit.runner.visualstudio 4.0.0; Ruby, PHP and Elixir to their current lines. - The action pins in CI said one version and ran another — thirty-odd
uses:lines pinned a SHA with a# vNcomment that had stopped matching what the SHA actually was:actions/cachewas commentedv4while running v6.1.0,actions/setup-dotnetv5while running v6.0.0,actions/github-scriptv7while running v9.0.0. Every pin now carries the exact tag its SHA resolves to, and fifteen actions advanced to their current release. Two were deliberately held back rather than advanced:gradle/actionsstays on the v5 line because v6 moves its caching component to a proprietary licence whose use requires accepting Gradle's commercial Terms of Use, andebitengine/puregostays on v0.10.2 because v0.11.0 requires Go 1.25, which would raise the Go binding's floor from 1.21. - Dependabot watched seven ecosystems out of the fifteen the repository actually has — Maven, Gradle, Composer, pub, Hex, Swift and the runnable examples' own manifests had no coverage at all, which is why the Java, Kotlin, Dart and PHP toolchains had drifted years behind while the Rust and GitHub Actions ones stayed current. All of them are now watched.
Security
- AES-256 encryption wrote a file nothing could decrypt, including this library — the write handler generated its own random file encryption key while
/UEwrapped a different one, so the key recovered on open was never the key the streams were encrypted with. A round-trip extracted"". Reproduced by execution before being fixed; AES-128, RC4 and the wrong-password path are pinned as controls, so a future change cannot fix one arm by breaking another (#1160). - An incremental update dropped
/Encryptand/IDfrom the trailer and appended its objects unencrypted — §7.5.6 requires an incremental update to carry the original trailer's entries forward, and §7.6.1 makes/Encryptthe document's declaration that its strings and streams are encrypted. Dropping it while appending plaintext objects produced a file whose new content was readable to anyone and whose old content no reader could decrypt (#1161).
Fixed
-
A zero-length or truncated compressed stream failed to decode, dropping content the file does depend on — flate2 1.1.10 rejects a deflate stream that stops without a final block marker, which zlib and every earlier flate2 returned as success. Two shapes of stream in the wild hit this. A truncated cross-reference stream (51 compressed bytes decoding to 70) made xref parsing fail, so the reader fell back to reconstruction, rebuilt the page tree from the wrong objects and assembled page 1 without its header. A zero-length stream — what a zero-area transparency group is written as, and which the partial-recovery path could not rescue because all three of its strategies require a non-empty buffer — failed as an empty form XObject, aborting the parent content stream part-way through and losing every mark painted after it, including a figure's only colour. Both now decode: a truncated stream yields the prefix it decoded, and an empty stream yields no bytes.
-
GitHub source archives of the repository contained only the PHP binding —
.gitattributescarriedexport-ignorerules for every other top-level directory, added in v0.3.56 and widened in v0.3.77 to slim the Packagist dist.git archivehonours them, and so do the release "Source code" assets, "Download ZIP" and codeload tarballs: the v0.3.77 source tarball had 79 entries and nosrc/, and OpenSSF Scorecard, which reads the repository through that tarball, reported no security policy, no fuzzing and no workflows. The rules are removed; slimming the Composer package moves to a subtree split ofphp/(#1347). -
Text inside a form XObject whose
/Resourcesis an indirect reference was painted with a fallback font as Latin-1 garbage — §7.3.10 lets any object value be written as a reference and Table 79 keeps a form's resources in its own dictionary, but the renderer seeded its font and colour-space caches only from a direct dictionary, so the form's fonts were never loaded. The reference is now resolved before seeding; images in the same form were never affected, andextract_textwas already correct, which is what made the loss silent. Reported by @metheglin (#1309). -
An exponential (Type 2) shading function was evaluated at its input's position within
/Domain, not at the input — Table 40 (docs/spec/pdf.md:7068) givesyⱼ = C0ⱼ + xᴺ × (C1ⱼ − C0ⱼ)on the inputxitself, and/Domainonly clips it. The shading resolver computed(x − d0) / (d1 − d0)first, which is invisible while a function's domain is[0 1]and wrong for any other: a stitching sub-function declaring/Domain [-2 5]and fed[0 1]by its/Encodewas evaluated at(x + 2) / 7, so the ramp's first stop came out two sevenths of the way toward the next colour ((180, 75, 0)where the file'sC0is red). Exposed by this release's move from readingC0/C1to evaluating the function; caught by the resolution-pipeline probes once the rendering tier ran them. -
A booktabs table's first row-group was emitted as prose while the groups beside it read as tables — the intersection grid is built from closed cells. On a results table with full-width rules between its row-groups, hairlines between a few columns and a shaded last row per group, the shaded rows' rectangles close their cells and yield a grid for two groups; the third group's unshaded rows are bounded by rules and crossed by the hairlines, but the shaded row's top edge stops at the hairline instead of crossing it, so none of those rows has a closed cell. The grid's slice of that group is its one shaded row, which the section-divider split isolates and the validity filter drops, and the group's rows fall to the prose flow as bold fragments. The horizontal-rules detector reads exactly such bands but only ran when the grid found nothing; it now runs as well when the grid did, from the rule lines only (a shaded row's rectangle contributes an edge that would split the band), keeping each band no grid table already spans — measured against the band's own height, since the grid's one-row slice overlaps the band it was cut from — and consolidating the survivors afterwards, because consolidating first fused every band into one fragment that overlapped a grid table and was thrown away with the missing group. §14.8.4.3.4 makes a row the element that holds its cells, boxed or not. A band is read only where a grid table stands within a couple of rows of it and the band covers most of that table's width — a row-group of the same table runs its whole measure — which keeps a form's field labels and a model summary's three lines under a layers table (a 108 pt block beside a 534 pt table, whose rows would otherwise be emitted twice) as the prose they are; measured over the 2008-document battery, 43 documents gain table rows on rule-bounded groups with no word lost from plain text. The group's columns come from text-edge clustering — twelve where the grid groups have ten — which is the rules-only detector's known shape and a separate matter. The fixture had to make its shaded rectangles abut the hairlines exactly; overlapping them by a few points closes the cells and the grid reads all three groups (#1344).
-
A JPEG 2000 image in an
/Indexedspace lost its colour: the page came out neutral grey — §7.4.9 (docs/spec/pdf.md:3143) has the dictionary's/ColorSpacedecide how a/JPXDecodeimage's samples are read: "If present, it shall determine how the image samples are interpreted, and the colour space specifications in the JPEG2000 data shall be ignored." An/Indexedspace makes the codestream's single component a table index. The JP2 file on the page that exposed this carries a palette box of its own for those same indices, and the decoder was left to resolve it, so one declared component came back as three, and the first of them — the red channel — then stood in for the whole pixel. A page whose palette is sixteen entries, four of them distinctly blue, renderedR = G = Bat 246.45 where v0.3.77 read246.45 / 248.29 / 248.29and MuPDF, pdfium and poppler all show the sameR < G < Btint. A first attempt routed the decoded buffer through the palette expansion every other filter uses and made the page darker and still grey, because the buffer already held resolved RGB rather than indices. The decoder is now asked for the palette indices — its own palette is never consulted — and they come back unscaled, one byte each, so the dictionary's table is read at 8 bits per index whatever/BitsPerComponentsays the codestream packed them at; an index component deeper than 8 bits is refused, since §8.6.6.3 boundshivalat 255. The fixture is generated: a 4x2 4-bit codestream of indices0 1 2 3 / 3 2 1 0in a JP2 whose own palette is a grey ramp, against a dictionary palette of red, green, blue and white — the render must show those four colours in that order and no neutral pixel (#1334). -
A paragraph set in per-word runs was cut down an aligned word gap, and a hyphenated word lost its second half — the XY-cut finds a column corridor where the density profile falls under a fraction of its peak, which is right for a gutter beside a dense column: a stray stub or a folio in the gutter must not hide it. But where one side of a region is much denser than the other, a band holding a row or two of ordinary words scores as a valley too. On a magazine page whose justified paragraph is emitted as one run per word — the word gaps exceed the merge threshold — the listing above it and the footnotes below made the left mass dense, everything right of x≈165 fell under the threshold, and the "valley" ran seventy points wide with
is,noandfileinside it on the paragraph's own rows. Its centre landed in the word gap afterno, the full lines around it crossed the corridor, and once this release lowered the crossed-corridor guard's column-height bar the two halves (0.79 of the region) passed as columns:pro-was emitted with the listing's right-hand fragments, three lines fromceeds, andproceedsleft the page. What tells that corridor from a gutter is the rows it divides: every row with runs on both sides of the split held letters inside the corridor, where the page's real gutter, measured the same way, holds letters on two of sixteen such rows (a line end in the valley's fringe, a listing fragment) and a two-column paper's per-word title putstoandGeVin its gutter on one row of thirty. The cut is now refused when at least two of the divided rows, and at least half of them, hold letters inside the corridor that continue the row's own text — a word space or less after the run to their left; digits alone are not counted, so a folio or a verse number centred between columns stays furniture, and a chart's axis title beside a paragraph, three ems from its line end, does not count either. A row is divided only when both its sides carry letters, so a column of tick numerals is not the other half of a paragraph's lines. Two intermediate rules were measured on the 2008-document battery and rejected — one refused the real gutter through the column's ragged line ends, one refused every abstract set between a paper's header and its columns. §9.4.4 has a horizontal run occupy one unbroken interval on the writing axis, so a run of letters inside the corridor is proof there is text there and no column boundary on that row. Sourced by bisecting the release range; v0.3.77's own chopping of the page's letter-spaced listing is unchanged (#1340). -
A book's cropped-off margin numbers stayed in the text and broke the words beside them — a book set from a print master carries the proof's marginal line numbers in its content stream and crops them away:
MediaBox [0 0 480 678],CropBox [41.76 41.76 438.24 636.24], numbers at x≈456. Table 30 (docs/spec/pdf.md:5761) makes the CropBox "the region to which the contents of the page shall be clipped (cropped) when displayed or printed"; the renderer honoured it and text extraction clipped to the MediaBox only, so the numbers stayed on the page. Once the leaf sort banded on the baseline — itself correct — a number landed between a wrap hyphen and its line break,prove a theo- 18/rem, and the dehyphenation rule, which needs<lower>-\n<lower>, could not fire:theoremandCompliancefell out of the page's words. Text is now clipped to CropBox ∩ MediaBox, falling back to the MediaBox when the CropBox is absent, malformed or misses the medium, and a run straddling the crop edge is kept so bleed and trim marks are never lost (#1340). -
A two-column regulation page was read straight across the gutter where a table crossed it — since the table filter began judging by the public predicate, the page's full-measure resistance table is detected, correctly, and that detection reached the dispatch's tabular override:
is_multi_column_pageis true, a table exists, andmulticol_signal_is_tabularreports the multi-column signal as coming from the table alone. It cannot do otherwise — it keeps the minimum left edge of each row band, which on a two-column page is the left margin on every band, so the right column is never seen. The page was sorted row-aware and every wrapped word at the seam was cut:Warning devices must be config-/urednever rejoined. The column branches decline the page for a good reason (the table's rows and caption straddle the gutter, so there is no clean corridor), and what the dispatch keeps when it declines is the content stream's own order, which §14.8.2.3 (docs/spec/pdf.md:37221) asks writers to set "from column to column". The override is now withheld whenever the prose outside the tables starts at exactly two column positions; a single-column page carrying the same table keeps its row-aware order (#1340). -
A two-column journal page lost its gutter when a float crossed the channel — the gate that stops a data grid's inter-column gap being taken for a page gutter asks whether the content outside the tables is single-column, and answered by taking the minimum left edge in each Y band. A band crosses the whole page, so wherever both columns print on one line it records only the left margin; a balanced two-column page cannot produce two clusters under that measurement. On the page that exposed it, 42 of 55 bands shared one cluster, the classifier's gutter at x=303.48 was thrown away, and the columns were read straight across — splicing the left column between
Septem-andber. A separate predicate now walks each band left to right, opens a column start wherever the gap since the last ink exceeds 10 pt, and is true only when exactly two clusters carry a sixth of the starts each; on that page, 107 starts in two clusters of 42 and 37. The old predicate is left where it decides the row-aware sort, because rewriting it there moved eight documents in a sweep, at least three for the worse.September 1977returns whole and the document's long-word deficit against poppler falls 60 → 49, losing nothing. -
A numeric grid's own column gap was taken for a page gutter, and every row was cut at it — a grid has real empty corridors between its columns, and the fallback gutter detector sweeps the middle of the page for the widest one, so each row's right-hand cells surfaced as a column-run eleven lines below the label they belong to. The class gate does not stop this: a column of short numerals reads as a reference list and the labelled half as mixed content. The page's own signal was already computed one branch later — one dominant left-edge cluster outside the tables means single-column prose with a grid on it — and is now consulted before the corridor sweep as well. Only the fallback is gated;
prose_two_column_gutterkeeps deciding genuine two-column bodies, pinned by a two-column body with a table beside it. §14.8.4.3.4 (docs/spec/pdf.md:37805) makes a row the element that holds its cells, so a grid row is one reading unit. Sourced by a 254-revision bisect to the change that began keeping words the grid does not re-emit; that change stands — over the document the token multiset is identical with and without this fix, only grouping and order move. -
A contents page was emitted as a rail of bare section numbers followed by titles with no numbers — the guard that refuses a column cut through a table counts the runs on the left that blanket the right column, and counted a run as a row only when some span in the right partition shared its band. A data row satisfies that; a labelled row drawn as one run does not, and on a contents page the candidate split falls between the section number and the title so both land on the left and nothing on the right vouches for the row. The guard counted zero rows on a page made entirely of them:
10.3.1 300 Multiple Choices......became10.3.1and, sixty lines later,300 Multiple Choices....... A blanketing run is furniture only when it is alone on its row — nothing in the right partition shares its band and nothing on that band is printed to its left — so a full-measure title or caption is still furniture and a labelled row is counted again. §9.4.4 (docs/spec/pdf.md:17396) has a horizontal show-string occupy one unbroken interval on the writing axis, so a run reaching across a corridor is proof the corridor is not empty there. The document's numeric-only line count returns to v0.3.77's 2. -
Three full-measure lines were enough to refuse a column cut, and a share too low to refuse one at all — the veto that refuses a column cut through a table's rows counted the left rows whose ink blankets the right column and refused at three. A two-column page with a heading, a footnote and a caption has exactly three among a hundred and lost its columns for them, every line spliced to the one printed beside it. The count is now read as a share of the side, since a table's header and every data row run the whole measure; the absolute floor of three stays so a short region cannot veto on a fraction alone. The share was then set from both bounds the corpus gives rather than one: a quarter cleared the 3-of-100 page and cleared two real tables with it — a contents page whose shallower entries put 6 of 49 rows across the right column (0.122) came out as a rail of numbers, and a ruled table in a rotated frame lost its cells. A tenth sits between 0.03 and 0.122 and nothing observed falls in the gap. Measured in
.textlines against v0.3.77: a headers/footers page 122 → 65 → 147, an arXiv paper 1516 → 1431 → 1541. -
A column that ends early was refused as a column, and the page read straight across — the crossed-corridor guard added for the newspaper masthead above asked that the two sides of a cut end within a fifth of the region's height of each other. Columns of running text are only that coextensive when nothing interrupts them: a photograph, an advertisement or the end of a story stops one column above its neighbour, and the corpus pairs sit at 0.48, 0.746 and 0.79. Refusing there splices two unrelated sentences —
knitted together Aristotelian brother in The Fishermen could— and tears any word hyphenated at the seam (post-in-/dependence). The shapes the guard exists to refuse are far shorter than a column: a masthead's side covers 28% of its region, a page-number column is a band nested inside the titles beside it. The threshold is now what separates a column from a band — a column covers at least half the region it is a column of. And a single crossing run is left out of each side's height measurement: a banner bucketed by its left edge into one column had been stretching that column to 152 pt of a 159 pt region, scoring two identical 112 pt columns at 70% of each other and refusing the cut, after which the recursion peeled the banner off and read the first two rows row-major and the rest column-major. Two or more crossings keep the raw measurement, because there they are the page's content. The masthead page is unaffected: its nameplate side stays a ~78 pt band against a ~279 pt region. -
Three glyphs of a display equation were promoted as table stub labels and reordered the paragraph around them — the stub-label promotion looks for a sparse column of labels beside a dense column of data, clustering spans on their left edge. A mathematics page whose lines are cut into many runs by inline symbols offers three single glyphs of an equation as its sparse column; hoisting them to the head of their block put a trailing line ahead of the line it continues —
criterionThe ridge regression. Gating the promotion on a detected table was tried first and was too blunt: it also stopped the promotion on prose pages where it was ordering correctly (Department of Legal ...). What goes wrong is the shape of the candidates. §14.8.4.3.4 makes a stub cell carry the row's name — text, not a lone glyph — so most candidates must now be more than one character. Over the 2008-document corpus, words torn apart against v0.3.77 fall from 12 documents / 15 words to 11 / 13 with zero newly-damaged words on any surface; un-gating without the guard brings the fusion back, measured. -
Markdown and HTML spliced two-column pages that plain text read correctly —
reorder_two_column_proseis shared by all three surfaces "so every flow agrees on the reading order of a two-column body", but the converters asked only the geometric gutter detector, which demands a corridor wider than a dense journal page gives it, and never the classifier the text path falls back to. Over a multi-column corpus the text path took a column branch on 91 of 149 pages where the converters could take one on 5. Returningfalsealso told the pipeline the caller had no opinion, so a row-major order was re-derived and consecutive same-baseline spans were joined into one line:is a less toxic properties against several RNA viruses. §14.8.2.3.1 leaves an untagged page with no reading order, and the layout model in §14.8.3 reads a multi-column body one column at a time; the converters now run the same fallback the text path does. This is the largest single share of the converter-stage cost recorded under #1339 — the cost is the fix. -
Two footers stamped on top of each other were shuffled into one line — row membership was decided from vertical evidence alone, and a footer stamped 0.145 pt above an earlier one passes every vertical test, so both were assigned one row and ordered by left edge:
The Molecular Probes The Molecular Probes(R) Handbook: (TM) Handbook: A Guide to Fluorescent.... §9.4.4 advances the text position along the writing axis by each glyph's displacement, so a run occupies one unbroken interval there, and two runs whose intervals overlap by more than a quarter of the shorter (and two points) cannot both be reading matter on one line. The stamped footers overlap by 95%. The test is against every span already on a row, not only its seed, because the second footer collides with the first's continuation. A blank run is exempt — one drawn a fifth of a point under a heading belongs to that heading's row, which an existing test pins. Introduced when row keys began coming from an explicit assignment; before that the two footers landed in different bands by grid phase, so the correct output was luck. v0.3.77, MuPDF and pdfium all emit two lines. -
A blank rotated run stranded a table label on a line of its own — a run with no glyphs displaces nothing on any axis (§9.4.4), so its rotation records the text matrix that happened to be in force, not evidence about the page. A rotated watermark scatters blank runs across every row it crosses; two of them landed between a journal table's row label and its first cell, and the axis-change line break fired twice, once entering them and once leaving. An axis change now breaks a line only between two runs that both carry ink. Making blank cross-axis runs transparent altogether was tried and rejected: it let the page's rotated stamp join the body text after it (
Downloaded from ... 2019acquired<! f (2 %d/t %d/d), which is the second symptom reported alongside this one, made worse. -
A tagged form's callout labels were emitted fifteen lines late — a marked-content id is unique within its content stream, so the lookup that places tagged runs keys on
(scope, id), which is right where a page and a form both number from zero. A form may instead share one continuous numbering with the page that draws it, and a structure element may reference those ids as bare integers with no/Stm, which §14.7.4.3 resolves against the page; the glyphs carry the form's scope, the element asks for the page's, and the element emits nothing. The unreferenced-id tail then appends those runs after the whole structure-ordered page, so on a tax form bothTIPlabels were lifted out of the paragraphs they interrupt. A cross-scope match is now allowed only for a bare id that exactly one stream numbers; where a page and a form both number 0 the scoped key stands alone, and the collision fixture passes unaltered. Swept over 497 documents, exactly one file differs — the reporting one — and every hunk moves a label back between its paragraphs. -
A rotated page's table cells were emitted twice, once by the table and once as prose — a span is claimed by a table by measuring it against the runs a cell actually renders, and mapping a rotated page into the reading frame moved the table's box and each cell's box but left
TableCell::spansin page space. The two sides never met, so no cell claimed the runs it draws andAlpha,Beta,GammaandDeltaeach appeared twice on a page whose grid rendered correctly. The runs now travel with the cell through the same mapping the page's own spans get. -
A heading drawn twice for fake bold came out as doubled words — a page that fakes a bold face draws every glyph in a grey pass and a black pass a fraction of a point apart, and nothing deduplicated the pair:
SICHERHEITSSICHERHEITS CHECKLISTECHECKLISTE, with no correct copy anywhere. Dedup runs before adjacent spans are merged, so every span is a single glyph, and all three filters declined single glyphs; the geometric one compares only against the immediately preceding span, which cannot work when the row sort emits the whole first pass before the second begins. The content filter now accepts short runs, recording every position a string has been kept at (one slot seesSICHERHEITS's closingSoverwrite its openingS) and matching a short run on the per-glyph advance rather than five points — which is what separates an overprint from the twols of a doubled letter. Plain text is repaired too, fromSICHERHEITS--CHECKLISTEtoSICHERHEITS-CHECKLISTE. Positions tracked per string are bounded. Ablated for #1339 and measured 2.7% faster with the widening, not slower. -
A
Tmrepositioning jump was read as the previous glyph's advance, fusing two words — taking a glyph's width from the distance to the next origin is right while the two are consecutive; aTminside the text object puts the next origin an arbitrary distance away, and reading that as an advance made the glyph as wide as the jump, closing the gap the word clusterer splits on. A footer setting(page)at x=100 and(312)at x=140 becamepage312, and the rotation those words carry went with them. §9.4.4 makes the advance the glyph's own displacement, bounded by its design width: beyond one and a half em the distance is not an advance and the nominal width stands. Three rotated-text fixtures that were red on the branch are green again. -
Per-glyph advances were recorded per byte, not per character —
TextSpan::char_widthscarries one advance per character oftext, but was filled by asking how much the accumulatingStringhad grown, which answers in bytes. An em dash is one character and three bytes, so it contributed three entries of a third of its advance and every glyph after it carried a neighbour's width:AB—CDat 10 pt gave[6.67, 6.67, 1.83, 1.83, 1.83, 7.22, 7.22], seven entries for five characters, and now[6.67, 6.67, 5.50, 7.22, 7.22]. This is the drift an earlier fix recorded from the other side — "on a table-of-contents line containing an em dash the widths ran two entries out of step" — and fixed there by preferring measured offsets. §9.4.4 gives each glyph one displacement, and the array has exactly as many entries as the text has characters. The fixture includes an all-ASCII control that passes either way, because an ASCII-only fixture would not exercise the defect. -
A Standard-14 font's em dash and en dash advanced 550 units, and the run beside it acquired a space — the built-in width tables ran from code 32 to 126, so every glyph above printable ASCII fell to the generic 550/1000 em default. Helvetica advances an em dash by a full em, so a regulation caption's
TABLE 66.01–11(5)—Cat 8 pt ended 3.65 pt short of its own ink, the small-capitals run beside it appeared to sit across a gap the page does not have, and the space heuristic — which widens its bar at a font-size change — wrote the gap out:TABLE 66.01–11(5)—C OORDINATES OF CHROMATICITY. Five captions across three CFR volumes came apart this way and one lost its last word. Which code carries which glyph depends on the named encoding, and Annex D.2 lists both: StandardEncoding puts the em dash at 208 and the en dash at 177, WinAnsiEncoding at 151 and 150; the caption declares StandardEncoding, which is why covering only the WinAnsi block left it unchanged. MacRomanEncoding names different glyphs in that range and keeps the previous behaviour, as does any font with/Differencesor its own/Widths. §9.6.2.2 (docs/spec/pdf.md:17706) has the reader supply the metrics when a Standard-14 dictionary omits/Widths(#1345). -
Small capitals after a full-size initial were separated from it by a space — a regulatory table titles itself with a full-size
Cfollowed byOORDINATESin small capitals: two show operators in one font at two sizes on one baseline, the second beginning at the first's advance edge.small_caps_gluerecognised the shape and admitted the merge, but only intoshould_merge, whose branch then asks the space heuristic whether to separate the runs — and the heuristic widens its bar 30% at a font-size change, and answered yes, handed 8.977 against a 0.778 threshold for a measured gap of −0.002 pt. The single-character case had its own direct-concatenation branch; the multi-character case now has one too. §9.3.1 makes the font size a graphics-state parameter that may change between show operators, and nothing in §9.4 makes such a change a word boundary.COORDINATES,WHERE,APPROPRIATE,DEADLINESandMAXIMUMare whole again; a genuine word space still separates. -
A shading with a non-monotonic ramp rendered blank — §8.7.4.5.3 gives the colour at parametric distance t as the shading's function at t. Resolving only
/Domain's two ends and handing the rasteriser a two-stop gradient is faithful for a monotonic ramp and discards any other: one axial shading in the corpus ramps white to near-black and back to white across 5120 samples, both ends resolved to white, and the page rendered blank (mean RGB 255, coverage 0) where poppler and PyMuPDF paint it at 193.2 and 192.9. v0.3.77 had painted it as a near-black slab, so this release first turned a wrong answer into no answer. The resolver now samples the ramp at 33 points across/Domainand both the axial and radial backends build their gradient from that list; the page lands at 193.57. Where the ramp cannot be resolved at all the black-to-white safety net still applies, so an unreadable shading paints something rather than nothing. -
Converting a 725-page book went from 42.8 s to 74.8 s, past the corpus harness's budget — the space test introduced with the stamped-footer fix above was evaluated for every candidate row, scanning that row's members, for every span on the page: quadratic in the spans a page carries. A run is only ever placed on its nearest row, so the test now runs once, on the row that won on distance. The one behavioural difference is stated: a vetoed run now opens a row of its own rather than falling to the second-nearest row, which is the better answer for a footer stamped over another footer. Timings on that book, all three surfaces: 74.8 s before, 44.2 s after, against 42.8 s with the test removed entirely (#1339).
-
The converter stage's remaining slowdown against v0.3.77 was recovered where it could be without moving output —
snap_baselines_to_rowscompared each span against every row seeded so far; rows are now indexed by baseline and only a window is searched, since a row outside3 + h_i + h_maxcan only lose.classifier_column_gutterswept the corridor by rescanning every run at each half-point step; two sorted endpoint lists and a binary search answer the same question. On the 725-page book:snap_baselines_to_rows0.565 s → 0.250 s, the corridor scan 0.121 s → 0.074 s, and end to end 39.95 s → 37.82 s, about 1.15x v0.3.77 from 1.22x. Every surface was hashed per page across 208 documents and 2088 pages and is byte-identical. What remains is a constant per-span cost spread across changes that each do more work than v0.3.77 did — table word-span clustering, spatial table passes, and the column classifier the converters had been skipping (#1339). -
A right-to-left page's diacritics were read as table columns, and its content emitted twice — the spatial detector has a guard against reading Arabic and Hebrew alignment as columns, but it sat downstream of the detection it was written to prevent: it gated only the text-only retry, and by the time it was consulted the main call had produced a table which
return tableshanded straight back. An Urdu verse page with no rects, one path and 25 words — eleven of them zero-width diacritics at aligned x positions — became a 6x4 table on that route, and its runs were then emitted a second time as a trailing paragraph. The duplication has its own cause underneath: the orphan-recovery pass compares a flow span's glyphs against the row text, but flow spans have been through the visual-to-logical bidi reversal and the detector's cell spans have not, so every right-to-left span reads as unclaimed. The decision now happens before detection and is gated on the ruling available: bounding one cell takes two rules on each axis, so fewer than four path primitives cannot describe a grid however they are arranged, and on a page that sparse with more than a third of its spans right-to-left there is no evidence for a table. A genuinely ruled right-to-left table clears the threshold untouched — an appendix table still emits 259 markdown rows, a Persian table 9, a Farsi form 24 (#1328). -
Dates, percentages and section numbers in right-to-left text gained spurious breaks — the converters treat a span ending before the previous one begins as a reading discontinuity, a premise that is left-to-right and was already scoped to decline when either side carries a right-to-left character. Absence of such a character does not establish that a run is left-to-right. Unicode Standard Annex #9 sorts characters into strong, weak and neutral types, and digits along with
/,%,-and#are weak or neutral: they take direction from context and assert none of their own. Table 344 defers to that annex by name, making a writing mode's inline-progression direction "subject to local override within the text being laid out, as described in Unicode Standard Annex #9, The Bidirectional Algorithm". A Persian form draws its issue date left-to-right as1403/09/19at ascending x; the bidi pass reverses it into reading order, and every consecutive pair then steps leftward with no Arabic character anywhere for the guard to catch, so19/09/1403came out19 / 09 /1403and50%came out50 %. The asymmetry was the diagnosis: the final pair ends at 160.32 against a previous start of 160.22 and misses the backward-step test by a tenth of a point where the other three met it. The rule now needs positive evidence — a strong left-to-right character present and no right-to-left one (#1318). -
A sentence fragment was promoted to a heading on a garbled page — heading promotion reads typography: font size, weight, word count, capitalisation. On a page whose text layer is scrambled those signals survive intact while the words stop forming titles, so a body fragment carrying a large font is promoted. A 1919 broadsheet produced
## Furthermore, one reads in theand### palaces league., both of which clear every existing test — the first leads with a capital and runs to five words, and the second is two words, under the five-word floor the lowercase-initial rule uses. Two properties of the text settle it without appealing to layout. A title does not end on a function word, becausein,the,ofand their kin exist to attach what follows them, so a run ending in one has had its continuation cut away; three words are required before that applies, and no auxiliaries or pronouns are listed, sinceLet It Be,Yes We CanandDoctor Whoend exactly so. And a run that opens lowercase and closes on a full stop is a sentence with its head removed. Both tests only ever reject and both are English-shaped, so a heading in another language passes untouched: its words match no entry, and scripts without case reportis_lowercase() == false. Shared between the markdown and HTML predicates, which gate promotion separately and must agree (#1324). -
A tall centred title was ordered by its top edge and split the phrase beside it — rows are assigned by taking whichever of two edges agrees better, baseline or top, which is what lets a superscript, a drop capital or a box carrying a descender join the line it belongs to: between runs of similar height, agreement on either edge implies agreement on the other. That implication fails once one run is much taller. On a government form a 19 pt centred title spans all three lines of an 8 pt stamp printed beside it, so its top edge falls 0.4 pt from the stamp's first line while its baseline sits 11.5 pt away; the better-agreeing edge put the title on that row, between the two halves of one phrase, and pushed the second half onto the line below —
Prescribed by Treasury/ title /Department Treasury Dept. Cir. 1076where the file readsPrescribed by Treasury Departmentand then the title. Four of six reference engines emit the whole stamp first. Sharing a row means sharing a baseline, and above twice the height the top edge stops being evidence of that, so only the baseline counts there. §9.4.4 sets the cross-axis displacement component to 0, so a horizontal run's two edges are both fixed by its font — which is why either may be the one the producer aligned on, and why neither can be trusted between runs whose fonts differ this far in size (#1326). -
A two-line section heading was reduced to its last word — spans are ordered on the row they belong to rather than on their own baselines, so a row mixing font sizes holds together. Two spans in one row therefore carry the same row key, and a tiebreak read back from that key compares them equal and leaves their order to the sequence they were drawn in. Producers emit space-only runs freely; one drawn a fifth of a point under a heading joins the heading's row, and where it was drawn first it sorted ahead of the heading's own text. The markdown converter saw the first line interrupted, closed it as body text and promoted only the second line, turning
### International Students---Less than Full-Time Statusinto a paragraph plus### Status. Every site ordering on a row key now takes the band andxfrom the key and gives the last word to the baseline the page draws — the same rule as the promoted-label fix above, applied wherever a key stands in for a position. -
A two-column schedule row was split apart and its halves reordered — reading order breaks a row-band tie on the baseline, which is correct when the values being compared are baselines. One caller does not pass baselines: the pass that lifts a row-spanning label to the head of its block re-keys it to
anchor + 1.0, an offset chosen to land inside the anchor's band. That is bookkeeping, not a position on the page. A wrapped table cell's continuation line, misread as a label and promoted, shares its column's left edge exactly with the line above it, so the synthetic key outranked a real baseline and the continuation sorted ahead of the line it continues —Specimen must be received IN LAB no later than/12h00 on Tuesday, August 9thcame out as12h00 on Tuesday, August 9/Specimen must be received…/th. All four reference engines agree on the original order. The comparator is split rather than weakened: one form is band-and-x only, the other adds the baseline and is unchanged for every caller passing real geometry, so the OCR fragment injection the tiebreak was added for stays fixed. This change had been landed and reverted twice on conflicting single-document measurements, so both directions are now pinned by tests (#1308). -
A newspaper nameplate was read after the whole body column — the column detector leaves runs wider than 55% of the region out of its density profile, so that a banner headline cannot hide the columns beneath it. A run left out of the profile still occupies the page, though: on a front page whose masthead sits to the right of a single body column, the gap between them read as an empty gutter and the cut was taken straight through the full-measure headline, emitting the nameplate and the dateline after the entire column instead of at the top of the page where they are printed. The valley was an artefact of the exclusion rather than a corridor. §9.4.4 computes the glyph displacement along the writing axis and sets the component for the other axis to 0, so a horizontal run occupies one unbroken interval in X; when that interval covers the candidate corridor on both sides there is no column boundary there, and the cut is now refused so the recursion takes a row split first — which is what peels a banner off the columns underneath it. The refusal is deliberately narrow, because a banner above two ordinary columns crosses every corridor between them too, and refusing there cost the column split on two-column prose — which then read row-major and glued hyphenated words to the facing column's text (
Caliline,secthe,conand). The second condition is the one that separates them: two columns of running text each cover most of the region's height, and the bands this guard exists for do not (the exact bar and the treatment of the crossing run itself were refined later in this release; see the entry on a column that ends early) — a newspaper masthead's side is 28% of the region and a contents page's page-number column 69%, against 99.7% for a prose page's two halves. Counting the crossings instead was measured and rejected: a body page carrying two full-measure headings crosses twice and is still two columns. On the regulation volume the fusions came from, tokens no engine reports go from 621 at v0.3.77 to 598, with no fusions. The crossing is measured with the same core-width estimate the projection uses, because extractor boxes overreach to the right on trailing whitespace and stretched advances (#1303). -
A timetable read its stage labels one row late — row membership was decided by quantizing each baseline onto a fixed 3pt grid, which makes it depend on which side of an arbitrary boundary a baseline happens to fall. The grid is sized for 10-12pt body text; at the 5.31pt row pitch of a festival timetable one band spans two rows, so a label drawn between rows banded with the row below it and was emitted after a row it is plainly drawn above. Seven labels moved this way. Rows now come from an explicit assignment: the page's dominant text size lays down the grid and every other span joins the row it aligns with best. Producers align mixed sizes sometimes on the baseline and sometimes on the cap top — this page does both — so two runs count as aligned when either edge agrees, whichever agrees better, and taking the best row rather than the first within tolerance is what settles a label centred between two rows, which is close to both. §9.4.4 sets the cross-axis displacement component to 0, so a horizontal run's two edges are both fixed by its font and either may be the one the producer aligned on. Applied at every site that orders spans into rows, including the re-sort that lifts row-spanning labels, which otherwise undid the grouping it was handed (#1304).
-
A shading ignored its own
/BBoxand flooded whatever shape used it — Table 78 says the entry gives "the shading's bounding box … interpreted in the shading's target coordinate space. If present, this bounding box shall be applied as a temporary clipping boundary when the shading is painted, in addition to the current clipping path". It was not applied at all, so a pattern fill covering more area than the shading declares painted the whole fill: a page filling 595 x 842 with a pattern whose shading is bounded to[72 72 540 720]covered 74.3% of the page where four engines cover 51.9%. With the box honoured, coverage lands on 0.5190 against MuPDF's 0.5189 and every channel agrees to 0.4 of a level. The mask is only ever narrowed, never widened, and a box that cannot be rasterised is dropped rather than applied — failing to allocate must not paint less than the file asked for (#1256). -
A shading pattern painted through an image mask came out a flat colour — §8.7.4.1 lists what a shading pattern set as the current colour may be used with: "painting operators such as f (fill), S (stroke), Tj (show text), or Do (paint external object) with an image mask". Only
fwas handled. The image-mask path painted its stencil ings.fill_color_rgb, which a coloured pattern never sets — it is selected by/P scnwith no operands — so a page whose whole content is one CCITT stencil filled with an axial gradient rendered in whatever flat colour happened to be current, at mean tone 180.75 against a panel agreeing on 230.01–230.18. The stencil now becomes the coverage the gradient is painted through, sharing one helper with the fill path, and the page moves to 230.30. Its per-channel colour is still wrong — ours reads[216.7, 219.2, 255.0]where MuPDF reads[239.7, 242.4, 208.0]— so the gradient is painted but not yet evaluated correctly; that residual is tracked separately (#1260). -
A form's
/BBoxclip dimmed the geometry it was sized around — Table 95 makes the/BBoxa clip on the form, and this release began applying it. Producers routinely size the box to exactly the content inside it, so the boundary pixels are already partially covered by the content's own antialiasing; multiplying that by the clip's partial coverage attenuates a one-pixel ring no other renderer touches. On a luminosity soft mask whose box maps to precisely the outer extent of the stroke it bounds, we sat 0.54 grey levels off MuPDF where without any clip we sit 0.06. Rasterising the clip non-antialiased makes it worse (0.68), because a box edge landing on a half-pixel then loses the whole column rather than half of it. The clip is now grown by half a device pixel, which changes nothing about its real job — excluding content that lies outside the box — and the test pins both directions: a box sized to its content must not dim it, and a box genuinely smaller must still cut the rest away (#1276). -
/CalRGBwas treated as if it were/DeviceRGB, rendering everything too dark — the two shared a dispatch arm, so the components were passed straight through as sRGB. They are CIE values: §8.6.5.3 says "the transformation defined by the Gamma and Matrix entries … shall beX = X_A × A^G_R + X_B × B^G_G + X_C × C^G_B", after which XYZ is projected to the device. With the common/Gamma [1 1 1]the components are linear, and a linear value read as sRGB is far too dark — linear 0.5 is about sRGB 0.735, not 0.5. On the corpus file for this case we sat 31.5 grey levels below two engines that agreed with each other while coverage matched to 0.0003, which is the signature of a colour-conversion error rather than a geometric one. Gamma and matrix are now applied and the result projected through the existing XYZ-to-sRGB path: mean tone goes from 85.27 to 117.12, against pdfium's 117.12 and MuPDF's 116.83 (#1259). -
An
/Indexedfill colour never looked at its palette — the colour resolver returnedindex / 255as a grey level, so index 3 of a palette of saturated colours painted near-black. §8.6.6.3 makes the palette the definition of the colour, and the same clause governs the operand: the index "should be an integer in the range 0 to hival. If the value is a real number, it shall be rounded to the nearest integer; if it is outside the range 0 to hival, it shall be adjusted to the nearest value within that range." None of that was happening — no lookup, no rounding, no clamping. The resolver now reads the lookup (string or stream), rounds and clamps the index, and evaluates the entry in the base space, recursing so an/ICCBasedor/CalRGBbase is honoured. On the corpus file named for this case, mean tone goes from 222.34 to 233.77 against MuPDF's 233.79 — inside a panel band of 231.72–235.49 it previously sat well below. The image path's out-of-range handling is aligned with the same clause: it painted black, which is a colour the file never named and which darkens the page (#1258). -
A regression test had outlived the decision it pinned, and passed silently everywhere it could not run —
fix_535_cmap_miss_recovers_textasserted that a Type0 font whose/ToUnicodemisses the drawn codes has its text recovered by treating each CID as a Unicode codepoint, which v0.3.54 introduced. v0.3.71 removed that guess deliberately (#773, #775) because it emitted plausible-but-wrong characters — atiligature decoding as:, sonotificacaoreadno:ficacao— and the test was never revisited. It went unnoticed for six releases because it read its fixture from an absolute path outside the repository and returnedOkwhen the file was absent, so it executed on one developer machine and nowhere else. Verified to fail identically at thev0.3.77tag, so this was never a regression in this release. The test now asserts the current contract — the one code the CMap genuinely covers still decodes, and the uncovered ones are not invented into letters — and builds its own fixture in-code. The wider pattern, 41 such sites across 11 files, is tracked separately (#1249). -
Everything a page set before invoking a form XObject was thrown away — §8.10.1 lists what
Dodoes to a form (save the state, concatenate/Matrix, clip to/BBox, paint, restore) and then closes: "Except as described above, the initial graphics state for the form shall be inherited from the graphics state that is in effect at the time Do is invoked". The renderer began every form with a fresh state reset to DeviceGray black, so constant alpha, blend mode, colour and colour space set by the caller all vanished at the boundary. A highlight annotation drawn under a multiply blend at/ca 0.5painted flat opaque over the words it should have tinted, hiding them; a fill in aSeparationorDeviceNspace, whose colour the caller had established, came out as the default black. The form now starts from the invoking state, with only the CTM, the/BBoxclip and theq/Qbracket its own. Two cases deliberately keep a fresh state, because they are not invoked by aDoin a content stream at all: a soft-mask group, which §11.6.5.2 evaluates in its own initial state, and an annotation appearance, which §12.5.5 renders in its own (#1232, #1233). -
Labels on a perspective diagram ran together into one token — the shared text assembly decided whether two runs needed a separator from an axis-aligned gap,
span.bbox.x - (prev.bbox.x + prev.bbox.width). For a run on a diagonal baselinebbox.widthis the width of a box drawn around that diagonal rather than an advance along it, so on a figure whose labels sit at 20.3, 25.3, 30.4 and 35.5 degrees the boxes overlap, the gap comes out negative, and consecutive labels concatenated with nothing between them:Opt_DecoderandOpt_HeadsbecameOpt_DecoderOpt_Heads. §9.4.4 puts a glyph's displacement along the writing direction the text matrix establishes, so runs whose matrices differ in rotation are on different axes and cannot be one line — they now break, as they already did in the converter path, which had been taught this for a rotated marginal stamp but shared none of it with the assembly behindextract_text(#1243). -
A shape filled with a gradient was painted a flat colour, usually black — §8.7.4.1 says that by setting a shading pattern as the current colour "a PDF content stream may use it with painting operators such as f (fill), S (stroke), Tj (show text) … to paint a path, character glyph, or mask with a smooth colour transition", so the gradient is required rather than a nicety. The renderer implemented tiling patterns (PatternType 1) and left shading patterns (PatternType 2) to a solid-colour fallback that painted
fill_color_components— components a coloured pattern never supplies, because it is selected by/P0 scnwith no operands at all. The fallback therefore painted whatever fill colour happened to be current, and in a stream whose first colour operator is thatscnthat is the initial black. On one approval stamp, a pale green gradient came out as a black rectangle: mean RGB 94, 102, 87 where MuPDF, pdfium and poppler all agree on about 200, 212, 189. Type 2 patterns now reach the same painter theshoperator uses, with the filled shape as the clip and the pattern's own matrix for the coordinates — Table 77 is explicit that "when a shading dictionary is used in a type 2 pattern, the coordinates are expressed in pattern space", unlikesh, which reads them in current user space (#1247). -
A CID-keyed CFF font rendered no glyphs at all — a page whose entire content was a single
Tjcame out blank. The font resolved and its 11396-byte/FontFile3parsed; what failed was the dispatch. The renderer asked "does this font have a Unicode cmap?" and let the answer win, but Table 126 requires an OpenTypeCIDFontType0to include acmaptable, so its presence says nothing about how the codes should be resolved. §9.7.4.2 is explicit that for a CFF-based CIDFont "the CIDs shall be used to determine the GID value ... using the charset table in the CFF program", or where the Top DICT has no CIDFont operators, "used directly as GID values" — thecmapbelongs to the Type 2 mechanism, which the same clause describes separately as TrueType's way of mapping character codes to glyph indices. Sent through a Unicode lookup instead, every CID resolved to glyph 0 and nothing was painted; the file in question drew its CIDs from an embedded CMap with a privateGrpOneordering, for which no predefined table exists either.CIDFontType0now takes the CFF route regardless of whether acmaphappens to be present (#1224). -
A soft mask that masks nothing blanked the page it was applied to — a page whose whole content was a 918 x 427 photograph rendered pure white, because the
/ExtGStateabove it set a/S /Luminositysoft mask whose transparency group contained, in full, the two bytesq Q. Read literally §11.6.5.2 does produce a mask from that: the group paints nothing, so it leaves the backdrop untouched, and Table 144 defaults/BCto "the colour space's initial value, representing black", whose luminosity is zero — mask zero everywhere, content erased. Every reference engine disagrees, and not by computing a different value: they discard the mask. Deleting the/SMaskentry and re-rendering gives MuPDF byte-identical output, and pdfium, poppler and Ghostscript all paint the picture too. It is also the only reading that fits the file, since a producer wanting the photograph invisible would not have embedded it. A luminosity group that paints nothing is now treated as no mask, tested behaviourally rather than by inspecting its operators so that a group drawing only invisible things is caught as well — and only where the file gave no/BC, since an explicit backdrop is a deliberate statement about what the mask should be and a producer that wrote one meant it. The rule stays narrow: a group that paints something genuinely dark still masks, which is the feature working (#1252, #1224). -
A JPEG 2000 image with an opacity channel rendered as a blank page — a JPX codestream may carry alpha beside its colour, so a greyscale image decodes to two components rather than one, and the decoder rejected any component count it did not recognise. The extraction failed, the
Dowas skipped, and a page whose entire content was one such image came out pure white: total content loss, where MuPDF, pdfium, poppler and Ghostscript all paint it. Table 89's/SMaskInDataentry settles what to do with that channel, and its default is 0 — "If present, encoded soft-mask image information shall be ignored" — while the same table's/ColorSpaceentry settles how many of the decoded components are colour, since "if ColorSpace is present, any colour space specifications in the JPEG2000 data shall be ignored". The opacity channel is now dropped and the image painted from its colour channels: the page in question goes from blank to ink 0.20461 at mean tone 209.01, against MuPDF's 0.20307 at 208.74./SMaskInData1 and 2, which ask a reader to build a soft mask from that channel, are still ignored (#1224). -
A 6 MB file could take the host process down with an 11.6 GB allocation — one page declares a 12608 × 16806 pt medium carrying a JPEG 2000 image of the same 211.9 megapixel size, and nothing anywhere bounded what that cost. The output pixmap was allocated straight from the page box times the scale with no cap, but that was the smaller half of the problem: instrumenting the stages showed peak memory going from 274 MB to 11.3 GB inside a single call, the JPEG 2000 decode, which materialises every resolution level at roughly 70 bytes of working set per decoded pixel. The failure mode made it worse than a slow render — an OOM kill is a signal, not a
Result, so a thumbnailer or a service rendering untrusted input got a dead process with nothing to catch, and the WASM and mobile targets have far less headroom than the machine that died. Both halves are now bounded. The raster obeys the new budget above, and the decode is told how large the image will actually be painted: images "shall be mapped to the unit square in user space (as are all images)" and are painted by mapping that square "to a region of the page by temporarily altering the CTM", explicitly "regardless of the number of samples in the image" (§8.3.2.4, and §11.6.5.3 for the mask images that share that square), so the stored sample count never determines the painted size and detail finer than the device footprint cannot reach the output. JPEG 2000 stores successive resolution levels, so the decoder stops at the one that covers that footprint instead of decoding samples the sampler would discard;/Maskand/SMaskshare the base image's unit square and so share its footprint. Peak memory on that page falls from 11.0 GB to 2.8 GB at the default budget. Formats without a reduced-resolution decode path still decode in full, which is tracked separately (#1244). -
An ordinary
0.5 gfill could abort the renderer on a valid file — ISO 32000-1:2008 §8.6.5.6 makes a bareg/rg/kbehave as if it had named the page's/DefaultGray,/DefaultRGBor/DefaultCMYKoverride, so the colour space's declared family and the operand count the content stream supplies are independent: a one-operand0.5 gunder/DefaultGray [/DeviceCMYK]reached the four-component projection with a single component and indexed out of bounds. Withpanic = "abort"in the release profile that terminated the calling process. The arity precondition now belongs to the projection helpers themselves — two of the three dispatch sites had guarded for it and the third had not — so no caller can omit it (#1146). -
A zero-dimension
/SMaskaborted the host process — the soft-mask resample loop computedsw - 1on a zero width, which underflows tou32::MAXin release and then indexes out of bounds. The sibling/Maskloop had been widened tou64and given a zero guard for exactly this hazard; the two are the same operation — resample a single-channel mask onto the base grid and fold it into alpha — written out twice, and only one copy was hardened. Both now share one helper that owns the guards. Image/Widthand/Heightare also validated rather than cast: Table 89 requires positive integers and §8.9.5.1's image-to-user matrix is undefined at zero, so-1no longer becomes 4294967295 nor2^32zero (#1147). -
A
/PageLabelsnumber tree that referenced an ancestor overflowed the stack — §7.9.7 describes a number tree as a tree, but nothing in the file format stops a/Kidsarray from naming a node already on the path, and a stack overflow is not a catchable panic. The walk now carries the visited set and depth cap this crate's two other tree walkers already use, degrading to the ranges recovered before the cycle with a warning (#1163). -
A page box written on the opposite diagonal did not render — §7.9.5 says a rectangle may be given by "any two diagonally opposite corners" and that readers "should be prepared to normalize" them, but
Rect::from_pointsbuilt the struct literally whileRect::new— the same type's other constructor — normalised. So/MediaBox [612 792 0 0]produced negative extents, and pixmap allocation failed: the page rendered not at all. Both constructors now agree, and the page-box reader normalises at the point it reads the file, so no consumer has to know which diagonal was used (#1164). -
Two words positioned separately on the same line ran together — the extractor batches a run of
Tm-positioned show operations into one span, which keeps a producer that positions every glyph individually from yielding thousands of one-character spans. That continuation test required the same line, the same transform and forward progression, but bounded only the direction of the jump and never its distance, so a reposition into the next column was accepted and two show operations separated by empty page were glued into one span carrying no separator and a width spanning the void between them. ISO 32000-1:2008 Table 108 givesTmandTdthe same effect on the text and text line matrices, andTd,TDandT*all end the run outright: continuity is a property of the resulting pen position, not of the operator that moved the pen. The bound is an em rather than a word space deliberately — a producer can leave an intra-word repositioning seam wider than the same font's declared space advance, so no word-space constant separates a seam from a space, and everything below an em is left to the span merger, which reads the source-order evidence that does. The cost was never only a missing space: anything reasoning about a span's extent — table-cell ownership, column detection, reading order — saw one span straddling the gap (#1138). -
Links, form values, table cells and
preserve_layoutall broke together on a page with a rotated table — a landscape table typeset on an upright page carries a dominant text-matrix rotation, and the row-major assembler only reads it correctly once the spans are rotated upright. That map was applied inside the converters and left as an unwritten convention of whichever local variable held the mapped spans, so every other page-space value a converter compared them against stayed in the frame the file wrote it in. Four failures followed from the one mismatch: hyperlinks vanished (§12.5.2 puts an annotation's/Rectin default user space, and intersecting it against a mapped span matches nothing); form values detached from their fields and collected at the end of the page (widget spans are built from page-space/Rectvalues and were appended to the mapped page spans, so one vector held two frames); every table cell was emitted twice, once by the grid and once as flow text beside it (the table geometry comes from page-space words and paths, so no cell could claim the spans it renders); andpreserve_layoutplaced every span wrong, since it writes each bbox straight out as absolute CSS and so needs the frame the page displays in. The frame is now a value rather than a convention — link rectangles, widget spans and table geometry follow the spans into it, and layout mode, which consumes no reading order, does not take the map at all. The emitted grid still keeps its page-space row and column orientation; only the cell contents are corrected (#1136). -
Two columns of sideways text ran together into single lines — in two independent places. Runs are merged into a rotated line by their offset across the writing axis, with nothing said about their separation along it, so two columns fused however wide the gutter; a rotated line is the same line with its axes exchanged (ISO 32000-1:2008 §9.4.4 puts the glyph displacement along the text matrix's writing direction) and now takes the same
max(3 × font size, 30 pt)split the upright path uses, measured along its own axis. Upstream, theTmrun-continuation test compared the matrix translation components directly, which assumes a run advancing along+xand separating alongy; under a quarter turn the perpendicular tolerance collapsed to its 0.5 pt floor, so every consecutive glyph of a rotated run became its own span — ten glyphs that batch into one span upright produced ten spans rotated. A frame-correct helper existed but was ANDed onto the raw comparison rather than replacing it, so it could only veto and never admit. The three questions — on the line, forward along it, near enough to the run's end — are now asked once in the run's own frame. Vertical writing mode keeps the raw comparison, since §9.7.4.3 gives it an axis convention the(a, b)row does not describe (#1139). -
A form XObject painted outside its own bounding box — ISO 32000-1:2008 §8.10.2 step (c) intersects the form's
/BBox, mapped through/Matrix, with the current clipping path before the content stream runs, and Table 78 makes/BBoxrequired for exactly that reason; it was never applied, so a form's content bled onto the page. The clip is installed at depth 0 of the nested stream's own clip stack rather than by wrapping the stream's operators in an injected save/restore: a nested stream already gets a fresh stack andQnever pops below depth 0, so the clip holds however unbalanced the form's ownq/Qpairs are — which real content streams frequently are. Verified against PyMuPDF and poppler on the pages it changes: the clipped output lands within 0.004 coverage of both references where the unclipped output was off by 0.05–0.08. Annotation appearance streams were exempt at first, because they were positioned by a plain translation to the annotation's lower-left corner rather than by §12.5.5's fit of the mapped box onto/Rect, and clipping under that approximation trims real content; with that fit now in place (below) the clip applies to them too (#1167). -
A JBIG2 stencil
/Maskwas silently ignored, so scanned pages rendered as the raw grey scan — the stream decoder passesJBIG2Decodethrough untouched, because the pixel decode lives on the image path rather than in the filter chain, so the compressed bitstream reached the stencil loop unchanged. Every sample index then fell past the end of the buffer and took the "no sample to test, leave the base image visible" fallback, which disables the mask completely rather than partially. On a scanned book, where the stencil carries the text and the base image is the grey scan behind it, that left the scan with nothing knocked out: three pages of one such book rendered at mean tone 129–134 where MuPDF and poppler both report 246–251; after the fix they read 246.4 / 251.1 / 248.8 against MuPDF's 245.9 / 251.1 / 248.8 and poppler's 245.8 / 251.1 / 248.8. Polarity follows Table 12's own example, which writes a JBIG2 image as/DeviceGray /BitsPerComponent 1— 0 is black there, and ISO 32000-1:2008 §8.9.6.2 makes sample 0 the one that marks the page, which for an explicit/Maskmeans the base image shows through. The decode is now selected on the filter name rather than on the old "is the data smaller than it should be" heuristic, which a small stencil defeats: its compressed form can be the larger of the two (#1197). -
Extraction wrote library diagnostics into the extracted content — a page with no text layer had
> [OCR REQUIRED — page N]and a sentence of English prose inserted into its markdown, and the same notice reached.textand.html. That is the library's message about the document, not the document's content: it poisons search indexes and RAG corpora, cannot be localised by the application, and on a 60-page scan was the entire output. A scanned page is now reported out of band as aNoTextLayerstructured warning that the application can render however it likes, andannotate_skipped_pagesis deprecated with its default flipped tofalse. The corpus shows the size of it plainly: on documents whose pages carry no text, the markdown surface loses several thousand injected tokens and gains nothing (#1189, #933). -
Two parallel warning systems, and a diagnostic sink that leaked between documents — the library carried both a free-text
Vec<String>and the structuredWarningtype, so which of the two a defect was reported through depended on which code path found it, and a caller reading one saw an arbitrary half. The free-text sites are converted andwarnings()/take_warnings()are deprecated. The sink behind the structured half was a process-globalMutex<Vec<Warning>>that nothing ever drained: a long-lived process ingesting documents accumulated every warning from every document it had ever opened, and a caller asking about document N received the history of 1..N. It is now thread-local, bounded at 1000 entries, de-duplicated against the last 16, and reports its own truncation rather than silently dropping (#1191). -
A ruled table's cell text reached the page twice — three separate ownership rules disagreed about which side renders a span, and each disagreement was a case of two sides comparing spacing they do not have to agree on. Fixed with the repositioning-jump bound and the retention-budget widening above; the reporter's exact reproducer is pinned as its own fixture (#1184).
-
A clipping path whose device bounds miss the pixmap was discarded, painting everything it was meant to hide — resolving past an arithmetic limit must not resolve in the direction that paints more than the file asked for. The same asymmetry now governs both the clip path and the form
/BBoxclip (#1137). -
A glyph-drop warning fired once per page on every OCR'd PDF — a glyphless font is how an OCR text layer is supposed to be built, so reporting each of its pages as a defect buried real diagnostics under thousands of lines on an ordinary scan (#1140).
-
A damaged or truncated CCITT stencil painted the rows it could not decode as solid ink — a decoder that stops early left the remaining rows at their initialised value, which under the stencil rule marks the page. An undecodable region now paints nothing rather than a black rectangle over the content beneath it (#1141).
-
set_excluded_layerswas silently ignored on content streams over 256 KB — the optional-content filter ran only on the prescan path, which is skipped above that size, so a caller excluding a layer on any substantial document got the layer anyway with no error and no warning (#1142). -
A colour-key
/Maskwas skipped when the image also carried/Decode, and a 1-bpc/Decode [1 0]scan rendered as a negative — §8.9.6.4 puts the colour-key ranges in the image's pre-/Decodecomponent space, so the two entries compose rather than conflict. A large scanned page came out white-on-black (#1143). -
Two release notes claimed more than the code does — the rotated-run bbox correction reaches no internal consumer, and
page_bboxis unreachable from WASM and is the identity on/Rotate 90pages carrying ordinary horizontal text, which is the commonest rotated-page shape. Both notes now say what is true, because a note that overstates is worse than no note: it stops the reader checking (#1144, #1145). -
Explicit
/Masktransparency was inverted — the mask painted its complement — and the pixmap was blitted without premultiplying, which had to be fixed first before the inversion could be measured at all. §8.9.6.2 gives sample 0 the meaning "mark the page", which for an explicit mask means the base image shows through; the code took bit 1 as opaque under a comment citing the very clause that refutes it (#1148). -
Every AcroForm checkbox and radio button rendered blank, and Hidden annotations were drawn —
/ASselects which of an appearance subdictionary's states to draw (§12.5.5) and was not consulted, so a checked box rendered as an empty one; and the/FHidden and NoView flags (Table 165) were ignored, so annotations the file marks as not-for-display appeared (#1149). -
Every ink plate of a
/Rotate 270or/Rotate -90page was mirrored — the two renderers built the page transform independently and disagreed about the sign of the determinant, so the same page rendered one way in RGB and mirrored on the separation plates. They now share one transform, which documents the invariant that made them disagree (#1151). -
Inherited page attributes resolved to the most distant ancestor, and the answer changed past the lazy-load threshold — §7.7.3.4 makes an inheritable attribute resolve to the nearest ancestor that supplies it, and the walk took the furthest; separately, the lazily-loaded path and the eager path disagreed, so the same document gave different
/Resourcesdepending on its size (#1152). -
A scope-ignoring marked-content fallback swapped Form XObject text into table cells — an MCID is unique only within its content-stream scope, so a form XObject's MCID 3 and the page's MCID 3 are different marks. The key is now
(scope, id)in the four places that needed it, not the one the issue named (#1153). -
Objects recovered from a truncated file were evicted from the cache and became permanently unreachable — recovery reconstructs objects the xref cannot reach, so an eviction that assumes it can re-read them from the file is assuming the thing recovery exists to work around. Recovered objects are now pinned (#1154).
-
Unchecked object-number arithmetic on the xref-reconstruction path, and a nondeterministic object-stream walk — a crafted or corrupt file could overflow the object number, and the objstm walk iterated a
HashMap, so the same file could reconstruct differently across runs of the same binary (#1155). -
The
/PlacedPDFkeep-gate tokenised encoded bytes, so an Identity-H page extracted every word twice — the gate decided whether a placed page duplicates the host by comparing raw string bytes, which for a two-byte CID encoding are not words in any sense. It now decodes before comparing and fails closed (#1156). -
A sampled tint transform was refused when
/Encodeor/Decodeheld the Table 39 defaults — the check tested for the presence of the entries where it meant to test their value, so a file that writes out the defaults explicitly — which is legal and common — had its Separation or DeviceN colour dropped (#1157). -
CMYK black no longer converted to (0, 0, 0), so five converters stamped grey instead of inheriting the theme —
0 0 0 1 kthrough the process-ink model is a dark grey rather than pure black, and three of the office converters wrote that grey into DOCX, PPTX and XLSX as a hard-coded colour instead of leaving the run unstyled for the theme to supply black. A word-level corpus diff cannot see this at all: the text is identical and only the colour attribute changed (#1158). -
The CMYK-JPEG inversion gate keyed on the Adobe marker — Table 13 equates the marker with
ColorTransform 0, so a CMYK JPEG without the marker took the wrong branch and inverted (#1159). -
A self-referential form XObject or tiling pattern recursed without a depth guard — nothing in the file format stops a form's
/Resources /XObjectfrom naming the form itself, and withpanic = "abort"a stack overflow is not catchable. Both now carry the depth cap Type 3 glyphs and soft-mask chains already had (#1162). -
/CropBoxwas parsed and then never used by the renderer — Table 30 makes the crop box the region to display, and every viewer honours it, so pages rendered at media size and showed the margins the file asked to crop. Verified page for page againstpdftoppm -cropboxand PyMuPDF, and against the file itself: all 55 pages whose extent changes now render at exactly the size their/CropBoxdeclares, accounting for/Rotate(#1166). -
The separation renderer had no inline-image,
shor marked-content arms, so optional-content exclusion never reached the ink plates — two renderers of one page gave contradictory answers about the same ink: content excluded from the RGB render still appeared on the separations (#1165). -
An annotation's appearance was painted at its own scale instead of being fitted to its rectangle — ISO 32000-1:2008 §12.5.5 places an appearance stream by mapping the four corners of its
/BBoxthrough/Matrix, taking the smallest upright rectangle enclosing them, and computing the matrix that puts that rectangle onto the annotation's/Rect. The renderer translated to the rectangle's lower-left corner and drew the form at whatever size it declared, so a stamp whose/BBoxwas[0 0 512 543]inside a 93 × 98 pt/Rectcovered a fifth of the page. The arithmetic settles it without consulting anything:512 × 543is 57% of a 612 × 792 page and the rectangle is 1.9% of it. Four renderers with separate lineages agree to within 0.0002 on the two pages this was found on, and after the fix our coverage lands on their median (0.21936 → 0.01503 against a median of 0.01512; 0.35857 → 0.03843 against 0.03905). A third page moves from 1.8× the panel to its median. With the appearance in the right coordinate system the form's own/BBoxclip is meaningful again, so the exemption noted above is lifted (#1196). -
A CCITT image whose
/DecodeParmsis an indirect reference rendered almost blank — §7.3.10 lets any object be written as an indirect reference and/DecodeParmsroutinely is, but the parameter reader accepted only a dictionary or an array and returned nothing for a reference. Without those parameters the CCITT decode step is skipped altogether, and the still-compressed codestream is then unpacked as though it were packed 1-bit pixels: a 221-byte stream standing in for 12,341 bytes of a 344 × 287 image meant everything past the first ~1.8% fell out of bounds and defaulted to white. The file settles the expected value without any renderer — decoding the strip independently gives 13,355 black pixels of 98,728 (0.13527), the image occupies 0.58991 of the page, so the ink is0.13527 × 0.58991 = 0.07980, and the four references report 0.08004–0.08297. Coverage moves from 0.00988 to 0.08322 and mean tone to 234.68 against a panel of 234.59–234.64 (#1216). -
Overprint erased a Separation paint on a composite render, blanking whole pages — ISO 32000-1:2008 §11.7.3 lets a Separation or DeviceN source address the device's process colorants "as if they were spot colours" only when the group inherits the output device's native colour space; otherwise "the Separation or DeviceN colour space shall be converted to its alternate colour space", and §11.7.4.3 NOTE 2 then reads that alternate as the current colour space for Table 149 — its "any process colour space" row,
B = c_s. Table 149 NOTE 1 says it from the other side: the group's process components "cannot be treated as if they were spot colours in a Separation or DeviceN colour space". With no CMYK sidecar the composite pixmap is the group colour space and it is RGB, so the rasteriser has already written the right colour and there is nothing to compose — but Table 149 row 3 was applied anyway, preserving the backdrop on all four process lanes with no spot lane in existence to receivec_s. Two scholarly documents painting their body text in[/Separation /Black …]under/OP true /op true /OPM 1therefore rendered blank, at coverage 0.00009 and 0.00286, where MuPDF, pdfium, poppler and Ghostscript all paint them; they now render at 0.17851 and 0.06280, both inside the panel's 0.10070–0.23289 and 0.03743–0.07583. The guard is scoped to the Separation/DeviceN source class: DeviceCMYK-direct and the other process spaces keep their composite behaviour, which 85 existing overprint tests pin and which all still pass (#1215). -
A Pattern colour space reached through a resource name was not recognised as one, and the fill came out solid black — the renderer compared
gs.fill_color_spaceagainst the literal stringPattern, but that field holds the resource name the content stream used. It therefore matched only a stream writing/Pattern csverbatim; a file doing it the ordinary way,/CS0 cswhere/CS0resolves to/Patternor[/Pattern /DeviceRGB], was not treated as a pattern at all.scnnever recorded the pattern name (§8.7.3.2 makes its operands name a pattern in that space), the tiling rasteriser was never invoked, and the fill fell through to the solid-colour path withfill_color_rgbat its untouched default — black. A whole-page pattern fill produced a solid black page. The space is now resolved through the resource dictionary. A TikZ tiling-pattern page moves from mean tone 107.91 to 228.05, inside the panel's 191.41–230.30, and its coverage from 0.63154 to 0.37548, inside the panel's 0.29013–0.46646. A page whose fill is a shading pattern (PatternType 2) still renders as a solid slab of thescncomponents, because shading patterns are not implemented; that half of the issue stays open. Making an unpaintable pattern paint nothing instead was tried and reverted — it reads as correct, since the operands of anscnin a Pattern space name a pattern rather than a colour, but the corpus disagreed: it blanked eleven shading-pattern pages that four renderers agree on, several of which the solid fallback had been matching to five decimal places. In a[/Pattern <base>]spacescncarries base-space components alongside the name, and painting those approximates the gradient far better than painting nothing (#1210). -
A JPEG 2000 image with no declared colour space rendered as a blank page — ISO 32000-1:2008 Table 89 makes
/ColorSpace"Required for images, except those that use the JPXDecode filter", and states that when it is absent "the colour space specifications in the JPEG2000 data shall be used". The extractor required it unconditionally and returnedImage missing /ColorSpace, so a legal file was rejected outright and a page whose only content was such an image came out empty — where MuPDF, pdfium, poppler and Ghostscript all paint it and agree on its tone to within 0.81 of a grey level. The JPX decoder already ignores the entry and derives the pixel format from the codestream's own component count, so nothing downstream needed changing. The page now renders at mean tone 105.73 against MuPDF's 105.73. Found by adjudicating pages that are identical in both release arms against the panel — a check no regression sweep runs, because unchanged is precisely what hides a defect of this kind (#1211). -
Dehyphenation ate a compound's own hyphen, fusing it into a word that exists in no language — a typesetter breaking
Cross-sectionalacross a line writes the real hyphen and then U+00AD, the discretionary-break marker. ISO 32000-1:2008 §14.8.2.2.3 makes that marker invisible content, so it is stripped — but stripping it before the wrap decision leaves a bareCross-, which the rejoiner downstream then reads as the wrap marker and removes in turn.Cross-sectionalcame out asCrosssectionalandReceiver-operatingasReceiveroperating, and three token types that MuPDF, pdfminer.six, pypdf and poppler all report fell to zero. The marker now survives the strip when it directly follows a hyphen-minus at the end of a fragment — the only shape where the two characters mean different things — so the rejoiner can tell the wrap point from the word. A plain wrapped word (modali-/ties) still rejoins without its marker (#1207). -
A table cell ran its vertically stacked members together — all three cell renderers decided whether to separate two consecutive spans by asking
has_horizontal_gap, which compares x. A cell whose members are stacked, at nearly the same x and different y, therefore had no gap by that test and the two were concatenated: on an architectural site plan whose contour lines carry stacked elevation labels,128above126came out as128126,124above122as124122, andLOCATIONabove its address asLOCATION123. §9.4.3 makes those separate tokens — a cell that stacks its members renders them on separate lines — and the paragraph path has always separated lines while the cell path had no equivalent. Found by scoring.mdagainstpymupdf4llmand.htmlagainst popplerpdftohtml, the first external references either surface has had (#1206). -
to_html's table-orphan recovery emitted spans the table was about to render anyway — the recovery, which exists because a span claimed by a table can appear in no cell and was otherwise lost outright, decided "the table did not render this" by looking the span up incell.text. That is not the string the table shows:render_cell_htmlwalkscell.spanswhenever the cell has any, inserting a space wherehas_horizontal_gapfinds one and routing each span throughpush_span_text, which can itself split a column-spanning decimal. And for a multi-word span the lookup compared whitespace-normalised text, where the two sides disagree about where the spaces go rather than about the glyphs — a table of contents rendersChapter I— Federal Trade Commission ....from four cells while the flow span readsChapter I—Federal Trade Commission ...., one file splitDepartmentacross cells asD epartm entand another joinedNational ParkintoNationalPark. The comparison now runs on glyph sequences produced by the same span walk the renderer uses, bounded to a single row: cells of one row are adjacent on the page, so matching across them is right, while matching across the whole table is the looseness that duplicated whole paragraphs on an earlier attempt. Measured over 2008 documents on paragraphs of 25 glyphs or more, duplicates fall from 45 to 5 — below v0.3.77's own 7 — while the surface drops 4,016 fewer token types than v0.3.77 does (#1150). -
A line whose words sat on jittered baselines came out backwards in
to_markdownandto_html—XYCutStrategy's leaf sort, which is what the default strategy falls back to whenever a file carries no structure tree, ordered spans bybbox.top()and fell back toxonly when the two tops were exactly equal. Exact equality is not a row test: any sub-point difference put two words of one line into different "rows", thextiebreak never ran, and the order degenerated into a pure descending sort. On a scanned book's OCR layer, whose per-word baselines jitter by a couple of points, whole lines were emitted right to left.top()is also the wrong edge to band on, because it moves with the font size — a line mixing 2 pt punctuation with 8 pt words has tops further apart than the line spacing while the baselines agree to a fraction of a point — and ISO 32000-1:2008 §9.4.4, which puts the glyph displacement along the writing axis, makes the baseline what identifies a line. Both now use the banded-baseline comparator the single-column geometric path already used; the multi-column branch ofGeometricStrategy, which had noxtiebreak at all, takes the same one. This does not finish the page in the report — the two columns of that dictionary page still interleave, because the running header straddles the gutter and defeats the column split — but the HTML surface now reproducesextract_texttoken for token on it (#1195). -
to_htmlglued words together wherever a run stepped backwards — the inline-flow separator measured the gap from the previous span's right edge to the current span's left edge and treated anything at or below 0.15 em as inter-glyph kerning. A span positioned to the left of the previous one yields a negative gap, which that test read as kerning and concatenated: on a scanned book's OCR layer, whose per-word baselines jitter enough that the reading-order sort can emit a line right to left,It is thecame out astheisIt. A span that ends before the previous one begins cannot be a continuation of it — it is separated by a reading discontinuity, whether a new line, a new column, or a re-ordered run — so a complete backward step now requires a separator, while a small negative gap (accent composition, an over-wide advance estimate) stays joined as before. Measured over 2008 documents, this recovers 190,381 word tokens that the HTML surface had fused into compounds no consumer could split. The reversal itself has a separate root cause in the XY-cut leaf sort and is tracked as its own issue (#1194). -
A table cell's text was emitted twice, once by the table and once as flow text beside it — both halves of the suppression compared spacing that the two sides do not have to agree on. A span leaves the flow only by consuming its tokens from a covering cell's retention budget; the budget could already consume a span token that is a substring of one budget token, but not the mirror case, a span token that is the concatenation of several. Word clustering and the flow assembler break words at different distances, so a cell offering
abcanddeffaced a flow span ofabcdefand could not absorb it. Markdown's orphan recovery had the same blind spot from the other side: it decided whether the table had already rendered a span with a literal substring test, and the cell builder joins its member spans with a space where the flow assembler joins the same glyphs with none. Whitespace is a rendering choice of each side and the glyphs are the content, so the comparison is now on the squashed glyph sequence — the row's|delimiters are not whitespace, so they survive the squash and still stop a span that straddles two cells from matching the concatenation of their texts (#1138). -
extract_charsandextract_spansdisagreed because they used different parsers — the two APIs walked the content stream through separate code paths, so a page could report characters that no span contained (and vice versa), leaving callers unable to correlate the two. Both now parse through the same parser, so their output describes the same glyphs (#1006, #1010). -
Sideways (rotated) runs can now report a bounding box in the displayed frame — a run drawn under a ±90° text matrix has its bbox described in unrotated page space, and the new
page_bbox()accessor maps it into the frame the run displays in. The rotation and mirror geometry is correct in all eight combinations. Note what this does not yet change:extract_spans/extract_wordsstill return the raw rectangle, andextract_text_in_rect/extract_spans_in_rectstill select on it, becauseLayoutObjectSpatialhas not been pointed at the corrected value. A caller feeding a reported rect back therefore still selects the wrong region on a rotated page; the accessor is available for callers that want the corrected rectangle themselves (#806, #989). -
extract_text_linessplit one rotated line into many — lines were grouped by banding words ony, which only identifies a line for horizontal text; under a ±90° text matrix a single line advances alongy, so every word of one sideways line landed in its own band. Rotated runs are now grouped along their own writing axis, and merged rotated lines are ordered along that axis too (#983, #987). -
Sideways (rotated) text fused across line breaks — the extractor judged
Tmrun continuation in unrotated page axes, so consecutive runs of one sideways line ran together ("the quick brown foxjumps over the lazydog"). Per ISO 32000-1:2008 §9.4.4 the glyph displacement lies along the text matrix's(a, b)row: under a ±90° matrix a run advances alongfwhile successive lines separate alonge. Continuation is now judged along the run's own writing axis (#806, #982). -
render_page()panicked on a page carrying a zero-dimension image — a/Width 0or/Height 0image XObject blit unwrapped past the existing "skip quietly" path into a panic instead of being skipped. The zero-dimension blit is now skipped cleanly, matching the pre-existing handling for other degenerate-geometry cases (#1019). -
Inline-image extraction was non-deterministic on dictionaries carrying both abbreviated and full forms of the same key (
/Fand/Filter,/CSand/ColorSpace,/DPand/DecodeParms) — the surviving value depended onHashMapiteration order, which varies by the per-process hash seed, so the same file could extract a different number of images (and apply or skip a predictor) across identical runs. The abbreviated form now always wins deterministically, matching how pdf.js resolves these keys (#1017). -
Table cell text could gain spurious spaces inside words —
extract_tablesdecided word boundaries from a fixed gap threshold independent of the span merger's own per-glyph advance evidence, so a word drawn as several show operations could split (Crédit→Cré d it) even thoughextract_spansreconstructed it correctly on the same page. The table path now reuses the span merger's word-boundary verdict instead of re-deriving it from a separate, disagreeing heuristic (#1018). -
extract_pathsdropped or merged geometry painted with the combined fill+stroke operatorsB,B*, andb*— only the plain fill/stroke/close operators were recognized as path-painting operators, so a path closed with one of the three combined forms fell through unrecognized, either vanishing entirely or getting merged into a neighboring path's geometry. All six PDF path-painting operators are now recognized uniformly (#1028). -
extract_text()could hang indefinitely at 100% CPU (holding the GIL in Python) on a page with a degenerate content transform matrix — the two-column gutter-detection heuristics derive a fine-resolution scan step from the page's content width but never bounded that width itself, so a degenerate CTM inflating span x-coordinates by orders of magnitude drove the scan into an effectively unbounded loop. Content width is now capped at 100,000pt, matching the same bound already used elsewhere in the codebase for this identical hazard (#977). -
Image XObjects with indirect
/Widthor/Heightreferences were silently dropped — the image dimension lookup only handled inline integer values, so a/Width 5 0 R-style indirect reference resolved to nothing and the image was skipped entirely instead of being extracted. Both dimensions are now resolved through the document's indirect-object table before use (#1031). -
Document
/Infodictionary fields (/Title,/Author, etc.) decoded UTF-16BE text strings as raw UTF-8, mangling them into replacement characters —DocumentInfo::from_objectcalledString::from_utf8_lossydirectly on each field's raw bytes instead of the existing PDF text-string decoder every other call site already uses, so a UTF-16BE-with-BOM value (per ISO 32000-1:2008 §7.9.2.2) was corrupted since almost no UTF-16BE byte pair also forms valid UTF-8. All 8 Info fields now route through the shared decoder (#978). -
Word spacing (
Tw) was incorrectly applied to multi-byte CID codes whose low byte happened to be 32, corrupting glyph spacing for embedded CID subset fonts — per ISO 32000-1:2008 §9.3.3,Twapplies only to the single-byte character code 32, never to byte value 32 inside a multi-byte code (e.g. a 2-byte Identity-H CID). This gate already existed at two call sites but was missing at six others across the text extractor and rasterizer, all of which discarded the byte-width signal already available and gated on the character code alone — dropping word breaks or injecting spaces mid-word (#1016). -
Redaction's opaque overlay could be drawn in the wrong place when the pruned content stream left its CTM non-identity —
redact_content_streamserialized the pruned operators and then drew each region's overlay right after, with no CTM reset in between; a content stream carrying a trailing unmatchedcm(e.g. a Y-flip, which is legal at end-of-stream) left that transform active, so the overlay — drawn in absolute page-space coordinates — inherited it and landed off-position. The pruned body is now wrapped in its own outerq/Qso the overlay always draws against the stream's original CTM, matching the fix shape qpdf uses for the same hazard (#1015). -
Strict table extraction merged adjacent columns in tables with a dense column pitch (e.g. a 24-column numeric table) —
detect_columnsmerged adjacent column clusters using a fixed absolute-point threshold regardless of how narrow the table's actual column pitch was, so a modest fixed threshold fused every adjacent column pair in a dense table into one. The merge threshold is now capped at 0.6× the table's own median inter-column gap once at least 3 columns are present, reusing the same on-pitch ratio the table's numeric-lattice detector already relies on; sparse tables with fewer than 3 columns keep the original fixed threshold (#975). -
CCITTFaxDecode images with no explicit
/Krendered blank — per ISO 32000-1:2008 Table 11, an absent/Kdefaults to0(pure 1-D Group 3), but the parameter extractor and its default both defaulted to-1(Group 4) instead, decoding Group 3 scans with the wrong algorithm. The correct Group 3 decoder already existed; only the default value was wrong (#1030). -
180°-rotated text runs reported
rotation_degrees=0and could merge into the wrong span — the rotation-detection fast path only checked the matrix's off-diagonal terms to spot horizontal text, but those terms are also ~0 for a 180°-rotated matrix (sin 0°andsin 180°are both 0), so upside-down runs were indistinguishable from ordinary horizontal text. Separately, the span-merge rotation gate only rejected the ±90° vertical case, so a 180°/180° pair could still merge under the portrait same-line test even though 180° text advances in the opposite X direction. Fixed both: the fast path now also checks the matrix's diagonal sign, and the merge gate now rejects any non-zero rotation on either side (#1029). -
extract_spansreturned pre-CTM coordinates on large (>256KB) CAD-style content streams — the fast-path prescan located each region's starting graphics state by scanning backward for the nearest unmatchedq, but only ever trackedq/Q, nevercm. A common CAD-exporter pattern issues a single top-levelcmwith no enclosingqat all, right at the start of the stream; that transform's bytes sit before the region the backward scan carved out, so it was silently excluded with no fallback ever triggered to recover it — text was then parsed under an identity CTM instead of the real scale, whileextract_chars(which doesn't use this fast path) reported the same glyphs correctly. The prescan now also tracks whether it saw a top-levelcmand forces the existing forward-CTM-recovery fallback when it did (#974). -
strip_running_headers_footerscould delete body text in multi-column documents — the running-header/footer detector collected repetition signatures from individual spans rather than assembled lines, so a span that was only a fragment of a visual line (common where font/emphasis changes split one line into several spans, e.g. italicized terms in academic body text) could coincidentally recur across pages while the rest of its line differed every time, and got deleted everywhere as a false-positive header/footer — including mid-sentence in unrelated paragraphs. Signatures are now collected from whole assembled lines instead, with per-span stripping still applied by bbox intersection against the matched lines (#1022). -
A
/PlacedPDFmarked-content scope left open across a >256KB prescan region boundary suppressed every later region on the page — the fast-path prescan wraps each text region in fresh graphics state but never tracked the marked-content stack, so a/PlacedPDFBDClanding inside one prescanned region whose matchingEMCfell outside it (e.g. InDesign wrapping a placed figure's label and artwork, where the artwork itself is too large to become its own text region) left the suppression flag stuck on forever — every subsequent region's text was silently discarded. The prescan now tracks its own BDC/BMC-vs-EMC balance per region and synthesizes the missingEndMarkedContentat the region boundary, closing only what that region itself opened (#1033). -
Invisible text (render mode
Tr 3/7) under aFixedPitch-flagged orGlyphLessFont-named font could be misclassified as monospace —is_monospacederivation trusted the FontDescriptor'sFixedPitchflag and name heuristic unconditionally, but invisible text has no visual "monospace" meaning at all: it's an OCR text-sandwich layer sitting under a scanned page image, and OCR tools (ocrmypdf, Tesseract, etc.) conventionally emit a synthetic font — literally namedGlyphLessFont— whose FontDescriptor setsFixedPitchpurely for positioning simplicity, since the glyphs are never rendered. Markdown conversion usesis_monospaceto fence a paragraph as a code block, so a scanned novel's OCR'd dialogue trippedFixedPitchand got served as a code block.is_monospaceis now gated off for invisible render modes and theGlyphLessFontnaming convention, alongside the existing detection (#1024). -
Write paths that copy objects out of an already-open, encrypted source document re-serialized the source's raw ciphertext verbatim —
save()/save_to_bytes(),extract_pages(), andremove_page()followed by a save all inherited the source document's stream bytes as-is when copying them into an output with no/Encryptdictionary of its own. The output was a structurally valid PDF that opened without a password, but every copied content stream was still ciphertext behind/Filter /FlateDecode, so a conforming reader failed to inflate it and rendered a blank page — silently, with no error or warning. Every such write path now decrypts stream data from an authenticated encrypted source before re-emitting it (#1032). -
Sparse two-column pages (as few as 2 spans per column) had their columns interleaved into reading order instead of read column-by-column —
ReadingOrder::ColumnAware's recursive partitioner falls back to a flat top-to-bottom, left-to-right sort below a minimum-span floor, and that floor sat above the span count a genuinely sparse two-column page can produce; no other classifier in this module can reliably distinguish sparse column-major prose from a small row-major table at this scale either, so simply lowering the floor wasn't safe. The base case now uses the existing clean-gutter check as a yes/no signal only: when a clean gutter exists, spans are ordered by their original content-stream emission order (matching PDFium's behavior here) instead of a geometric Y-then-X sort, since table generators and column generators reliably differ in stream-emission order even when their geometry looks identical. Falls back to the prior flat sort when no clean gutter exists at all (#979). -
Table cell extraction from a tagged PDF's structure tree hardcoded
rotation_degreesto0.0, dropping rotated cell text's angle —extract_cellre-synthesized each cell'sTextSpanand copied every other style field (bbox,font_name,font_size,font_weight,is_italic,mcid) from the sourceTextBlock, but hardcoded rotation to zero instead — the span-based table path already threaded this field correctly, so the two paths disagreed on the same field. Downstream consumers that key offrotation_degrees(e.g.grid_to_table's advance-axis ordering) silently lost rotation on structure-tree-derived cells.extract_cellnow carriesblock.rotation_degreesthrough like every other field (#1034). -
Ruby's
render/render_with_layersalways returned an empty byte string — two stacked bugs in the rendered-image byte-buffer path: the Ruby wrapper probed a C-ABI symbol name the cdylib never actually exported and silently fell back to empty bytes when it was absent, and the real accessor was declared under an auto-generated placeholder signature shared by many unrelated FFI functions instead of its actual 3-argument C ABI. The Ruby binding now points at the accessor that actually exists with the correct signature, matching the working pattern already used by the Go binding (#1048). -
PHP's
render/renderWithLayershad the same empty-byte-string bug as the Ruby binding, plus a missing accessor — the PHP wrapper had never wiredrender()/renderWithLayers()to a rendered-image byte accessor at all. Both methods are now wired to the existingpdfRenderPageZoom()/pdfRenderPageWithOptionsEx()C ABI calls, matching the working pattern used by the Go and (now-fixed) Ruby bindings (#1053). -
Soft hyphens (U+00AD) leaked verbatim into
extract_text(),to_markdown(), andto_html()output — the existing soft-hyphen stripper only ran on the deprecatedMarkdownConverterpath and the opt-in intelligent-text-processing path, and even there it only stripped U+00AD when it sat at the very end of a line immediately before a line break; by the time text reaches the three main prose surfaces, a soft-hyphenated word has already been reflowed onto one line, stranding the character mid-word with no adjacent break to key off.push_span_text— the function shared by all three surfaces — now filters U+00AD out of appended text regardless of position, fixing all three at once with no newConversionOptionsflag;extract_chars/extract_words/extract_spansare untouched since those are meant to stay glyph-position-faithful (#1023). -
extract_charsstill applied word spacing to a 2-byte CID whose value was 32, shifting every later glyph on the line — per ISO 32000-1:2008 §9.3.3Twapplies only to the single-byte character code 32, never to byte value 32 inside a multi-byte code. That gate was added at the span-accumulation arms and the render paths, but one site was missed: the per-character loop inshow_textthat positions every glyphextract_charsreports. A Type0/Identity-H CID0x0020is a real glyph, so it took word spacing anyway and every character after it landedTwtoo far right — whileextract_spans, already gated, described the same glyphs correctly. The remaining site now carries the byte-width guard, so the two APIs agree (#1058, #1059). -
The same binary produced different text, different redacted bytes, and different XMP packets for the same input across runs — four output paths iterated
HashMaps and let per-process-random order reach the result. The dominant-font-size calculation usedmax_by_key, which returns the last maximal element, so a font-size tie madedominant_ema coin flip that in turn flipped the multi-column reading-order gate: measured on a page whose tie histogram was exactly 8pt:32 / 41pt:32, 6 of 12 runs disagreed on the extracted text. Redaction serializedObject::Dictionaryunsorted (writer::ObjectSerializeralready sorts; redaction was a second copy that missed it),XmpWriter::buildwrote custom properties in map order so redaction and export packets differed between runs, ExtGState validation emitted its SMask/CA/ca/BM errors in hash order whenever two or more entries were invalid, and spatial table detection ordered rule families by hash iteration (latent — no corpus page reaches it). All five now use the same collect/sort/get idiom, with the rule-family grouping moved verbatim into a private helper so the ordering invariant is unit-testable (#1004, #1008). -
A glyph that failed to paint vanished silently while the cursor still advanced, leaving a gap with no diagnostic — four rasterizer sites swallowed the drop: a
Noneoutline from ttf-parser painted nothing, an unmapped non-whitespace character never painted even.notdef, characters resolving to U+FFFD left the shaping input entirely, and total loss was reported atdebugonly. Every paint path now records its drops and emits aGlyphDroppedwarning naming the font, the first character code and glyph id, and the count. Reporting is deduplicated per font, but the previous process-lifetime latch went silent for the rest of the process in exactly the bulk-ingestion case this affects, so the latch now lives on the rasterizer and clears at the start of every page — the same scope as the page renderer's existingk_zero_warning_emittedlatch, with no entry cap to exhaust. Painting itself is untouched and raster output is byte-identical across the corpus; this is reporting only.WarningCategorygains aGlyphDroppedvariant and becomes#[non_exhaustive], so downstream exhaustive matches keep compiling (#991, #1013). -
A rotated page read one way through
extract_textand another way through every other text surface — the rotated reading frame was applied only insideextract_text, soto_markdown,to_html,to_plain_textand the filtered surfaces assembled in raw page space and produced a different reading order for the same page;extract_text_in_rectcompounded it by mapping before filtering, so its rect selected in mapped coordinates while every other rect surface used page space. Every surface now enters the reading frame through one choke point placed after the region filters, making the ordering structural rather than per-caller discipline (#984, #1012). -
Gradients backed by a sampled (Type 0) function decompressed their whole colour lookup table at every grid point — painting a shading evaluates its colour function at up to 16,641 points, and each evaluation inflated the compressed lookup table from scratch, so decompression cost multiplied by the entire grid and larger tables made every gradient proportionally slower. The table is now decoded once where the function is resolved and the decoded bytes handed to the evaluator. Rendered output is byte-for-byte identical. A top-level array of functions and the children of a Type 3 stitching function deliberately keep the per-call path; #999 tracks that work (#999, #1000).
-
Every clippy tier began failing on unmodified code after Rust 1.98 — the 1.98.0 toolchain (released 2026-08-18) added
clippy::chunks_exact_to_as_chunks, which fires on 35 pre-existingchunks_exact(N)call sites across 15 files. Since CI runs clippy with-D warnings, the workspace, Python-feature, WASM and FIPS clippy jobs all failed at once on code nobody had touched, andClippyis a required check, somainand every open pull request were blocked. The call sites now useas_chunks::<N>(), clippy's own machine-applicable rewrite — behaviour is identical (the same split, the same discarded remainder) and it is MSRV-safe, sinceslice::as_chunksstabilised in Rust 1.88.0 and this crate requires 1.88 (#1105). -
A Type0 font with a UTF-8 CMap extracted correctly but rendered garbage — extraction and rendering each carried their own glyph decoder with different feature sets, so the two paths disagreed about the same bytes. One decoder now serves both. The two genuine policy differences remain, carried by a
DecodePolicyrather than a forked decoder: extraction prints'?'for an invalid scalar value, rendering routes it to the drop tally. The rasterizer also now builds its parallel CID/width arrays from the decode's own segmentation, so variable-width UTF-8 codes paint against the widths they were decoded with (#1007, #1011). -
render_pagepanicked, and could hard-abort the process, on a damaged content stream carrying enormous path coordinates — coordinates as large as ~4.5×10¹⁸ are beyond what f32 pixel math represents precisely, and tiny-skia's antialiased run accounting loses sync and panics on them; catching the panic is not a safe recovery either, since it can escalate to an abort that takes down a long-running process. tiny-skia 0.12.0 is current and the failing code is unchanged upstream since 2023, so the guard belongs on this side of the boundary: before a path is handed over, its bounding-box corners are run through the transform (twelve multiply-adds, no path copy) and the draw is skipped if any corner is non-finite or beyond the bound. Strokes are checked on outline reach and have an over-reaching width narrowed rather than being dropped, and clip paths drop the clip rather than materializing an empty mask that would erase every subsequent draw. The bound is measured against tiny-skia 0.12 at 72 dpi — fills and cubics still rasterize correctly at 5×10⁸ device units and produce nothing at 7×10⁸ — rather than assumed from f32's 2²⁴ integer-separation limit, which tiny-skia rasterizes well past; one corpus page legitimately fills to 1.3×10⁸ (#1001, #1002). -
Images with 1, 2 or 4 bits per component were returned still packed but labelled as 8-bit grayscale — a 1-bit 8×8 image came back as 8 bytes, far too short for a valid 8×8 plane, so consumers read whatever the short buffer aliased onto. Samples now unpack to one byte per component per ISO 32000-1:2008 §8.9.5.2, with the unpacked result capped at 256 MiB so a malformed header cannot request gigabytes. In the same unpacking path:
/Decodepreviously applied only at 1 bpc and for CCITT and now applies at 1, 2, 4, 8 and 16 bpc, andPdfImagenow states whether its samples are still in the space the dictionary entries describe — colour-key/Maskand separation plates read that fact to route correctly, and both indexed expansion and 16-bpc reduction clear the flag (#1066, #1067). -
A Type 3 stitching function with an empty
/Domainpanickedrender_pageon a file that parses cleanly —eval_type3readdomain[0]unguarded while already readingdomain[1]throughget(1)?. The first element now usesfirst()?to match the guard the second already had; the function contributes no colour and the page still renders (#1074, #1075). -
A sampled function declaring an enormous
/Sizeoverflowed its sample-index arithmetic —eval_type0multiplied/Sizeentries into a stride and a flat index with no bound, so/Size [4294967296 4294967296]panicked in debug and, in release, wrapped to a silently wrong sample offset. The declared grid is now bounded once, up front, by what the sample stream can actually hold (product(Size) × outputs × BitsPerSamplebits), so every product downstream is structurally unable to overflow rather than relying on each site to keep checking (#1076, #1077). -
Image-mask geometry that no stream could back was taken into arithmetic that cannot represent it, at three sites reachable from
render_page/render_separations— the separation plate painter cast/Widthand/Heightwithas usize, so-1became a near-usize::MAXpixel count and a positive-but-unbacked size sized an allocation from the declaration alone. That painter now usesPageRenderer::image_mask_layout, which narrows tou32, rejects zero, checks the pixel count, and computes the packed length a 1-bpc stencil requires (§8.9.6.2) — the length check running before expansion, becauseexpand_1bpc_to_8bpczero-pads and a padded 0-bit means "paint". Separately, a zero-width/Masksub-image underflowedmw - 1before sampling; a zero-size mask carries no sample to test, so the mask is skipped and the base image paints opaque (#1078, #1079). -
An annotation whose
/APhad/Dand/Rbut no/Nrendered one of two images depending on per-process hash order — the appearance stream was resolved asget("N").or_else(|| values().next())over aHashMap, so the same file could paint differently between runs. The fallback is dropped rather than made deterministic: per ISO 32000-1:2008 §12.5.5,/Dand/Rappear only under pointer press or hover, so drawing either on a static page was wrong in every ordering and sorting would only have frozen one specific wrong answer. Annotations carrying/N— which is every annotation that renders today — are unaffected (#1080, #1081). -
extract_charsreturned Form XObject text that no conformant renderer paints, disagreeing withextract_spansabout the page's content — a form's marks are clipped to its/BBox(ISO 32000-1:2008 §8.10.1), and the span layer applied that clip while the character layer did not. On the pdfTeX pattern, where a whole page is embedded as a figure-sized form and the embedded file still carries a full draft galley,extract_charsreturned a second, invisible copy of the article interleaved with the real one — roughly twice the charactersextract_spansreported for the same page. Beyond the API inconsistency this corrupted assembled text, because word-boundary detection reads the character layer: a statistics table came out asTest 8 0.71 3 … 0.0 676andPD Me dswhere the clipped glyphs split the tokens. Forms covering ≥60% of the page are still treated as content frames rather than figures and are not clipped, so wrapper bodies are unaffected. Across the 419-PDF regression corpus, pages where the two layers disagreed by more than half went from 7 to 0 (#970). -
Large-format CAD/construction drawing sheets lost most of their text from every text-level API —
extract_text(),extract_spans(),extract_words()and