Skip to content

v0.3.78 | Correctness under audit: 144 defects across rendering, text, reading order, files and colour

Latest

Choose a tag to compare

@github-actions github-actions released this 08 Sep 17:01
ad49c4c

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 Warning values 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_warnings and pdf_document_take_structured_warnings return them as JSON, and structuredWarnings / takeStructuredWarnings return 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 hasRenderOptions::max_output_pixels (default DEFAULT_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 bounds page_box × scale — Table 30 defines /MediaBox with 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 own rotation_degrees is zero, so a landscape page stored portrait with /Rotate 90 and ordinary horizontal text — the commonest rotated-page shape — still reports pre-/Rotate space. 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 as extract_text_in_rect still 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 treeimageproc and tokenizers were declared optional, wired into the ocr and ml features, and referenced by zero lines of Rust anywhere in the workspace. Both also sat on the cargo-shear ignore list, which is why the unused-dependency gate never reported them: that list exists for crates used behind a dep: feature gate (cms, rsa, x509-parser genuinely are), and these two had outlived it. Removing them drops 43 packages including the esaxx-rs / onig-sys / spm_precompiled C/C++ chain, nalgebra, and ab_glyph. Both are also gone from the ignore list, so the gate can police them from now on. ocr and ml build unchanged; there is no behavioural difference, because nothing was calling them.
  • ttf-parser is now reached only by our own font parsing — with imageproc gone, the imageprocab_glyphowned_ttf_parserttf-parser chain leaves the tree, and fontdb 0.24 had already dropped its own dependency on it. That leaves no third-party consumer at all, so the planned migration onto skrifa/read-fonts (both already present via subsetter and harfrust) 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: taffy 0.14 retyped min_size / max_size as LengthPercentageAuto (only size and flex_basis still take Dimension), and quick-xml 0.42 moved element names and attribute values from bytes to str and made xml11_content() return the Cow directly instead of a Result. The remaining Rust bumps — brotli 9, office_oxide 0.1.9, ort 2.0.0-rc.13, regex 1.13, uuid 1.26, smallvec 1.16, bytes 1.12, crc32fast 1.5 — are source-compatible. Four were held back deliberately, each for a reason the version number does not show: tract stays on 0.22 because 0.23 pulls dyn-eq (MPL-2.0) into the graph, which the licence policy rejects, and raises the ML features' rustc floor to 1.91; aes stays on 0.9.2 because 0.9.3 raised its own floor above this crate's 1.88; simplecov stays 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 # vN comment that had stopped matching what the SHA actually was: actions/cache was commented v4 while running v6.1.0, actions/setup-dotnet v5 while running v6.0.0, actions/github-script v7 while 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/actions stays on the v5 line because v6 moves its caching component to a proprietary licence whose use requires accepting Gradle's commercial Terms of Use, and ebitengine/purego stays 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 /UE wrapped 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 /Encrypt and /ID from 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 /Encrypt the 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.gitattributes carried export-ignore rules for every other top-level directory, added in v0.3.56 and widened in v0.3.77 to slim the Packagist dist. git archive honours them, and so do the release "Source code" assets, "Download ZIP" and codeload tarballs: the v0.3.77 source tarball had 79 entries and no src/, 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 of php/ (#1347).

  • Text inside a form XObject whose /Resources is 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, and extract_text was 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) gives yⱼ = C0ⱼ + xᴺ × (C1ⱼ − C0ⱼ) on the input x itself, and /Domain only 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 /Encode was 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's C0 is red). Exposed by this release's move from reading C0/C1 to 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 /Indexed space lost its colour: the page came out neutral grey — §7.4.9 (docs/spec/pdf.md:3143) has the dictionary's /ColorSpace decide how a /JPXDecode image'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 /Indexed space 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, rendered R = G = B at 246.45 where v0.3.77 read 246.45 / 248.29 / 248.29 and MuPDF, pdfium and poppler all show the same R < G < B tint. 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 /BitsPerComponent says the codestream packed them at; an index component deeper than 8 bits is refused, since §8.6.6.3 bounds hival at 255. The fixture is generated: a 4x2 4-bit codestream of indices 0 1 2 3 / 3 2 1 0 in 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, no and file inside it on the paragraph's own rows. Its centre landed in the word gap after no, 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 from ceeds, and proceeds left 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 puts to and GeV in 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: theorem and Compliance fell 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_page is true, a table exists, and multicol_signal_is_tabular reports 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- / ured never 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- and ber. 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 1977 returns 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_gutter keeps 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...... became 10.3.1 and, 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 .text lines 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 correctlyreorder_two_column_prose is 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. Returning false also 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 ... 2019 acquired <! 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 both TIP labels 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::spans in page space. The two sides never met, so no cell claimed the runs it draws and Alpha, Beta, Gamma and Delta each 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 sees SICHERHEITS's closing S overwrite its opening S) and matching a short run on the per-glyph advance rather than five points — which is what separates an overprint from the two ls of a doubled letter. Plain text is repaired too, from SICHERHEITS--CHECKLISTE to SICHERHEITS-CHECKLISTE. Positions tracked per string are bounded. Ablated for #1339 and measured 2.7% faster with the widening, not slower.

  • A Tm repositioning 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; a Tm inside 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 became page312, 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 characterTextSpan::char_widths carries one advance per character of text, but was filled by asking how much the accumulating String had 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—CD at 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)—C at 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 /Differences or 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 C followed by OORDINATES in 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_glue recognised the shape and admitted the merge, but only into should_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, DEADLINES and MAXIMUM are 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 /Domain and 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 outputsnap_baselines_to_rows compared each span against every row seeded so far; rows are now indexed by baseline and only a window is searched, since a row outside 3 + h_i + h_max can only lose. classifier_column_gutter swept 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_rows 0.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 tables handed 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 as 1403 / 09 / 19 at 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, so 19/09/1403 came out 19 / 09 /1403 and 50% came out 50 %. 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 the and ### 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, because in, the, of and 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, since Let It Be, Yes We Can and Doctor Who end 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 report is_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. 1076 where the file reads Prescribed by Treasury Department and 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 Status into a paragraph plus ### Status. Every site ordering on a row key now takes the band and x from 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 9th came out as 12h00 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 /BBox and 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 f was handled. The image-mask path painted its stencil in gs.fill_color_rgb, which a coloured pattern never sets — it is selected by /P scn with 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 /BBox clip dimmed the geometry it was sized around — Table 95 makes the /BBox a 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).

  • /CalRGB was 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 be X = 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 /Indexed fill colour never looked at its palette — the colour resolver returned index / 255 as 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 /ICCBased or /CalRGB base 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 runfix_535_cmap_miss_recovers_text asserted that a Type0 font whose /ToUnicode misses 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 — a ti ligature decoding as :, so notificacao read no: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 returned Ok when the file was absent, so it executed on one developer machine and nowhere else. Verified to fail identically at the v0.3.77 tag, 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 Do does 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.5 painted flat opaque over the words it should have tinted, hiding them; a fill in a Separation or DeviceN space, 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 /BBox clip and the q/Q bracket its own. Two cases deliberately keep a fresh state, because they are not invoked by a Do in 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 baseline bbox.width is 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_Decoder and Opt_Heads became Opt_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 behind extract_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 scn with no operands at all. The fallback therefore painted whatever fill colour happened to be current, and in a stream whose first colour operator is that scn that 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 the sh operator 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", unlike sh, 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 Tj came out blank. The font resolved and its 11396-byte /FontFile3 parsed; 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 OpenType CIDFontType0 to include a cmap table, 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" — the cmap belongs 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 private GrpOne ordering, for which no predefined table exists either. CIDFontType0 now takes the CFF route regardless of whether a cmap happens 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 /ExtGState above it set a /S /Luminosity soft mask whose transparency group contained, in full, the two bytes q 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 /BC to "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 /SMask entry 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 Do was 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 /SMaskInData entry 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 /ColorSpace entry 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. /SMaskInData 1 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; /Mask and /SMask share 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 g fill could abort the renderer on a valid file — ISO 32000-1:2008 §8.6.5.6 makes a bare g/rg/k behave as if it had named the page's /DefaultGray, /DefaultRGB or /DefaultCMYK override, so the colour space's declared family and the operand count the content stream supplies are independent: a one-operand 0.5 g under /DefaultGray [/DeviceCMYK] reached the four-component projection with a single component and indexed out of bounds. With panic = "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 /SMask aborted the host process — the soft-mask resample loop computed sw - 1 on a zero width, which underflows to u32::MAX in release and then indexes out of bounds. The sibling /Mask loop had been widened to u64 and 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 /Width and /Height are 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 -1 no longer becomes 4294967295 nor 2^32 zero (#1147).

  • A /PageLabels number 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 /Kids array 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_points built the struct literally while Rect::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 gives Tm and Td the same effect on the text and text line matrices, and Td, TD and T* 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_layout all 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 /Rect in 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 /Rect values 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); and preserve_layout placed 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, the Tm run-continuation test compared the matrix translation components directly, which assumes a run advancing along +x and separating along y; 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 /BBox required 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 and Q never pops below depth 0, so the clip holds however unbalanced the form's own q/Q pairs 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 /Mask was silently ignored, so scanned pages rendered as the raw grey scan — the stream decoder passes JBIG2Decode through 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 /Mask means 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 .text and .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 a NoTextLayer structured warning that the application can render however it likes, and annotate_skipped_pages is deprecated with its default flipped to false. 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 structured Warning type, 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 and warnings() / take_warnings() are deprecated. The sink behind the structured half was a process-global Mutex<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 /BBox clip (#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_layers was 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 /Mask was 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-/Decode component 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_bbox is unreachable from WASM and is the identity on /Rotate 90 pages 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 /Mask transparency 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/AS selects 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 /F Hidden and NoView flags (Table 165) were ignored, so annotations the file marks as not-for-display appeared (#1149).

  • Every ink plate of a /Rotate 270 or /Rotate -90 page 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 /Resources depending 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 /PlacedPDF keep-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 /Encode or /Decode held 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 theme0 0 0 1 k through 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 /XObject from naming the form itself, and with panic = "abort" a stack overflow is not catchable. Both now carry the depth cap Type 3 glyphs and soft-mask chains already had (#1162).

  • /CropBox was 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 against pdftoppm -cropbox and PyMuPDF, and against the file itself: all 55 pages whose extent changes now render at exactly the size their /CropBox declares, accounting for /Rotate (#1166).

  • The separation renderer had no inline-image, sh or 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 /BBox through /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 /BBox was [0 0 512 543] inside a 93 × 98 pt /Rect covered a fifth of the page. The arithmetic settles it without consulting anything: 512 × 543 is 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 /BBox clip is meaningful again, so the exemption noted above is lifted (#1196).

  • A CCITT image whose /DecodeParms is an indirect reference rendered almost blank — §7.3.10 lets any object be written as an indirect reference and /DecodeParms routinely 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 is 0.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 receive c_s. Two scholarly documents painting their body text in [/Separation /Black …] under /OP true /op true /OPM 1 therefore 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_space against the literal string Pattern, but that field holds the resource name the content stream used. It therefore matched only a stream writing /Pattern cs verbatim; a file doing it the ordinary way, /CS0 cs where /CS0 resolves to /Pattern or [/Pattern /DeviceRGB], was not treated as a pattern at all. scn never 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 with fill_color_rgb at 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 the scn components, 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 an scn in 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>] space scn carries 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 returned Image 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-sectional across 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 bare Cross-, which the rejoiner downstream then reads as the wrap marker and removes in turn. Cross-sectional came out as Crosssectional and Receiver-operating as Receiveroperating, 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, 128 above 126 came out as 128126, 124 above 122 as 124122, and LOCATION above its address as LOCATION123. §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 .md against pymupdf4llm and .html against poppler pdftohtml, 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 in cell.text. That is not the string the table shows: render_cell_html walks cell.spans whenever the cell has any, inserting a space where has_horizontal_gap finds one and routing each span through push_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 renders Chapter I— Federal Trade Commission .... from four cells while the flow span reads Chapter I—Federal Trade Commission ...., one file split Department across cells as D epartm ent and another joined National Park into NationalPark. 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_markdown and to_htmlXYCutStrategy's leaf sort, which is what the default strategy falls back to whenever a file carries no structure tree, ordered spans by bbox.top() and fell back to x only 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", the x tiebreak 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 of GeometricStrategy, which had no x tiebreak 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 reproduces extract_text token for token on it (#1195).

  • to_html glued 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 the came out as theisIt. 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 abc and def faced a flow span of abcdef and 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_chars and extract_spans disagreed 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_words still return the raw rectangle, and extract_text_in_rect/extract_spans_in_rect still select on it, because LayoutObjectSpatial has 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_lines split one rotated line into many — lines were grouped by banding words on y, which only identifies a line for horizontal text; under a ±90° text matrix a single line advances along y, 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 Tm run 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 along f while successive lines separate along e. 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 0 or /Height 0 image 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 (/F and /Filter, /CS and /ColorSpace, /DP and /DecodeParms) — the surviving value depended on HashMap iteration 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 wordsextract_tables decided 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éditCré d it) even though extract_spans reconstructed 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_paths dropped or merged geometry painted with the combined fill+stroke operators B, B*, and b* — 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 /Width or /Height references 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 /Info dictionary fields (/Title, /Author, etc.) decoded UTF-16BE text strings as raw UTF-8, mangling them into replacement charactersDocumentInfo::from_object called String::from_utf8_lossy directly 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, Tw applies 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-identityredact_content_stream serialized the pruned operators and then drew each region's overlay right after, with no CTM reset in between; a content stream carrying a trailing unmatched cm (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 outer q/Q so 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_columns merged 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 /K rendered blank — per ISO 32000-1:2008 Table 11, an absent /K defaults to 0 (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=0 and 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° and sin 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_spans returned 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 unmatched q, but only ever tracked q/Q, never cm. A common CAD-exporter pattern issues a single top-level cm with no enclosing q at 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, while extract_chars (which doesn't use this fast path) reported the same glyphs correctly. The prescan now also tracks whether it saw a top-level cm and forces the existing forward-CTM-recovery fallback when it did (#974).

  • strip_running_headers_footers could 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 /PlacedPDF marked-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 /PlacedPDF BDC landing inside one prescanned region whose matching EMC fell 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 missing EndMarkedContent at the region boundary, closing only what that region itself opened (#1033).

  • Invisible text (render mode Tr 3/7) under a FixedPitch-flagged or GlyphLessFont-named font could be misclassified as monospaceis_monospace derivation trusted the FontDescriptor's FixedPitch flag 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 named GlyphLessFont — whose FontDescriptor sets FixedPitch purely for positioning simplicity, since the glyphs are never rendered. Markdown conversion uses is_monospace to fence a paragraph as a code block, so a scanned novel's OCR'd dialogue tripped FixedPitch and got served as a code block. is_monospace is now gated off for invisible render modes and the GlyphLessFont naming 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 verbatimsave()/save_to_bytes(), extract_pages(), and remove_page() followed by a save all inherited the source document's stream bytes as-is when copying them into an output with no /Encrypt dictionary 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-columnReadingOrder::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_degrees to 0.0, dropping rotated cell text's angleextract_cell re-synthesized each cell's TextSpan and copied every other style field (bbox, font_name, font_size, font_weight, is_italic, mcid) from the source TextBlock, 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 off rotation_degrees (e.g. grid_to_table's advance-axis ordering) silently lost rotation on structure-tree-derived cells. extract_cell now carries block.rotation_degrees through like every other field (#1034).

  • Ruby's render/render_with_layers always 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/renderWithLayers had the same empty-byte-string bug as the Ruby binding, plus a missing accessor — the PHP wrapper had never wired render()/renderWithLayers() to a rendered-image byte accessor at all. Both methods are now wired to the existing pdfRenderPageZoom()/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(), and to_html() output — the existing soft-hyphen stripper only ran on the deprecated MarkdownConverter path 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 new ConversionOptions flag; extract_chars/extract_words/extract_spans are untouched since those are meant to stay glyph-position-faithful (#1023).

  • extract_chars still 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.3 Tw applies 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 in show_text that positions every glyph extract_chars reports. A Type0/Identity-H CID 0x0020 is a real glyph, so it took word spacing anyway and every character after it landed Tw too far right — while extract_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 used max_by_key, which returns the last maximal element, so a font-size tie made dominant_em a 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 serialized Object::Dictionary unsorted (writer::ObjectSerializer already sorts; redaction was a second copy that missed it), XmpWriter::build wrote 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 None outline 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 at debug only. Every paint path now records its drops and emits a GlyphDropped warning 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 existing k_zero_warning_emitted latch, with no entry cap to exhaust. Painting itself is untouched and raster output is byte-identical across the corpus; this is reporting only. WarningCategory gains a GlyphDropped variant and becomes #[non_exhaustive], so downstream exhaustive matches keep compiling (#991, #1013).

  • A rotated page read one way through extract_text and another way through every other text surface — the rotated reading frame was applied only inside extract_text, so to_markdown, to_html, to_plain_text and the filtered surfaces assembled in raw page space and produced a different reading order for the same page; extract_text_in_rect compounded 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-existing chunks_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, and Clippy is a required check, so main and every open pull request were blocked. The call sites now use as_chunks::<N>(), clippy's own machine-applicable rewrite — behaviour is identical (the same split, the same discarded remainder) and it is MSRV-safe, since slice::as_chunks stabilised 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 DecodePolicy rather 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_page panicked, 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: /Decode previously applied only at 1 bpc and for CCITT and now applies at 1, 2, 4, 8 and 16 bpc, and PdfImage now states whether its samples are still in the space the dictionary entries describe — colour-key /Mask and 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 /Domain panicked render_page on a file that parses cleanlyeval_type3 read domain[0] unguarded while already reading domain[1] through get(1)?. The first element now uses first()? 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 /Size overflowed its sample-index arithmeticeval_type0 multiplied /Size entries 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 × BitsPerSample bits), 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 /Width and /Height with as usize, so -1 became a near-usize::MAX pixel count and a positive-but-unbacked size sized an allocation from the declaration alone. That painter now uses PageRenderer::image_mask_layout, which narrows to u32, 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, because expand_1bpc_to_8bpc zero-pads and a padded 0-bit means "paint". Separately, a zero-width /Mask sub-image underflowed mw - 1 before 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 /AP had /D and /R but no /N rendered one of two images depending on per-process hash order — the appearance stream was resolved as get("N").or_else(|| values().next()) over a HashMap, 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, /D and /R appear 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_chars returned Form XObject text that no conformant renderer paints, disagreeing with extract_spans about 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_chars returned a second, invisible copy of the article interleaved with the real one — roughly twice the characters extract_spans reported 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 as Test 8 0.71 3 … 0.0 676 and PD Me ds where 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 APIextract_text(), extract_spans(), extract_words() and