Skip to content

Release v0.3.78 — audit scope: 33 issues across rendering, text, files and colour - #1185

Open
yfedoseev wants to merge 45 commits into
mainfrom
release/v0.3.78
Open

Release v0.3.78 — audit scope: 33 issues across rendering, text, files and colour#1185
yfedoseev wants to merge 45 commits into
mainfrom
release/v0.3.78

Conversation

@yfedoseev

@yfedoseev yfedoseev commented Aug 26, 2026

Copy link
Copy Markdown
Owner

Closes #1136, closes #1137, closes #1138, closes #1139, closes #1140, closes #1141, closes #1142, closes #1143, closes #1144, closes #1145, closes #1146, closes #1147, closes #1148, closes #1149, closes #1151, closes #1152, closes #1153, closes #1154, closes #1155, closes #1156, closes #1157, closes #1158, closes #1159, closes #1160, closes #1161, closes #1162, closes #1163, closes #1164, closes #1165, closes #1166, closes #1167, closes #1184, closes #1150, closes #1189, closes #1190, closes #1191, closes #1194, closes #1195, closes #1196, closes #1197, closes #933, closes #1206, closes #1207, closes #1211, closes #1215, closes #1216

The v0.3.78 audit scope: 33 issues, every one either fixed here or closed
with the evidence that it needed no fix — plus nine more the release
sweep found afterwards (see "Found by the sweep" below).

The release gate

#1160 — AES-256 writing derived a second random key, so a file this
library encrypted could not be decrypted by anything, including itself.
The write handler generated its own file key while /UE wrapped a
different one. Reproduced by execution before being fixed: a round-trip
extracted "". AES-128, RC4 and wrong-password paths are pinned as
controls.

Aborts on valid files

panic = "abort" in the release profile turns each of these into a dead
host process, not a catchable error.

Rendering

Text and structure

Files and recovery

Colour

Documentation

#1144 / #1145 two release notes claimed more than the code does — the
rotated-run bbox correction reaches no internal consumer, and page_bbox
is unreachable from WASM and is the identity on the commonest rotated-page
shape. Both notes now say what is true.

#1150, withdrawn and then fixed

This PR originally left #1150 open, on the grounds that the change it
proposed is a provable no-op on its own reproducer and that forcing it
through duplicated table text as prose. Both of those remain true of the
proposed change. The issue itself was real, and it is now fixed here by
a different mechanism.

to_html drops a span a table claims but renders in no cell — the
detector assigns a span to a cell by its bbox centre while ownership is
decided from the span's origin, so the two disagree at a boundary — and
markdown had a recovery pass for exactly this while HTML had none. Adding
one is what duplicated text before, because the guard deciding "the table
did not render this" asked the wrong question twice over: it read
cell.text, which is not the string render_cell_html produces, and it
compared whitespace-normalised text, where the two sides disagree about
where the spaces go rather than about the glyphs. The comparison now runs
on glyph sequences from the same span walk the renderer uses, bounded to
a single row.

Measured over 2008 documents, on recovered paragraphs the table already
renders:

                    v0.3.77     cell.text   plain-text   row-bounded
  >= 25 glyphs      7 pairs     45          45           5
  >= 12 glyphs      16 pairs    62          62           17
  >=  1 glyph       66 pairs    140         116          80

At every length where a substring test against table text means anything,
the branch is now at or below v0.3.77 — while the HTML surface drops
4,016 fewer token types than v0.3.77 does, on ten fewer documents, which
is what the recovery pass is for.

Three issues are closed with an open half recorded on them rather than
silently: #1143 (colour-key /Mask ordering needs a decode-pipeline
reorder), #1159 (flat /DeviceN DCT images error and vanish), and #1165
(the separation renderer still has no inline-image or sh arms).

Verification

Every fix has a synthetic reproducer built in code, named by defect class,
with recorded red-on-baseline evidence and a control that fails if the fix
over-reaches. Four fixtures were rebuilt after the revert-check showed them
vacuous.

  • 5828 lib tests; 393 of 395 integration suites pass. The two that do not
    are unrelated: one is an uncommitted local WIP file, the other fails
    identically on main and reads a fixture from outside the repo.
  • Type-checks with --all-targets on default, rendering, and
    --no-default-features --features fips,icc. Clippy clean.

The regression figures above supersede the earlier single-reference
numbers this section used to carry. The change worth keeping is not the
figures but the method: a coverage drop only reports that fewer pixels
cleared a threshold, which is indistinguishable from a fix that stops
over-painting — reading it as a regression is what caused the #1167
revert — and it is equally blind to ink that moved without changing the
pixel count. Flagged pages are now compared against four independent
renderers on both coverage and tone, and against the file's own
arithmetic wherever the format allows it.

Found by the sweep, fixed here

None of these was reported. They came out of the v0.3.77 → v0.3.78
comparison described under "Regression" below, which scores our two arms
against four independent renderers and four independent text extractors
rather than against each other.

  • [Bug]: An annotation's appearance stream is painted at its own scale instead of being fitted to /Rect #1196 — an annotation's appearance was painted at its own scale.
    §12.5.5 places an appearance by mapping its /BBox corners through
    /Matrix, taking the enclosing upright rectangle, and fitting that onto
    /Rect. We translated to the rectangle's lower-left corner and drew the
    form at whatever size it declared, so a stamp with /BBox [0 0 512 543]
    inside a 93 × 98 pt /Rect covered a fifth of the page. The arithmetic
    convicts without a reference: 512 × 543 is 57% of a 612 × 792 page and
    the rectangle is 1.9% of it. Coverage on the three affected pages moves
    0.21936 → 0.01503 (panel median 0.01512), 0.35857 → 0.03843 (0.03905)
    and 0.15084 → 0.08384 (0.08413). With the appearance in the right
    coordinate system the form's /BBox clip is meaningful again, so the
    exemption [Bug]: /BBox is not applied as a clip for form XObjects, transparency groups, or shadings #1167 left for annotations is lifted.
  • [Bug]: A JBIG2-filtered /Mask is never decoded, so the stencil is silently ignored #1197 — a JBIG2 stencil /Mask was never decoded. The stream
    decoder passes JBIG2Decode through untouched, so the compressed
    bitstream reached the stencil loop, every sample fell past the end of
    the buffer, and the "leave the base image visible" fallback disabled the
    mask completely. Three pages of one scanned book rendered at mean tone
    129–134 where MuPDF and poppler both report 246–251; they now read
    246.4 / 251.1 / 248.8 against MuPDF's 245.9 / 251.1 / 248.8.
  • [Bug]: to_html glues words together and reverses run order on pages extract_text handles correctly #1194to_html glued words wherever a run stepped backwards. The
    inline-flow separator read a negative gap as sub-em kerning and
    concatenated, so It is the came out as theisIt. A span that ends
    before the previous one begins cannot be its continuation. Recovers
    190,381 word tokens across the corpus.
  • [Bug]: XY-cut leaf sort is not row-aware, so jittery OCR baselines reverse the reading order #1195 — the XY-cut leaf sort was not row-aware. It ordered by
    bbox.top() and fell back to x only on exact equality, so any
    sub-point difference put two glyphs of one line into different "rows"
    and the order degenerated to a pure descending sort — whole lines came
    out backwards on OCR text layers. top() is also the wrong edge: it
    moves with the font size, so 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. Now uses the banded-baseline comparator
    the single-column path already used. The multi-column branch of
    GeometricStrategy, which had no x tiebreak at all, takes the same
    comparator.

Three more the sweep exposed are filed and not fixed here, because
none is root-caused and each needs its own investigation: #1198 (a
stencil under /Decode [1 0] and a bit-packed PNG predictor paints ~2×
the panel), #1199 (a small text page renders at 41% of the panel
median), and #1200 (two low-magnitude divergences — JPX + /SMask,
and Type 1 CM glyph weight).

Regression: v0.3.77 vs v0.3.78

Scored against the tag, not main, over 2008 documents and 3214 pages,
with two panels of four independent implementations:

surface engines
rendering MuPDF 1.27.2, pdfium, poppler pdftoppm (-cropbox), Ghostscript 10.00
text MuPDF, pdfminer.six, pypdf, poppler pdftotext

A difference from a reference is a question, not a verdict — the four
renderers disagree with each other by up to 27% on a single page — so a
value inside the panel's own spread is not a defect, a page whose extent
changed is not comparable by coverage at all, and anything outside the
whole panel is settled from the file and the spec rather than by
tie-break.

Rendering, 3214 pages, adjudicated page by page against all four on both
coverage and mean tone:

total distance to panel median, v0.3.77 : 23.2309
total distance to panel median, v0.3.78 :  8.5964
-> v0.3.78 is BETTER by 14.6345
   inside panel spread: 383   toward consensus: 25   away: 13
   extent-changed: 67   skipped: 0

The 13 remaining "away" pages are eight documents and every one is
pre-existing
— seven render byte-identically in both arms, and the other
two moved by less than half their pre-existing gap. All nine are filed
(#1199, #1200, #1202, #1203, #1204), except issue4246, which #1198
withdrew once tone showed we match MuPDF and Ghostscript to 0.3 of a grey
level.

Reading both quantities matters more than it sounds. Coverage counts pixels
under a threshold, so it measures edge softness as much as ink; mean tone
measures how much ink was laid down. Two of the pages the coverage metric
flagged turned out to be nothing — issue4246 differs from the panel by
2.1x on coverage while agreeing to 0.3 of a grey level out of 255 on tone,
and its expected ink can be derived from the file exactly (0.15388 of the
stencil's samples set, times the 500 x 400 / (595 x 842) = 0.39918 the
image occupies, gives 0.06143; Ghostscript reports 0.06144). Three
pages the coverage metric could not see were only visible on tone.

The 67 extent changes are the /CropBox fix. Coverage is a fraction and
a crop changes the denominator, so they are not comparable that way and
were judged against the file instead: all 55 unique pages now render at
exactly the size their /CropBox declares, accounting for /Rotate.

Two of this release's late fixes are visible in that figure. A JPEG 2000
image with no declared /ColorSpace was rejected outright and its page
rendered blank (#1211); it now renders at mean tone 105.73 against MuPDF's
105.73. And a Pattern colour space reached through a resource name was not
recognised as one, so a tiling fill painted solid black; that page moves
inside the panel on both coverage and tone. Both were found by adjudicating
pages that render identically in both arms — 2688 of 3214 — which no
regression sweep examines, because unchanged is exactly what hides a defect
we have always had.

.md and .html against engines that emit those surfaces. No engine
in the render or text panels produces markdown or HTML, so until this run
those two surfaces could only be compared arm-to-arm and against our own
.text. Two do: poppler pdftohtml and pymupdf4llm. With one engine per
surface there is no majority to appeal to, so the gate is two-of-two
instead — a token that BOTH the external engine and our own .text report
is one the page draws, and the surface must carry it.

.html vs pdftohtml   98 docs, 20939 agreed token types
   v0.3.77 misses 604   v0.3.78 misses 180   -> BETTER by 424
.md   vs pymupdf4llm 99 docs, 21771 agreed token types
   v0.3.77 misses 215   v0.3.78 misses 169   -> BETTER by  46

Two documents on .html went from 203 and 129 missing to zero. This run
also found #1206 — all three cell renderers ran a cell's vertically
stacked
members together, because they asked only has_horizontal_gap,
which compares x — and it is fixed here.

Text, .text / .md / .html over all 2008 documents, plus a
164-document panel sample:

286-document stratified sample, 224 scored
panel-majority token types : 113686
  missed by v0.3.77 : 2474  (2.1762%)
  missed by v0.3.78 : 2470  (2.1727%)
  -> BETTER by 4

Eight documents improve, four lose ground, and every one of the four was
opened: source.pdf loses no token type at all (the delta is instance
counts, which §9.4.3 makes formatting); a4bf0c8a… is correct
dehyphenation the reference does not perform; TAMReview.pdf is #1201;
030ae7ca… is #1208, filed for a decision rather than fixed, because our
output is what the page paints and the panel's disagreement is the
artefact.

This run also found and fixed #1207, a real regression: dehyphenation
ate a compound's own hyphen, turning Cross-sectional into
Crosssectional and Receiver-operating into Receiveroperating. A
typesetter breaking an already-hyphenated word writes the real hyphen and
then U+00AD; stripping the marker before the wrap decision left a bare
Cross-, which the rejoiner then read as the marker and removed in turn.

Arm against arm on our own three surfaces, scored by the §9.4.3 rule that
a drawn token must appear at least once and counting separately the types
that disappeared by being joined into a longer token or split into
several:

surface real loss joined split
.text 51 types / 27 docs 892 2391
.md 2876 types / 177 docs 2528 7467
.html 386 types / 94 docs 3250 57712

The .md figure is dominated by the removal of the injected
[OCR REQUIRED — page N] prose (#1189, #933) — those documents have no
text layer, so the marker was their entire markdown. The rest, on all
three surfaces, are fused compounds that no longer get produced:
balancto, Comvision, insectthe, ありぶ. Every one was read
individually.

Cross-surface, which the panel structurally cannot see: comparing our
own .html against our own .text on the same document, the HTML surface
recovers 190,381 word tokens it had been fusing, and on the document in
#1194 it now reproduces extract_text token for token.

A page may declare /DefaultGray, /DefaultRGB or /DefaultCMYK, and
ISO 32000-1:2008 8.6.5.6 makes a bare g/rg/k behave as if it had named
that override colour space. The declared family and the operand count the
content stream supplies are therefore independent: an ordinary one-operand
`0.5 g` under /DefaultGray [/DeviceCMYK] reaches the DeviceCMYK
projection with a single component.

three_as_rgb and four_as_cmyk_native indexed [1..3] and [1..4] with no
bounds check. Two of the three dispatch sites guarded for that and the
array-name arm did not, so an otherwise ordinary file indexed out of
bounds — a host-process abort under the release profile's panic=abort.

Move the arity precondition into the two helpers, which degrade to the
first component as gray exactly as the guarded sites already did, and drop
the now-duplicated guards from those sites. One rule in one place instead
of a precondition three callers have to remember.

Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
A base image whose /SMask declares a zero dimension aborted the host
process: the resample loop computed `sw - 1` on 0u32, which underflows to
u32::MAX in release and then indexes out of bounds. With panic=abort in
the release profile that terminates the caller, from a file that parses.

The sibling /Mask loop had been widened to u64 and given a zero guard for
exactly this hazard; the /SMask copy had not, because the two are the same
operation — resample a single-channel mask onto the base grid and fold it
into alpha — written out twice. Extract fold_mask_into_alpha and route
both through it, so the guards belong to the operation rather than to one
of its two copies.

Also validate the image dimensions where they are read. Table 89 requires
positive integers and 8.9.5.1 mandates the image-to-user matrix
[1/w 0 0 -1/h 0 1], undefined at zero, so a negative or out-of-range value
is invalid rather than a number to truncate: `as u32` turned -1 into
4294967295 and 2^32 into 0, both of which flowed into allocation and
sampling. The stencil path's image_mask_layout already rejects these with
try_from; the general extractor was the outlier. Every caller skips a
failed image and logs, so a bad image costs its own page area, not the
render.

Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
ISO 32000-1:2008 7.9.7 describes a number tree as a tree, but nothing in
the file format stops a /Kids array from naming an ancestor. The walker
recursed with neither a depth cap nor a visited set, so a self-referential
node overflowed the stack — and a stack overflow is not a catchable panic:
under panic=abort it takes the host process down.

Both guards already exist in this crate's two analogous walkers. Apply the
same shape here: a visited set keyed on ObjectRef, since a cycle through
indirect references is the realistic form and a depth cap alone would
still walk a wide one for a long time, plus a depth cap matching the
colour-space walker's.

Page labelling is an extraction feature, so a malformed tree degrades to
the ranges recovered before the cycle, with a warning, rather than failing
the open.

Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
ISO 32000-1:2008 7.9.5: "Although rectangles are conventionally specified
by their lower-left and upper-right corners, it is acceptable to specify
any two diagonally opposite corners. Applications that process PDF should
be prepared to normalize such rectangles in situations where specific
corners are required."

Rect::from_points built the struct literally while Rect::new — the same
type's other constructor, fed a width and a height — normalised. The two
disagreed, and the callers needing specific corners got the unnormalised
one: /MediaBox [612 792 0 0] produced negative extents, so the page
dimensions went negative and pixmap allocation failed. The page did not
render at all.

Have from_points delegate to new, which fixes every rectangle built from
two corners at once, and normalise in get_page_media_box, which returns
the four numbers rather than a Rect. Both are the read boundary, so no
consumer has to know which diagonal the file used — several had already
grown their own local .abs() and min/max to compensate.

Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
Each entry states the clause, the mechanism, and what a reader would have
seen — and none of them claims a surface the code does not have. The
release notes for this version are being checked against the code rather
than against the previous notes.

Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
ISO 32000-1:2008 8.11 lets a reader choose whether to honour optional-content
state and blesses callers supplying their own, so returning hidden text by
default is legitimate. Offering that mechanism and then ignoring it is not:
above a 256 KB content stream the extractor took a prescan route that keeps
only BT..ET/Do regions and discards the BDC/EMC pairs carrying optional-content
membership, so the exclusion silently did nothing. The caller asked, got no
error, and got the content.

The gate that chose the parser tested excluded *inks* alone. Replace it with
one predicate — has_emission_filter — that names every filter needing
fully-interpreted state, with the reason, so a filter added later is either
listed there or inherits the same hole.

This localises the class; it does not close it. 8.11.3 requires that when
optional content is hidden the content shall not be drawn while graphics state
operations shall still be applied, so visibility is a decision about marking
the page taken after interpretation, never a licence to stop parsing. The
structural answer is one sequential interpreter with suppression at emission,
which retires this predicate along with the prescan branch.

Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
ISO 32000-1:2008 14.7.4.2 makes an /MCID unique only "within its content
stream", and 14.7.4.3 gives a marked-content reference an /Stm entry naming
that stream so a consumer can tell two apart. A page and a Form XObject may
each number theirs from 0.

The crate models this as (McidScope, mcid) and computes the right key at the
boundary — then threw it away in four places:

  - a table-rank lookup retried the page namespace on a miss, so a form's
    MCID 0 matched the table's MCID 0;
  - ReadingOrderContext::mcid_order carried bare u32, and page_order projected
    the scope away under a comment claiming reading-order strategies "don't
    disambiguate by content-stream scope";
  - both structure-order text assemblers bucketed spans in HashMap<u32, _>,
    so a form's span joined the page's bucket and was emitted at the page
    element's slot, while the form's own element then found the id already
    consumed and emitted nothing.

Result: form text was reordered into a table cell and a table cell went
missing. Widen all four to the key that was already being computed.

Deleting the retry alone was not enough — the spans carried correct scopes and
the corruption persisted, which is how the other three sites were found.

Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
ISO 32000-1:2008 7.7.3.3 Table 30 makes /Rotate a clockwise multiple of 90.
Every page transform also carries the PDF y-up to raster y-down flip, so each
of the four matrices has a negative determinant — a positive one is a mirror,
not a turn.

The composite renderer's 270 case was corrected to from_row(0, -s, -s, 0, ..).
The separation renderer kept its own copy, from_row(0, s, -s, 0, ..), whose
determinant is +s^2, so every ink plate of a /Rotate 270 page came out mirrored
while the composite of the same page did not: two renderers of one page
disagreed. /Rotate -90 was worse — it had fallen through to the unrotated arm
and was at least legible, and normalising the angle without fixing the matrix
moved it to mirrored.

One shared page_base_transform, so the copies cannot diverge again, with the
determinant invariant recorded where it can be checked. The existing plate
rotation test used a centred symmetric square on a square page, invariant under
every rotation *and* every reflection, which is why this shipped; the new
fixture is asymmetric on both axes and asserts plate against composite.

Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
ISO 32000-1:2008 12.5.5: an appearance entry is either a stream or a
subdictionary of appearance states, and when it is the latter /AS names the one
to use — the specification's own checkbox example. The renderer accepted /N
only when it resolved to Object::Stream, so the subdictionary was rejected and
every AcroForm checkbox and radio button rendered blank, while
Annotation::appearance_state was parsed and read nowhere in the renderer.

With no /AS, or an /AS naming no member, the clause blesses displaying nothing,
so those cases stay blank deliberately.

Annotation::flags was equally unread, so 12.5.3 Table 165's Hidden and NoView
annotations were painted. Both are now skipped.

Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
ISO 32000-1:2008 Table 39 gives a Type 0 function the defaults
/Encode [0 (Size_0 - 1) ...] and /Decode "same as the value of Range". A
dictionary that writes those out explicitly means exactly what an absent entry
means.

The evaluator declined on *presence*, while its own doc comment said it
declined a non-default /Encode//Decode — so a conforming file fell back to the
1 - tint grey approximation instead of its real colour. Measured over a
154-document sample, 11 of 122 sampled function dictionaries carried both keys
and all 11 held the defaults, including one reachable from a /Separation space
in this repository's own fixtures.

Compare against the computed defaults instead. A genuinely non-default array is
still declined, because this evaluator implements only the default mapping.

Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
Reconstruction runs only on files that are already malformed, so hostile object
numbering is guaranteed on this path rather than merely possible. The synthetic
Catalog and /Pages node were numbered max_obj + 1 and max_obj + 2 unchecked; no
profile sets overflow-checks, so a file containing 4294967295 0 obj panicked in
debug and wrapped in release, and the wrapped number then collided with a real
object, placing the synthesized page tree on top of it.

Also on this path: parse_object_stream returns a HashMap and the selections
walking it are get_or_insert, so the same damaged bytes recovered a different
/Root between runs of the same binary. Walk it in ascending object number, the
order the sibling non-object-stream path in this file already uses.

Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
…ting

Three changes to one file that have to land together, because the first is a
prerequisite for observing the second.

**Premultiplication.** tiny_skia::Pixmap stores premultiplied RGBA and
Pixmap::from_vec takes the bytes verbatim, while the image crate produces
straight alpha. A fully transparent pixel therefore kept its full colour and
the compositor *added* it: a masked-out region showed the base image's colour
blended over the backdrop instead of the backdrop alone. Premultiplying also
now happens before the resample, which is the correct order — resampling
straight alpha bleeds colour across an alpha edge from pixels contributing
none.

**/Mask polarity.** 8.9.6.2: with the default /Decode, "a sample value of 0
shall mark the page ... and a 1 shall leave the previous contents unchanged.
If the Decode array is [ 1 0 ], these meanings shall be reversed." The stencil
path implemented the complement, under a comment citing that very clause — so a
reader checking the citation against the code saw two things agree and both be
wrong. /Decode is read here rather than fixed separately because the two
compose: a /Decode [1 0] mask rendered correctly by accident, two errors
cancelling, and correcting polarity alone would have broken those files.
Past the end of the stream there is no sample to test, so the masked-out value
is used rather than resolving toward paint.

**Nesting caps.** A form's own /Resources /XObject may name the form, and a
tiling pattern's cell may set the pattern it is painting; neither clause
requires an acyclic reference graph. Without a cap that recursion overflows the
stack, which under panic=abort is a process abort. Type 3 glyph and soft-mask
chains were already capped this way; these two were not.

Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
ISO 32000-2 Algorithm 8 generates one random file encryption key, wraps it into
/UE under a key derived from the user password, and requires every string and
stream to use that same key.

compute_u_and_ue produced and returned exactly that key. EncryptDictBuilder
build discarded it. EncryptionWriteHandler::new then called
compute_encryption_key, which for revision >= 5 returns
generate_random_encryption_key — a second, unrelated random key. So the streams
were encrypted under key B while /UE wrapped key A: every AES-256 file this
library wrote authenticated and then decrypted every stream to noise, in this
library and in any other.

Carry the key from the builder to the handler. build_with_key returns it
alongside the dictionary and build() delegates, so neither existing signature
changes; R<=4 returns None because its key genuinely is a derivation the
handler can recompute.

This had been read statically by two reviews and executed by neither. The new
round-trip test reproduced it first — AES-256 extracting "" while AES-128 and
RC4-128 pass — and now covers write-then-read for all three, that the canary is
absent from the output bytes, and that a wrong password is still refused.

Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
ISO 32000-1:2008 7.7.3.4 Table 30: an inheritable attribute comes from the
nearest ancestor that specifies it. Two walkers implemented this and disagreed.
The eager one snapshotted the inherited map around the recursion and used
insert, which is correct. The lazy one used entry().or_insert_with() on a
root-first walk, which keeps the value already present — the first seen, i.e.
the most distant ancestor — so the root won and every intermediate node was
ignored, and its comment claimed the opposite. Because the two disagreed, the
same page could resolve differently depending on which one ran.

The walker change itself is in the preceding commit: both edits are in
document.rs and were staged together, so it is not separable after the fact.
These tests cover /MediaBox and /Rotate from the nearest ancestor, a page's own
attribute beating every ancestor, no leak between sibling subtrees, and the
order-independence property that the two walkers violated.

Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
The corpus render sweep caught this in the preceding commit's own change.

Past the end of a stencil's data there is no sample to test. That commit
resolved the case toward transparent, reasoning that the mask's masked-out
value was the conservative reading. It is not: for an explicit /Mask, alpha 0
*hides* the base image, so an undecodable mask erases the very content it was
only meant to refine. An absent /Mask shows the image; an unreadable one must
degrade to the same place.

The case is not hypothetical. JBIG2 stencils reach this path still compressed —
the decode happens on the other branch — so a 1718-byte stream stands in for a
1.1 MB bitmap and virtually every pixel is past the end. A scanned book in the
corpus went from its scan to blank pages: mean tone 134 -> 245 across its pages
with coverage held at ~1.0, the signature of an image that stopped painting.
Attribution was by bisecting the two hunks: disabling premultiplication left it
unchanged, which identified this default as the cause.

Restores the previous disposition for that case only. The polarity and /Decode
correction, which is what the clause is about, is unaffected and its fixtures
still pass.

Recorded while here: this path never JBIG2-decodes the stencil it reads, so for
these files it is testing bits of compressed data. That is a real defect and a
separate one.

Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
…erands

InDesign wraps placed artwork in a /PlacedPDF marked-content scope. A placed
galley that merely repeats the page body must be suppressed or extract_text
emits every word twice.

The gate decides by tokenising the text-show operands and measuring how much of
the placed text also appears outside. But those operands carry encoded
character codes, not text (9.4.3: a show operand is a string of character codes
interpreted through the font's encoding). Under Identity-H — the dominant
modern encoding, and the one this producer emits — the bytes are two-byte CIDs,
no run of ASCII alphanumerics forms, the measured duplication came out 0.0, and
the gate kept. Every word of the page was then emitted twice, which is exactly
what the suppression exists to prevent.

text_duplication_fraction now returns Option and the caller treats None as
absence of evidence rather than evidence of absence. Gate 2 above already keeps
a placed body that dominates the page whatever its encoding, so what reaches
this point is placed text comparable in size to the rest of the page, where a
duplicate overlay is the likely reading.

The real discriminator is bounding-box overlap, which exists downstream and is
not consulted here; decoding the operand through the font would also settle it.
Either is a larger change than this gate.

Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
It was never demonstrated to work and the corpus sweep shows it is harmful.

The intent was to have span_in_table consult TableCell::spans — the spans the
detector actually placed — instead of re-deriving membership by origin +/-2pt
against the detector's centre rule. On synthetic fixtures it produced
byte-identical markdown and HTML, so no test could show it doing anything. On
the 445-document golden set it moves 38 documents, all in the same direction:
MORE text, because the identity match fails and cell spans fall through to
prose while the table renders them too. That is duplication, not a fix.

It reached the branch by accident:  in the preceding commit
staged this file along with the intended one, so a later checkout restored the
change from the index rather than removing it.

The underlying defect is real but differently shaped than recorded — there are
three ownership implementations (detector by centre, span_in_table by origin,
and assemble_text_from_spans by containment plus a cell-text set), and the
observable symptom is governed by the third. Tracked with a reproducer
separately; it needs one ownership decision consulted by all three consumers,
not an edit to one of them.

Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
… update

ISO 32000-1:2008 7.5.6: "The added trailer shall contain all the entries except
the Prev entry (if present) from the previous trailer, whether modified or not.
In addition, the added trailer dictionary shall contain a Prev entry giving the
location of the previous cross-reference section."

Only /Size, /Prev, /Root and /Info were written, so every other entry the
original trailer carried was dropped. /ID is the one that matters: Table 15
NOTE 2 warns its absence "might prevent the file from functioning in some
workflows that depend on files being uniquely identified", and it is required
outright whenever an /Encrypt entry is present.

Carried entries are emitted in sorted key order, because a HashMap's iteration
order is not stable and two saves of one document must produce the same bytes.
/Size and /Prev are recomputed for the update by definition, /Root is written
from the source trailer just above, and /Info is either rewritten or inherited
through the /Prev chain, so those four are skipped.

/Encrypt cannot reach this trailer: an encrypted source is already refused by
this path, because appending plaintext objects under a document key would
corrupt them. The audit item that prompted this read the trailer writer without
that guard and so overstated the exposure; the /ID loss is the real half.

Noted while here: this crate's own DocumentBuilder writes no /ID at all, so the
fixture is hand-built — a source produced by the builder would have had nothing
to carry and would have passed vacuously.

Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
ISO 32000-1:2008 8.10.2 step (c): the form's /BBox, expressed in form space and
transformed by /Matrix, is intersected with the current clipping path before the
content stream is executed. Table 78 makes /BBox required for exactly this
reason. It was not applied at all, so a form painting outside its own bounding
box bled onto the page.

Expressed as the operators the clause describes — q, the box, W n, the content,
Q — rather than as a second clipping mechanism beside the interpreter's own.
combined_transform already carries /Matrix, so the rectangle lands in the right
place, and the existing clip stack intersects it with whatever clip is already
in force.

The box is normalised (7.9.5 permits either diagonal) and a degenerate box
declines to clip rather than erasing the form: a zero-area /BBox that cannot be
honoured should not delete content.

Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
…ages

The corpus render sweep shows 11 pages losing substantial ink, up to 40% of a
page: PMC8025825 p2 coverage 0.14150 -> 0.08545, PMC8103263 p1 0.41453 ->
0.33425, PMC8103253 p1 0.36263 -> 0.29524. Those are real losses, not the
rounding-level movement the rest of the sweep shows.

The clause is not in doubt — ISO 32000-1:2008 8.10.2 step (c) does require the
/BBox, mapped through /Matrix, to be intersected with the current clip before
the content stream runs. The implementation is what is wrong.

Most likely mechanism, stated as the hypothesis it is: the clip was expressed by
wrapping the form's operators in a save, the rectangle, a clip-and-end-path,
the content, and a restore. Real content streams are frequently unbalanced, and
a stray restore inside the form consumes the injected save; the trailing restore
then pops one level too many and corrupts the clip stack for everything drawn
afterwards. That matches the symptom — loss concentrated in content *after* a
form rather than inside it.

Doing this safely means giving the nested-stream call its own clip rather than
borrowing the operator stack's, which is the RenderScope change the release
notes deliberately hold for v0.3.80. Reverting rather than shipping a
content-losing change into a release; the finding goes on the issue.

Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
ISO 32000-1:2008 8.9.6.2: for an image mask with the default /Decode a sample
of 0 marks the page, and [1 0] reverses that. The CCITT path pads a truncated
stream white and substitutes a blank buffer when both decoders fail — but
neither value is neutral, because a sample is a sample. One of the two /Decode
readings takes the padding as "paint every pixel", so an unreadable stencil
covers its whole footprint in the fill colour.

Measured rather than assumed: under the default /Decode a damaged stencil
already paints nothing, and it is /Decode [1 0] that covers 100% of the
footprint. The audit said exactly this; the issue text generalised it.

decompress_ccitt_reporting now returns how many leading rows actually came from
the stream, and the stencil path overwrites only the padded tail with the value
that draws nothing under that mask's own /Decode. Rows that did decode are kept
— this crate deliberately recovers partial content instead of blanking the page
— and the page ends up looking as it would with the stencil absent, which is
the honest degradation. The decoder already logs the failure.

decompress_ccitt keeps its signature and delegates, so the other two callers
are untouched. The eight existing CCITT rendering tests still pass, including
the one that paints real T.6 data, which is what rules out over-blanking.

Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
A Catalog and page tree rebuilt for a truncated file are seeded into the object
cache and have no byte offset. Reconstruction also pre-populates the
scanned-offset map, so a later lookup reports "not found" immediately rather
than rescanning the file. Evicting one therefore does not cost a re-parse — it
makes the object permanently unreachable, and a large truncated document would
open, extract pages 1..k, then fail silently on every subsequent call once the
64 MB FIFO turned over. The seeding site's own comment already said these were
reachable only through the cache, and then used the evictable insert.

They are now inserted pinned: eviction rotates past them, and clear() — which
callers use to force a re-parse after authentication — keeps them, because a
pinned object has nothing to re-parse from and clearing it would destroy rather
than refresh it. The rotation is bounded so an all-pinned cache terminates
instead of cycling its queue.

Tested on the cache directly rather than end to end: reproducing the symptom
through the public API needs a document large enough to churn 64 MB, which is
not a reasonable fixture. Four unit tests cover the pinned entry surviving the
flood, clear() keeping it, an all-pinned cache terminating, and — the control
that stops the first from passing vacuously — an ordinary entry being evicted
under identical pressure.

Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
outline_glyph returns None for an empty glyph as well as a missing one, so the
drop tally could not tell "the font gave us nothing" from "the font says paint
nothing". Two populations are legitimately the latter: invisible text
(ISO 32000-1:2008 9.3.6 render mode 3), and the glyphless fonts OCR tools emit
— Tesseract and ocrmypdf ship a synthetic font, conventionally named with
GLYPHLESS, mapping every CID to a non-zero glyph id with an empty outline and a
correct /ToUnicode.

Every such page rendered and extracted perfectly and still produced one warning
per page claiming invisible data loss. src/extractors/text.rs already gates on
exactly these two signals for the same population; this diagnostic gated on
neither. A diagnostic that fires on the healthy common case trains callers to
ignore the channel.

The fixture is the font from the neighbouring drop-reporting test — one that
genuinely drops a glyph — so the assertions are about the gate rather than about
a page that never drops anything. Its control asserts a real drop still warns,
and the pre-existing per-page reporting test still passes, which is what rules
out over-suppression.

Render mode 7 is named by the gate but not asserted, and the reason is worth
recording: such a page is rasterised twice, once with a default graphics state
and once with mode 7, and the first pass emits before the gate can see the mode.
That double rasterise is a separate defect. Pinning the current behaviour would
enshrine it and asserting the desired behaviour would fail for a reason this
change does not own, so it is documented in the test instead. Modes 3 and the
glyphless convention are the populations the report is actually about.

Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
…ode does

Both were verified against the code rather than against the previous notes.

"Exposed in every language binding ... available across the C ABI and every
binding built on it" was false twice over. WASM cannot reach page_bbox at all:
it is a method rather than a serialized field, and the two fields it derives
from are marked serde(skip), so JavaScript cannot even recompute it. And the
accessor is the identity when the run's own rotation_degrees is zero, which
means a landscape page stored portrait with /Rotate 90 carrying ordinary
horizontal text — the commonest rotated-page shape there is — still reports
pre-/Rotate space, exactly what the note promised it would not.

"Rotated runs now report a page-space rectangle that matches where they
display" reads as a fix to extraction, and it is not one. page_bbox has zero
internal consumers: LayoutObjectSpatial for TextSpan still returns the raw
mixed-frame bbox, so extract_spans/extract_words are unchanged and
extract_text_in_rect still selects on the uncorrected rectangle. The geometry
itself is right in all eight rotation/mirror combinations; it is the wiring
that does not exist. A user who read the old note and deleted their workaround
would have got wrong results.

Both entries now state the limits alongside the capability. The wiring and the
WASM surface are tracked separately; this commit makes the notes honest about
what shipped.

Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
ISO 32000-1:2008 8.5.4: content outside the clipping path shall not be painted.
A clip whose device coordinates exceed the rasteriser's ceiling was discarded
outright, so an off-page clip with huge coordinates — which excludes the whole
page — painted everything it was hiding. The page went from correctly blank to
entirely filled.

Annex C.1 is worth quoting against the obvious objection: it does say a
conforming reader has arithmetic limits and that exceeding one is an error, so
having a MAX_DEVICE_COORD is legitimate. What it does not license is the
response. Dropping a clip is neither raising an error nor skipping the
construct; it renders something the file did not describe, and a limit must
never be resolved in the direction that paints more.

The previous behaviour was not simply wrong, which is why this is a split
rather than a flip. Two unrasterisable clips need opposite answers: one whose
bounds miss the pixmap excludes everything and must clip everything away, while
one with enormous coordinates that still encloses the page restricts nothing
visible and must be discarded exactly as before — an empty mask there would
erase every subsequent draw, which is the failure the old comment describes.
device_bounds_miss_pixmap tells them apart; non-finite bounds support no
conclusion and keep the existing fallback.

The fixture covers all four cases, and the enclosing-clip control is the one
that stops this from trading one regression for another.

Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
…efused

ISO 32000-1:2008 8.9.5.2 maps each raw sample through
Dmin + raw * (Dmax - Dmin) / (2^bpc - 1). At 1 bpc a /Decode [1 0] is a pure
inversion, and on packed samples that is a byte-wise NOT — no unpacking and no
allocation.

The unpacking path refuses buffers past a 256 MiB ceiling, which is a correct
bound on a hostile /Width x /Height. But refusing left the samples packed *and
the /Decode unapplied*, so a large 1-bpc scan came out as the exact negative of
the picture. The code's own comment named this outcome and accepted it on the
grounds that no real corpus reaches the cap; a negative page is too loud a
wrong answer to accept on that basis, and the byte-wise NOT handled it at any
size before the unpacking path existed.

Applied on the packed bytes in the refusal arm, with samples_are_raw cleared
and decode_folded_in set so the colour-key masker and the plate router both
still read the right fact about the buffer.

This is one half of the reported issue. The other half — a colour-key /Mask
skipped when the image also carries a /Decode — is not fixed here. The flag the
masker reads is the correct one: the field documentation is explicit that
range-testing stored samples is only meaningful in raw sample space, and every
rescale leaves it, not just /Decode. The real answer is pdf.js's ordering, mask
against the raw samples and decode afterwards, which means reordering the
extractor's decode pipeline and threading an alpha channel out of it. That is a
larger change than this one and is left to its issue.

Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
ISO 32000-1:2008 8.11.4: the /OCProperties /D configuration sets the document's
default visibility, and the composite renderer already honours it. The
separation renderer had no marked-content arms at all, so the exclusion never
reached the plates: a layer omitted from the render was still counted as ink,
and two renderers of one page gave contradictory answers about the same
document. Measured on the fixture, the baseline plates carry identical ink —
1836000 either way — while the composite correctly differs.

8.11.3 settles how to suppress: "the content shall not be drawn" while
"graphics state operations ... shall still be applied". So a painting operator
is rewritten to the path-clearing `n` rather than skipped, leaving path
bookkeeping and every state change exactly as they would be if the content were
visible. XObjects, shadings, inline images and text shows have no
path-clearing equivalent and mark the page directly, so those are skipped.

The excluded set is computed the same way the composite renderer computes its
baseline — compute_default_off_ocgs — because the separation entry points take
no render options and the defect is about a layer the *document* hides.

Not addressed here, and still open on the issue: the separation renderer also
has no arms for inline images or `sh`, so those contribute no ink at all. That
is under-reporting rather than wrong reporting, and closing it means teaching
this renderer to rasterise images and shadings, which is a different piece of
work from honouring visibility.

Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
Caught by the crate's own CCITT polarity test, which went black-majority under
/Decode [1 0].

The preceding commit applied the 1-bpc inversion in the arm where unpacking
produced nothing, but that arm covers three different situations: a CCITT
buffer that is deliberately kept raw with its /Decode polarity carried in
ccitt_params, the 8-bpc identity case with no /Decode, and the one actually
intended — an unpack that was attempted and refused past the size cap. Applying
the inversion in the first case ran that mapping a second time and turned a
mostly-white scan black.

The predicate that chose the branch is now named and reused, so the refusal
handling cannot be reached by a path that never asked to unpack.

Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
Text painted 0 0 0 1 k does not convert to (0, 0, 0). ISO 32000-1:2008 10.3.5's
naive additive complement would give exact black, but this crate converts
through the measured process-ink corners and the 000K corner is approximately
(0.137, 0.122, 0.126) — a dark grey. That is the right colour to paint; it is
the wrong thing to hand a word processor as an explicit run colour.

Three converters — DOCX, PPTX and XLSX — decided "leave the colour unset so the
theme applies" by testing for exact (0, 0, 0). So every print-origin PDF
stamped a hard-coded dark grey into its output instead of inheriting the
destination theme. Nothing covered it, and a word-level corpus diff cannot see
it: the text is identical and only the colour attribute changed.

All three now share one predicate. It recognises the specific value the CMYK
conversion produces rather than applying a blanket tolerance, so an author's
deliberate dark grey still carries its colour through — a tolerance wide enough
to catch 0.137 would have swallowed that too.

The fixture asserts the precondition explicitly (CMYK black is not exact RGB
black, else the rule would be unnecessary rather than wrong) alongside the
positive and negative cases.

Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
…sent

jpeg-decoder applies a 255-x inversion to 4-component JPEG output, and the
extractor undoes it to recover the raw DCT samples poppler treats as straight
CMYK ink. That undo ran only when an Adobe APP14 marker was present, which is
wrong twice over.

ISO 32000-1:2008 Table 13: when the marker is absent "the default value of
ColorTransform shall be 1 if the image has three components and 0 otherwise".
For four components, no marker *is* transform 0 — the very case the undo
handles — so the specification equates the two situations the code treated
oppositely.

And measurement settles it independently of the clause. The new test encodes
one CMYK pixel, strips the APP14 segment so the entropy-coded data is
byte-identical and the marker is the only difference, and decodes both:
jpeg-decoder returns the same samples either way. So a marker-less
4-component JPEG kept the decoder's inversion and rendered as the complement of
its ink — the canonical Distiller/WeasyPrint shape coming out near-black.

The predicate is now named and shared by both decode paths, and an explicit
marker declaring some other transform is still taken at its word. The three
pre-existing CMYK JPEG suites still pass, including the contract test that pins
the decoder's marker-present direction.

Not addressed here: the audit also reports that the four-component test fires
for flat /DeviceN, so those DCT images error and vanish. That is a separate
gate and stays on the issue.

Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
ISO 32000-1:2008 Table 30: /CropBox is "a rectangle ... that shall define the
visible region of default user space. When the page is displayed or printed,
its contents shall be clipped (cropped) to this rectangle." The crop box was
parsed onto PageInfo and then consulted by neither renderer, so a cropped scan
rendered at full media size showing the margins the file asked to crop away —
the everyday case being a scan whose black scanner borders were cropped off.

One shared page_render_box, used by the composite renderer and the separation
renderer alike, so plates and composite keep the same size and origin. §14.11.2
takes the crop box as its intersection with the media box, so an oversized one
does not enlarge the page; a crop box disjoint from the medium describes
nothing to show and falls back to the media box rather than rendering an empty
page.

This changes the output dimensions of every page carrying a crop box smaller
than its medium, which is a large and intended behaviour change — it is what
every other reader does. The fixture covers the size, the origin offset, the
clamp, the disjoint fallback, and that content outside the box stops painting.

Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
The extractor batches a run of Tm-positioned show operations into one
span, which is what keeps a producer that positions every glyph
individually from yielding thousands of one-character spans. The
continuation test required the same line, the same transform and forward
progression, but placed no bound on how far forward: a jump into the
next column was accepted, so two show operations separated by empty page
were glued into a single span carrying no separator and a width spanning
the void between them.

ISO 32000-1:2008 Table 108 gives Tm and Td the same effect on the text
and text line matrices, differing only in whether the displacement is
absolute or relative. Td, TD and T* all end the run outright, so a
displacement that ends a run for one operator must end it for the other:
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; only the source-order evidence the span merger reads tells them
apart. Everything below an em is left to the merger, and this rule
speaks only to gaps too large to be typographic slack at any size.

The cost is not only a missing space. Anything reasoning about a span's
extent -- table-cell ownership, column detection, reading order -- saw
one span straddling the gap, so a cell could no longer claim the text
drawn inside it.

Corpus: 3 of 419 and 9 of 445 documents change, every one an un-glued
word or a removed duplicate; no content lost.

Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
…ntly

A table cell's text reached the page twice: once from the table's own
rendering and again 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, which is denominated in the cell's member-span
tokens. The budget could already consume a span token that is a
substring of one budget token -- the cell builders glue spans while
joining them -- but had no path for 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 built from two show
operations offers "abc" and "def" against a flow span of "abcdef"; that
span was retained and emitted a second time. take_concatenation covers
the token exactly or spends nothing, so absorption stays bounded by the
material the cell was built from.

Markdown's orphan recovery re-emits a span the table did not render, and
decided that with a literal substring test against the rendered table.
The cell builder joins its member spans with a space while the flow
assembler joins the same glyphs with none, so a span the table already
rendered failed the test purely on whitespace. Whitespace is a rendering
choice of each side; the glyphs are the content. Comparing the squashed
glyph sequences fixes it, and the row's pipe delimiters are not
whitespace, so they survive the squash and still keep a span that
straddles two cells from matching the concatenation of their texts.

Corpus: 9 of 445 documents change, each one a duplicate removed; the
text survives in every case, spaced as the table renders it.

Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
…omments

The inherited-page-attributes test read the page rotation through a
`get_page_info` accessor that does not exist, so the target failed to
compile. It now reads `get_page_rotation`, which is the accessor the
crate actually exposes, and the five tests pass.

Two earlier commits in this branch inserted a new function between an
existing doc comment and the item it documented, which detached the
comment from `cmyk_to_rgb` and from `is_table_separator_line` and left
two `empty line after doc comment` lints. Both new functions move below
the item they were splitting.

Type-checked with `--all-targets` on the default, `rendering`, and
`--no-default-features --features fips,icc` combinations.

Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
… spans

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 user-visible failures follow from the one mismatch.

Hyperlinks vanished: ISO 32000-1:2008 section 12.5.2 puts an
annotation's /Rect in default user space, and intersecting a page-space
rectangle against a mapped span matches nothing.

Form values detached from their fields: widget spans are built from
page-space /Rect values and were appended to the mapped page spans, so
one vector held two coordinate frames and the values sorted to the end
of the page instead of beside their own labels.

Table cells were emitted twice: extract_page_tables works from
page-space words and paths, so no cell could claim the spans it renders
and every one was emitted again as flow text beside the grid.

preserve_layout placed every span wrong: it writes each bbox straight
out as absolute CSS and so needs the frame the page displays in, while
the map deliberately moves spans out of it.

The frame is now a value rather than a convention. spans_in_reading_frame
returns the ReadingFrame it applied, and link rectangles, widget spans
and table geometry follow the spans into it; layout mode, which consumes
no reading order, does not take the map at all. A consumer can map its
own geometry instead of having to know the convention.

One thing this does not change: the emitted grid keeps its page-space
row and column orientation, because the table detector does not know
about the reading frame. The cells and their contents are now correct.

Corpus: byte-identical on 419 and 445 documents. The map fires only on
an upright page whose text carries a dominant rotation, which no corpus
document reaches through these consumers.

Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
Two columns of quarter-turn text ran together into single lines, in two
independent places.

Downstream, 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 between them. The upright path
splits a line at max(3 x font size, 30 pt). A rotated line is the same
line with its axes exchanged -- ISO 32000-1:2008 section 9.4.4 puts the
glyph displacement along the text matrix's writing direction -- so it now
takes the same rule measured along its own axis. Projecting each word's
origin onto the writing direction makes both quarter turns one
computation.

Upstream, the Tm run-continuation test compared the matrix translation
components directly, which assumes the run advances along +x and
separates along y. Under a quarter turn the two axes are exchanged, so
the perpendicular tolerance collapsed to its 0.5 pt floor and 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 already existed but was ANDed onto the raw comparison rather than
replacing it, so it could only veto, never admit.

The three raw-matrix questions -- on the line, forward along it, near
enough to the run's end -- are now asked once in the run's own frame, and
the accumulated width advances along that frame too. Note that the raw
form was not merely stricter for a quarter turn: it split a rotated
column jump by accident, via a tolerance that had collapsed. Substituting
the helper alone would have started gluing those, which is why the gap
bound moves into it rather than staying beside it. Vertical writing mode
keeps the raw comparison; section 9.7.4.3 gives it an axis convention the
(a, b) row does not describe.

Corpus: byte-identical on 419 and 445 documents. extract_text and
to_markdown body text do not route through the rotated line grouping, and
no corpus document sets a rotated run glyph by glyph.

Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
A 2x2 ruled grid with one show operation per cell needed no fix of its
own: the flow assembler glued "North" and "120" across the column rule
into one span, which no single cell's bbox contained and so no cell's
budget could absorb, and every cell was emitted a second time as prose
beneath the grid. Ending the glyph run at the repositioning jump splits
that span back into two, each inside its own cell.

Pinned separately because it reaches the same defect through a different
seam from the split/join case above, and because it is the shape a
reporter is most likely to hit -- four cells and six rules, nothing
unusual.

Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
ISO 32000-1:2008 section 8.10.2 step (c): the form's bounding box, mapped
through /Matrix, is intersected with the current clipping path before the
content stream runs, and Table 78 makes /BBox required for exactly this
reason. It was not applied, so a form painting outside its own box bled
onto the page.

The clip is installed at depth 0 of the nested stream's own clip stack
rather than by wrapping the stream's operators in an injected
save/restore. A nested stream already gets a fresh stack, and `Q` never
pops below depth 0, so the clip holds however unbalanced the form's own
q/Q pairs are -- which real content streams frequently are. Both tests
for that are in the fixture.

This reinstates a change reverted earlier in this branch, and the reason
given for that revert was wrong on both counts. The stated mechanism --
a stray restore consuming an injected save and corrupting the parent's
clip stack -- cannot happen, because execute_operators builds a fresh
clip stack per call and the two never share. And the evidence was
misread: the render sweep's "content lost" bucket only reports that
coverage went DOWN, which is what a fix that stops over-painting looks
like. Adjudicated against PyMuPDF and poppler, all six PMC pages cited
in the revert are corrections, not losses:

  PMC8025825 p2   ours 0.14150 -> 0.08545   pymupdf 0.08509  poppler 0.07779
  PMC8103263 p1   ours 0.41453 -> 0.33425   pymupdf 0.33680
  PMC8103253 p1   ours 0.36263 -> 0.29524   pymupdf 0.29939
  PMC8025752 p2   ours 0.23272 -> 0.17852   pymupdf 0.18125
  PMC8103263 p2   ours 0.27355 -> 0.23808   pymupdf 0.24222
  PMC8103279 p2   ours 0.09016 -> 0.05555   pymupdf 0.05721

The unclipped output was the outlier by 0.05-0.08 coverage; the clipped
output lands within 0.004 of the reference.

Annotation appearance streams are exempt. They reach the same renderer,
but positioned only by a translation to the annotation's lower-left
corner, where section 12.5.5 calls for the mapped bounding box to be
fitted to /Rect. Clipping to a box computed under a transform that does
not implement that fit trims real content: it was the one page in the
sweep the reference judged a regression, and exempting the path removes
it. The fit itself is not in this change.

Render sweep, 561 pages, adjudicated against PyMuPDF: 7 pages moved,
7 closer to the reference, 0 regressions.

Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
…uns unmerged

Two corrections to the two commits before this one, both found by running
the suites those commits had not run.

The run-continuation gap bound was an em. That is narrower than a wide
inter-word gap: a full-width header emitted as two show operations, with
22.6 pt between the end of the first and the start of the second at 12 pt
type, was split into two spans, and the column cut then relocated its tail
into the second column -- the exact failure the header fixture exists to
catch. The bound is now max(3 x font size, 30 pt), which is the threshold
the line grouping already uses to decide that two pieces of text belong to
different columns. Making the two the same constant is the point: a
displacement wide enough to be a column boundary one level down should not
be a continuation one level up, and now they cannot disagree.

The frame-correct rule is ANDed with the raw-matrix band again rather than
replacing it. Substituting reads better and is what the writing-axis helper
was built for, but it changes what a quarter-turn run does. The raw band
collapses to its 0.5 pt floor under a quarter turn, so rotated runs never
merge today; letting them merge concatenates them in content-stream order,
which defeats the writing-axis ordering the rotated line grouping performs.
A chart label that draws its subscript last then reads "H02" instead of
"H2O". Merging rotated runs is still worth doing -- a run set glyph by
glyph yields one span per glyph where the same text upright yields one --
but only together with an ordering rule that survives it, and that is not
this change. Recorded on the issue.

One pre-existing fixture is adjusted rather than satisfied. Its four
rotated words sat 40 to 60 pt apart at 10 pt type, which is a column gap,
not a word gap: an upright line spaced that way splits into two lines under
the same threshold, so the fixture described two lines rather than the one
it asserts. The words now sit about 11 pt apart and its subject -- several
runs, one visual line -- is unchanged.

Verification: 111 text-exposed integration suites and 5828 lib tests pass.
Corpus vs main: 1 of 419 signatures, 7 of 445 golden docs. Every diff read
individually -- one pair of fused numbers un-glued, six duplicates removed
with the text still present. No content lost.

Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
@yfedoseev yfedoseev changed the title v0.3.78 — audit scope: 33 issues across rendering, text, files and colour release: v0.3.78 — audit scope (33 issues across rendering, text, files and colour) Aug 26, 2026
@yfedoseev yfedoseev changed the title release: v0.3.78 — audit scope (33 issues across rendering, text, files and colour) Release v0.3.78 — audit scope: 33 issues across rendering, text, files and colour Aug 26, 2026
All three pass locally and fail on the runners, and each is a real defect
in the test rather than in what it tests.

The encryption round-trip fixture exercised AES-128 and RC4-128, which do
not exist in a FIPS build: RC4 is not an approved cipher and the R4 key
derivation is built on MD5, which FIPS 140-3 forbids, so the crate makes
the exclusion a build-time one. Those two revisions are now cfg'd out of
the FIPS configuration; AES-256 is approved and keeps running everywhere,
including the canary check that the output is genuinely encrypted. This
was missed locally because the FIPS combination was type-checked but never
run.

The glyph-drop tests share a process-global warning collector and drain
it, so with cargo running a file's tests concurrently one test could drain
another's warnings. It is a race, not a platform difference; the runners
just have a different core count. They now take turns over the
clear/render/drain sequence.

And a doc comment linked to `EncryptDictBuilder::build_with_key` from a
module where that type is not in scope, which fails the
warnings-as-errors documentation build. Now a fully qualified path.

Verified with the configurations that caught them: the round trip passes
under both default and `--no-default-features --features fips,icc`, the
glyph-drop file passes five consecutive runs, and
`RUSTDOCFLAGS="-D warnings" cargo doc --no-deps` is clean.

Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
The pitch-scaled cap on the column-merge threshold caps it at 0.6 of the
table's median inter-column gap whenever three or more columns are found.
Its premise is that the median gap IS this table's pitch, and that premise
was never checked. On a layout with no pitch -- most often prose the
detector has mistaken for a table -- the median is the middle of a spread
of unrelated gaps, and 0.6 of it forbids merging fragments that are one
column.

The visible cost is larger than a stray column boundary. A label and the
number beside it split apart (`Line 1` -> `Line` | `1`), and because
neither half then matches the flow span it was built from, the cell
retention budget cannot absorb that span and the row is emitted a second
time as prose beneath the table.

The gate is `is_regular_lattice`, not the on-pitch ratio alone: that ratio
tolerates two off-pitch gaps, which on a handful of columns is every gap
there is, so it answers "yes" for any small irregular group. The lattice
predicate carries the column-count floor that makes the tolerance mean
something. Both now share one `gaps_are_on_pitch` helper so the ratio the
cap borrows and the ratio the lattice test applies cannot drift apart.

The dense-pitch case the cap exists for is unaffected -- a six-column 20 pt
lattice under a 25 pt fixed threshold still keeps its columns distinct, and
that test passes unchanged.

Found by a v0.3.77-to-HEAD regression sweep over a feature-stratified
913-document set, scoring each surface against PyMuPDF with a multiset
Jaccard so duplication counts against the score. Bisect named the commit;
this restores seven regressed surfaces, the worst of them 0.667 -> 0.500
before and 0.667 after.

Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
…vention

A page whose only content is one image with a CCITT-coded /Mask painted the
whole image rectangle: on issue4379 the rendered coverage was 0.39850, and
500x400 / (595x842) is 0.3992 -- the image's own rectangle to four
significant figures, so the mask was having no effect at all. ISO 32000-1
section 8.9.6.3 requires the opposite: unmasked areas shall be painted with
the base image and masked areas shall not be.

Two defects, one on top of the other.

`decompress_ccitt_group4` built its parameters with `..Default::default()`
and set only the dimensions. Table 11 makes the filter's default K = 0,
which is Group 3 one-dimensional, so this function -- whose entire purpose
is Group 4 -- asked for the Group 3 decoder every time and never decoded
anything. The failure is silent: the caller falls back to the
still-compressed bytes, and a mask sampled from those runs past the end of
its own data at almost every pixel. It now sets K = -1, which Table 11
defines as pure two-dimensional encoding.

With the mask decoding, its polarity was still inverted. The consumer
applies section 8.9.6.2 correctly -- sample 0 marks the page, so 0 is
opaque -- but the CCITT decoder emits 1 for an inked pixel, which is the
complement of the sample value that rule is written against: Table 11 makes
`BlackIs1` false the normal PDF convention, in which 0 is black. The
decoder's output is normalised at the branch that produces it, so one
polarity rule serves both it and a mask whose filter the stream decoder
already applied.

Settled from the file rather than from a reference renderer: the decoded
mask carries 15.39% set bits, and 0.1539 x 0.3992 = 0.0614 of the page,
against a rendered 0.05947 once the base image's own content is taken into
account. MuPDF (0.0712) and poppler (0.0655) agree, and v0.3.77 rendered
0.05947 -- by a different route, since its fallback for an unreadable mask
was transparent and happened to hide the same region.

Found by a v0.3.77-to-HEAD render sweep over 1298 pages, adjudicated
against two independent references and claiming a verdict only where they
agree.

Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment