yfedoseev/pdf_oxide
 Watch   
 Star   
 Fork   
6 days ago
pdf_oxide

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

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

2026-07-28 12:38:40
pdf_oxide

v0.3.77 | Search-index control lands in every first-party binding: `prepare_search()`/`clear_search_index()` (added to the Rust core in 0.3.76 alongside the new per-page search-index cache) can now be called from Python, JavaScript/WASM, Java/Kotlin/

Added

  • prepare_search()/clear_search_index() exposed across every language binding — callers can now build the search-index cache at a controlled point instead of paying for it on the first search() call, and free it before heavy extraction on the same document object, from any binding, not just Rust. Also fills in a pre-existing gap in the PHP binding, which had no public search() method at all (#952).
  • include_artifacts option on extract_text()/to_markdown()/to_markdown_all()/to_plain_text()/to_plain_text_all() (Python; ConversionOptions.include_artifacts in Rust) — matches the include_artifacts parameter extract_words()/extract_text_lines() already had, default true.

Fixed

  • extract_text(), to_markdown()/to_markdown_all(), and to_plain_text() unconditionally dropped content tagged /Artifact (ISO 32000-1:2008 §14.8.2.2.1 — running headers/footers, page numbers, watermarks), with no override, unlike extract_words()/extract_text_lines() which already defaulted to including artifact-tagged content for backward compatibility. On documents that tag a repeated footer carrying real information (e.g. a section identifier on every page of an engineering spec) as an artifact, this silently dropped that content from the vast majority of pages. All five methods now default to including artifact-tagged content, with include_artifacts=False available for the spec-correct exclusion behavior (#954).

Contributors

Issues reported by:

  • @ankursri494 — #952 (prepare_search()/clear_search_index() missing from every binding but Rust)
  • @tealtonyplanhub — #954 (extract_text() silently drops /Artifact-tagged content with no override)

Thank you!


Installation

Rust (crates.io)

cargo add pdf_oxide

Python (PyPI)

pip install pdf_oxide

JavaScript/WASM (npm)

npm install pdf-oxide-wasm

CLI (Homebrew)

brew install yfedoseev/tap/pdf-oxide

CLI (Scoop — Windows)

scoop bucket add pdf-oxide https://github.com/yfedoseev/scoop-pdf-oxide
scoop install pdf-oxide

CLI (Shell installer)

curl -fsSL https://raw.githubusercontent.com/yfedoseev/pdf_oxide/main/install.sh | sh

CLI (cargo-binstall)

cargo binstall pdf_oxide_cli

MCP Server (for AI assistants)

cargo install pdf_oxide_mcp

Pre-built Binaries Download archives for Linux, macOS, and Windows from the assets below. Each archive includes both pdf-oxide (CLI) and pdf-oxide-mcp (MCP server).

Platform Support

Platform Architecture Archive
Linux x86_64 (glibc) pdf_oxide-linux-x86_64-*.tar.gz
Linux x86_64 (musl) pdf_oxide-linux-x86_64-musl-*.tar.gz
Linux ARM64 pdf_oxide-linux-aarch64-*.tar.gz
macOS x86_64 (Intel) pdf_oxide-macos-x86_64-*.tar.gz
macOS ARM64 (Apple Silicon) pdf_oxide-macos-aarch64-*.tar.gz
Windows x86_64 pdf_oxide-windows-x86_64-*.zip

Changelog

See CHANGELOG.md for full details.

2026-07-27 11:28:56
pdf_oxide

v0.3.76 | Redaction/editor persistence and rendering-accuracy release: DOM edits and `add_text()` overlays on source-loaded pages now survive `save()` without leaving dangling `/Contents` references; multi-input (DeviceN) Type 0 tint transforms, non-

Added

  • PDF/A conversion exposed in the Java binding, with idiomatic Kotlin/Scala/Clojure facades (PdfAConverter, ConversionResult/ConversionAction/ActionType/ConversionError in fyi.oxide.pdf.compliance) — completes PDF/A conversion coverage across every first-party binding (#948).

Fixed

Editor / redaction persistence

  • PdfDocument.save() silently discarded DOM edits (set_text(), remove_element(), erase_header()/erase_footer()/erase_artifacts()) made to a page loaded from an existing PDF — the overlay-merge path that reconciles DOM edits back into a page's /Contents on save had a defect that dropped the edit entirely; edits made via the DOM API now persist through save()/save_page() (#940).
  • A page whose original /Contents was already a multi-entry array (ISO 32000-1 §7.7.3.3) kept a dangling reference to its other original streams after destructive redaction — the merge step that replaces /Contents with the redacted stream assumed a single original stream at array position 0; it now replaces every original content reference, keeping only genuine overlay/addition streams. The same defect class was independently hit through two different triggers: set_text()/remove_element() on a page whose /Contents was already an array, and add_text() combined with apply_redactions_destructive() on the same page (#940, #941, #799).
  • add_text() overlay font registered under the raw font name while the content stream emitted the map_font_name()-transformed name, leaving a dangling /Tf resource for bold/italic overlays, generic family names (Arial, sans-serif), and Symbol/ZapfDingbats — registration now keys off the exact name the Tf operator emits, and /Resources//Resources/Font are resolved through indirect references (common for pages loaded from existing documents) (#941).
  • save_page() discarded overlay text staged by a prior save_page() call on the same pageoverlay_additions now accumulates across calls instead of being overwritten (#941).

Rendering

  • CCITT Group 3/4 /ImageMask XObjects were treated as raw stencil rows instead of decoded — compressed fax data is now actually decompressed; DecodeParms/filter-chain handling, K < 0 Group 4 semantics, and allocation on oversized/malformed input are also corrected (#935, #939).
  • Separation/DeviceN colour spaces with a genuinely multi-channel (N>1) Type 0 (sampled) tint transform rendered black or dropped shapes entirely — the sampled-function evaluator only ever handled a single input dimension, forwarding just the first scn operand and silently falling back to gray = 1 − components[0]; it now performs full N-dimensional multilinear interpolation across the sample grid (ISO 32000-1 §7.10.2's general algorithm, of which the prior 1-D case is the N=1 special case), gated by a MAX_SAMPLED_FUNCTION_DIMS = 8 bound against a pathological /Size array (#849, #859).
  • Non-CCITT 1-bit /DeviceGray images (uncompressed or FlateDecode) were force-fed through the CCITT decompressor and silently dropped when decompression failed — the CCITT path is now gated on the XObject's filter actually being CCITTFaxDecode; the non-CCITT case is unpacked directly, folding /Decode [1 0] inversion the same way the CCITT path already does (#860).
  • Inline images (BI…ID…EI) were parsed and classified as a paint operator but never actually painted — the renderer's operator dispatch had no match arm for Operator::InlineImage; it now expands the abbreviated dictionary keys and routes through the same render_image/render_image_mask path used for Do-invoked image XObjects (#860).
  • Spatial table-cell ownership misattributed text near cell boundaries — stale per-glyph offsets outside a text span's bounding box are now rejected for singleton spans, and exact half-open internal grid intervals keep superscripts and boundary-adjacent text in their correct geometric cell; outer-edge tolerance near the table boundary is unchanged (#937, #938).

Performance

  • Repeated search()/search_page() calls on the same document re-extracted and re-postprocessed every page's full spans on every call — a multi-pattern scan over one document cost O(searches × full extraction) with no benefit from repetition, since the existing span cache is bounded to 8 entries. An unbounded, lighter-weight per-page search index (page text + span bounding boxes only, no font/glyph data) is now built lazily on first use and reused across calls; prepare_search()/clear_search_index() give callers control over eager population and memory reclamation. Measured: repeat search() calls on a 60-page document dropped from ~30ms to ~20-50µs per call after the first (#936).

Changed / Dependencies

  • Test/CI stability: two ocr-feature-gated CCITT diagnostic test files still called fax 0.2's u16 decode_g4 signature after the crate's 0.3 API bump (u32), breaking cargo clippy --all-targets --features rendering,barcodes,signatures,ocr — exactly the combo CONTRIBUTING.md's recommended pre-commit hook uses (#945).

Contributors

Rendering fixes from @Goldziher — CCITT /ImageMask decoding (#935, #939) and spatial table-cell ownership (#937, #938). Redaction/overlay persistence from @thomnico — the add_text()/destructive-redaction interaction (#941, #799, superseding an earlier iteration). Thank you!

Issues reported by:

  • @lightedlogic — #940 (save() doesn't persist DOM edits made via set_text()/remove_element())
  • @ankursri494 — #936 (repeated search() calls on the same document don't get faster)
  • @ultrasaurus — #945 (recommended pre-commit hook fails on main)
  • @bfchiheb — #947 (PDF/A conversion missing from the Java binding)

Installation

Rust (crates.io)

cargo add pdf_oxide

Python (PyPI)

pip install pdf_oxide

JavaScript/WASM (npm)

npm install pdf-oxide-wasm

CLI (Homebrew)

brew install yfedoseev/tap/pdf-oxide

CLI (Scoop — Windows)

scoop bucket add pdf-oxide https://github.com/yfedoseev/scoop-pdf-oxide
scoop install pdf-oxide

CLI (Shell installer)

curl -fsSL https://raw.githubusercontent.com/yfedoseev/pdf_oxide/main/install.sh | sh

CLI (cargo-binstall)

cargo binstall pdf_oxide_cli

MCP Server (for AI assistants)

cargo install pdf_oxide_mcp

Pre-built Binaries Download archives for Linux, macOS, and Windows from the assets below. Each archive includes both pdf-oxide (CLI) and pdf-oxide-mcp (MCP server).

Platform Support

Platform Architecture Archive
Linux x86_64 (glibc) pdf_oxide-linux-x86_64-*.tar.gz
Linux x86_64 (musl) pdf_oxide-linux-x86_64-musl-*.tar.gz
Linux ARM64 pdf_oxide-linux-aarch64-*.tar.gz
macOS x86_64 (Intel) pdf_oxide-macos-x86_64-*.tar.gz
macOS ARM64 (Apple Silicon) pdf_oxide-macos-aarch64-*.tar.gz
Windows x86_64 pdf_oxide-windows-x86_64-*.zip

Changelog

See CHANGELOG.md for full details.

2026-07-23 22:35:47
pdf_oxide

v0.3.75 | Rendering-accuracy and extraction-fidelity release. DeviceCMYK now renders through measured process inks; page rotation (including `/Rotate 270` and negative values) and Separation/DeviceN tint transforms are honoured; inline images and CMY

Added

  • ReadingOrder::Structure — order a Tagged PDF by a pre-order traversal of its /StructTreeRoot (ISO 32000-1 §14.8.2.3), fixing table and complex-layout order where a geometric XY-cut guesses; falls back to ColumnAware when the structure tree is absent or not trustworthy (#877).
  • Mapping provenanceextract_spans reports which ISO 32000-1 §9.10.2 tier (ToUnicode / encoding / heuristic) produced each span's text, surfaced across all bindings (#893).
  • extract_spans_filtered_with_reading_order — reading-order extraction combined with optional-content (OCG) and ink-coverage filtering (#883).
  • Type 0 (sampled) and Type 3 (stitching) tint-transform evaluation in the renderer for Separation/DeviceN colours (#849).
  • CJK vertical-writing running header/footer detection — recognizes folios in the left/right side bands on tategaki pages (#889).
  • resolve_named_destination is now public (#881); a remove_artifacts markdown-conversion example (#845).

Fixed

Rendering

  • DeviceCMYK rendered via the naive 1−(C+K) additive clamp — now converts through measured SWOP-style process inks across every composite / vector / text / image path, so 0 0 0 1 k black renders #231F20 and process cyan renders #00ADEF instead of over-saturated additive values (#861).
  • /Rotate 270 rendered MIRRORED rather than rotated; negative /Rotate values were mishandled (% instead of rem_euclid); real /Rotate and indirect Separation alternates are now resolved correctly (#862, #848, #854).

Text extraction

  • Form-XObject (Do) text went missing when stray operands preceded the name — a dropped/malformed cm left dangling numeric operands, so Do read operands[0] (a stray number) and resolved to an empty name; it now reads the operand immediately preceding the operator per ISO 32000-1 §7.8.2 (#914).
  • Pages were lost when /Count-based page counting returned 0 — now recovered by walking the page tree (#909).
  • Inline images (BI/ID/EI) were parsed but never decoded/Subtype is now supplied and the Table 92 abbreviated keys/values expanded (#863).
  • Unique /PlacedPDF bodies are kept instead of suppressed (#896); spans entirely outside the MediaBox are dropped from extract_spans_with_reading_order (#894); the top-level fill colour set before BT is preserved (scn/cs/rg no longer dropped) (#857).
  • Running-header/footer detection now requires position-consistency before removing repeated text as chrome, and recognizes non-Latin folio digits (#888, #887).

Images

  • /Decode [1 0] is honoured for 1-bit CCITT/DeviceGray images (#856); the jpeg-decoder Adobe inversion is undone for CMYK JPEGs (#855).

Recovery / parsing

  • A truncated file that lost its own Catalog is recovered by rebuilding one from the surviving pages (#890); a file padded after %%EOF is no longer rejected outright as 0 pages (#875).

Fonts

  • The referenced /Encoding /Differences is folded into the font identity hash, and subset choice in extract_embedded_fonts is made deterministic (#878, #853).

Writer

  • Coloured text on a registered embedded font rendered black — FluentPageBuilder::inline_color (and any TextStyle.color) was silently dropped for embedded fontsPdfWriter::add_element routed embedded-font text through the deliberately colour-agnostic add_embedded_text (the HTML painter sets and resets the fill colour around its own calls) without ever emitting the element's fill colour, so no rg operator reached the content stream and the glyphs painted in whatever fill colour was last set (default black). The base-14 path (add_text_content) always emits rg from style.color; the embedded path now matches it by emitting fill_color before the glyph run. No restore is needed — every text element sets its own colour, mirroring the base-14 branch's "always set explicitly" contract.

Performance

  • extract_words / extract_text spent most of their time re-deriving per-glyph facts that cannot change (#882) — text extraction asked each font for its weight and slant once per glyph, inside the show-text loop. Both answers are name-derived: the weight lowercases the base font name (allocating) and runs up to a dozen substring searches, and the slant lowercases it again for two more — so a 13,234-page document repeated ~14 substring scans and 2 allocations 48.7M times for a value fixed at font-load. A sampling profile put str::contains and friends at ~38% of all samples. The Standard-14 width lookup had the same shape, re-stripping the subset prefix and re-scanning the 15-name table per glyph purely to choose a width table. Both are now resolved once per font and memoized, mirroring the existing byte-width-table memo. Alongside: postprocess_spans rescanned every glyph on the page per span to find its baseline (O(spans × chars) — now a bracketed y-sorted index); the page's characters were re-parsed for span post-processing and re-copied on every access (now cached and shared); the word and line paths materialized every glyph twice; article threads — a document-wide parse that walks the whole page tree — were re-parsed per page; the glyph dedup rebuilt the whole array to drop a handful; and the word-merge loop re-derived RTL-ness from an accumulating buffer, costing O(k²) characters per merge chain (the exact blow-up the backtrack guard above it exists to prevent). Measured on the reporter's PDFs: extract_words 156.9s → ~121s and extract_spans 88.7s → ~54s on a 13,234-page document; extract_text 6.03s → 3.94s on a 2,124-page one. Output is byte-identical — verified across a 419-PDF corpus for extract_spans/extract_chars/extract_words/extract_text_lines (including geometry and per-glyph x-offsets) and for text/markdown/HTML.

Changed / Dependencies

  • Text shaping migrated from rustybuzz to harfrust (#899); fax 0.2 → 0.3 (#873); the ttf-parser migration decision for RUSTSEC-2026-0192 is documented (#900).
  • office_oxide bumped to 0.1.8 (#904, #932). A combined dependency roundup (crates + CI actions + Go), plus routine crate and CI-action bumps (#931, #907, #898, #872, #869, #870, #871, #864, #865, #866, #867, #868, #874, #891).
  • Test/CI stability: the flaky structured_warnings round-trip test is fixed (#912); misc test guards and binding-format fixes (#846, #897, #880).

Contributors

The majority of this release was contributed by @ajbufort — rendering (/Rotate handling and Separation/DeviceN tint transforms #848, #849, #854, #862; DeviceCMYK process inks #861), image decode (#855, #856), text extraction (#857, #863, #894, #896, #909), fonts (#853, #878), recovery/parsing (#875, #890), and reading-order / span filtering (#877, #881, #883). Additional fixes from @norbusan (#911) and @ultrasaurus (#845, #846). Thank you!

Issues reported by:

  • @ankursri494 — #882 (extract_words/extract_text far slower than extract_spans on large PDFs)
  • @tobocop2 — #876 (signal to callers when a page's text cannot be extracted)
  • @ultrasaurus — #879 (remove_footers removed real content on IRS forms)
  • @norbusan — #913 (text inside a Form XObject missing from extraction)

Installation

Rust (crates.io)

cargo add pdf_oxide

Python (PyPI)

pip install pdf_oxide

JavaScript/WASM (npm)

npm install pdf-oxide-wasm

CLI (Homebrew)

brew install yfedoseev/tap/pdf-oxide

CLI (Scoop — Windows)

scoop bucket add pdf-oxide https://github.com/yfedoseev/scoop-pdf-oxide
scoop install pdf-oxide

CLI (Shell installer)

curl -fsSL https://raw.githubusercontent.com/yfedoseev/pdf_oxide/main/install.sh | sh

CLI (cargo-binstall)

cargo binstall pdf_oxide_cli

MCP Server (for AI assistants)

cargo install pdf_oxide_mcp

Pre-built Binaries Download archives for Linux, macOS, and Windows from the assets below. Each archive includes both pdf-oxide (CLI) and pdf-oxide-mcp (MCP server).

Platform Support

Platform Architecture Archive
Linux x86_64 (glibc) pdf_oxide-linux-x86_64-*.tar.gz
Linux x86_64 (musl) pdf_oxide-linux-x86_64-musl-*.tar.gz
Linux ARM64 pdf_oxide-linux-aarch64-*.tar.gz
macOS x86_64 (Intel) pdf_oxide-macos-x86_64-*.tar.gz
macOS ARM64 (Apple Silicon) pdf_oxide-macos-aarch64-*.tar.gz
Windows x86_64 pdf_oxide-windows-x86_64-*.zip

Changelog

See CHANGELOG.md for full details.

2026-07-14 09:49:40
pdf_oxide

v0.3.74 | Scientific and print-era PDF extraction fixes — per-glyph advance now folds `TJ` kerning per the spec so it matches the renderer (poppler/PDFium/pymupdf), fixing word spacing on justified and kerned text; displayed-math tokens no longer fus

Fixed

  • Displayed-math tokens fused into one word — dx/dt = extracted as =dt, and whole equations could collapse into a single token (#830, #836) — the word-gap merge's backtrack check (gap ≤ font_size × 0.15) had no lower bound, so a run backtracking far behind the previous word's origin (a fraction bar returning to typeset the denominator, a relation sign closing an equation) still satisfied "a large negative gap ≤ a small positive threshold" and merged; because the merge is incremental, a chain of such backtracks could collapse an entire displayed equation — and in the worst corpus case, the start of the following sentence — into one word. The fix landed in two stages: the composed-text emitter (extract_text/to_markdown/to_html) was guarded first, then the identical guard (real baseline offset, origin-or-left backtrack, multi-em overlap, gated off for RTL) was applied to extract_words_inner's post-clustering merge so word geometry is correct too. Because table detection consumes word geometry, the detector was hardened in the same change so the corrected words no longer fabricate phantom tables out of ordinary wrapped captions.
  • Subscript index numbers extracted as decimals — P₁,₀ became P1.0 (#816) — the decimal-merge rule joins two adjacent pure-digit runs with a . (a heuristic for split-box dollar amounts where the whole part and cents print in separate fixed-width boxes, 123456 + 72123456.72). Its upper gap bound was too permissive: real split-box amounts sit ~0.8–1.0× the font size apart, but subscript index digits are a smaller font spaced ~1.5–1.7× apart, so the old 2.0× ceiling let the rule invent decimals the document never contained. The ceiling is tightened to 1.3× the font size, separating genuine integer/cents boxes from widely spaced subscripts.
  • Born-digital pages were classified as Scanned and routed to pages_needing_ocr — 13.7% of a 6,269-page corpus (#840) — OCR-ing a page that already carries good native text replaces it with worse output, so a wrong Scanned verdict is actively harmful. The dominant cause: gather_page_signals and a second text_quality_gate call site both built their word-fragmentation input by joining raw content-stream spans with a forced space after each one. Math typesetting draws each atom — a parenthesis, an operator, a subscript — as its own span, so (∞) became three one-character "words"; on a dense LaTeX page this inflated the fragmented-word ratio and collapsed average word length until the quality gate mistook it for a scan and overrode an otherwise-correct TextLayer verdict. Both call sites now build their word list from extract_words — the same glyph/span clustering extract_text relies on, including the new math-backtrack guard — instead of one token per span.
  • extract_words split single string literals into fragments (modulem|odu|le) via phantom glyph gaps (#811) — TJ-offset space spans (ISO 32000-1 §9.4.4) were created with one char but an empty char_widths, and the span merge kept the widths in lockstep by tail-append + tail-resize. Whenever a width-less span contributed chars anywhere but the tail, every subsequent width shifted one slot, so per-glyph decomposition paired each glyph's accurate x-origin with its neighbor's nominal width — phantom ~0.3 em intra-word gaps that the word-gap clusterer split on. Space spans now carry their advance from creation, and the merge normalizes every contribution at its own position — inserted separators get the real geometric gap they stand in for.
  • PathContent geometry ignored stroke_width, so stroke-width-encoded table rules extracted as 1×0 pt specks (#812) — print-era generators draw a table's vertical rule as a ~1 pt segment stroked as wide as the table is tall (430 w … 0 0 m .998 0 l S). The geometric bbox of that path bears no resemblance to the rendered bar, so is_table_primitive() and the line-based table detector missed the whole grid and its text extracted column-major. stroke_width is now CTM-scaled at extraction (§8.4.3.2 — the line width transforms like all other geometry), the new PathContent::rendered_bbox() exposes the stroke-inflated extents (exact perpendicular + cap inflation for straight segments, conservative half-width outset otherwise), and line classification, clustering, and the per-row/column separator checks all judge rendered extents. The geometric bbox is unchanged for every other consumer. Also exposed as pdf_oxide_path_get_rendered_bbox (C FFI) and rendered_bbox in the Python/WASM path dicts, and threaded through the go, ruby, php, swift, csharp, dart, elixir, zig, julia, r, objc, cpp, and node bindings.
  • 90°-rotated pages extracted in portrait order and words carried no rotation metadata (#813) — landscape tables typeset on portrait pages (text-matrix rotation, no /Rotate key) came out as interleaved word salad: the reading-order pipeline re-sorted spans with portrait-frame comparators, the plain-text assembler grouped lines in the portrait frame, and rotation_degrees was dropped at both TextSpan::to_chars and word assembly. A dominant-rotation vote (half-or-more of the page's non-whitespace spans sharing one quadrant rotation, mirroring the tategaki vote) now orders the whole page in its rotated reading frame — coordinates are restored afterwards, so callers keep true page space — and minority rotated runs (margin stamps, figure labels) are ordered upright per rotation group and appended after the horizontal flow, matching the span path's existing firewall. Runs sharing a ±90° rotation no longer span-merge across rotated lines. rotation_degrees now flows span → char → Word and is exposed on Word (Rust/serde), PyWord, the WASM word JSON, and pdf_oxide_word_get_rotation (C FFI), and surfaced on the word type of the go, ruby, php, swift, csharp, dart, elixir, zig, julia, r, objc, cpp, and node bindings, plus the JVM TextWord (Java, inherited by the Kotlin/Scala/Clojure wrappers).
  • Scanned Hebrew/Arabic OCR text layers extracted reversed — every word both letter- and word-order-reversed (#826) — scanned RTL PDFs whose invisible OCR text layer emits one TJ array per recognized word (the standard OCR-sandwich shape, e.g. Tesseract-style producers) had two compounding bugs in the Tj/TJ buffer-flush path. flush_tj_buffer (the default WordBoundaryMode::Tiebreaker path) never received the confidence-gated geometric direction detector, so it still used the old accumulated_width > 0.0 heuristic — true for nearly every non-empty RTL buffer — and reversed unconditionally instead of detecting direction; all three flush sites now route through one shared decision point (bidi::apply_rtl_verdict). And because already-logical invisible-OCR text and genuinely visual-order text have identical geometric signatures, text render mode is now threaded through so invisible runs (Tr 3/7) skip the geometric heuristics entirely and trust extraction order as-is.
  • FluentPageBuilder::rich_paragraph drew consecutive TextRuns flush together — TextRun::bold("Text Run 1") + TextRun::normal("Text Run 2") extracted as Text Run 1Text Run 2 (#837) — each run word-wraps and emits its own text, then advances cursor_x by exactly the emitted width, with nothing separating one run's end from the next's start, so a run boundary falling mid-line drew the next run against the previous one. Consecutive runs on the same line are now separated.
  • Stacked two-line column/table-header cells fused into one token — Comparison over rate extracted as Comparisonrate (#847) — when the structure-tree (tagged-content) assembler linearizes a header cell drawn as two stacked rows, the rows arrive as consecutive spans that horizontally overlap (negative gap) at a baseline drop sitting just under the same-line threshold, so the assembler treats them as one line and defers to the space decision — which, seeing a negative gap, returned no space and glued them. A negative gap combined with a genuine baseline shift is two stacked tokens, never intra-word kerning (which shares a baseline), so a separator is now inserted. Scoped to the tagged/structure-tree path so main-flow inputs (e.g. LaTeX math fraction stacks, already handled by dedicated line-break branches) stay byte-identical; a 419-PDF sweep confirmed the change is isolated to tagged tables/forms with only glyph-preserving spacing gains.
  • Per-glyph advance drifted behind the true rendered position on kerned/justified text, manufacturing phantom inter-glyph gaps (#847) — a sub-threshold TJ positioning number (ISO 32000-1 §9.4.4) advanced the text matrix but was dropped from the run's stored per-glyph advance (char_widths/accumulated width), so on a line drawn as one continuous buffer the many small post-space kerning offsets accumulated into a multi-point undershoot: the reconstructed glyph positions fell behind where the glyphs actually render. Poppler/PDFium/pymupdf all agree on the true position because they fold the offset into the advance; pdf_oxide was the sole outlier (−2.3 pt over one measured line, concentrated at word gaps). The stored advance now folds the exact §9.4.4 displacement — −Tj/1000 × Tfs × Th — into the run, so per-glyph geometry equals the text-matrix position by construction (closing ~72% of the drift on the worst case; the residual is the /Widths-vs-substitute-font-metric difference, a separate axis). This is a generic positioning fix, not a heuristic — it is the same advance the renderer uses — and it is what lets the narrow-word-gap rescue below operate on true gaps instead of phantom ones (a phantom ~0.15 em gap is what previously over-split matchedmatch ed, forcing this rescue to be held back). A companion guard tightens the cross-font single-letter glue ceiling from 0.25 em to 0.12 em: 0.25 em is a full word space, so a word followed by a single-letter variable set in a different font run (roman solution → math-italic U) was wrongly glued into solutionU; drop-caps and small-caps initials — the glue's real target — sit tight against their word at ~0 em, so 0.12 em keeps them while releasing genuine word→variable boundaries (poppler and PDFium keep the space).
  • Condensed headings and tracked runs typeset with no space glyph fused adjacent words — conformance test plansconformancetestplans (#847) — a bold heading or a running header whose word separation is pure Td/TJ positioning (no 0x20 glyph) opens inter-word gaps of only ~0.18 em, below the intra-word kerning guard (0.75× the space-glyph advance), so the words glued. Because it now runs on the accurate per-glyph advance above, the gap distribution reflects the real render rather than the old undershoot. A fixed magnitude can't separate a 0.18 em word gap from ~0.15 em kerning — but within one line the intra-word glyph gaps cluster near zero while the inter-word gaps form a distinct larger cluster. A per-line multi-level bimodal split of the gap distribution now pins the word boundary regardless of absolute magnitude — splitting at every gap level above the intra-word cluster, so a condensed running footer (© ISO 2021 – All rights reserved) recovers its ~0.10 em word gaps too, matching the advance-aware extractors (pdfminer, poppler, Adobe Acrobat) that pymupdf/pdfplumber miss. It only ever adds a space, only when the suppression came from the geometric kerning guard (a new SpaceSource::IntraWordKerning marker) — never the semantic no-space rules, so complex-script text (Devanagari, Bengali, …), CJK, ligatures, and RTL are untouched. Two guards keep it off dense math, whose sub/superscript gaps are the same ~0.10 em magnitude: it never fires across a super/subscript baseline shift, nor when another glyph's ink occupies the gap (a subscript drawn between a variable and the next symbol, λᵢr → keeps λᵢr, never λ i r). 419-PDF sweep: glyph-preserving spacing gains on headings/footers/condensed runs, zero fusions, zero over-segmentation of math or complex scripts. (A word boundary whose two glyphs overlap — negative advance, e.g. the rights reserved seam — carries no geometric signal and is recovered by no extractor, Adobe included.)

Security

  • Bumped crossbeam-epoch to 0.9.20 (RUSTSEC-2026-0204) (#827).

Internal

  • Pinned the Go toolchain to 1.26.5 in CI (GO-2026-5856) (#834).
  • Consolidated the July 2026 Dependabot cargo + github-actions updates (#835).
  • Added tests that remove_footers preserves body content (#800).
  • Fixed the broken --all-features test commands in the PR template and dev guide (#838).
  • Bumped office_oxide to 0.1.6.

Contributors

Community fixes merged this release:

  • @tobocop2 — reported and submitted the fixes for the fragmented-word, stroke-encoded table-rule, and rotated-page bugs (#811, #812, #813 → #814), the subscript-decimal bug (#816 → #817), and the displayed-math relation-sign fusion (#830 → #831); also reported the word-layer math fusion (#836) and the born-digital misclassification (#840). A standout contribution across the whole release.
  • @ultrasaurus (Sarah Allen) — contributed the remove_footers content-preservation tests (#800).

Issues reported by:

  • @tobocop2 — #811, #812, #813, #816, #830, #836, #840
  • @RubberDuckShobe — #837 (rich_paragraph run spacing)
  • @palmoni5 — #826 (Hebrew OCR-sandwich reversal)
  • @Goldziher (Na'aman Hirschfeld) — #847 (word fusion on positioned runs)

Thank you all — reporters and fixers alike.


Installation

Rust (crates.io)

cargo add pdf_oxide

Python (PyPI)

pip install pdf_oxide

JavaScript/WASM (npm)

npm install pdf-oxide-wasm

CLI (Homebrew)

brew install yfedoseev/tap/pdf-oxide

CLI (Scoop — Windows)

scoop bucket add pdf-oxide https://github.com/yfedoseev/scoop-pdf-oxide
scoop install pdf-oxide

CLI (Shell installer)

curl -fsSL https://raw.githubusercontent.com/yfedoseev/pdf_oxide/main/install.sh | sh

CLI (cargo-binstall)

cargo binstall pdf_oxide_cli

MCP Server (for AI assistants)

cargo install pdf_oxide_mcp

Pre-built Binaries Download archives for Linux, macOS, and Windows from the assets below. Each archive includes both pdf-oxide (CLI) and pdf-oxide-mcp (MCP server).

Platform Support

Platform Architecture Archive
Linux x86_64 (glibc) pdf_oxide-linux-x86_64-*.tar.gz
Linux x86_64 (musl) pdf_oxide-linux-x86_64-musl-*.tar.gz
Linux ARM64 pdf_oxide-linux-aarch64-*.tar.gz
macOS x86_64 (Intel) pdf_oxide-macos-x86_64-*.tar.gz
macOS ARM64 (Apple Silicon) pdf_oxide-macos-aarch64-*.tar.gz
Windows x86_64 pdf_oxide-windows-x86_64-*.zip

Changelog

See CHANGELOG.md for full details.

2026-07-06 08:19:51
pdf_oxide

v0.3.73 | Two independent reading-order sort panics fixed — a non-transitive vertical-CJK (tategaki) column comparator and an oversized-literal lexer overflow — so malformed and scanned PDFs no longer crash extraction instead of returning text.

Fixed

  • Reading-order sort could panic on malformed or scanned PDFs instead of returning text (#807) — Rust's sort_by/sort_unstable_by (1.81+) detects a comparator that violates total order and panics with does not correctly implement a total order — uncatchable across the FFI boundary, aborting the host process across every binding. Two independent causes were fixed:

    • Tategaki (vertical-writing) column grouping (ISO 32000-1 §9.7.4.3, WMode 1). sort_spans_vertical_tategaki and its two duplicated call sites (postprocess_spans's tategaki intercept, TategakiStrategy) decided "same column" with a pairwise |a - b| <= tol check on each span's X-center. That check is not transitive: a chain of spans each within tol of its neighbor can span far more than tol end to end, so the comparator can claim A<B, B<C, and C<A all at once. This is exactly what a scanned vertical-CJK OCR layer produces — hundreds of single-glyph, sub-point-wide spans whose X-centers step by a fraction of the column pitch. Columns are now found by single-linkage clustering of X-centers (order right-to-left, start a new column when the gap to the previous center exceeds the tolerance), then sorted by (column, Y) — a genuine total order, and more accurate than quantizing each center into a fixed-size band independently, which can split two spans only a couple points apart into different columns if they straddle a band boundary.
    • Oversized real-number literals silently overflowing to Infinity. PDF 32000-1:2008 Annex C.2 bounds real values to approximately ±3.403×10^38, but the lexer parsed real literals via f64::from_str, which saturates an all-digit literal past that limit to f64::INFINITY rather than erroring. Combined with a degenerate content-stream matrix (a zero CTM/Tm component), 0.0 × Infinity produced a NaN glyph coordinate that could panic the same class of sort elsewhere in the pipeline. Oversized literals are now clamped to the spec's implementation limit at parse time, so an out-of-range literal can no longer poison downstream arithmetic into NaN.

    @tobocop2 reported this, root-caused it, and submitted a working fix (#808) using single-linkage column clustering, along with a minimal repro and three real-world vertical-Japanese novels to stress-test against. We folded that clustering approach directly into this fix (verified byte-identical output against #808 on all three novels) alongside the separate lexer fix below, so #808 was closed in favor of this PR.

Thanks to @tobocop2 (#807, #808) for finding, root-causing, and fixing this.


Installation

Rust (crates.io)

cargo add pdf_oxide

Python (PyPI)

pip install pdf_oxide

JavaScript/WASM (npm)

npm install pdf-oxide-wasm

CLI (Homebrew)

brew install yfedoseev/tap/pdf-oxide

CLI (Scoop — Windows)

scoop bucket add pdf-oxide https://github.com/yfedoseev/scoop-pdf-oxide
scoop install pdf-oxide

CLI (Shell installer)

curl -fsSL https://raw.githubusercontent.com/yfedoseev/pdf_oxide/main/install.sh | sh

CLI (cargo-binstall)

cargo binstall pdf_oxide_cli

MCP Server (for AI assistants)

cargo install pdf_oxide_mcp

Pre-built Binaries Download archives for Linux, macOS, and Windows from the assets below. Each archive includes both pdf-oxide (CLI) and pdf-oxide-mcp (MCP server).

Platform Support

Platform Architecture Archive
Linux x86_64 (glibc) pdf_oxide-linux-x86_64-*.tar.gz
Linux x86_64 (musl) pdf_oxide-linux-x86_64-musl-*.tar.gz
Linux ARM64 pdf_oxide-linux-aarch64-*.tar.gz
macOS x86_64 (Intel) pdf_oxide-macos-x86_64-*.tar.gz
macOS ARM64 (Apple Silicon) pdf_oxide-macos-aarch64-*.tar.gz
Windows x86_64 pdf_oxide-windows-x86_64-*.zip

Changelog

See CHANGELOG.md for full details.

2026-07-05 12:18:20
pdf_oxide

v0.3.72 | Rotated-page text extraction & a transitive-dependency security patch — the spatial extractors no longer garble text on rotated pages, and the optional Office-export path clears an untrusted-XML denial-of-service advisory.

Security

  • office_oxide 0.1.2 → 0.1.3 (clears RUSTSEC-2026-0194 / RUSTSEC-2026-0195) — the optional Office-document export path depended on office_oxide 0.1.2, whose transitive quick-xml 0.40 has an unbounded per-xmlns heap allocation in NsReader::push that a crafted DOCX/XLSX/PPTX could use to exhaust memory (a denial-of-service on untrusted input). office_oxide 0.1.3 upgrades to quick-xml 0.41, which bounds the allocation. pdf_oxide's own quick-xml was already 0.41; this bump closes the remaining transitive path so the dependency tree is advisory-clean.

Fixed

  • extract_words / extract_spans / extract_text_lines garbled text on rotated pages (#804) — on rotated pages the spatial extractors clustered along the wrong axis and fused unrelated cells into giant tokens (a whole column returned as a single 1000+ character "word", separate rows fused into one line). Two independent root causes were fixed:

    • Page /Rotate 90/270 (§7.7.3.3). Span bounding boxes were mapped into the page's displayed frame before word/line clustering, but a span decomposes into characters by laying glyphs horizontally along its bbox with their raw advance widths — a representation that cannot express a run whose visual direction has become vertical. Every raw text row therefore collapsed onto one displayed band and perpendicular columns fused. Because the horizontal clustering is already correct in raw user space (and extract_chars already reports raw coordinates), 90°/270° pages now keep their span geometry in raw space; all four spatial APIs agree. (180° pages, where text stays horizontal, keep their existing mirror.)
    • Rotated text matrices (rotation_degrees = ±90 — vertical column headers, chart-axis labels). A run drawn with a rotated text matrix advances along a rotated axis, but the extractor stores a span bbox flattened onto the x-axis (width = Σ advances, height = font), so adjacent rotated columns overlap and the reading-order word merge and y-band line grouping fused them. Rotated runs are now excluded from both the cross-span word merge and the line grouping — each stays its own word(s) and its own line.

    Thanks @ankursri494 for the report and the public, PII-free reproducers.

Thanks to @ankursri494 (#804) for reporting the issue that drove this release.


Installation

Rust (crates.io)

cargo add pdf_oxide

Python (PyPI)

pip install pdf_oxide

JavaScript/WASM (npm)

npm install pdf-oxide-wasm

CLI (Homebrew)

brew install yfedoseev/tap/pdf-oxide

CLI (Scoop — Windows)

scoop bucket add pdf-oxide https://github.com/yfedoseev/scoop-pdf-oxide
scoop install pdf-oxide

CLI (Shell installer)

curl -fsSL https://raw.githubusercontent.com/yfedoseev/pdf_oxide/main/install.sh | sh

CLI (cargo-binstall)

cargo binstall pdf_oxide_cli

MCP Server (for AI assistants)

cargo install pdf_oxide_mcp

Pre-built Binaries Download archives for Linux, macOS, and Windows from the assets below. Each archive includes both pdf-oxide (CLI) and pdf-oxide-mcp (MCP server).

Platform Support

Platform Architecture Archive
Linux x86_64 (glibc) pdf_oxide-linux-x86_64-*.tar.gz
Linux x86_64 (musl) pdf_oxide-linux-x86_64-musl-*.tar.gz
Linux ARM64 pdf_oxide-linux-aarch64-*.tar.gz
macOS x86_64 (Intel) pdf_oxide-macos-x86_64-*.tar.gz
macOS ARM64 (Apple Silicon) pdf_oxide-macos-aarch64-*.tar.gz
Windows x86_64 pdf_oxide-windows-x86_64-*.zip

Changelog

See CHANGELOG.md for full details.

2026-07-05 00:40:33
pdf_oxide

v0.3.71 | Spec-alignment & extraction-leadership release — the renderer gains tiling patterns, Type 3 fonts, and mesh shadings; the markdown converter gains first-class tables, images, links, headings, nested lists, running header/footer removal, and

Added

  • Renderer spec alignment (ISO 32000-1) — the CPU rasteriser now paints several previously-unsupported constructs: tiling patterns (PatternType 1, §8.7.3), Type 3 font glyphs (CharProcs executed under the font matrix with d0/d1, §9.6.5), mesh shadings (free-form and lattice-form Gouraud triangle meshes and Coons/tensor patches — types 4/5/6/7 — plus function-based type 1, §8.7.4.5), text rendering modes 4–7 (glyph-outline clip accumulation across BT/ET, §9.3.6), and colour-key masking (/Mask [ranges], §8.9.6.4). JPEG 2000 images with chroma-subsampled components are now upsampled and decoded rather than skipped.
  • First-class tables in the markdown/HTML converters — the pipeline converter renders detected tables directly (pipe tables with header rows and colspan handling), replacing the fragile text-post-processing path.
  • Images, links, and document structure in markdown — figures are emitted as ![](…), /Link annotations become [text](uri) / <a href> (with a safe-scheme gate), heading hierarchy is inferred as #######, indentation-based nested lists are preserved, cross-page running headers/footers are detected and filtered, and superscript-marker + page-bottom footnotes become [^n] references.
  • Hybrid-reference files (/XRefStm, §7.5.8.4) — a classic trailer's cross-reference-stream supplement is now parsed and merged, so hybrid PDFs resolve all objects.

Fixed

  • Per-glyph coordinates in extract_words / extract_spans / extract_text_lines drifted on CID/Type 0 fonts (#780, part 2) — these APIs reconstructed each glyph's x-position by summing nominal advance widths, which omits the ISO 32000-1 §9.4.3 TJ-array kerning, so positions drifted cumulatively along a line (up to tens of points) versus extract_chars. Each glyph's x now comes from the accurate content-stream position (matching extract_chars and Poppler's pdftotext -bbox); on the reporter's repro, glyphs within 0.5 pt of the reference went from 15 % to 97 %. Word segmentation is unchanged (the char-width array is untouched), so complex-script extraction does not regress. Thanks @ankursri494 for the report and reproducer.
  • Valid ICC profiles reported as [XCOLOR-005] … not a valid stream in validate_pdf_x (#797) — an ICCBased colour space embeds its profile as a stream (§8.6.5.5, [ /ICCBased stream ]), but the validator only accepted a bare dictionary and flagged every conforming profile (including the Ghent Workgroup PDF/X-4 suite). It now reads /N from the stream dictionary. Thanks @takoportal for the detailed report and repro.
  • Structure-tree parsing dropped large trees under a hard-coded budget (#801)parse_structure_tree imposed a 200 ms wall-clock budget and a 10 000-element cap and returned no structure tree at all when either was exceeded (e.g. the 756-page ISO 32000-1 specification), which is non-deterministic across machines and silently loses data. The default now parses the complete tree; callers that need to bound the work can opt in via the new parse_structure_tree_with_budget(&doc, Option<Duration>) (and doc.structure_tree_with_budget(…)). The redundant post-parse size check is removed. Thanks @bjorn3 for the report and proposed API.
  • Inter-word spaces dropped on justified TJ-positioned text (#803) — on documents whose words are positioned with TJ/Td offsets in embedded Type 0 / Identity-H subset fonts (e.g. the 214-page ISO 21111-10 standard), whole runs extracted glued together — All rights reserved came out as Allrightsreserved. The word-gap detector derives its threshold from the font's space-glyph advance, but under Identity-H character code 0x20 maps to CID 32 — an arbitrary glyph, not the space (ISO 32000-2 §9.7.5.2, §9.10.2: the space is reached through the font's CMap/ToUnicode, never code 0x20). Reading that ~0.56 em glyph advance as the space width inflated the threshold so far that genuine ~0.25 em word gaps fell below it and were suppressed. Identity-encoded Type 0 fonts now fall back to the 0.25 em typographic default; non-Identity CMaps that legitimately place a space at 0x20 still use their explicit /W entry. Thanks @Goldziher for the precise report and geometry.
  • Numeric median selection — heading/base-font-size statistics now use select_nth_unstable_by (exact O(n)) instead of a full sort.

Thanks to @ankursri494 (#780), @takoportal (#797), @bjorn3 (#801), and @Goldziher (#803) for reporting the issues that drove this release.


Installation

Rust (crates.io)

cargo add pdf_oxide

Python (PyPI)

pip install pdf_oxide

JavaScript/WASM (npm)

npm install pdf-oxide-wasm

CLI (Homebrew)

brew install yfedoseev/tap/pdf-oxide

CLI (Scoop — Windows)

scoop bucket add pdf-oxide https://github.com/yfedoseev/scoop-pdf-oxide
scoop install pdf-oxide

CLI (Shell installer)

curl -fsSL https://raw.githubusercontent.com/yfedoseev/pdf_oxide/main/install.sh | sh

CLI (cargo-binstall)

cargo binstall pdf_oxide_cli

MCP Server (for AI assistants)

cargo install pdf_oxide_mcp

Pre-built Binaries Download archives for Linux, macOS, and Windows from the assets below. Each archive includes both pdf-oxide (CLI) and pdf-oxide-mcp (MCP server).

Platform Support

Platform Architecture Archive
Linux x86_64 (glibc) pdf_oxide-linux-x86_64-*.tar.gz
Linux x86_64 (musl) pdf_oxide-linux-x86_64-musl-*.tar.gz
Linux ARM64 pdf_oxide-linux-aarch64-*.tar.gz
macOS x86_64 (Intel) pdf_oxide-macos-x86_64-*.tar.gz
macOS ARM64 (Apple Silicon) pdf_oxide-macos-aarch64-*.tar.gz
Windows x86_64 pdf_oxide-windows-x86_64-*.zip

Changelog

See CHANGELOG.md for full details.

2026-07-03 01:32:53
pdf_oxide

v0.3.70 | Extraction-fidelity release — kerning-split words rejoined in plain text, table/form line cells split consistently regardless of word width, resolved `/BaseFont` names on the span/word APIs, and content-stream order exposed on extracted spa

Added

  • Content-stream order exposed on extracted spans and words (#779)extract_words and extract_text_lines now carry the originating span's sequence (the content-stream emission order). It is surfaced idiomatically on the word/span types of every language binding — Python, Node.js and WASM, Go, the JVM (Java/Kotlin/Scala/Clojure), C#, Ruby, PHP, C and C++, Objective-C, Swift, Dart, R, Julia, Zig, and Elixir — via the new C-ABI accessor pdf_oxide_word_get_sequence. This lets consumers tell genuinely-consecutive draw calls apart from spatially-close-but-stream-distant ones (e.g. table cells vs. overlays), independent of the final reading order. Thanks @ankursri494 for the request.

Fixed

  • A word split by a spurious space when its glyph runs overlap slightly (#791) — a single word drawn as two adjacent same-font runs whose glyphs overlap by a fraction of a point (ordinary tight kerning, e.g. (PLANAL) then (TINA) positioned just inside PLANAL's right edge) was extracted as PLANAL TINA. The plain-text assembler now recognises this case — a negative inter-run gap, same font/weight/style, word characters on both sides, real (varying) per-glyph metrics, and not a lowercase→uppercase word boundary — and joins the runs with no inserted space, reconstructing PLANALTINA, matching pdftotext / PyMuPDF / lopdf on the same file. The spans are left unmerged, so page layout, reading order, and table detection are unaffected. Thanks @schelip for the report and minimal repro.
  • extract_text --format lines merged table/form cells across column gaps inconsistently (#792) — a flat 50 pt column-gap threshold made cell splitting depend on how wide each row's words happened to be, so a header row of short values (CEP/Cidade/UF) split into one line per cell while the value row directly below it (73751-452/PLANALTINA/GO, wider words, same gutters) merged into a single line. The threshold in line clustering is now font-relative ((font_size × 3).max(30 pt)), so rows sharing the same columns split the same way. Thanks @schelip for the report.
  • Span-derived APIs reported unresolved (alias) font names (#780, part 1)extract_spans, extract_words, and extract_text_lines reported the page's /Resources/Font alias (e.g. F1) rather than the resolved /BaseFont (e.g. Helvetica, CIDFont+F1). They now resolve to the base font, matching extract_chars and pdfminer.six / pdfplumber. (The second part of #780 — per-glyph coordinate drift on CID/Type0 fonts in extract_words — is tracked for a follow-up release.) Thanks @ankursri494 for the report.
  • Cased and caseless non-Latin prose no longer mis-detected as spatial tables — the no-rulings table detector's prose-paragraph guard now recognises sentence boundaries in cased non-Latin scripts and treats the Bengali/Devanagari danda (, ) as a sentence terminator, so complex-script running prose that happens to align into columns is not extracted as a table grid.

Thanks to @schelip (#791, #792) and @ankursri494 (#779, #780) for reporting the issues that drove this release.


Installation

Rust (crates.io)

cargo add pdf_oxide

Python (PyPI)

pip install pdf_oxide

JavaScript/WASM (npm)

npm install pdf-oxide-wasm

CLI (Homebrew)

brew install yfedoseev/tap/pdf-oxide

CLI (Scoop — Windows)

scoop bucket add pdf-oxide https://github.com/yfedoseev/scoop-pdf-oxide
scoop install pdf-oxide

CLI (Shell installer)

curl -fsSL https://raw.githubusercontent.com/yfedoseev/pdf_oxide/main/install.sh | sh

CLI (cargo-binstall)

cargo binstall pdf_oxide_cli

MCP Server (for AI assistants)

cargo install pdf_oxide_mcp

Pre-built Binaries Download archives for Linux, macOS, and Windows from the assets below. Each archive includes both pdf-oxide (CLI) and pdf-oxide-mcp (MCP server).

Platform Support

Platform Architecture Archive
Linux x86_64 (glibc) pdf_oxide-linux-x86_64-*.tar.gz
Linux x86_64 (musl) pdf_oxide-linux-x86_64-musl-*.tar.gz
Linux ARM64 pdf_oxide-linux-aarch64-*.tar.gz
macOS x86_64 (Intel) pdf_oxide-macos-x86_64-*.tar.gz
macOS ARM64 (Apple Silicon) pdf_oxide-macos-aarch64-*.tar.gz
Windows x86_64 pdf_oxide-windows-x86_64-*.zip

Changelog

See CHANGELOG.md for full details.

2026-06-27 12:21:41
pdf_oxide

v0.3.69 | Language-bindings release — idiomatic bindings for **C++, Swift, Kotlin, Dart, R, Julia, Zig, Scala, Clojure, Objective-C, and Elixir**, each over the stable C ABI, with per-language CI, package-registry publishing, cross-language regressio

Added

  • Eleven new language bindings, each with an idiomatic wrapper, an api-coverage test (one assertion per public method), runnable CI-asserted examples, a README with install coordinates, and a dedicated CI workflow (Linux+macOS) running the same verification set:
    • C++ (cpp/) — header-only C++17 RAII wrapper; CMake with install/export targets and a Conan recipe.
    • Swift (swift/) — SwiftPM package + C module map.
    • Kotlin (kotlin/) — thin facade over the Java JNI binding.
    • Dart/Flutter (dart/) — dart:ffi.
    • R (r/) — .Call C shim, external-pointer handles.
    • Julia (julia/) — ccall.
    • Zig (zig/) — @cImport.
    • Scala (scala/) — thin facade over the Java JNI binding (Scala 3).
    • Clojure (clojure/) — direct Java interop over the JNI binding.
    • Objective-C (objc/) — NSObject wrappers over the C ABI.
    • Elixir (elixir/) — dirty-scheduler NIF (CPU-bound work never blocks the BEAM).
  • Package-registry publishing wired into the release pipeline for the new bindings: Maven Central (Kotlin, Scala), Clojars (Clojure), Hex.pm (Elixir), and pub.dev (Dart, via GitHub OIDC). Objective-C ships as a Trunk-free CocoaPods binary pod — an xcframework + podspec uploaded as release assets and installed via a :podspec URL — since CocoaPods Trunk goes read-only on 2026-12-02. C++ (vcpkg/Conan), R (CRAN), Julia (General registry), and Swift/Zig (git tag) are documented in docs/RELEASING-bindings.md.
  • Cross-language regression examples — alongside each binding's basic example, three shared-scenario examples (HTML extraction, word geometry, table extraction) run with output assertions in every binding's CI workflow.
  • Single-source version managementscripts/sync_version.py propagates the canonical Cargo.toml version into every binding manifest and version/parity assert (--check verifies, --set X.Y.Z bumps everything). A Version Consistency CI workflow fails if any binding drifts.

Fixed

  • Non-Identity-ordered Type0 fonts no longer emit a wrong character for CIDs missing from /ToUnicode (#773, #775) — for an embedded Type0 font whose /ToUnicode CMap omits some drawn CIDs (e.g. a ligature glyph with no single Unicode codepoint), the decode path fell back to a numeric guess — the GID via the standard glyph-name table → AGL, or the CID itself as a code point (char::from_u32) — emitting a plausible-but-wrong, content-like character that varied per subset (e.g. a ti ligature → : / D, so notificacaono:ficacao). The glyph has no Unicode anywhere in the file (no /ToUnicode entry, no post name, no GSUB), so the letters are unrecoverable, but substituting a wrong character is silent corruption. When a usable /ToUnicode is present, the GID→AGL guess is now suppressed for all Type0 fonts, and the CID-as-Unicode guess is suppressed for fonts whose CIDSystemInfo ordering is not Identity, so an uncovered CID there decodes to U+FFFD instead. For Identity-ordered (Adobe-Identity-0) fonts the CID-as-Unicode guess is restricted to whitespace (U+0020 → space, which producers routinely omit and is reliably CID == codepoint); any other uncovered CID likewise decodes to U+FFFD. A font with no /ToUnicode still uses the CID-as-Unicode heuristic exactly as before, and the authoritative embedded-cmap/post lookups are unchanged. This also resolves the opt-in-flag request (#775) by making the detectable-gap behaviour the default rather than a configuration flag. Thanks @schelip for reporting both issues and contributing the fix.

Installation

Rust (crates.io)

cargo add pdf_oxide

Python (PyPI)

pip install pdf_oxide

JavaScript/WASM (npm)

npm install pdf-oxide-wasm

CLI (Homebrew)

brew install yfedoseev/tap/pdf-oxide

CLI (Scoop — Windows)

scoop bucket add pdf-oxide https://github.com/yfedoseev/scoop-pdf-oxide
scoop install pdf-oxide

CLI (Shell installer)

curl -fsSL https://raw.githubusercontent.com/yfedoseev/pdf_oxide/main/install.sh | sh

CLI (cargo-binstall)

cargo binstall pdf_oxide_cli

MCP Server (for AI assistants)

cargo install pdf_oxide_mcp

Pre-built Binaries Download archives for Linux, macOS, and Windows from the assets below. Each archive includes both pdf-oxide (CLI) and pdf-oxide-mcp (MCP server).

Platform Support

Platform Architecture Archive
Linux x86_64 (glibc) pdf_oxide-linux-x86_64-*.tar.gz
Linux x86_64 (musl) pdf_oxide-linux-x86_64-musl-*.tar.gz
Linux ARM64 pdf_oxide-linux-aarch64-*.tar.gz
macOS x86_64 (Intel) pdf_oxide-macos-x86_64-*.tar.gz
macOS ARM64 (Apple Silicon) pdf_oxide-macos-aarch64-*.tar.gz
Windows x86_64 pdf_oxide-windows-x86_64-*.zip

Changelog

See CHANGELOG.md for full details.