chore: sync upstream (125 Commits) #12

Merged
alf merged 125 commits from chore/sync-upstream-20261030 into main 2026-10-30 08:07:51 +00:00
Owner

Upstream-Sync von upstream/main (125 Commits). Fast-Forward.

Upstream-Sync von `upstream/main` (125 Commits). Fast-Forward. - 1d1cbc4e UPD.1: honor app pretend versions in update checks (#5397) - 3517d05b Update prompt shows the app icon and accent color instead of a letter (#5388) - f76dbb9e deps: bump tar, zip, roxmltree, rustls-webpki and symphonia to their latest versions (#5381) - 834a4daf CI: workflows only call craft-common-actions at @main (#5378) - 726ecf26 packaging: Flatpak finds and prints to CUPS printers (#5346) - c735fb7d Make PdfCraft's app color red (#5245) - 3109bb23 Merge pull request #5236 from FredApps/integration/kenm76-2026-10-29 - d9953814 Clarify integrated vendor regression coverage - 97c062a1 Complete additive integration fields and test boundaries - f280f3a2 Integrate KenM76 PR #5228 - 7fc20334 Integrate KenM76 PR #5199 - 1b0e3b61 Integrate KenM76 PR #5178 - e34cb1d1 Integrate KenM76 PR #4991 - 07edd05a Integrate KenM76 PR #4987 - 9e30913d Integrate KenM76 PR #4977
alf added 125 commits 2026-10-30 08:07:49 +00:00
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
The open worker can still be reading after the harness's four startup frames.
Removing the synthetic file then asserted zero tabs before opening completed.
A fixture-specific 300 ms delay reproduced the exact ui.rs:843 failure on
644f7ab6; waiting by state passes under the same delay. Keep the file until
its tab opens, retaining the one-tab, filename and OS-quit assertions.
The diagnostic delay is removed. CropBox production code is unchanged.

Narrowly adapted from Guillem Hermida's OS-event test repair in upstream
https://github.com/storytold/pdfcraft/pull/2812 at
47e5398b6ba650dbc7008a23f5231b2767af41a0 (MIT OR Apache-2.0).
No other changes from that PR are included.

Validation: controlled red/green; final UI suite 52 passed, zero ignored;
CropBox render regression passed with its fully qualified exact test name;
strict UI Clippy all targets, formatting, layers, assets and wasm (31 crates).
The CLI CropBox acceptance test will also be verified in automatic exact-head CI.

Co-authored-by: Guillem Hermida <guillemhr2@gmail.com>
Bookmark options > New bookmarks from text styles... lists the styles the
document's text is set in (font without its subset tag, the size shown,
bold, italic, how many lines, a sample), the largest first, and takes one
style per bookmark level. Every line in a level's style becomes a
bookmark of that level under a new first "Untitled" bookmark, as from
structure; a heading set on several rows is one bookmark, and each goes
to the top of its heading.

The detection lives in engine::text_style_bookmarks on top of
edit::text_lines, reading each page top to bottom then left to right, so
two headings on one row stay two. text_styles and
bookmark_from_text_styles reach it headlessly, and the command
bookmark.from_text_styles opens the dialog. OutlineEntry gets an
optional top for the /XYZ destination; structure bookmarks keep theirs.
CodeRabbit on #4560:
- The bookmark titles are the document's text, so the edit now needs
  copying permission as well as assembly, and Document::text_styles (the
  dialog's samples) is empty when copying is withheld; the automation
  text_styles tool already checked it.
- A heading went on to the next row whenever it was close below in the
  same style, so same-style headings in two columns on staggered rows ran
  together into one bookmark. A line continues a heading only when it lies
  under it.
- Lines found their style by a scan over every style so far; they are now
  indexed by font, weight, slant and quarter-point size, so a page with
  thousands of styles no longer makes the scan quadratic.

Regression tests for the first two (each fails without its fix).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LymhVaGZCWwB9HNEbLzeMM
Prepare Form deletes the selected widget: one radio button of a group or
one copy of a duplicated field, as Acrobat does; a field's last widget
takes the field with it. The widget leaves its page, the field's /Kids
and /CO; a per-widget /Opt loses its entry, and a group that was on only
through it turns Off. form_delete_field takes an optional widget.
CodeRabbit on #4631: a twin with the same on-state name that is currently
off does not keep the choice visible, so compare the remaining buttons'
appearance state (/AS), not their on-state name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LymhVaGZCWwB9HNEbLzeMM
# Conflicts:
#	crates/forms/src/tests.rs
CodeRabbit on #4631: with /V already Off, a /DV naming the deleted button's
state was kept, so Reset would put back a state no button has.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LymhVaGZCWwB9HNEbLzeMM
Update Background, Update Watermark and Update Header and Footer
previewed the new marks on the page's thumbnail, which still has the
marks Update replaces. A pale yellow background updated to light blue
showed a muddy teal (the blue multiplied onto the yellow), and a new
watermark or header was drawn over the old one. The applied result was
right; only the preview was wrong.

In Update, the preview now draws the page without its marks of that
kind. The engine removes them on a copy of the document
(Document::page_without_marks, read-only like the other preview layers),
and the dialog renders that page off the UI thread at the preview's
size, showing a blank sheet until it arrives. Add, and pages without
marks of that kind, keep the thumbnail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every page module set egui's cursor itself and the last write of a frame won, so the
order of document_area's per-page chain decided the cursor, not what a press there does.
The link hover loop ran last and showed the hand over any link for every tool but Fill &
Sign, though only the Select tool follows links: a click with the stamp tool placed a
stamp, a drag with the rectangle tool drew, and in Edit text a drag from linked words
moved the paragraph, all under the hand. The text layer ran after the comments and showed
the I-beam over a movable comment lying on text, which a drag there moves.

Page modules now claim the cursor instead (src/page_cursor.rs): a layer, an icon and
optionally a tip. The layers are the review's priority table, Editor > Handle > Placed >
Field > Comment > Link > Text > tool area, with Drag on top for a drag in progress, whose
owner names the cursor until the release. document_area resolves the claims once per
frame, after the pages, and calls set_cursor_icon once: the claim on the highest layer
the active tool reaches wins. Links count only for the Select tool (follow) and the Link
tool (edit), comments only for the Select tool; the other layers are claimed only by the
modules of the tools that act on them. The claims go into a per-frame collector on
DocView, which every page module already takes, so the calls in the dispatch block keep
their arguments (camera, content and redact lose a `ui` they took only for the cursor).

A link's tip goes with its claim, so it shows only where the link wins: no longer with a
stamp, drawing or other tool that does not follow links. A comment's tip is a description
instead (comments::describe), shown with any tool beside that tool's cursor unless the
winning claim has a tip of its own: Acrobat shows a comment's tooltip with every tool
(observed with Select, Hand, Add comment, Highlight, Rectangle and Stamp). It keeps the old
exceptions (Fill & Sign, freehand drawing for #429, a comment gesture in progress) and
comments. A signed field's validation badge (#1895) says its verdict the same way, with any
tool, ranked below a comment's description as it was below the comment's tip; the topmost
of overlapping badges still speaks.

FieldPointer::Over existed only to keep the text layer from overwriting a field's cursor
(#2099); the Field layer above Text does that now, so forms_ui::page_input returns whether
a field owns the press. Who acts on a press is unchanged (step 3 of the router).

The picture's rotate knob claims too: Grab as a handle, Grabbing as a drag while it turns,
which dragging_the_rotate_knob_turns_a_page_image now checks.
The contains_pointer checks #2755 put before the cursor in content_ui, edit_text_ui,
forms_ui and the text layer go: the pages' pointer has no position where the mode bar or a
side panel covers a page, so nothing is claimed there, and #2755's tests pass unchanged.
During a press the pages hold, those four now keep their cursor over the chrome too, as
the other page modules and a text selection being dragged already did.

Un-ignores the four step-2 probes in tests/it/pointer.rs, each seen failing on the
previous commit at its cursor assertion, after its press assertions passed. A new test,
also seen failing there: a link's tip shows only with its hand. Another pins a comment's
tip with every tool beside its cursor, and its exceptions; it was seen failing with comment
tips tied to the cursor's winner instead, which shows them with the Select tool only. In
tests/it/signing.rs, a signed field's badge says its verdict with the Select tool and with a
stamp; it fails when the badge is left out of the resolver, as resolving after the page
loop would otherwise do. A new ignored probe pins a step-3 bug: over stacked comments the
tip names the one drawn on top, but a click selects the one underneath, because
PageCx::hit takes the last in inspection's top-edge order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A sibling of page_modules_read_the_pointer_only_through_page_pointer in page_pointer.rs,
sharing its scan of the page modules (now `misuses`, which both tests call): it fails on
set_cursor_icon, a `.cursor_icon = ` write to egui's output, on_hover_cursor and
on_hover_and_drag_cursor in a page module, naming the file and line and pointing to the
claims in src/page_cursor.rs. Only the resolver, which is not a page module, sets the
pages' cursor. NOT_PAGE_CURSOR allows the three writers that are not the pages' cursor:
the signature dialog's Scan tab (fill_sign::scan_tab), the paragraph editor's frame
(edit_text_ui::overlay, an Area of its own drawn after the pages' cursor is set) and the
Calculation order panel, whose rows are dragged to reorder them (calc_order_ui::panel). An
entry that sets no cursor any more fails too.

Seen failing with a set_cursor_icon put back into canvas.rs's link loop, an output_mut
cursor write in forms_ui, an on_hover_cursor in comments and the Scan tab's write
removed: one message each. The pointer guard still fails on a hover_pos() read put
into crop.rs. Rebased onto main's newer page modules, it named one write before the panel's
entry was added, calc_order_ui.rs's Grabbing: main's other new cursors are claims now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A "Page cursor" non-negotiable in CLAUDE.md, under "Page input": page modules claim the
cursor for what is under the pointer (view.cursor.claim, crates/ui-egui/src/page_cursor.rs)
and never call set_cursor_icon, write cursor_icon or use on_hover_cursor; document_area
sets it once, from the claim on the highest layer the active tool reaches. It names the
test that enforces it, page_pointer::tests::page_modules_claim_the_cursor_and_never_set_it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The vendored hayro-interpret kept the text and text line matrices in the
saved graphics state, so a `q … Q` inside BT … ET put the pen back where it
was before `q`. Text after the `Q` was drawn, extracted and found at the
wrong place, and search redaction refused to apply because its find boxes
(from the render text layer) disagreed with the redactor's own measurement.

Tm and Tlm are not graphics state (ISO 32000-2 §8.4.1 Table 51, §9.4.1);
q/Q inside a text object is allowed in PDF 2.0 (§8.2). Patch (67) carries
them across the restore; font and the other text parameters are still
restored. Forms and annotations save and restore around their own content,
and Type 3 glyphs, patterns and soft masks run in a context of their own, so
a stream's pen does not reach its caller unless the stream pops past its own
saves (two or more stray Q in a form already corrupt the page state on main;
unchanged here).

Tests: pdfcraft-render it::text_matrix_q (three behaviour tests that fail
with the fix reverted, two guards) and pdfcraft-automation
it::redact_text_after_q (end to end, fails at redact_apply when reverted).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
A form XObject or annotation appearance with more `Q` than `q` popped
graphics states saved by the content that drew it, so later page text,
paint and clip lost the page's `cm` and clip, and search redaction of that
text failed closed at `redact_apply`.

A content stream balances its own `q` and `Q` (ISO 32000-2 §8.4.2,
§8.10.1). Vendored hayro-interpret patch (68): `interpret()` records the
state depth at which its stream starts (`Context::enter_stream`) and
restores the outer floor on exit; a `Q` at the floor is ignored with a
warning. Page content (`interpret_content`) is unchanged.

Tests: pdfcraft-render `it::stray_q_in_a_form` (three behaviour tests fail
with the fix reverted, one guard) and pdfcraft-automation
`it::redact_after_stray_q_form` (end to end; fails at `redact_apply` with
the fix reverted).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
An auto-sized (`0 Tf`) single-line text field drew at most 12 pt however
tall the box. Acrobat Reader draws it at the size the height gives (checked
black-box on a synthetic form: 30 pt and 60 pt fields); ISO 32000-2
§12.7.4.3 leaves the size implementation-defined. auto_text_size_by now
keeps the existing height / 1.15 size without the 12 pt cap, still shrinks
to the width and never goes under 4 pt, with the height bounded at 14,400
(MAX_AUTO_HEIGHT, the largest page side ISO 32000-1 Annex C recommends).

Multiline and comb auto-size keep their 12 pt cap: not yet checked against
Acrobat.

Tests: the forms unit tests now pin a tall box's size (they pinned 12 pt),
and an automation test fills a 30 pt field through form_fill and measures
the drawn text with text_find. All three fail with the cap restored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
Review on #4862 found two mismatches between the on-page field editor and
the field's appearance:

- Comb fields: the editor sized them as single-line text (20.87 pt for a
  500 x 30 comb) while the appearance uses 4-12 pt from the cell height.
  The comb test is now `Field::is_comb` and the comb size
  `appearance::comb_auto_text_size`, both shared by the appearance and
  `forms_ui::field_text_size`. The editor's test was looser (it ignored
  Multiline and Password); it now matches the page's.
- Tall fields: `field_text_size` capped at 300 pt and the three call sites
  clamped the screen font to 64. Both are gone; `field_screen_font` scales
  by zoom and bounds the result at MAX_SCREEN_FONT (256 screen points) so a
  glyph stays well inside the font atlas.

A non-finite comb height now gives 4 pt, as single-line auto-size does.

Test: it::forms::the_field_editor_sizes_auto_size_text_as_the_page_does.
With the comb change reverted the comb assertion fails (20.87 vs 12); with
the 300/64 caps restored the 100 pt assertion fails.

Also fixes it::forms::dragging_over_another_fields_text_focuses_it_and_selects_from_the_press,
which the previous commit broke: the name field now auto-sizes to 13.9 pt
instead of 12, so the press point 30 px in fell on "A|da"; it is now 38 px
in, inside "Ad|a" as the test intends.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
Review on #4831 pointed out the PDF Association's erratum to ISO 32000-2
(pdf-association/pdf-issues #368): inside a text object, q/Q also save and
restore Tm and Tlm (§9.4.1, referenced from §8.4.2). Acrobat 26.2 agrees on
a synthetic PDF 2.0 page. The renderer on main already did this, so the
previous commit's hayro-interpret patch (67) was wrong: it is reverted here,
with its vendor/README row and the render tests.

The real disagreement was in the redactor's own interpreter, which kept Tm
and Tlm across Q. Find took its boxes from the renderer's text, the redactor
measured the glyphs elsewhere, and redact_apply refused on residue. Its
state stack now holds (Gs, Tm, Tlm) and Q restores all three.

Test: it::redact_text_after_q, end to end on %PDF-2.0 fixtures, three cases
(font change inside q/Q; q 100 0 Td Q; the same followed by T*). With the
redactor change reverted, each case fails on its own at redact_apply. With
only Tm restored, the T* case fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
A non-isolated knockout group starts from the backdrop under it (ISO 32000-2
§11.4.7, §11.4.8), but was drawn into a layer of its own starting transparent,
so a blend mode inside it had nothing to blend with: white with /BM /Difference
over magenta stayed white (#4886).

Entered plainly (full opacity, no soft mask, Normal, not itself a knockout
element), the group is now drawn once per blend mode its elements use. Each pass
is a knockout layer composited onto the backdrop in that mode; elements of other
modes clear what is under their shape instead of painting. The Normal pass finds
the other modes, so a group without blend modes takes one pass as before.

hayro-interpret patch 67 (Device::begin_knockout_pass / end_knockout_pass,
draw_knockout_passes), hayro patch 26 (the renderer side). Test:
pdfcraft-render it::knockout_backdrop, three cases failing with the fix
reverted, plus an isolated-group control.

Fixes #4886

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
main deleted crates/engine/src/reexports.rs and moved its re-exports
into crates/engine/src/lib.rs; the comb_auto_text_size re-export this
branch added now sits in lib.rs. forms_ui.rs moved to
crates/ui-egui/src/tools/; the branch's hunks carried over unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
Inside a text object q/Q save and restore Tm and Tlm too (ISO 32000-2
§9.4.1, as corrected by the PDF Association's erratum,
pdf-association/pdf-issues #368). The renderer's saved state already
holds both. The text-editing interpreter and the heading detector kept
them across Q: text after Q was listed (and, once edited, moved) where
the q ... Q block left the pen, and a word continued after Q was split
("Chap ter One"). Both saved stacks now hold the two matrices.

Tests: pdfcraft-edit and pdfcraft-a11y
tests::q_inside_a_text_object_restores_the_text_matrices. Reverting
the edit fix puts the first case at y = 600, not 700; restoring only Tm
puts the T* case at 586, not 686; reverting the a11y fix reads
"Chap ter One". Each fails its test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
An auto-size single-line field's size follows its height, up to about 12,500 pt for a
14,400 pt field. The editor measured the line's width by laying it out at that size,
before `field_screen_font` clamps to `MAX_SCREEN_FONT`, and egui rasterizes every glyph it
lays out: one such measurement took 1.2 s in a debug build, on every frame. The width is
now measured at no more than `MAX_SCREEN_FONT` and scaled, which is exact up to the bound
and linear above it.

`tools::forms_ui::tests::a_line_is_never_laid_out_above_the_screen_font_bound` fails with
the bound removed (laid out at 12,522 pt).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
kurbo 0.13.1's stroker can give a cubic that is nearly a line (all control points within a
rounding error of its chord) a NaN join, and vello_common's `flatten::fill` drops any path
containing NaN, so the whole outline vanished. An Illustrator outline ending in
`73.763 -0.536 l 73.764 -0.536 73.764 -0.536 73.764 -0.536 c` did (#4907). kurbo tracks the
bug class in linebender/kurbo#617 and #519; #610 is unreleased.

Vendored vello_common patch (2): when the expanded stroke is not finite, `flatten::stroke`
strokes the path again with every quadratic or cubic whose control points lie within the
tolerance of its chord replaced by a line, one element out per element in. A finite outline
is unchanged, and a path with non-finite input is still dropped. `stroke` and
`StripGenerator::generate_stroked_path` take a `Clone` path; vello_cpu's callers pass
`&BezPath` and a copied slice iterator.

Tests: `flatten::tests::a_tiny_degenerate_cubic_does_not_drop_the_stroke` and the render
crate's `stroke_degenerate_cubic` pixel test fail with the fallback disabled;
`a_finite_outline_is_stroked_as_before` and `a_path_with_nan_is_still_dropped` are guards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
# Conflicts:
#	crates/engine/src/lib.rs
#	crates/forms/src/author.rs
#	crates/ui-egui/src/prepare.rs
#	parity/acrobat-features.toml
LiveCycle Designer wrote BMPs whose file header gives bfOffBits = 0. image
(0.25) seeks to that offset as given, so the pixels were read from the file
header on and the picture was drawn shifted and wrapped around (#4937).

`pdfcraft_xfa::image::bmp_with_pixel_offset` recomputes the offset from the
info header (its size, BI_BITFIELDS masks in a 40-byte header, the palette)
when the declared one lies inside the headers and the computed one fits in
the file; anything else is passed through unchanged and not copied. XFA
pictures (size and decode) and `create::from_images` both go through it, so
pdfcraft-create gains a dependency on pdfcraft-xfa (L4 -> L3).

Fixes #4937

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
Review follow-up: the repair reads the 40-byte header's compression and
colour count, which a 16-byte OS/2 header does not have. New cases pin
a 12-byte core header (3-byte palette entries) and a biClrUsed of u32::MAX.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
Review follow-up: an offset equal to the end of the info header is only
right when no bitfield masks or colour table come after it; otherwise the
pixels were read from the masks or the table. It is now repaired like an
offset inside the headers. Offsets strictly inside the masks or table are
still taken as given (they may come from an overstated biClrUsed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
Renaming a radio button to an existing radio group's name was refused as
a duplicate, so buttons placed separately could never become one group.
set_props now moves the renamed field's buttons into the named group
(ISO 32000-2 §12.7.5.2.4: a group is one field whose widgets are its
buttons). A clashing export value is renamed to the first free ChoiceN
in /AP /N, /D and /R; a moved button is on only if its value is the
group's value; merged field+widgets are handled on both sides (the
target split via split_merged, now shared with duplicate_field).
Refused when either group has /Opt, has no button, or the target is
locked.

Adapted from pdfcer (MIT, same author) 79870b56 and 116f48d3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
Review follow-up. A clash is checked against the group's own values only,
and each clashing value gets one fresh ChoiceN shared by every joining
button that had it, so a button repeated on another page stays in unison.
The group's value selects a moved button only when one of the group's own
buttons has it. A joining field that is its own widget leaves the
calculation order too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
# Conflicts:
#	crates/render/src/text.rs
Review follow-up (CodeRabbit): the join read the target's /Kids only when
it was a direct array, so an indirect one was replaced by the moved
buttons alone and the group's own buttons were lost. It now reads
through the reference with tree.rs's list_of, as link() does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
set_props rewrote /Opt (an items edit or "Sort items") but left /I, the
selected positions in /Opt (ISO 32000-2 §12.7.5.4), so /I named whatever
now sat at the old positions. It now re-points /I at the selected values'
new positions, by set_value's rule, and removes it when none is offered;
a field /TI past the end of the new list is removed.

Adapted from pdfcer (MIT, same author) 149db0662ecfe5b23a050cdb7513821da918d7cb.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
Changing a list box's options could leave /TI (the first visible row)
pointing at a row that moved or no longer exists. A widget's own /TI is
read before the field's, and one past the end draws no options at all.

set_props now runs the same ListRows::top_showing rule set_value uses
(ISO 32000-2 12.7.5.4): /TI stays put while the selected rows are still
visible, otherwise it scrolls to them, clamped to the last full page.
The field's /TI is updated, and so is every widget that carries its own.
The scroll helpers move out of set_value so both paths share them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
/DV is inheritable (ISO 32000-2 §12.7.4.1). set_props removed a cleared
/DV, which brought back a parent field's, so Reset Form restored the
parent's default. When an ancestor sets /DV, a cleared default is now
written empty (/Off for buttons, () otherwise); with none it is removed
as before. New tree::ancestor_sets, cycle-safe and capped.

Adapted from pdfcer (MIT, same author) 6af2773ccf599b416fab379673119c6b37c55a2c.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
/V is inheritable like /DV (ISO 32000-2 §12.7.4.1). Clearing a text or
choice field's value removed its own /V, so a parent field's /V showed
through: Reset Form on a kid with no default refilled it with the parent's
value. When an ancestor sets /V, an empty value is now written instead of
removing the key. Check boxes and radio buttons already write /Off.

Test clearing_a_kids_value_does_not_bring_back_its_parents covers a text
kid and a list box kid through reset and through set_value; each half of
the fix was switched off in turn and the test failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
A widget's own /TI is read before the field's, so each one scrolls by its
own top row and size (ListRows::top_showing). Before, the widgets were only
moved, all to the first widget's new row, when the first widget itself
scrolled: a second widget scrolled past the remaining options, or away from
a new value the first already showed, kept its stale row.

Test each_widget_with_its_own_top_row_scrolls_by_itself covers both
set_props (options shrink) and set_value; restoring the first-widget gate
in either caller makes it fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
Review of #4898 (FredApps):

- An image or nested group of another blend mode than the pass's composited
  its layer at opacity 0, on the assumption that its shape was cleared first.
  `knockout_image` skips images with an `/SMask` and stencil masks, so a
  white Difference square stayed under a blue Normal masked image in the
  Difference pass and turned it yellow. The layer now composites with
  `Compose::DestOut` at full opacity and no soft mask, clearing exactly its
  coverage.
- In another pass (and an erase pass), a shading cleared under its path
  before the mesh-extent check that makes an off-page mesh paint nothing,
  so a Difference `sh` of such a mesh erased the blue under it. The clear
  now makes the same decision (`shading_bbox`, `mesh_area`, shared with the
  paint path).

Tests: `it::knockout_backdrop` gains a masked image, a stencil and an
off-page mesh case; each fails with its half of the fix reverted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
Review follow-ups on #4977:

- ui-egui: after a rename, the Properties dialog re-selects the edited
  widget by object, not by its old index. A button that joins a group is
  appended after the group's own buttons, so the old index selected the
  group's first button and a nudge moved the wrong one.
- split_merged (join and Duplicate): the new widget takes /A and the
  widget triggers of /AA (E X D U Fo Bl PO PC PV PI); the field keeps
  K F V C.
- join: a clashing export value searches at most taken + 1 ChoiceN
  names, so one is always free, and the names are chosen before
  anything is written.

Tests: a_joined_merged_groups_button_keeps_its_actions,
a_joining_button_gets_a_free_export_value_when_choice_names_run_long,
a_radio_button_joining_a_group_by_name_stays_selected (ui-egui). Each
fails with its fix reverted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
set_props chose /TI inside the field-dictionary update, from the field
read before the edit, so a font size or look changed in the same call
was not counted: at a larger size fewer rows fit and the selection
could end up below the visible rows. The field and widget top rows are
now chosen after every layout change, from the field read again,
before the redraw.

Test: a_list_boxs_top_row_follows_a_font_size_changed_with_its_items
(fails with the old ordering: rows 1..2 shown, selection on row 3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
When the source radio field is also its widget, the join removed the
field-only keys but kept its whole /AA, so field triggers (K, F, V, C)
stayed on a dictionary that is now only a widget. Split it as
split_merged does for the target: keep the widget triggers, drop the
field's, and remove /AA when nothing is left.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
Review of #4898 (CodeRabbit): a shading clearing in another blend-mode
pass cleared its whole drawing path, so a small mesh triangle erased the
earlier elements of its pass around it. The same happened in the
shading's own pass, and in any knockout group, because a knockout paint
is copied over the path and a mesh texture is transparent outside its
triangles.

A shading in a knockout group now goes through knockout_shading: the
shading at full opacity, composited with Compose::DestOut, clears its
own shape (mesh triangles, /BBox), then in its own pass it is painted
with SrcOver. A shading that paints nothing still clears nothing. The
clear uses the shading without the fill opacity it carries, so a
half-transparent shading still replaces all of what is under it.

Test: it::knockout_backdrop::a_small_mesh_replaces_only_what_is_under_its_triangle
(own pass, another pass, isolated group). Revert-proven per half; keeping
the opacity in the clear fails
raster::tests::knockout_groups_replace_what_their_elements_cover.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
# Conflicts:
#	crates/automation/src/lib.rs
#	crates/engine/src/lib.rs
#	crates/ui-egui/src/lib.rs
#	crates/ui-egui/src/panels.rs

Main's module layout (#4889) moved what this branch changed: the tools to
automation's dispatch.rs (the levels parser to json.rs), the edit to
engine's edit.rs, edit/labels.rs and edit/run.rs, Document::text_styles
to document.rs, the dialog state to state.rs and app.rs, and the menu
item to panels/right.rs and panels/bookmarks.rs. The dialog module joins
the others in dialogs/. Main's craft-menu bar (#4752) lists its commands
by hand, so bookmark.from_text_styles is added after
bookmark.from_structure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GbuSKAS2k5248LGFEapMUR
Deleting one widget took it out of the field tree through its /Parent,
which a damaged file can leave out or point at another field; the group
kept the widget in its /Kids with no page showing it, and the delete
reported success. The field that owns the widget drops it from its own
/Kids as well (CodeRabbit on #4631).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GbuSKAS2k5248LGFEapMUR
A line goes to the first level whose style it is in, so the same style
for two levels, or one within the size tolerance of the other, left the
later level empty without a word. The edit now refuses it, for the dialog
and automation alike, naming the two levels (CodeRabbit on #4560).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GbuSKAS2k5248LGFEapMUR
Main now refuses egui::ComboBox through clippy (#4988: its menu can't
grow back while open, egui#5225), and the merge brought that rule in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GbuSKAS2k5248LGFEapMUR
Adopt upstream's split set_props and explicit import lists. The list box
scroll after a property edit moves into scroll_list, keeping set_props under
the new cognitive_complexity limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
Upstream moved the interpreter state into a Walk struct; the saved-state
stack now carries (Gs, tm, tlm) there. Re-proved: the redact test fails
with upstream's interp.rs and passes with this one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
Adopt upstream's explicit import lists in author/edit.rs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
Adopt upstream's split set_props: rename_field returns the group a renamed
radio button joins, and redraw_or_join keeps set_props under the new
cognitive_complexity limit. join.rs imports explicitly (wildcard_imports).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
Sorting a field's own items carries its /I positions through the sort
(ISO 32000-2 12.7.5.4) instead of re-deriving them from /V, which names
only the export value. Values no carried position covers still take their
first position. items_to_sort and write_sorted_opt keep set_props under
the complexity limit.

Test: sorting_keeps_which_of_two_items_with_one_export_value_is_selected
(fails before: /I [0], Alpha, instead of [2], Zeta).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
redraw_field after the join redrew every button of the target group, so a
button with its own appearance states lost them to the default marks. Only
the moved buttons are now redrawn under the group's name.

Test: joining_a_group_leaves_its_own_buttons_appearances_as_they_are
(fails before: the group's own button gets new /AP streams).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
# Conflicts:
#	crates/ui-egui/src/commands.rs
#	crates/ui-egui/src/dialogs.rs

Main split the command and dialog dispatch for its stricter clippy
rules; New bookmarks from text styles goes into the command list after
New bookmarks from structure, and its dialog into the dialog dispatch
next to the other dialogs with their own buttons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GbuSKAS2k5248LGFEapMUR
main split lib.rs: the unmarked_page field stays on PdfCraftApp in lib.rs,
its init and the render_pending check moved to app.rs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main split canvas.rs, comments.rs, edit_text_ui.rs and moved the page
modules under canvas/ and tools/. The cursor claims are re-applied where
that code now lives:

- page_cursor.rs moves to canvas/, beside page_pointer.rs, re-exported
  from lib.rs the same way.
- canvas.rs: claims in the stamp, hand, text-cursor and link code;
  over_field and badge_hover are gone; the cursor is resolved once after
  the page loop in draw_pages. annotation_hover_text (canvas/area.rs) and
  its test give way to comments::describe and hover_tip.
- comments: describe, hover_tip and on_link go to tools/comments/input.rs,
  the hover_tip test to tools/comments/tests.rs.
- Edit text's picture claims go to tools/edit_text_ui/image.rs.
- The cursor guard test goes to canvas/page_pointer/tests.rs, with
  NOT_PAGE_CURSOR on main's paths.
- Tools that took ui only to set the cursor stop binding it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The preview cloned and wrote the whole document on the UI thread for every
page it stepped to, and showed a blank sheet while the page rendered.
Document::without_marks now removes the marks of a kind from every page
once per dialog (UnmarkedPages), each page shown renders from it off the
UI thread, and the page's thumbnail stays until it arrives.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The preview showed the page's thumbnail, old marks and all, until the
unmarked page rendered, and one page that failed to render switched the
unmarked preview off for every page. The page shown is now drawn from the
unmarked document straight away at the preview's size; the pages a step
goes to render ahead off the UI thread; a page that fails falls back to
its own thumbnail while the others keep theirs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The page shown was drawn through a fresh renderer each time, which parsed
the whole unmarked document on the UI thread; UnmarkedPages now parses it
once and keeps it. An error from rendering a page ahead (the pool retiring
its parser, a cancelled request) no longer marks that page failed: only
drawing it when shown decides, and a page that has a picture keeps it. On
the web, where the pool renders on the UI thread, nothing renders ahead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An error from rendering a neighbour ahead left it wanted and unqueued, so
each frame queued it again. It is now kept out of rendering ahead at that
scale (drawing it when shown still decides). On the web, where nothing
renders ahead, no render pool is made; picture()'s doc says a page that
fails keeps a picture drawn earlier.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Type1 font whose FontFile3 has subtype /OpenType (ISO 32000-2 §9.9)
keeps its CFF program in the `CFF ` table of an OpenType wrapper.
CffFontBlob::new rejected the wrapper, so the font fell back to the
standard-font stand-in and the text drew in the wrong face and weight.
The CFF table is now taken from the wrapper, as composite fonts already
do (hayro-interpret patch (69)).

The test wraps the renderer's bundled CFF program in a one-table OTTO
directory at run time (no font file added) and requires the wrapped and
bare programs to give identical pixels; it fails with the fix reverted.
A wrapper without a `CFF ` table still draws, with the stand-in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A wrapper without a `CFF ` table must draw exactly what the same font
with no embedded program draws, and not the embedded program. The old
check (some ink on the page) also passed if the program were read
despite the missing table; with the lookup changed to take the first
table whatever its tag, the new assertion fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
vello_cpu 0.0.8's clip_color, shared by the nonseparable blend modes,
divided 0 by 0 when a gray's equal components were rounded just below 0
or above 1 after set_lum. The NaN drew the pixel black at the source's
alpha whatever the backdrop: nine gray levels under /BM /Color in an
empty isolated group (0.96 g among them) rendered (0, 0, 0), and two
more at half opacity came out wrong.

Vendor vello_cpu 0.0.8 (Apache-2.0 OR MIT) with a backport of
linebender/vello#2000, which clips only when the denominator is
positive. No vello_cpu release carries the fix yet; drop the copy when
one does.

Tests in it::nonseparable_gray run every gray level. The two Color
ramps fail with the unpatched blend.rs. The Luminosity ramp (black over
each opaque gray, upstream's other case) passes either way here: pages
use the u8 pipeline, and that case only failed in the f32 one; it stays
as a guard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
The previous commit's message and this header said the Luminosity ramp
passes because pages use the u8 pipeline. That wasn't established; the
case simply renders correctly here, so the test is a guard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
The script already validated CARGO_BUILD_TARGET and named the archive after the arch, but
cargo build ignored it, so a cross build compiled for the host. Pass --target, resolve
host-tuple to the build host's triple first, and skip the --version smoke run for any
CARGO_BUILD_TARGET build (an aarch64 binary cannot run on the x86_64 VM).
Issue #2368: flattening keeps a NoRotate widget upright on a rotated page
Export Data (CSV) wrote field names and values as they are, so a value
from a received form that starts with = or @ (or + or - when it is not a
number) runs as a formula when the CSV is opened in a spreadsheet.

It now uses the rule merge_csv got in #4101, moved unchanged into
runs_as_formula, so the merge report's output is the same. Import Data
takes the added ' off again, so Export Data then Import Data gives back
every value unchanged.
* M3: improve reading-colour preferences and add settings shortcut

* M3: clarify the disabled reading-colour preview

* M3: adapt rebased preferences to current UI dependencies
fix(render): clip extracted text to the page CropBox (#2670)
* M6: form script messages keep their title, icon and buttons, and every one is shown (#4293, #4294)

* M6: a form script message waits for a notice the toast already shows

* M6: form script messages past the queue's room are counted and reported

* M6: a sentence break in the merged parity note

* M6: an object that names no parameter is a message's text, as a String object or a caught error

* M6: the count of left-out script messages is said again if another notice replaces it before its time

* M6: a full stop between two sentences of the FormCalc parity note

* chore: re-run CI (a frame-budget timing test missed on a shared runner)
* M6: Organize pages ▸ Page properties sets the selected pages' tab order

Page properties in the Organize pages panel was listed as planned. In
the app, a page's tab order could only be set for every page at once,
with the form.tab_order commands, which offer no Unspecified. (The
form_tab_order tool already set chosen pages, Unspecified included.)

- Page properties (page.properties) opens a Tab Order dialog for the
  selected pages, with Use Row Order, Use Column Order, Use Document
  Structure and Unspecified. OK writes Edit::SetTabOrder for those pages
  as one undo step. The dialog opens on the pages' order, or with no
  choice made when they differ, and then OK is off until one is chosen.
  OK on the order the pages already have adds no undo step.
- Rows and columns run right to left in a document bound on the right,
  as Use Row Order and Use Column Order are described. PdfCraft reads a
  right-to-left binding from /ViewerPreferences /Direction /R2L
  (ISO 32000-1, 12.2, Table 150). Before, rank_tabs ranked them left to
  right.
- A page whose /Tabs is an indirect object now takes its row, column or
  structure order. Before, rank_tabs read only a direct name and ranked
  the page in annotation order.
- pdfcraft_forms::tab_orders and Document::tab_orders read each page's
  /Tabs. form_tab_order returns them as page_tabs, and reads without
  changing anything when it is given no order or field.
- set_option("dialog", "page-properties") opens the dialog.
- Parity: organize.page-tab-order is shipped, and its notes say the
  Actions tab of Page Properties (organize.page-actions) is still
  planned; the Organize pages grid notes list Page properties.

* Page properties: OK applies only to the document the dialog was opened on

The draft keeps the document's id. When another document is active by
OK, the dialog closes without changing either document.

* Menus: list Page properties… in the Pages menu

The menu bar now lists its commands explicitly, and every command with a menu group must appear in it.

* Page properties: apply the tab order only to an unchanged document

An edit, undo or redo made while the dialog is open can move the pages
at the stored indices. OK now closes the dialog without changing a page
when the document's edit generation differs from when it opened.

* Page properties: say when a document change cancels the tab order

OK shows a notice when the document changed or another document became
active while the dialog was open, so the user knows to open it again.
M4: New bookmarks from text styles, for headings that aren't tagged (#1693)
* M5: Import comments takes another PDF and copies its comments onto the same pages

Import comments read only XFDF, FDF, XML, XDP, CSV and text data files.
It now also takes a PDF: each comment of that PDF is copied onto the
page with the same number, at the same place, with its appearance
stream, its pop-up, its replies and its layer. Comments on pages past
the document's last page are left out, together with replies to them.
Links, form fields and redaction marks are not copied. A comment named
like one already on its page replaces it, as an XFDF or FDF import
does. A PDF without comments is refused with "nothing in the file
matches this document", and a PDF that needs a password is refused.

- organize: import_comments copies the comments with the page copier
  that Insert Pages uses, and registers the layers they use.
- engine: Edit::ImportData reads a file that starts with %PDF- as a PDF.
  Any other file goes to the data importer first, and is read as a PDF
  when that fails, %PDF- occurs in its first 1,024 bytes and the PDF
  reader opens it.
- ui: Comments > Import comments lists .pdf files; form.import_data
  lists the same files as before.
- automation: the doc_import_data description names the PDF case.
- parity: comment.import-from-pdf is shipped.

* Import comments: an annotation that is no comment keeps its name

A comment imported from a PDF replaced any annotation of the same /NM
on its page that was not locked, a redaction mark or a widget, so a
link, screen or 3D annotation of that name was overwritten. Only an
annotation whose subtype is one of the copied comment subtypes is
replaced now; for any other, the copy is added beside it without the
name. The subtype is read through an indirect reference.

* Import comments: index each page's annotations instead of scanning them per comment

* Import comments: a layer that BaseState OFF hides stays hidden

* Import comments: a PDF with binary bytes before its header imports on an XFA form, and two comments with one name both land

On an XFA form, a decode failure no longer stops the import when a PDF
header is in the first 1,024 bytes; the PDF's comments are copied. Two
incoming comments on one page with the same /NM no longer replace the
same comment: the first replaces it, and the second is added without
the name.

* Import comments: an inline annotation on the page is replaced by name

An annotation written inline in a page's /Annots is made an indirect
object before names are matched, so an incoming comment with its /NM
replaces it instead of being added beside it.

* Import comments: two new comments with one name keep it once

When no comment on the page has the name, the first incoming comment
keeps its /NM and each later one with the same name is added without it.
M6: Delete removes one selected radio button, not its whole group (#4609)
fix(ui): the page sets its cursor once, from the top claim the tool reaches
fix(ui): Update previews the page without the marks it replaces
ISO 32000-2 §11.3.4: the elements of a transparency group blend in the
group's colour space. For a subtractive space, each component is
complemented before the blend function and again after it. §11.3.5.3:
the nonseparable modes blend C, M and Y as their complements R, G and B,
and K comes from the backdrop (Hue, Saturation, Color) or from the source
(Luminosity). PdfCraft converted each colour to RGB and blended in RGB.
A print test file's blend-mode patch then showed an X in almost every
cell: magenta under black with Difference stayed magenta, not green.

hayro-interpret patch (45): CmykGroup and Device::draw_isolated_cmyk_group
(default false) for isolated, non-knockout groups with /CS /DeviceCMYK.
interpret_form_contents is now a function of its own. Color::ink_tints
and color::cmyk_to_srgb8 are new.

hayro patch (21): the renderer draws such a group as a CMY pass and a K
pass of complemented tints. It converts the tints to RGB with the CMYK
profile and composites the result through the group's layer. The group
is drawn as before with reading colours, for contents without process
tints (RGB, spot colours, patterns, images) and above 2^24 pixels.

Test: raster::tests::isolated_groups_blend_in_device_cmyk.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of #3777. `Color::ink_tints` no longer gives tints for a
four-component ICCBased colour. Such a colour keeps its own profile, so a
group that holds one is drawn as before. vendor/README.md: the hayro
patch (21) entry refers to hayro-interpret patch (45), not (44).
Review of #3777 (test report by mtlcl410).

- A group wider than about 512 device pixels lost its blend mode in the
  tiles that a paint covers completely: on the bare pass canvas, vello_cpu
  fills such a tile without the blend mode. At 300 dpi, magenta then black
  with Difference drew black in the middle of the group, not green. Each
  ink pass now draws inside a layer of its own.
- A nested isolated group that names another blending space (for example
  /CS /DeviceRGB) blends in that space (ISO 32000-2 §11.3.4). The
  interpreter now tells the device about such a group
  (Device::group_blends_outside_device_cmyk), and a pass that meets one
  declines, so the enclosing group is drawn as before. A non-isolated
  group's /CS is ignored (ISO 32000-2 §11.6.6, Table 145, CS: it takes its parent's space), so
  it stays in the enclosing group's CMYK.

Test: raster::tests::isolated_cmyk_groups_blend_at_any_size_and_respect_nested_spaces.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main has taken hayro-interpret patches up to (66) and hayro up to (25)
since #3777 was opened, and open PRs claim hayro-interpret (67)-(69) and
hayro (26). The isolated DeviceCMYK group patches become hayro-interpret
(70) and hayro (27). vendor/README.md names the tests by their new path,
raster::tests::isolated_cmyk_groups.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
The nested-group hook was inserted between the comment and the function it
documents, so rustdoc gave it to group_blends_outside_device_cmyk.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
main's knockout patch (hayro (22)) draws each element of a knockout group
once in erase mode, to clear what the group drew under the element's shape.
An isolated DeviceCMYK group nested in such an element still took the CMYK
path in that pass, and drew its finished pixels with Normal blending over
what should have been cleared. draw_isolated_cmyk_group now declines while
erasing, so the group's contents are interpreted in erase mode as before.

The new test fails without the fix (the centre shows red through the
magenta: [240, 63, 168] against [245, 127, 197] for the DeviceRGB control).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
Resolves test-file conflicts where main and this branch both appended tests:
main's file plus this branch's appended tests, unchanged.
fmt, clippy -p pdfcraft-forms -p pdfcraft-automation, and both crates' tests pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
Resolves test-file conflicts where main and this branch both appended tests:
main's file plus this branch's appended tests, unchanged.
fmt, clippy -p pdfcraft-forms -p pdfcraft-automation, and both crates' tests pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
Resolves test-file conflicts where main and this branch both appended tests:
main's file plus this branch's appended tests, unchanged.
fmt, clippy -p pdfcraft-forms -p pdfcraft-automation, and both crates' tests pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
From the review of #5228. The finished CMYK group was drawn through
whatever paint transform a preceding shading pattern left on the
context, so its cyan half showed magenta. Hayro patch (27) now resets
the paint transform before drawing the group's pixels.

Two regression tests, both reported in that review:
- a pattern painted before the group (fails without the reset);
- the Color blend mode over an empty CMYK group, swept over all 256 K
  levels (failed on nine levels before #5199's vello_cpu fix was merged
  in; passes with it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
# Conflicts:
#	crates/automation/tests/it/main.rs
# Conflicts:
#	vendor/README.md
# Conflicts:
#	crates/render/tests/it/main.rs
# Conflicts:
#	crates/forms/src/author/edit.rs
#	crates/forms/src/tests.rs
# Conflicts:
#	crates/automation/tests/it/automation.rs
#	crates/forms/src/author/edit.rs
#	crates/forms/src/tests.rs
#	crates/forms/src/values.rs
#	parity/acrobat-features.toml
# Conflicts:
#	vendor/README.md
# Conflicts:
#	crates/render/tests/it/main.rs
#	vendor/README.md
# Conflicts:
#	vendor/README.md
#	vendor/hayro-interpret/src/device.rs
Integrate 13 KenM76 fixes for forms, text and rendering
* Make PdfCraft's app colour red

The icon tile, the Windows PDF file icon's badge and lines, the DMG background and the README
badges move from green #12a58a to crimson #b70228 (darker shade #8a011e, fold #f3cfd6).
Every PNG, .ico and .icns is re-rendered from the edited master SVGs with packaging/icons.sh
and resvg 0.47; the .icns is written with Pillow because iconutil is macOS-only, with
iconutil's size set. The DMG background is re-rendered with generate.py; its .DS_Store is
unchanged. ATTRIBUTION.toml hashes updated; cargo xtask assets passes.

* Fix OCR model attribution hashes
* packaging: Flatpak finds and prints to CUPS printers

The Print dialog listed no printers in the Flatpak: the runtime has libcups but not
lpstat/lp, the sandbox couldn't reach the CUPS socket, and driverless network printers
(lpstat -e) need Avahi. Bundle lpstat/lp/lpoptions from CUPS 2.4.20 (Apache-2.0,
statically linked, licence installed) and grant --socket=cups and
--system-talk-name=org.freedesktop.Avahi in both manifests.

* packaging: Flatpak reads the user's CUPS settings (~/.cups, read-only)

Review (CodeRabbit): without it the sandbox's lp/lpstat ignore the user's default
printer and saved options (lpoptions) and a print server named in their client.conf.

* packaging: Flatpak CUPS tools reach the host socket when client.conf names a custom path

Review: Flatpak mounts the host's CUPS socket at /var/run/cups/cups.sock whatever
path ~/.cups/client.conf or CUPS_SERVER names, so lp/lpstat/lpoptions now run through
a wrapper that points a socket path missing in the sandbox there. Server names
(remote servers) and sockets that exist are left alone. Tested in the sandbox:
CUPS_SERVER=/run/custom/cups.sock lists the host's printers through the wrapper and
none without it; a host name is passed through unchanged.

* packaging: redirect a custom CUPS socket only when it is the one Flatpak mounted

Review (CodeRabbit): Flatpak picks the socket it mounts at /var/run/cups/cups.sock
with stricter rules than CUPS (flatpak-run-cups.c: a socket CUPS_SERVER, else the
first line spelled exactly "ServerName /path" in ~/.cups/client.conf, else the
default), while CUPS reads the keyword in any case and takes the last ServerName. The
wrapper now redirects only when CUPS' socket is the one Flatpak chose, so a job can't
reach a different server; otherwise CUPS fails on the missing path.

* packaging: never redirect a CUPS_SERVER the sandbox was given

Review (CodeRabbit): Flatpak picks the socket it mounts from the host's CUPS_SERVER,
so one set by flatpak run --env or an override may not be what it mounted. The
wrapper now leaves any CUPS_SERVER alone and redirects only a client.conf socket that
matches Flatpak's choice; otherwise CUPS fails on the missing path instead of
reaching a different server.

* packaging: point CUPS at the mounted socket only when it is the one Flatpak chose, else fail

Review (CodeRabbit): with a custom socket listed first and the default last in
~/.cups/client.conf, Flatpak mounts the custom one at /var/run/cups/cups.sock while
CUPS picks the last line, finds that path, and talks to the custom server. The
wrapper now compares CUPS' socket with Flatpak's choice even when the path exists:
equal, CUPS is pointed at the mount; different, the tool fails with an error instead
of reaching another server.
AGENTS.md (and CLAUDE.md where present) gets a CI workflows section: workflows only call
craft-common-actions at @main, with no other jobs; feature and fix PRs don't touch CI and
link or open a craft-common-actions issue (a PR only for a small fix that is safe for
every app and does not increase CI time); legacy jobs
aren't added to and move only as their own task, staying until a shared workflow runs them.

windows-arm64.yml moves from its SHA pin to @main. The pinned revision is on
craft-common-actions main and the workflow (and the actions it calls) is identical
there, so nothing changes in behaviour.
- `zip`: 9.1.0 → 9.1.3

Requirement `roxmltree` 0.20 → 0.21: `ParsingOptions` gained `entity_resolver` (initializers take `..Default::default()`, i.e. none, as before) and is no longer `Copy` (two functions build it with a closure, the pattern merge.rs already used).
UPDATE_APP (native builds; craft-update is a native-only dependency) carries our 128 px icon and accent (#b70228, its red, from the icon (#5245)). The updater, the self-update
helper and craft-update's startup prompt all get them through crate::UPDATE_APP; the
prompt draws the icon, the accent and "by ArtCraft" (craft-update's default developer).

craft-update is pinned to storytold/craft-libs main at 1065653e7 (#434).
Clippy fixes for Rust 1.99 in untouched code (same logic):
- crates/edit/src/images.rs
- crates/forms/src/appearance.rs
UPD.1: honor app pretend versions in update checks (#5397)
Some checks failed
ci.yml / UPD.1: honor app pretend versions in update checks (#5397) (pull_request) Failing after 0s
freebsd.yml / UPD.1: honor app pretend versions in update checks (#5397) (pull_request) Failing after 0s
nix.yml / UPD.1: honor app pretend versions in update checks (#5397) (pull_request) Failing after 0s
packaging-lint.yml / UPD.1: honor app pretend versions in update checks (#5397) (pull_request) Failing after 0s
sonarcloud.yml / UPD.1: honor app pretend versions in update checks (#5397) (pull_request) Failing after 0s
ci.yml / UPD.1: honor app pretend versions in update checks (#5397) (push) Failing after 0s
freebsd.yml / UPD.1: honor app pretend versions in update checks (#5397) (push) Failing after 0s
nix.yml / UPD.1: honor app pretend versions in update checks (#5397) (push) Failing after 0s
packaging-lint.yml / UPD.1: honor app pretend versions in update checks (#5397) (push) Failing after 0s
sonarcloud.yml / UPD.1: honor app pretend versions in update checks (#5397) (push) Failing after 0s
1d1cbc4ec9
alf merged commit 1d1cbc4ec9 into main 2026-10-30 08:07:51 +00:00
alf deleted branch chore/sync-upstream-20261030 2026-10-30 08:07:51 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
iris/pdfcraft!12
No description provided.