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