A widget (or any annotation) with the NoRotate flag is shown upright on a
page with /Rotate: its appearance turns with the page about the rectangle's
upper-left corner (ISO 32000-2 12.5.3), which is how MuPDF and Poppler draw it.
Flattening drew the appearance onto the rectangle as it lies in user space
(Algorithm 8.1 alone), so on a /Rotate 90 page the flattened widget turned
sideways and moved while its neighbours without the flag stayed put.
The placement matrix of a NoRotate annotation is now followed by that turn
(`upright`), for 90, 180 and 270.
Regression test: tests::flattening_keeps_a_no_rotate_widget_upright_on_a_rotated_page
(a plain and a NoRotate widget on pages rotated 0/90/180/270; the plain one's
cm is unchanged, the NoRotate one's is the turned matrix). Verified on qpdf's
manual-appearances.pdf page 2 (/Rotate 90): Poppler's render of the flattened
page differs from the original's by 80 pixels at 36 dpi (anti-aliasing),
against 648 before, when the NoRotate widget's box moved.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011K1peyrh5MdZ6ttHkVq8Gh
Main solved this branch's problem on its own: #1712 replaced the two
duplicated catalog paragraphs with one current paragraph that covers
every shipped language. Two and a half hours later #2320, branched
before that, added its two copies back, so main carries three again.
Keep #1712's paragraph exactly as main wrote it and delete only the two
stale copies, so this branch no longer rewords anything.
Docs only, no code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
No embedded interface face has Hangul, and the installed fallbacks covered Arabic, Devanagari,
Telugu, Sinhala, Tamil and (for a Chinese interface) Han only, so a Korean file name in a tab,
the title bar or the bookmarks panel drew as boxes (#2747, #407, #1043).
hangul_fallback() adds the first installed Korean face that has the whole syllable block, like
the Telugu and Tamil fallbacks, and it ends every family next to them. The probe is U+D7A3 (힣),
the last of the 11,172 modern syllables: Droid Sans Fallback has 가 but few other syllables, so
a 가 probe would accept a face that still draws boxes. Candidates: NanumGothic, UnDotum,
Baekmuk Dotum (Linux), Malgun Gothic, Gulim, Batang (Windows), Apple SD Gothic Neo, AppleGothic
(macOS). Noto CJK and Source Han are left out, as for Chinese (AGENTS.md §1.1).
PDFCRAFT_SYSTEM_FONTS=0 turns it off like the others.
Test: system_fallback_covers_hangul (skips on Linux without an installed Korean face).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNjF2Pc6JWeHW1UVPK8uVg
Found while testing crates/fonts/src/lib.rs:79 (wrap_with), called from crates/create/src/lib.rs:set_text
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A pageArea with no <occur> was given a subform's default (at most one use). Once the
first body page was out, the layout fell back to the last page area, here one with
occur max="1", and drew its content over every body page, with a copy of its fields
per page. A page area with no <occur> now repeats as long as pages are needed.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
qpdf counts every offset from the header in a file with bytes before it, so
it already reports the input of
`incremental_saves_of_a_file_whose_offsets_count_from_its_first_byte_keep_counting_so`
as damaged ("xref not found") and reconstructs it, before anything is
appended. The qpdf check there could never pass; the test's own reader checks
stay. The header-relative test keeps its qpdf check, which passes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflicts: ATTRIBUTION.toml, crates/ui-egui/src/i18n/es.tsv, crates/ui-egui/src/i18n/vi.tsv. Catalogs keep upstream's rows, then this branch's rows upstream lacks; ATTRIBUTION.toml is upstream's with every sha256 recomputed; progress lines keep both sides, upstream's first.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(ui): validated signature display, snapshot tab reuse
Signature widgets with layered appearances baked the unverified state
into the page, so a valid signature kept reading 'Signature Not Verified'
while the banner and panel said valid. Validated verdicts are now drawn
into the icon and caption layers in the display copy only: the signed
bytes are untouched, saving keeps the original appearance, and files
without those layers or without a valid verdict render exactly as before.
Other valid signatures still get the verdict mark over their widget,
sharing one style with the Signatures panel, and hide it again once the
appearance itself shows the verdict.
View signed version and View revision opened a new tab on every click,
and every tab showed the document title, so repeats piled up identical
tabs. Repeats now reuse the snapshot tab, snapshot tabs read like their
source plus the suffix in the tab strip, window title and Combine picker,
and carry a notice bar naming the revision with a Close that asks about
saving first.
Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>
* Address review comments on validated appearances and trust refresh
Count every field when checking shared appearance streams, so a
caption shared with an unvalidated field is never rewritten for the
valid one alone.
Pick the icon slot by drawing instead of length, and only when a
single layer qualifies. Bare rectangles never count as the icon, and
ambiguous or missing slots fall back to the overlay badge.
Revalidate and invalidate together through one helper, so trusting a
certificate always drops rendered pages. Trust alone still marks
nothing dirty.
Tests: mixed-verdict sharing, longer decorative borders, competing
and doubled layers, capture-before preservation, trust invalidation
without zooming, and signed Session round trips with rendering,
saving and passwords.
Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>
* Prove the icon slot geometrically and cover real layered signatures
A lone line drawing is still not the icon: frames and borders span the
box while the status mark lives in a corner of it. The icon now has to
sit strictly inside the appearance frame and cover at most half of it;
anything else falls back to the overlay badge. Widgets without a
measurable frame veto the rewrite the same way.
Tests: parsed extents and inhabitant checks as units, both reported
border repros plus a frameless widget, and real layered signatures
built with cms::sign_detached through Session open, trust, rendering,
save and reopen, plain and password-protected.
Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>
* Identify the icon by shape instead of geometry
A lone line drawing is still not the icon, but size and position were
the wrong signals: the real status mark is large, and geometry blocked
it. Frames, borders and panels are rectangles by nature while the mark
is not, so a drawing made only of axis-aligned rectangles is decoration
however it is stroked. Singularity, shared-stream and template checks
are unchanged; anything ambiguous still falls back to the overlay badge.
Tests: rectangularity as a unit, nested line-drawn frames, and a
triangular fixture mark in place of the rectangular one.
Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>
* Budget the appearance walk and keep the marks with the bytes
The walk that finds a signature's icon and caption capped depth,
layers and fields, but not the work: a dictionary whose entries all
point at the next one is visited once per entry per level, so a small
hostile file never finishes opening. The walk now counts its visits
and gives up, and a field whose tree cannot be read to the end stops
the whole pass, because a layer we never read may be the one two
fields share and a rewritten shared layer puts one field's verdict on
another field's page. The sharing count is exact again with that: two
widgets of one field pointing at the same appearance claim it once,
or the field vetoes its own rewrite.
A mark used to outlive the bytes it described. A failed display write
left the page showing the file's own caption while the verdict still
claimed its state was drawn; a metadata edit dropped the mark while
the page kept drawing the state, so the badge landed on top of it. The
write fallback now drops the marks and logs the loss, and metadata
carries them over.
Tests: a self-referential appearance tree, which never finished before
the budget, two widgets sharing one appearance, a metadata edit that
keeps its mark, a save that goes through the writer instead of
short-circuiting on the opened file, and the snapshot bar's Close
button pressed for real.
Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>
* Cite the new regressions in parity, restore main's installer note
The appearance entry now cites the bounded walk and the save through
the writer, and says what a tree that cannot be read to the end does.
The installer entry carried main's older text, which still claims
signed installers are planned; main's newer note is taken verbatim.
Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>
* M9: preserve rounded decoration beside raster status glyphs
* M9: place appearance progress beside signature history
* M9: retain appearances with ambiguous curved layers
* M9: keep signature verdicts in viewer overlays
Defer generic appearance rewriting: caption text and a unique drawing do not establish an icon role. Preserve original artwork for saves, image and text exports, and both print modes while retaining snapshot reuse and trust refresh. Cover chamfered borders and mixed signer/caption streams through signed Session round trips.
---------
Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>
Co-authored-by: skylar taylor-barrick <hi@skylar.cc>
- Detect a one-row centred title through a new `is_centred` helper in
`text_regions.rs`, shared by both rewrite paths, instead of the multi-row
ragged-centres rule that can never fire on a single row.
- A lone row counts as centred when its centre sits within one font size of
the page centre and its left edge is not the page's dominant left edge, so a
full-width left-aligned line at the margin is not mistaken for a title.
- In the single-style path, a centred block with no explicit width or
alignment no longer has its width stretched to the page margin; the line is
centred in the box it already sits in, so a grown row keeps its centre and
every wrapped row shares it.
- Gate the new rule behind main's `upright` flag: `is_centred` measures
page-space rects, which are not meaningful across a turned baseline; frame
coordinates (#1443) are the tool for that, not this change.
- Add `a_one_row_title_keeps_its_centre_when_it_grows_and_when_it_wraps`,
built from the issue's fixture; red before the fix (centre 398.025 vs
expected 306), green after.
Some producers head a classic xref table "1 n" and then write object 0's
free entry first, so every row is one number off and object 1 reads as
free. The renderer coped, but cos did not: printing a scanned invoice
built from cos dropped object 1 (page 1's image) and printed the page
blank. Such a subsection is now read from 0 when its second row locates
an object that is numbered 1, in both xref readers, and the repair is
logged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
Some files carry bytes before `%PDF-`, and their offsets count from the header rather than from
the first byte. We read those, retrying a section relative to the header when the absolute
offset misses, but the incremental writer then appended a section whose offsets and `startxref`
were absolute while its `/Prev` kept the header-relative value it was given. qpdf reported "xref
not found" and reconstructed the file, so a save that reported success left a damaged-looking
update behind (#2364, found with KillerPDF's reserve3.pdf and a Subject edit).
The reader now records on each `Revision` whether it was found counting from the header, and the
writer counts the new section the same way as the one its `/Prev` points to. A file whose
offsets already include the prefix keeps absolute offsets, which the second test pins so the fix
cannot overcorrect.
Seen red with the writer change reverted: the appended section's `startxref` missed its `xref`.
Fixesstorytold/pdfcraft#2364
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DsT1y8xBvkXeBxEUQqdDtj
* fix(render): sampled functions index a flat sample array (vendored hayro-interpret patch 31)
A Type 0 sampled function kept its samples in a HashMap keyed by each point of
its /Size grid (two SmallVecs per key, SipHash), and the renderer builds a
shading's functions again every time it is painted, so every paint hashed one
insert per sample. A 2,097,152-sample ramp painted 30 times took 38 s; the
pdf.js corpus's bug1721218 (InDesign) spent most of a 1.5 s page on it.
The samples now stay in stream order in one Vec, and a grid point is found by
strides (first input fastest). The interpolation keeps the same recursion and
arithmetic, so results are bit for bit the same, and a point the stream ended
before still gives no value. The repeated-paint page draws in well under a
second. The maximum sample value no longer computes 2^32 in a u32 (a panic with
overflow checks on at 32 bits per sample).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoEx9Nj8KC4WKDcf5YaokQ
* fix(render): one decode of an image serves every size it is drawn at (vendored hayro-interpret patch 32)
The decoded-image cache (patch 24) keyed each decode by the size the image is
drawn at, but only the JPX filter decodes a reduced resolution for a size:
every other filter decodes the whole image. So a JPEG or Flate scan missed the
cache at every zoom step, was decoded again, and was kept once per zoom level.
The key now carries the target size only when JPX decodes the image, its
/SMask or its /Mask (read from /Filter or /F, erring towards keeping the
target). `InterpreterCache::decoded_image_decodes` counts the cache's decodes;
the new test draws a Flate image at five sizes and sees one decode, and a
copy whose soft mask is JPX-encoded still decodes per size. Both assertions
fail with the old keying.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoEx9Nj8KC4WKDcf5YaokQ
* fix(render): patch meshes copy their vertices' colours less (vendored hayro-interpret patch 33)
A Coons or tensor-product patch is drawn as 722 triangles over a 20 x 20 grid
of points, and every vertex's colours (a SmallVec) were produced or copied up
to five times: each grid cell interpolated its four corners itself (1,444
interpolations a patch for 400 points), cloned all six vertices of its two
triangles, and the mesh sampler cloned each vertex again to move the triangle
into device space. Profiling page 8 of the annual report in #1584 put a
quarter of its time in those clones.
Each grid point's colours are now interpolated once, a cell's second triangle
takes the vertices its first copied, and the sampler moves a patch's freshly
made triangles instead of copying them (`TriangleSampler::sample_owned`; a
free-form triangle mesh, which the shading keeps, is still copied). The same
inputs give the same values: rendering the first three pages of every pdf.js
corpus file is byte for byte the same. Release build, Linux: report page 8
773 -> 696 ms, personwithdog.pdf 149 -> 129 ms, issue13520.pdf 104 -> 87 ms.
The new test pins the colours near each corner, at the centre and between them
for a Coons and a tensor-product patch (taken before the change); swapping two
vertices' grid points makes it fail.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoEx9Nj8KC4WKDcf5YaokQ
* docs: roadmap entries for faster sampled functions, image decodes and patch meshes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoEx9Nj8KC4WKDcf5YaokQ
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Adds crates/cos/tests/proptests.rs (464 lines) - three properties over generated catalogs (edit sequence
vs a plain ordered model; full save in both output styles; incremental save over bases built BOTH ways) -
and a self-credit entry in contributors/people.toml committed under the author's own identity. No product
code changes; total +466 lines across two files: people.toml +2; proptests.rs +464.
Two design decisions that came out of adversarial review, each measured rather than asserted:
* catalog keys insert in a scrambled order ([5,0,7,2,6,1,4,3] filtered to the drawn keys), because with
ascending insertion "preserves key order" and "sorted by key" are the same observable.
* incremental expectations derive from the values written, not from reading the writer's own output back,
so a consistently-wrong implementation cannot pass by agreeing with itself.
Measured against one-line breaks in crates/cos/src/writer.rs, each reverted and byte-compared afterwards
(verdicts from cargo exit codes; "pre-existing" = lib 48 + writer 10 + encryption 8 + shared_streams 3 +
bounded_writer 4, i.e. every target that existed before this PR):
sort every dictionary by key CAUGHT (props 1+2) pre-existing suite FULLY GREEN
reverse dictionary emission CAUGHT (all three props) pre-existing suite FULLY GREEN
move /Type to the end of every dict CAUGHT (prop 1) pre-existing suite FULLY GREEN
- caught only after catalog_keys() was added to pin the full key
sequence; without it this break was green everywhere
classic-table incremental startxref +1 CAUGHT (prop 2 alone; props 1+3 green), failure message names
"classic-table", confirming the incremental table path executes
drop null-valued dictionary entries CAUGHT (all three) at the pre-fix commit b51381c this same
break left property 2 green - the self-reference it removed
Known limits, stated not hidden: incremental saves are asserted to add bytes and leave the original prefix
untouched - minimality is NOT claimed; catalog key order pins one fixed scramble rather than drawn
permutations, so 25.4% of cases draw too few keys for order to be meaningful; nested dictionary key order
is deliberately not pinned (canon sorts inside values); /Perms and permissions semantics are out of scope
here.
Gates at this tip (upstream 24f93e6, drift 0): cargo test -p pdfcraft-cos 85 passed / 0 failed / 2
ignored; cargo fmt -p pdfcraft-cos --check clean; clippy exit 0 for this crate with
-D warnings -A clippy::chunks_exact_to_as_chunks (that lint fires only in upstream's own
crates/oracle/src/pixels.rs:189 from f988cf8, tracked by #2342/#2324; flags after `--` forward into every
crate in the build graph, and crates/cos itself emits zero warnings under a forced recompile).
cargo xtask layers OK: 37 crates.
Scope: part of #1187 (items 1-3 partly; the reference-following comparison, undo state machine, password
property and damaged-file splices remain open). The encryption-side properties are PR #2358 by the same
author; earlier drafts of this text mis-cited #2014, which is certificate encryption and still open.
Adds crates/crypt/tests/qpdf_literals.rs (305 lines): three tests that hand our security
handler /O, /U, /OE, /UE and /Perms values from outside implementations, then require our
reader to authenticate them and report the intended role. Tests only - no product code, no
new dependency; PDFs stay outside the repository and only quoted hex is committed.
Why outsiders wrote these numbers: create() and open() share one derivation, so a
wrong-but-self-consistent implementation passes every internal test. Two review rounds proved
it by mutation - several key-derivation breaks stayed green in both suites because writer and
reader drifted together. Quoting bytes we did not compute breaks the circle: open() recomputes
/U (or the revision 6 hash chain) and compares it to what was stored, so a divergent derivation
cannot authenticate.
Six cases come from qpdf core 12.2.0 via pikepdf (R2/R3, R4 in both metadata modes, R6 in both).
Revisions 2-4 are deterministic given /ID and passwords, so the generator reproduces their quoted bytes
byte for byte (verified). Revision 6 salts are fresh on every run by design: these literals match the
fixtures as captured, and re-running the generator will produce different - equally valid - revision 6
numbers, so compare R/V/Length structurally there and re-quote if you regenerate. One
case is HAND-DERIVED, labelled as such in the source: an independent re-implementation of ISO
Algorithms 2/3 at n = 5, cross-validated by having that same routine reproduce qpdf's n=16 /O
byte for byte. It exists because no conforming producer emits R3 at 40 bits - qpdf writes 128
there and this crate's create() maps Rc4_40 to revision 2 - yet the derivation loop only runs
for r >= 3, so without a crafted row its truncation is unobservable and mutating it changes
behaviour that nothing catches.
Measured effect, same battery, upstream --lib run separately each time:
r6-hash-selector (%3 collapsed to %2) CAUGHT here; upstream stays green
r6-sha-arms-swapped (SHA-384 <-> SHA-512) CAUGHT here; upstream stays green
r6-selector-window (sums 17 bytes not 16) CAUGHT here; upstream stays green
r4-key-chain-truncation (loop hashes whole digest) CAUGHT here, via the crafted row;
upstream stays green. Pristine 3/3 pass, mutated 2 passed / 1 FAILED - verified twice.
Still invisible, named rather than glossed: r6-round-floor (minimum rounds 64 -> 63) - the
terminator does not separate the two floors on these inputs; /OE,/UE length loosening and the
/Perms filler source, both unreached here.
Corrections carried from review: qpdf's advice against a static /ID does not weaken these
revision 6 vectors, because key_r6 never receives the file /ID at all; it matters only to the
revision 2-4 paths, where we quote /ID literally so the numbers stay exact. And no ATTRIBUTION
entry is added, matching crates/crypt/src/tests.rs:237 which already quotes qpdf vectors
unattributed; cargo xtask assets stays ok.
Gates on a clean tree: cargo test -p pdfcraft-crypt 28 passed / 0 failed (17 lib + the 8 properties from #2358, now merged,
plus 3 oracle tests - one
per concern - role authentication across seven dictionaries, metadata-flag key separation, /Perms
decode);
clippy --all-targets -D warnings clean; cargo fmt --check clean; xtask layers OK (35 crates).
Integrity note: the literals in this commit were checked field by field against the PDFs on disk -
27 of 27 fields quoted verbatim, no transcription drift. A regression test for that check belongs in the
generator's output comparison rather than here, since it needs pikepdf.
The find-step shortcuts sat after the canvas's "egui wants keyboard
input" check, so they did nothing while the find field had the focus
(Enter worked). They now run before it, while a search is open, as in
Acrobat and Preview.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
Remove Hidden Information and Sanitize took a link or comment off its
page's /Annots, but a tagged PDF's structure tree still referenced it
through an /OBJR, so the full rewrite kept the annotation, its URL or
text included, while link_list reported none. The annotations removed
from every page are now collected, and tags::drop_object_refs drops the
structure elements' object references to them (direct or indirect,
array or single kid), with the same seen-set bound as tags::clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
Saves write a staging file and rename it over the target. On Unix the
rename ignores the target's permissions, so a read-only file was
overwritten without a word and came out writable. The shared atomic
writer now refuses a read-only target before staging anything, with an
error that points to Save As; the edits stay unsaved in the document.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
A cover counted only when it hid 40% of the line's words or box, so a
black box over one number in a long line, and an unapplied redaction
mark over it, audited clean while the number was still extractable. A
cover that hides half of any one word now counts as well.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
Resolve independent progress-document conflicts while retaining upstream updates and the existing regression-tested fix. Refresh the relevant milestone notes; feature scores remain unchanged.
Fresh checks: cargo fmt --all -- --check, git diff --check, and layering/assets/parity gates. Focused production/test files and lockfiles match the prior green CI head. Full CI will rerun on this merge.
Resolve independent progress-document conflicts while retaining upstream updates and the existing regression-tested fix. Refresh the relevant milestone notes and rotation regression evidence; feature scores remain unchanged.
Fresh checks: cargo fmt --all -- --check, git diff --check, and layering/assets/parity gates. Focused production/test files and lockfiles match the prior green CI head. Full CI will rerun on this merge.
Resolve independent progress-document conflicts while retaining upstream updates and the existing regression-tested fix. Refresh the relevant milestone notes; feature scores remain unchanged.
Fresh checks: cargo fmt --all -- --check, git diff --check, and layering/assets/parity gates. Focused production/test files and lockfiles match the prior green CI head. Full CI will rerun on this merge.
Found by looking at screenshots: the knob press was taken by the marquee when
the image was smaller than the page; the angle label vanished on dark pixels;
the knob could leave the visible area for an image at the top.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>