chore: sync upstream (1081 Commits) #22

Merged
alf merged 1081 commits from chore/sync-upstream-20261111 into main 2026-11-11 22:04:05 +00:00
Owner

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

  • 1d7a9f9ff fix(edit,a11y): font aliases share the metrics built for their font (#5998)
  • 69936f1d1 fix(engine): run the renderer redaction check on redactions applied inside a batch (#5977)
  • abec4e3fe fix(js): bound array text, month parsing, name registration and list work (#5883)
  • 77078864f fix(render): charge inspection text (fields, tooltips, layers, bookmarks, blank comments) against shared allowances (#6015)
  • daec898d7 fix(sign): judge a shared signature value once per listing; dedupe DSS evidence by object number (#6041)
  • c73cccfb3 fix(redact): hidden-layer removal reads the default state as viewers do (#5867)
  • 2b65ab000 ci: use StepSecurity's maintained actions (#7026)
  • 1e91e8a93 fix(forms): a read-only radio button is not picked or left through set_value (#6038)
  • ee367a4a1 fix(xfa): bound the name and path bytes data nodes and queued instances copy (#6032)
  • b52bc3032 fix(annot): count ink points as strokes resolve and cap the eraser's samples (#6001)
  • 13d636738 fix(redact): read each Type 3 glyph program once per cleanup (#5993)
  • 87aebd1b2 fix(preflight): follow every /Next action and bound colour and intent work (#5926)
  • fba22fc73 fix(optimize): the fragment search charges every operator of a group it reads (#6095)
  • 683ccc9fc fix(optimize): walk shared resource dictionaries and name-tree kids once (#5956)
  • d46d9daea fix(edit): editor font enumeration reads a face name without building font metrics (#6086)
Upstream-Sync von `upstream/main` (1081 Commits). Fast-Forward. - 1d7a9f9ff fix(edit,a11y): font aliases share the metrics built for their font (#5998) - 69936f1d1 fix(engine): run the renderer redaction check on redactions applied inside a batch (#5977) - abec4e3fe fix(js): bound array text, month parsing, name registration and list work (#5883) - 77078864f fix(render): charge inspection text (fields, tooltips, layers, bookmarks, blank comments) against shared allowances (#6015) - daec898d7 fix(sign): judge a shared signature value once per listing; dedupe DSS evidence by object number (#6041) - c73cccfb3 fix(redact): hidden-layer removal reads the default state as viewers do (#5867) - 2b65ab000 ci: use StepSecurity's maintained actions (#7026) - 1e91e8a93 fix(forms): a read-only radio button is not picked or left through set_value (#6038) - ee367a4a1 fix(xfa): bound the name and path bytes data nodes and queued instances copy (#6032) - b52bc3032 fix(annot): count ink points as strokes resolve and cap the eraser's samples (#6001) - 13d636738 fix(redact): read each Type 3 glyph program once per cleanup (#5993) - 87aebd1b2 fix(preflight): follow every /Next action and bound colour and intent work (#5926) - fba22fc73 fix(optimize): the fragment search charges every operator of a group it reads (#6095) - 683ccc9fc fix(optimize): walk shared resource dictionaries and name-tree kids once (#5956) - d46d9daea fix(edit): editor font enumeration reads a face name without building font metrics (#6086)
alf added 1081 commits 2026-11-11 22:04:03 +00:00
An empty signature field that a click would sign now carries a small
accent tab, with the signature icon and "Sign", inside its top-left
corner while the Select tool is active (not while preparing a form). It
follows the field click's own checks: the document allows filling, the
field isn't read-only, and its widget is shown. The tab drops its label,
then itself, on a field too small at the current zoom. Like the
validation badges it is screen-only chrome, never written into the file,
exports or printing. The label reuses the catalog's "Sign".

The tab sits inside the field, so clicking it starts signing that field
and hovering it shows the pointing hand. Unit tests cover which fields
get a tab (signed, invisible, other-page, unplaced, non-finite and huge
rectangles, read-only fields, refused filling, hidden or moved widgets)
and the layout; a kittest frame test checks the painted tab, the cursor
and the click.
The tab was a fixed 18 pt chip that needed a 24 pt field: too big on a
29 pt signature box and missing on a 19 pt one. It is now 0.4 of the
field's height on screen, from 10 to 18 pt, and its inset, icon,
padding, label font and corners scale with it (3, 12, 5, 11 and 4 pt on
the 18 pt tab). The label shows from 14 pt up while the whole tab fits
in half the field's width; otherwise the tab is a square icon chip, and
it is left out only when that chip doesn't fit or the field isn't
finite. Which fields get a tab, the click and the tool condition are
unchanged.

The layout tests now cover an 18 pt tab with its label, an 11.6 pt and
a 10 pt icon chip on 29 pt and 19 pt fields, no tab on a 12 pt field,
the label and then the tab dropping as a field narrows, and the scaled
font and corners.
# Conflicts:
#	crates/ui-egui/tests/forms.rs
# Conflicts:
#	crates/ui-egui/src/canvas.rs
# Conflicts:
#	crates/engine/src/js.rs
#	crates/engine/src/tests.rs
#	crates/engine/src/xfa.rs
#	crates/js/src/tests.rs
#	crates/xfa/src/fixtures.rs
#	crates/xfa/src/tests.rs
#	parity/acrobat-features.toml
# Conflicts:
#	parity/acrobat-features.toml
# Conflicts:
#	parity/acrobat-features.toml
# Conflicts:
#	parity/acrobat-features.toml
Building on the centring of #3547, a step to a match could still leave
the match off screen after zooming, now and then:

- A zoom pending in the same frame (Ctrl+wheel, pinch, a zoom button or
  key) took the layout's anchor branch, so the step's `goto` waited for
  the next frame and its page-top offset (60 pt from the top, scaled by
  the zoom) undid the centring. At 600% that is below the window.
- egui eases a Ctrl-wheel notch over about a quarter of a second. Every
  frame of that set a new zoom anchor at the pointer, which moved the
  match just stepped to away from the middle again.

A step whose match centre is known now scrolls by that point alone and
drops a pending zoom anchor. Until the first frame without a zoom
gesture after the step is laid out, any zoom (`zoom_at`, so the wheel,
pinch, buttons and keys) and a changing fit zoom anchor on the match at
the window's centre. Another jump lets go of it. Match centres that are
not finite (a hostile page's glyphs spanning -inf..inf) fall back to
the page jump instead of a NaN scroll target.

Tests: search_position.rs steps through needles on pages larger and
smaller than the window at 10-3200%, in every page display, from the
Search panel, in a fixed pseudo-random mix of zooms and steps (some in
the same frame) and during real eased Ctrl-wheel zooms. With #3547 alone
three of them fail; on main all four original ones do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	crates/xfa/src/tests.rs
# Conflicts:
#	crates/engine/src/tests.rs
# Conflicts:
#	crates/engine/src/js.rs
#	crates/js/src/lib.rs
#	crates/js/src/tests.rs
A field's exit scripts run each time it loses focus, changed or not, and
its enter scripts each time it gains it (Session::field_focus, the app's
per-frame focus check, and the form_focus tool). Scripts that change
nothing leave no edit; an exit its value change already ran is not run
again; a radio button runs its own scripts.
PDF Optimizer > Discard User Data gets "Discard external cross-references",
off by default. It takes GoToR, GoToE, Launch and ImportData actions, and
Thread actions with an /F, out of the actions of link annotations and
bookmarks, and the actions chained after one through /Next take its place
in order. Other annotations, such as form fields, keep their actions. A
link left with no action and no /Dest is removed from its page; when the
link is an indirect object, that object is rewritten without the action
for anything else that refers to it. A bookmark keeps its title, place and
children, including a bookmark stored directly inside another one. Links
within the document and web links stay. An indirect action that changes
is written as a new indirect object, except the first action left at the
head of an /A, which is copied into the /A directly when other actions
follow it. Objects that use the old action keep it. An /A with more than
100,000 values to check, or one that opens another document and needs
more than 100,000 values to rewrite, stops the optimization with
OptimizeError::ExternalRefLimit, and so does an outline of more than
100,000 bookmarks.

doc_optimize takes discard_external_refs and reports external_links and
external_bookmarks.
PDF Optimizer > Discard Objects gets "Discard embedded search index", off
by default. It removes the entries of the catalog's /PieceInfo whose
application name contains "index" in any letter case, such as
IBM-ODIndexes, and removes /PieceInfo when nothing else is left in it.
Other application data and the pages' own /PieceInfo stay. An indirect
/PieceInfo is not edited: the catalog gets a direct copy without those
entries, and other objects that refer to the original keep it. The full
save leaves out the objects that only the removed entries refer to.

doc_optimize takes discard_search_index and reports search_indexes.
# Conflicts:
#	crates/engine/src/tests.rs
#	crates/xfa/src/tests.rs
#	parity/acrobat-features.toml
# Conflicts:
#	crates/xfa/src/layout.rs
#	crates/xfa/src/pdf.rs
#	crates/xfa/src/tests.rs
#	parity/acrobat-features.toml
# Conflicts:
#	crates/automation/src/a11y.rs
#	crates/engine/src/js.rs
#	crates/engine/src/tests.rs
#	crates/js/src/lib.rs
#	crates/js/src/tests.rs
#	crates/js/src/xfa.rs
#	crates/ui-egui/src/js_ui.rs
#	crates/xfa/src/tests.rs
# Conflicts:
#	ROADMAP.md
# Conflicts:
#	crates/ui-egui/tests/it/forms.rs
Previous view while a wheel zoom was still easing kept find_hold, so the
rest of the zoom anchored on the old match and the history jump waited
until zooming stopped (CodeRabbit on #4139).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LymhVaGZCWwB9HNEbLzeMM
# Conflicts:
#	crates/ui-egui/src/forms_ui.rs
# Conflicts:
#	parity/acrobat-features.toml
# Conflicts:
#	parity/acrobat-features.toml
# Conflicts:
#	crates/ui-egui/src/forms_ui.rs
#	parity/acrobat-features.toml
When an action to another document was removed and its /Next list
started with a null or a reference to something other than an action,
that entry became the head of the chain, and the actions after it, such
as a web link, were dropped with it. Leading entries that are not
action dictionaries are now skipped. The Settings::discard_external_refs
doc comment lists every action type the option removes.
After Apply redactions, Save wrote the redacted document over the file
it was opened from. The reference app shows Save As at that point with
"_Redacted" appended to the file name.

The Apply redactions dialog now marks the view as redacted. While the
mark is set, Save asks where, as Save As does, and the dialog proposes
<stem>_Redacted.pdf in the document's folder (contract.pdf gives
contract_Redacted.pdf). The .pdf extension is matched in any letter
case. The web build downloads under that name and keeps the mark, so
later downloads carry it too. A successful desktop save clears the
mark, so the next Save writes the copy in place. A name that already
ends in _Redacted keeps one suffix.

The naming helper, redacted_file_name, is in pdfcraft-redact. The
redact_apply tool returns the same name in save_as, for doc_save's
path. doc_save without a path still writes in place.

Tests: the redact UI test applies a page redaction, checks that Save
asks first, that the opened file is unchanged and that the copy is
written where the user chose. An automation test applies redactions
through the tools, saves to save_as and checks that the opened file is
unchanged. A unit test checks the proposed names.
# Conflicts:
#	crates/ui-egui/src/forms_ui.rs
Co-authored-by: EULA <90616501+EULArule@users.noreply.github.com>
widget_som fell back to the field's own SOM path for an index out of range, so for a radio field it ran the group's scripts. It now returns None unless the field has that widget.
Save, Print, Export, Sign and the other commands commit the form first. A focus move made in the same frame (or the editor they close) ran its enter and exit scripts only afterwards, so the document they read lacked what those scripts change. commit_form_typing now runs them before it returns. Also words the comment on noted exits so a failed exit reads as run.
Revert closed the field editor but kept the record of which field had the focus, so the next frame ran that field's exit on the reverted document and made it unsaved again. The record is cleared with the editor, and the engine forgets the exits that changes ran, as undo does.
# Conflicts:
#	crates/ui-egui/src/canvas.rs
#	crates/ui-egui/src/forms_ui.rs
# Conflicts:
#	crates/ui-egui/src/editing.rs
#	crates/ui-egui/src/forms_ui.rs
# Conflicts:
#	crates/xfa/README.md
#	parity/acrobat-features.toml
draw_draw and parse_subform, which this branch changes, had none (CodeRabbit's docstring coverage).
# Conflicts:
#	crates/xfa/src/pdf.rs
#	parity/acrobat-features.toml
# Conflicts:
#	crates/engine/src/lib.rs
#	parity/acrobat-features.toml
# Conflicts:
#	crates/engine/src/lib.rs
#	crates/engine/src/xfa.rs
#	crates/xfa/src/lib.rs
#	parity/acrobat-features.toml
# Conflicts:
#	crates/automation/tests/it/automation.rs
#	parity/acrobat-features.toml
# Conflicts:
#	crates/ui-egui/src/canvas.rs
# Conflicts:
#	crates/ui-egui/tests/it/main.rs
# Conflicts:
#	crates/ui-egui/src/forms_ui.rs
# Conflicts:
#	crates/ui-egui/src/lib.rs
On macOS and Linux the job sent no orientation, so CUPS left a landscape
sheet unrotated and shrank it into the portrait paper named by `media`
(Letter, A4, ...). Printing in Landscape therefore came out as a small
landscape page on a portrait sheet.

Add `pdfcraft_print::cups_orientation`, which gives
`orientation-requested=4` when every sheet is landscape, and attach it to
the job in two places:
- the Print dialog (`print_now`);
- the headless `doc_print` tool, which builds its own spooler job, so the
  automation tool and MCP server print the same way. An
  `orientation-requested` the caller already chose in `options` stands.

Neither applies on Windows, whose print ticket sets the orientation. A
job that mixes orientations gets nothing, since the option applies to the
whole job.

Record the new test as evidence on the `print.orientation` parity entry,
saying what is still only checked by hand, and mention the option in the
print crate README's CUPS paragraph.

Checked against Apple's CUPS filters with a generic PostScript queue: a
792x612 sheet sent to Letter without the option stays unrotated at about
77% scale; with it the page is turned 90 degrees to fill the sheet. The
new test fails with the fix stubbed out.
Review follow-ups to the landscape fix rebased from #3068:

- Both submission paths add the orientation through
  pdfcraft_print::add_cups_orientation, which never adds it over an
  orientation the job already names (a driver choice in the Print
  dialog, or a caller's doc_print options), so lp never gets it twice.
- doc_print adds it inside spool_job, next to the paper (#3752), so its
  test checks the job it sends.
- The Print dialog takes a stand-in for the spooler in tests
  (PdfCraftApp::spooler), like printer_source and options_source, and a
  test follows a real Print click to the job.
- Doc comments for the tests beside the change.

Checked on macOS with cupsfilter (cgpdftoraster, AirPrint PPD): a landscape
sheet sent as before lands unrotated at 100% and cut off at the paper's
right edge (#3739); with the option it is turned inside the margins.

Co-authored-by: Zach <90340773+zdblazer@users.noreply.github.com>
# Conflicts:
#	crates/engine/src/lib.rs
# Conflicts:
#	crates/engine/src/lib.rs
# Conflicts:
#	crates/engine/src/lib.rs
# Conflicts:
#	crates/ui-egui/src/canvas.rs
#	crates/ui-egui/src/page_pointer.rs
# Conflicts:
#	crates/ui-egui/src/canvas.rs
# Conflicts:
#	crates/automation/src/a11y.rs
#	crates/engine/src/edit.rs
#	crates/engine/src/session.rs
#	crates/js/src/lib.rs
#	crates/js/src/xfa.rs
#	crates/ui-egui/src/lib.rs
Main's module layout (#4889) moves the sign-tab unit tests to canvas/sign_tab_tests.rs.
# Conflicts:
#	crates/ui-egui/src/lib.rs
# Conflicts:
#	crates/xfa/src/layout.rs
# Conflicts:
#	crates/automation/src/a11y.rs
#	crates/automation/src/lib.rs
#	crates/engine/src/edit.rs
#	crates/engine/src/session.rs
#	crates/ui-egui/src/editing.rs
# Conflicts:
#	crates/xfa/src/layout.rs
#	crates/xfa/src/pdf.rs
# Conflicts:
#	crates/print/src/lib.rs
#	crates/ui-egui/src/lib.rs
#	crates/ui-egui/src/print_ui.rs
An open set the session's field highlight to the remembered Highlight fields for the new document's scripts, and the frame only set it back to the active document's afterwards: an edit of that document handled in between read the new one's default as app.runtimeHighlight. The open now passes the remembered setting in its own context (OpenContext::with_highlight) and the session's value stays (CodeRabbit, on #4691).
main split crates/automation/src/lib.rs into modules; the doc_optimize option and its two counts now go in documents.rs.
# Conflicts:
#	crates/ui-egui/src/canvas.rs

Main's module layout (#4889) split canvas.rs. The hold on the match stays
with DocView and document_area (canvas.rs); flash_match and the match's
centre go to canvas/find.rs, the page jumps letting go of it to
canvas/navigation.rs, the anchored zoom to canvas/zoom.rs, and the unit
tests to canvas/tests.rs. The wheel event in search_position.rs names
its source, which main's egui update made a field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GbuSKAS2k5248LGFEapMUR
# Conflicts:
#	crates/xfa/src/pdf.rs
# Conflicts:
#	crates/ui-egui/src/canvas/navigation.rs
#	crates/ui-egui/src/tools/forms_ui.rs
# Conflicts:
#	crates/ui-egui/src/canvas.rs
#	crates/ui-egui/src/tools/forms_ui.rs
# Conflicts:
#	crates/ui-egui/src/editing.rs
The view counts the Apply redactions steps in effect instead of keeping a
flag: Undo of an Apply redactions step takes one off, Redo adds it back,
and Revert, Reload and a desktop save set it to 0. Save after undoing every
Apply redactions step, or after Revert, writes the opened file in place.

The Save As choice after Apply redactions is made before the desktop and
web paths split, so an embedding page (`?host=parent`) receives Save As
with the `_Redacted` name.
# Conflicts:
#	crates/ui-egui/src/canvas.rs

Main split document_area into smaller functions for its stricter clippy
rules. The fit-zoom anchor that keeps a find step's match in the middle
goes into anchor_zoom_change, which now takes the view's size, and
letting go of the match after the step into pinch_zoom.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GbuSKAS2k5248LGFEapMUR
An empty multi-line text field was laid out three lines tall (38.5pt in
the default 10pt font) before its minimum height applied, so a growable
table cell (minH, no h; XFA 3.3 p. 731) could never be as short as its
own minimum. Texas HHS Form 1060's activity table, ten rows of such cells
at minH 7.62mm, came out at 38.5pt a row instead of 21.6pt and ran onto
a third page; readers fit the form on two.

Such a field now starts at its minimum (one line, or its text, below it)
and still grows with its text. A multi-line field without a minimum keeps
its three lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBVyQEwtmEw3mS1MoAiwrK
A flowing lr-tb or rl-tb subform without a height, laid out as one
block, moved whole to the next page when it didn't fit what was left of
this one. A subform is splittable "in margins and where consensus exists
among contained objects" (XFA 3.3 p. 1520), and readers start it on the
page and carry the rest over. It now does the same, as a block taller
than a page already did. One with a height still moves whole.

With the row-height fix before it, a public evaluation form's sections
shrank enough to fit a page, so they moved whole and left page 1 nearly
empty (3 pages, where readers lay the form out on 2). It is 2 again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBVyQEwtmEw3mS1MoAiwrK
A subform with no `h` was stretched to its `minH` when positioned (flowed
ones already weren't). A section holding about 80pt with
`minH="58.274mm"` was laid out at 165pt, which pushed the office-use box
after it onto a page of its own on the visitor-visa forms.

XFA 3.3 p. 847 makes `minH` the minimum height of a growable container,
but both readers checked lay these forms out without stretching such a
subform: the box follows the section directly and the form has 5 pages.
Drop `minH` for every subform without a height, as already done for
flowed ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBVyQEwtmEw3mS1MoAiwrK
egui spreads a ⌘/Ctrl-wheel notch over about a quarter of a second. The
hold on a find step's match was let go in the first frame the pointer
was off the page view, though the zoom was still easing; once the
pointer came back, the rest of the zoom anchored on it, and the match
ended up to 136 points off the middle at 60 frames a second. The hold is
let go only once no zoom is coming in (CodeRabbit on #4139). The test
runs at 60 frames a second: kittest's default quarter-second frames end
the zoom before the pointer leaves, which is why an earlier test of this
did not fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GbuSKAS2k5248LGFEapMUR
Undo of an Apply redactions step that was already saved brought back the
redacted content, and Save then wrote it in place over the _Redacted file.
The view now counts the saved steps that Undo takes back, and Save asks
where while any are undone. A new Apply redactions made while saved steps
are undone clears Redo, so Save keeps asking until the next save or Revert.
A web download keeps the document's name, so the view's count of applied
redactions is what names later downloads after the redacted copy. Revert
set that count to 0 even though it restores the downloaded, redacted
version. Revert now restores the count from the last download.
# Conflicts:
#	crates/engine/src/js.rs
#	crates/js/src/xfa/globals.rs
#	crates/ui-egui/src/workflows/js_ui.rs
# Conflicts:
#	crates/automation/tests/it/automation.rs
#	parity/acrobat-features.toml
# Conflicts:
#	parity/acrobat-features.toml
# Conflicts:
#	parity/acrobat-features.toml
# Conflicts:
#	parity/acrobat-features.toml
A repeated field gets a made-up PDF field name (`Cell4` -> `Cell4_2`). That name could be one
another template field already has as written, so two different fields shared one PDF name
and, per ISO 32000-2 12.7.4.2, one value. The layout now reserves the template's own names and
skips them when making one up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBVyQEwtmEw3mS1MoAiwrK
An unnamed field or group is named `field` or `group`. When it came before a field the
template names `field`, it took that name and the named field became `field_2`. A fallback
name now skips the template's own names too, and a field keeps the name the template gives
it while no other field has it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBVyQEwtmEw3mS1MoAiwrK
# Conflicts:
#	crates/ui-egui/src/canvas.rs
#	crates/ui-egui/src/canvas/area.rs
#	crates/ui-egui/src/tools/forms_ui.rs
# Conflicts:
#	crates/ui-egui/src/tools/forms_ui.rs
# Conflicts:
#	crates/ui-egui/src/tools/forms_ui.rs
A subformSet was always laid out top to bottom. In a positioned subform its members, which have
no x or y of their own, were stacked instead of overlapping at their positions, so a two-column
section pushed its second column past the page (XFA 3.3 p. 357: "The layout process treats
subformSet objects as transparent"). A set now takes its container's layout; in a table or a row
its members still go one under another.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBVyQEwtmEw3mS1MoAiwrK
A portfolio (ISO 32000-2 §7.11.6) keeps its documents as embedded files; its pages are only a
cover sheet for viewers without a file list. DocInfo::portfolio reads the collection dictionary
(Table 153): the attachment /D names, decoded as the name-tree keys are, and /View. Opening one
presents that PDF in a tab of its own, on the next frame so a nested portfolio waits for the
reader, and the portfolio's tab shows its files in the Attachments panel even with the right
panel closed (not with /View /H), giving the replaced panel back when the tab is left. doc_info
reports the portfolio; organize.portfolio-view is partial.

Fixes #1652
Scripts got an object that takes anything for `this.border`, `this.ui`,
`this.validate` and the like (XFA 3.3 ch. 3, "Properties vs. Children",
p. 78), so `ui.oneOfChild.className == "choiceList"` was never true,
`this.validate.nullTest == "error"` never matched, and
`border.getElement("edge", 0).color.value` threw a TypeError.

The parser now keeps each object's own attributes and its property
elements (border, font, para, margin, ui, validate, occur, break,
breakBefore, breakAfter, assist, format, value, bind, desc, extras, keep,
caption, items, calculate) as a generic tree, bounded per object: 256
elements, 16 KiB of text, 8 levels, 64 attributes an element; past that
the rest is left out with one warning. An element keeps only its own
text, not that of children left out (an extras, a script); a validation
or a calculation keeps its first script with its text outside the
budget, as forms read it, within the script engine's 256 KiB limit on a
script. A field's first two `<items>` (a field has two at most, p. 726)
are kept as lists: each counts against the budget as one element, and
its entries, outside it, are bounded by the list's own limits (1,000
entries), as the field's lists are; a further `<items>` (one public form
has a third, empty one) is an ordinary property, every entry charged.
The tree is shared through an Arc by the template, every instance of the
object and every script's snapshot, and the engine keeps one converted
copy of each tree in the form's template, so it is made once per opened
form rather than per event or per script, behind the form's own lock,
and goes when the form is closed. On a 10,000-field form with 17
property elements each, a script's snapshot takes 3.24 ms against
1.46 ms on main (median of 15 interleaved runs); the property trees are
converted once per form, and each later snapshot adds one walk over the
form and a lookup per tree.

In JavaScript a property object is a Proxy naming its node and path. It
reads what the template writes, or the default XFA 3.3 gives for the
properties the 367 inventoried public forms read: presence of border,
edge, corner, fill (p. 629, 687, 663, 734); colours (a fill white,
p. 734; a font fill, edges and corners black, p. 734, 687, 663); font
typeface/size/weight (pp. 741-744); para hAlign/vAlign (p. 800); margin
insets (p. 776); validate nullTest/scriptTest/formatTest (pp. 880-881);
occur min/max/initial (p. 786); break before/after (p. 632); bind ref
(p. 623); keep next/previous none and intact by container (pp. 762-763);
a comb's numberOfCells (p. 657); a gradient's type (p. 770, 806, 820); a
fill's type solid (p. 734) and a field's widget textEdit (p. 877); a
calculation's override, "disabled" where the template has no calculate
element and "error" where it has one that doesn't say (pp. 641-642).

Where XFA 3.3 leaves the outcome open, or where every reader tested
contradicts its text, reads follow the readers. A border's presence
reads "hidden" when the object has no border element ("visible" with
one that doesn't say, p. 629). For the edges and corners a border
doesn't list, p. 38 says the last one listed is reused; readers draw
such a side from the edge or corner on the same side instead (two
listed alternate, three reuse the second for the fourth, one serves
every side), and reads follow the readers: a side past those listed
reads as the one drawn there (with none listed, the defaults: black,
0.5pt, solid, a square cap, a corner's radius 0in, square join, not
inverted, p. 687, p. 663), and is made as a copy of it (with what a
script already set on it) when a script reaches it, so a write then
changes only that side. A border has four sides: a fifth or later edge
or corner, even one the template lists, is out of reach, as the layout
draws four. The copies have their own budget (8 MiB a run), apart from
the run's text budget, so reaching sides never crowds out a script's
writes; past it a side still reads as the one drawn there. A fill type
the fill doesn't have (its pattern when it is solid, p. 734) can't be
read: the read raises an error the script can catch (GeneralError,
"operation failed"), the fill keeps its type, and `"pattern" in fill`
is false. Anything else unspecified reads as an empty value that
compares as "" and never throws; further defaults are added as forms
need them.

Property objects have className, oneOfChild, children by name
(desc.vMandatory, message.scriptTest) and by class (ui["#textEdit"]),
getElement(name, index), isPropertySpecified, getAttribute,
setAttribute, rawValue (a desc's first text), a validate's or
calculate's script text and message (p. 827), resolveNode(s),
somExpression and nodes (length/item/namedItem); nodes get getAttribute,
getElement, isPropertySpecified, and mandatory, validationMessage,
formatMessage and mandatoryMessage read from their validate's nullTest
and message texts (the text of that name, as the template or a script
named it, else a single unnamed one, which serves every test: p. 782,
p. 880). Setting a test's message sets its named text, else overwrites a
single unnamed one (so every test then reads it), else adds a text of
that name, as readers do; reading validate.message.<test> where no such
text exists raises a script error, as in readers. fillColor, fontColor
and borderColor read from the border fill, font fill and first edge
colours; borderColor set colours all four edges. A child of the same
name comes before a property, an attribute or a member, on nodes and on
property objects alike, for reads and writes (a write to a member a
child shadows leaves the property alone), as XFA SOM resolves name
clashes in favour of the explicitly named object, and `#name` reaches
the property (p. 99). A SOM expression from a node, or from
xfa.resolveNode, that runs into objects' properties
(resolveNode("ui.#textEdit"), "border.edge[1]",
"row[*].border.fill.color", "extras.arrayGroup[*].groupName", "ui.*")
gives those property objects, each instance's, 4,096 at most (about 30
of the forms do this); `value.#text` on a draw stays null, as before,
until a node's value is its value element.

Writes (border.fill.color.value = ..., validate.nullTest = ...,
rawValue, setAttribute, mandatory, validationMessage = ...) are kept for
the rest of the script run and read back through the same properties,
bounded by the run's text budget and 64 attributes a property, but have
no effect on the form yet.

The caption (XFA 3.3 p. 643) and a field's items (p. 758) are property
objects too. A caption's value holds its label as text or rich text,
read by name, by class (`['#text']`), through resolveNode and
oneOfChild; rich text reads as its words; the content a value doesn't
hold reads as null (one of them, p. 598). Items list their entries
through `nodes`, each with the text the choice list or check button
shows. A field's `items` is its first `<items>` (the shown list), a
second is reached by index (`items[1]`), and a field with none reads an
items object with no entries. Items reads follow the template:
`HNode::items` is the hook PdfCraft's choice-list methods (addItem,
clearItems; not on main yet) will set, so that reads agree with them
once they land. exData.loadXML and saveXML do nothing yet. Over the 163
dynamic forms (118,208 objects) no object reaches the budget: elements
p99 30, max 41 (limit 256); bytes p99 773, max 8,500 (limit
16 KiB); item entries per list p99 51, max 342. The choice-list
methods (selectedIndex reads still give an object that takes anything),
execEvent and script objects are handled separately. The one known gap
is value.text and value.exData (about 15 of the 367 forms): a node's
value stays its raw value, and changing what value returns is left to a
follow-up with a full-corpus regression check. FormCalc does not read
these properties yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBVyQEwtmEw3mS1MoAiwrK
XFA 3.3 p. 38 gives a border's corners in the order top-left, top-right,
bottom-right, bottom-left, as its edges go top, right, bottom, left, and
says that when fewer than four are listed the last one listed is reused
for the rest. Readers don't do that: every reader tested draws a side not
listed from the edge or corner on the same side, one listed serving all
four sides, two alternating (0, 1, 0, 1), three reusing the second for
the fourth (0, 1, 2, 1). The layout already drew edges the readers' way,
but drew corners as p. 38's text says, so with corners [round, square]
only the top-left was round where readers round the top-left and the
bottom-right.

Corners now follow the readers too, through one helper, drawn_side, that
edges and corners share: with [round, square] the top-left and
bottom-right are round and the top-right and bottom-left square. Edges
are drawn as before. The scripting host's reads of a corner past those
listed (from the previous commit) mirror the same pattern, so a script
reads the corner the page draws.

Real forms (358 public forms with XFA, templates read through the
engine): 337 borders in 51 forms list two or three corners that differ,
all only in a square corner's presence, which the layout doesn't draw
differently; none has a rounded or inverted corner that would move, so
no rendered page changes.

Tests: one, two and three corners, two in both orders, and the
rounded-corners test's two-corner box (now [r1, r2, r1, r2]) fail with
the change reverted; a script test reads corners past those listed by
the same pattern.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBVyQEwtmEw3mS1MoAiwrK
A repeating subform laid out at most 50 instances, so rows 51 and on of a
long list or table (from the data, or added by a button's addInstance)
were left out. XFA 3.3, "The occur element" (p. 786): max -1 sets no
upper boundary.

The per-subform bound is now 1,000: the largest occur count the parser
reads and the most rows one script run may add (MAX_ADDED). The layout
budgets still bound the whole form. Opening a form with 1,000 one-field
rows takes about 3 s in a debug build and 2,000 rows about 13 s, so a
much larger bound would let one list stall the app. Rows past the occur
max or the bound are still reported, and the warning now names the
layout's bound when that is what cut them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC
A repeating subform drawn inside a positioned container (a tb, lr-tb or
rl-tb subform placed on a positioned page) was cut at its occur max or
MAX_INSTANCES like a flowed one, but the rows past the cut were dropped
without the layout warning that flowed subforms and tables give. Both
drawing paths now call warn_truncated, so the README, the MAX_INSTANCES
doc and the form.xfa-data note are true for these layouts too
(XFA 3.3, "The occur element", p. 786).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC
A table subform (layout="table") placed in positioned content was drawn
through draw_node, which drew each row subform once: the rows the data
holds past the first, and an occur initial count, were dropped with no
warning, while the same table in flowed content repeated them (XFA 3.3,
"Tables", p. 327; "Repeating Elements using Occurrence Limits", p. 263;
Chapter 9, p. 336).

The positioned table now draws each row once per instance, one under
another with its own SOM index, and is measured with them, so what
follows it in a top-to-bottom container moves down. The count and the
warning for the rows cut come from one helper, laid_out_instances, now
used by every layout that repeats a subform (flowed subforms, flowed
tables, positioned top-to-bottom, wrapped and table layouts).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC
Instances of one repeating subform can hold different numbers of rows in
the data (XFA 3.3, "Tables", p. 327; Chapter 9, p. 336). Measurement used
instance 0 for all of them and cached by template node only, so a later
instance with more rows ran into what followed it.

- size_at measures instance `index` under its own SOM path, and the
  measurement cache keys on that path when data drives the layout.
- Top-to-bottom, left-to-right, table and positioned measurement and
  drawing take each instance's own size.
- A repeating subform that isn't split across content areas (a positioned
  one) in flowed content now gets one block per instance, as in
  positioned content (XFA 3.3, "Repeating Elements using Occurrence
  Limits", p. 263), with the rows past the limit reported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC
A table row's height was measured under the table's SOM path, without the
row instance, while draw_row draws its cells under row[n]. A cell that
grows with its text (a multi-line field without a height) then found no
value: every instance took the empty height, and a long value was drawn
in a short slot over the rows after it (XFA 3.3, "Tables", p. 327;
"Growable Containers", p. 275).

row_height now takes the instance index and measures under that
instance's path, as draw_row does. Table measurement sums each instance's
own height, and flowed and positioned tables size each instance (and the
overflow leader) on its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC
cells() (behind row_height) measured every cell with size_of(), at SOM
index 0, while draw_row() draws cell i at its index among its same-named
siblings. With a short qty[0] beside a long qty[1] the row took qty[0]'s
height (38.5pt), and the long cell ran into the following rows and
content; a non-interactive cell drew its text over them.

Each cell is now measured with size_at() at SomIndices::of(..).at(i), the
index it is drawn and bound at (XFA 3.3, "Tables", p. 327; "Growable
Containers", p. 271). content_width(), group_height() and draw_group()
measured their children at index 0 too and now use the same index.
size_of() had no callers left and is removed, so every measurement names
its index.

Tests: a row with short qty[0] and long qty[1] is as tall as the long cell,
with the following rows, footer and content after it, in the positioned,
positioned-in-flow and flowed placements; a non-interactive long qty[1]
keeps its lines inside its own row. Both fail with the index forced back
to 0 (38.5pt rows).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBVyQEwtmEw3mS1MoAiwrK
A positioned subform without `h` is as tall as its content: with a `minH`
below the content, with content taller than its `minH`, and with a hidden
child, which takes no room. A fixed `h` stays the height whatever the
content.

A table row keeps its `minH`, as XFA 3.3 p. 719 says: no row in the 245
dynamic forms we test has a `minH` without an `h`, so there is no reader
behaviour to follow instead.

The test fails with this PR's change to `measure` reverted (the hidden-child
case is stretched to its `minH`), and with rows made to drop their `minH`
(the row grows by 0pt).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
`row[*].border` with a field named `border` in one row and only the border
property in another listed the field and dropped the other row's property:
the property was looked up only when no child matched at all. Each object
the expression names now decides on its own: a child named as the property
comes first, and an object without one gives its property, in order.
resolveNodes' list holds both, and item() and namedItem() return a node or
a property object accordingly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
A `tb` (or `lr-tb`, `table`) subform that is a cell of a table row was laid out at the height it
gives itself, even when its content is much shorter, so the rows after it and the next section
were pushed down, onto an extra page on a public form. Adobe Acrobat and PDF-XChange Editor both
lay such a cell out as tall as its content, although a height makes a container fixed (XFA 3.3
p. 275); this follows them. A positioned cell, and a flowed subform outside a table, keep their
heights. `measure`'s content height moves into `content_height` so both can use it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBVyQEwtmEw3mS1MoAiwrK
`row.border.edge[0]` with a field `border` in the row no longer falls back
to the row's own border when the field has no `edge`; `row.#border` still
names the property by class (XFA 3.3 p. 96). `namedItem` on a list of
property objects reads each name as written in the run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
XFA 3.3, "Formatting Text That Appears as Fixed or Variable Content"
(p. 43): text, caption text included, is formatted by its containing
element's font properties, inherited from an ancestor container when
it names none. A field's caption was drawn in the font around the
field, so a form whose fields set Arial and whose captions set nothing
showed its labels in the 10pt Courier default. Captions are now drawn,
and placed, in the field's font with the caption's own properties on
top, as they were already measured; a button's caption likewise. The
paragraph of a field caption is unchanged.

Part of #3432.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC
A field caption is drawn in its field's font (XFA 3.3, "Formatting Text
That Appears as Fixed or Variable Content", p. 43) and the paragraph
around the field, each with the caption's own properties on top. Its
room (the reserve above, below or beside the box, a caption beside the
box measured for the field's height, a growing text field's width, and
a button's natural width) was worked out in the field's own paragraph,
so a field paragraph's space above or line height gave the caption room
its drawn text does not take. caption_reserve, field_height and
grown_text_height now take the outer paragraph.

Part of #3432.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBVyQEwtmEw3mS1MoAiwrK
The button branch of draw_field shows the caption in the field's font with
the caption's own properties on top. No test reached it with a fontless
caption, so dropping the field font there passed. The new test fails
with the button face taken from the context outside the field (10pt
Courier instead of the field's Arial 8pt).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBVyQEwtmEw3mS1MoAiwrK
Without a fixed height the button is measured from its caption, so the
test now leaves `h` out and checks the button is one line of the
field's font tall, besides the face it is drawn in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBVyQEwtmEw3mS1MoAiwrK
Designer style sheets give fields and draws their size, anchor, borders,
fills, fonts and caption placement through usehref references to elements
in a <proto> subform (XFA 3.3, chapter 6, "Prototypes"). The parser did
not follow them, so such forms lost their section bars, input boxes and
check-box placement.

The new crates/xfa/src/proto.rs writes the template again with every
reference into the same template merged in, and parse() reads that:
- references: usehref "#id", "#som(expr)" (with or without the legacy
  "."), and use "#id" or a SOM expression; usehref wins over use;
- rules from "Overriding Prototype Properties": own attributes, data
  content and children win; id and name are not inherited; children
  match by class and position, and a matched prototype child is the
  prototype of the element's own; a ui widget or value kind and rich
  text come from the nearest layer that has one;
- prototypes in other documents are not followed (no file or network
  access); a missing target, a loop (Example 6.8) or a long chain is
  left out with a warning;
- caps: 32 merged layers, 256 levels of nesting, 2 million elements of
  SOM search, and growth to 4x the template (1 MB floor, 64 MB ceiling);
  past a cap the template is read unresolved, with a warning.

The resolver is ported from the closed #1566 by EULArule, who invited
reuse, and changed: the name attribute is no longer inherited, use takes
SOM expressions, ids and SOM look-ups are indexed and cached under one
search budget, loops through ancestors are cut, the output cap is
relative, every fallback is a warning, and the saved form state is
still read from the original XDP.

Part of #3432.

Co-authored-by: EULA <90616501+EULArule@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC
A SOM look-up cut short by its budget was cached as "not found", so later
references warned that they named nothing and the template was only partly
resolved. It now stops the expansion: the template is read unresolved with
one warning, as the README says.

Merging looked at every child of every prototype layer, also those that
write nothing (proto, unchosen ui/value alternatives), so a prototype with
many proto children used by many elements did quadratic work bounded only
by output. Merging now charges children, attributes and namespaces against
a 64 million budget before looking at them, and leaves out the skipped
classes before indexing them; namespace look-ups use a set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBVyQEwtmEw3mS1MoAiwrK
Bindings in scope were matched by prefix only, so an XHTML body's default
namespace inside the template's XFA default namespace was dropped and the
body read back in XFA's. Bindings are now kept as URI by prefix: a binding
is written when the URI differs from the one in scope, the nearest layer's
binding of a prefix wins, and an element in no namespace undeclares a
default namespace around it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBVyQEwtmEw3mS1MoAiwrK
Attributes were told apart and written by prefixed name, so an inherited
q:kind whose prefix the element binds to another URI read back in the
element's namespace, and a same-named attribute in another namespace was
dropped. Attributes are now told apart by (URI, local name); one whose
prefix is bound to another URI on the merged element is written with a
fresh prefix declared there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBVyQEwtmEw3mS1MoAiwrK
main's clippy now caps cognitive complexity at 30; `element` was at 33.
No change in behaviour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
A template's <image href="…"> without inline data is now found in the
catalog's /Names /XFAImages tree, keyed by the URI the template writes
(XFA 3.3 pp. 257-258, "Storing Images Inside PDF"). Only that tree is
looked in; a link is never followed outside the file. The tree walk is
bounded (depth, nodes, names, a seen-set), and so are the bytes read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
- A name-tree branch past the depth limit is skipped; the branches
  beside it are still walked (before, the walk stopped there).
- Every place a picture is put counts against the pictures' total,
  since each holds its own copy: one large picture used in many places
  can no longer hold a copy per place without limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
A flowed subform in a row cell is drawn at its column's width; its
content height is now measured at that width (less the cell's margins)
rather than at the width the cell gives itself, so content that wraps
in a narrower column no longer runs under the next row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
A picture read from the file is kept for the next place that links it.
Those kept bytes now count against MAX_TOTAL as well: past it, a name
not read yet is neither read nor kept, so a tree with many names holds
no more than the total however many names it has.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
Find, text_find and Search & Redact's word search read the Latin
compatibility ligatures U+FB00-U+FB06 as their letters, in the page and in
the query, so "office" finds a page drawn with an fi ligature glyph
(ISO 32000-2 9.10.3 maps ligature glyphs to their letters). Each letter
stays owned by its glyph, so the highlight covers the ligature.

Where a line ends in a hyphen (U+002D, U+00AD, U+2010) between two
letters, the page is also searched with the hyphen and the break dropped
("example" finds "exam-" + "ple") and with the hyphen kept but the break
dropped ("exam-ple"). The hits are merged in page order; a match covers
both lines and is highlighted on each. Pages without such a hyphen are
searched once, as before.

Accent-insensitive search is left to the open pull request for #3460.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC
On a page with a word divided by a line-end hyphen, Find searches three
readings and merged their hits by dropping any hit that overlapped the
one kept before it. That also dropped hits of one reading that share a
glyph, such as "f" twice in an "ffi" ligature glyph, so the match count
depended on unrelated text elsewhere on the page. A hit is now left out
only where it overlaps one kept from another reading. The test oracle
follows the same rule, and a fixed case covers it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC
Applying redactions removed the glyphs under the marks but left the same
words in the document information dictionary and the XMP metadata.

The redaction interpreter now collects the removed glyphs' text in runs by
position. After verification, /Info entries containing a run (or one of its
longer words) are removed, and the catalog's and pages' XMP streams whose
text mentions one are removed whole; unreadable or oversized XMP fails
closed. Report::metadata counts what went.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An /Info value whose words are too short to be terms ("J. Li") survived
when the sentence holding it was redacted. A value that is wholly part
of a removed run, with three letters or digits at least, now goes as
well (the reverse check #2598 proposed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A value shorter than the redacted sentence ("James Bond" from "JAMES BOND
IS A SPY") in a custom schema's element or attribute takes its packet
with it, as it goes from /Info, while a clean packet and an unrelated
entry stay. The regression #2598 suggested keeping whichever fix lands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review follow-up on #2539:
- the glyph end includes character and word spacing, so letter-spaced
  redacted text is one word and still matches /Title (Bluefin);
- an XMP value wholly part of a removed run goes, as an /Info value does
  (J. Li), compared as whole words (True stays with "untrue");
- when the removed text was cut short (too many runs, a run too long, too
  many terms) every /Info string and XMP stream goes instead of searching
  what was kept;
- XMP that is not valid UTF-8 or UTF-16 (also UTF-16 without a leading
  mark) is not searched lossily but removed;
- namespace declarations and the xpacket wrapper are not searched;
  comments and other processing instructions are;
- the raw-byte assertion saves without object streams so it can see /Info.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review follow-up on #2539:
- only a processing instruction whose target is exactly `xpacket` is
  skipped, so `<?xpacketAudit …?>` is searched like any other;
- an attribute name is kept across whitespace before `=`, so
  `xmlns:x = "adobe:ns:meta/"` is still a declaration, not a value;
- the XMP stream is decoded with decoded_within(MAX_XMP_BYTES), so a small
  stream that inflates hugely stops at the cap instead of after it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review by Losminek on #2539:
- The work cap was checked per value, so very many small /Info or XMP
  values could add up without bound. One budget now covers the whole
  scrub; past it every further entry or packet counts as matching
  (fail-closed). Identical removed runs are searched once.
- unescape looked for ';' through the rest of the text before checking
  the 12-byte entity limit, so a packet of bare '&'s was quadratic. It
  now looks only at the next 13 characters (200,000 '&': 943 ms -> 5 ms).
- A form XObject removed whole past the depth limit, or when its stream
  doesn't decode, never had its text read; both paths now set
  removed_cut so the metadata check clears everything instead of
  searching incomplete text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review follow-up: `&#00000000000066;LUEFIN` hid BLUEFIN from the 13-byte scan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review on #2539: a removed glyph with no known text (no /ToUnicode, a
glyph name outside the list) went in as an empty string and nothing marked
the collection as incomplete, so when every removed glyph was like that
the terms were empty and /Info and XMP were left untouched. It now counts
as text the check couldn't keep, and every /Info string and XMP stream goes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main raised the redaction pass's form depth limit from 12 to 50 (#3645),
so the 16-deep chain was walked and the test's form was no longer removed
unread. The chain is now built from MAX_DEPTH, one level past it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC
A rectangle with no height or no width encloses no area, so the
rasteriser marked nothing for it. As a clip (`10 10 100 0 re W n`) it
hid everything painted after it; as a fill it fell back to a
thinnest-line stroke that half-covered two rows when the line lay on a
pixel boundary.

ISO 32000-2 §10.7.4 paints every pixel whose half-open square the shape
intersects, "no matter how small the intersection is" ("A zero-width or
zero-height rectangle paints a line 1 pixel wide"), and a clipping
region is "the set of pixels that would be included by a fill
operation".

hayro-interpret patch (65), `degenerate_fill_area`: a fill or clip whose
path has no width or no height (but not both) gets the device pixels the
line touches - whole pixels when it runs along a device axis, a band one
device pixel wide when a rotated CTM turns it. Points, and paths with
area however thin, paint as before.

Tests: crates/render/tests/it/zero_area_fill_clip.rs (5 of 8 fail with
the patch reverted; 9 mutants of the patch each fail a test).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC
`degenerate_fill_area` took a side under 1/256 unit as zero, so a thin
rectangle with area that the CTM scales up to whole pixels (100 x 1/1024
under a 4096x vertical scale covers four device rows) was clipped and
filled as a one-pixel band along its bounding-box diagonal. The fill
fallback had the same threshold and stroked it as a hairline. Both now
treat only an exactly zero side as degenerate; a box with area is
filled and clipped as the shape it is.

A slanted zero-height line less than a pixel high was snapped to its
whole bounding box, marking both rows along its full length when it
crossed a row boundary. Whole-pixel snapping is now used only when the
line stays within one device row or column (a line under 1/256 pixel
across is taken at its middle); otherwise it is the one-pixel band, so
each row is marked only where the line runs through it (ISO 32000-2
§10.7.4, half-open pixel squares the shape intersects).

Tests: crates/render/tests/it/zero_area_fill_clip.rs
a_thin_rectangle_scaled_up_to_whole_pixels_keeps_its_area and
a_slanted_zero_height_line_crossing_a_row_marks_each_row_only_where_it_runs
each fail with their part of the fix reverted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC
A standard 14 font named without /Widths is drawn with a bundled CFF
stand-in. Its glyphs are fitted to the standard metrics only when the
stand-in's own advance is known, and for the CFF stand-ins it never was,
so each glyph was drawn at its own width. The stand-ins' Euro is 667 to
722 units wide against the metrics' 556 (Helvetica) and 500 (Times), and
it overlapped the glyph after it ("€5", "€€", "a€b"). The advances
themselves were right: both the renderer's metrics and the writer's
Std14 table already give the Euro 556/500/600.

hayro-interpret patch (67): CffFontBlob::advance_width reads a glyph's
advance from its charstring, and StandardFontBlob uses it, so a stand-in
glyph is fitted to the standard width as the OpenType stand-ins already
were (ISO 32000-2 §9.6.2.2; Annex D Table D.2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC
The stand-in fitting of hayro-interpret patch (67) also applies to Symbol
and ZapfDingbats (ISO 32000-2 9.6.2.2, Annex D.5 and D.6). Tests show
that their glyphs, and the check mark a check box draws for its /MK /CA
caption 4, keep the standard advance:

- symbol_dingbats_width (pdfcraft-render tests/it): Symbol alpha, beta,
  pi, Sigma, Omega, Delta, infinity, bullet and plus, and ZapfDingbats
  a20, a19, a23, a24, a71, a73, a77 and a28, drawn with no /Encoding:
  each glyph's ink lies within its standard advance and fills most of
  it, and the next glyph starts one standard advance on.
- dingbat_check_box (pdfcraft-engine tests/it): a check box with no /AP,
  /DA (/ZaDb 60 Tf 0 g) and /MK /CA (4) draws its check centred in the
  box, within its 846-unit advance and not squeezed.

The patch is renumbered (67), next after main's highest (66), and the
Tests column lists the new tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC
The walk queued every entry of a Kids array before checking the node
limit, and resolving a direct node or array deep-copied it (two to four
times for a direct tree). It now recurses (depth stays capped at 32),
borrowing each node from its resolved owner, and stops at the limit as
it goes: only the branch being walked is held.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
An image in [/Indexed /DeviceRGB 7 <FF0000 00FF00>] was drawn entirely
black, indices 0 and 1 included. The interpreter rejected the colour
space when the table ran out, and the image fell back to DeviceGray, so
its index values were painted as near-black grey levels.

ISO 32000-2 8.6.6.3 says hival "shall be an integer", no greater than
255, and the table "shall be m x (hival + 1) bytes long". An image whose
Indexed space breaks this (hival not an integer in 0..=255, or a lookup
missing or shorter than needed) is now not drawn, and the render reports
RenderWarning::MalformedIndexedImage once per page; pdfcraft-cli render
prints it. A longer table is still fine. Inline images, including one
naming such a space as a resource, are skipped the same way. Fill and
stroke colours in such a space go through a different path and are
unchanged.

Vendored hayro-interpret patch (60), recorded in vendor/README.md.
Tests: crates/render/tests/it/indexed_lookup.rs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC
The inline-image check of patch (60) looked the /CS name up in the
ColorSpace resources even when it was a device space name or its inline
abbreviation (/RGB, /DeviceRGB, /G, /CMYK, ...). Those names never refer
to resources (ISO 32000-2 8.6.8), and ImageXObject::new takes the device
space first, so a malformed Indexed resource that happened to share the
name skipped an image that would draw correctly. Device names are now
excluded before the resource lookup.

Test: indexed_lookup::an_inline_device_space_name_ignores_a_resource_of_the_same_name
(fails with the filter removed: the image is skipped and drawn white).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC
Vendored hayro-interpret patch 61.

- A degenerate subpath (a single-point closed path, or points that all
  coincide) is stroked as a dot of the line width with round caps and not
  at all with butt or projecting square caps (8.5.3.2). The zero-length
  `l` is no longer nudged a ten-thousandth of a point (which gave square
  caps a direction to draw a square in), and `x y m h` keeps its `h`
  (kurbo's `is_empty` counts only segments, so it was dropped and the
  subpath painted nothing). Other subpaths and zero-length dashes are
  untouched.
- A dash array that is all zero, or has a negative or non-finite entry,
  is kept as `[]`, a solid line (8.4.3.6). `[0 0]` used to become
  `[0.01 0.01]`, a faint half-covered line.
- A negative or non-finite line width from `w` or `/LW` is kept as 0,
  the thinnest line (8.4.3.2), so no device sees a width below zero. The
  renderer already drew it as a one-pixel line in the stroke colour.

Tests: crates/render/tests/it/stroke_geometry.rs (pixel checks and a
recording device).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
In a flowing (lr-tb/rl-tb) parent the members of a subformSet now go on
along the parent's line with its other children, rather than the set being
packed as one block that moves to the next line whole (XFA 3.3 p. 357: the
set is transparent to layout). The members keep the set in their SOM paths.
Adds a test that, in a table and in one of its rows, a set's members still
go one under another (review).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
The subform's own sibling indices are reused for the first step of each
placed child, and a subformSet's are built once per set (review).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
parse() refuses a template larger than the 64 MiB a document's packets may
hold (read_packets' limit), before any of it is parsed, so what parsing
keeps of it, item lists held by the field and its properties among them,
stays within a few times that whoever calls it (review).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
A UTF-16 packet within the reader's 64 MiB grows by up to half when it
becomes UTF-8, so parse() now takes up to 96 MiB rather than 64: a packet
read_packets accepts always parses. The refusal gives the size in bytes
(review).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
`m 10 50 l 30 50 m 60 50 l 90 50 f` used to be reduced to the bounding box of the whole path
and painted as one line from x 10 to 90, filling the gap no subpath touches (ISO 32000-2
§10.7.4). The fill and the clip now build the touched-pixel area for each subpath and combine
them, wound the same way, into one path painted with the non-zero rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
# Conflicts:
#	crates/ui-egui/src/tools/forms_ui.rs
A single squashed port of the branch's five commits onto current main, which
split annot's lib.rs into modules (create.rs, modify.rs, info.rs, draw.rs) and
appearance::build into per-subtype drawing functions. The behaviour and tests
are the branch's (ISO 32000-2 Tables 166, 177, 195):

- text boxes without /AP draw their default 1-pt border, their /DA face when it
  is a standard text face, and /DS font, size and colour (text_style.rs);
- unapplied redaction marks are outlined in an array /OC colour, with or
  without /QuadPoints, and keep /C otherwise;
- a text box's colour or size change keeps the face it is drawn in
  (restyle_text), and edited text boxes re-fit in their drawn face and size.

On main, the branch's set_style test calls use StyleChange, and the parity
entry keeps main's evidence paths (render raster/tests.rs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
# Conflicts:
#	crates/engine/src/tests.rs
# Conflicts:
#	crates/ui-egui/src/canvas.rs
#	crates/ui-egui/src/tools/forms_ui.rs
# Conflicts:
#	crates/engine/src/session/editing.rs
# Conflicts:
#	crates/engine/src/tests.rs
#	crates/ui-egui/src/app.rs
Master-page content repeated on each page shared one SOM path (`master[0]`), so its fields
shared one data node and one script node. Each page's copy is now `master[n]`:
- the layout names it so;
- data binds it per page, except a field bound globally or by explicit ref, which stays shared
  (XFA 3.3, "Page Background");
- the live form has one page-area node per page laid out from it, counted from the generated
  fields, so a button on page 2 runs its click script as itself.
- an event listed before a script laid the form out again can name an object that is gone (a
  page the form no longer fills): it is not run, rather than run with `this` at the form's root,
  where `this.presence = "hidden"` hid the whole form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBVyQEwtmEw3mS1MoAiwrK
apply_overrides walks the body only. Page-master content is now set
page by page: each page made from a page area gets its own copy of the
area's content with the overrides its SOM path (Body[n]...) carries, so
a field a script hides on page 2 is hidden there only after a relayout.
The copies are kept for the whole layout, as the measurement cache keys
on node addresses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
Each page laying the form out writes names the page area it was made from
(PCXfaArea), and page_area_uses counts those pages, so a page whose only
master field a script hid keeps its master nodes in the script model and a
later script can show them again (review). A file laid out before pages
were named is still read from its generated fields. Adds two tests from
wireless89's review: page-local and shared master values through relayout,
save, reopen and print, and the page whose last master widget is hidden.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
Two tests wireless89 offered in the #5368 review. Hiding the first, middle,
last or every master widget keeps every master[n] addressable and counted,
live and after save and reopen. With several page areas, a hidden-only
master still counts and a removed page leaves no stale count. Both fail
without 350a41f29 (master count 2 and a TypeError from resolveNode for the
last page; the Cover/master counts in the page-area test).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
The PR's five commits (base URI resolution, QuadPoints hit areas, their budget, the /Rect gate,
zero-area quads, f64 containment), ported onto main, where inspect.rs, canvas.rs and the
comments tool were split into modules: DocInfo::uri_base in inspect.rs, Link::quads and
Link::contains in inspect/model.rs, the reading in inspect/core.rs and inspect/annotations.rs,
their tests in inspect/tests.rs, and PageXform::link_contains in canvas/xform.rs.

ISO 32000-2 §12.6.4.8 Table 211 (/URI /Base) and §12.5.6.5 Table 176 (/QuadPoints).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
Once inspection's text budget is spent, a relative URI keeps its raw address, and that is what
the viewer opens. link_list resolved it afresh against /URI /Base and reported an address
nobody opens. It now reads resolved_url from the inspected target, and resolves only for a link
inspection did not list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
A single squashed port of the branch's seven commits onto current main, which
split the files this touched into modules: annot (create.rs, modify.rs,
info.rs), render inspect (model.rs, annotations.rs), engine comments.rs and
ui-egui tools/comments/*. The behaviour and tests are the branch's:

- a callout's arrow point and knee have handles and move on their own; its
  line follows the box as Acrobat's does; its text box resizes;
- comment_list gives a callout's points and text box, comment_edit reshapes it;
- /CL is read all or nothing, and a box sent back as f32 still moves or keeps
  its line.

On main, select_input's knee cursor moved into point_cursor to stay under the
cognitive-complexity limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
Review on #4094: resizing a callout's text box moved its frame, but
changing_points had no Resize branch, so no leader line was drawn to the box
until the page was redrawn. A Resize gesture, and the ResizeAnnotation edit
just released, now count as changing the callout: its points stay and its
line is drawn to where the box is going. A box moved alone and released keeps
its knee beside it, as while it was dragged.

Test: a_callout_being_resized_draws_its_leader_line, which fails with the fix
reverted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
comment_edit with one point on a callout whose /CL has six numbers built
the new line from that point and the end only, so the knee was dropped
without a word. An agent moving just the arrow lost the knee.

place_text_box now keeps the old knee when only the arrow point is given;
two points still set both, and a four-number /CL still gains a knee only
when one is sent. The points schema says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
Review on #2539 (nitpick): the README named only the collection limit
and unreadable XMP as fail-closed cases. It now lists all of them (the
run and run-length limits, a removed glyph with unknown text, a form
XObject removed unread past the depth limit or unreadable, the term
limits, the search work limit, oversized or undecodable XMP) and the
metadata the check leaves to Sanitize: XMP on objects other than the
catalog and pages, bookmark titles, and attachments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
Review on #4474: past the text budget every link still resolved and
parsed its whole /QuadPoints array before dropping the result, so many
links sharing one long array cost its length each. Now no array is read
once less than one quadrilateral's bytes is left, and an indirect array
that did not fit is remembered and skipped for later links (the budget
only shrinks). A guard on the budget being exactly zero alone would
rarely trigger, since URIs spend from the same budget.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
FrameMaker files distilled with Distiller 3 embed symbolic TrueType subsets
with no /Encoding and no /ToUnicode, whose codes are glyph positions in the
Macintosh order, 29 below ASCII; the font's own cmap maps them to the right
glyphs. Read as WinAnsi (extraction, Word export) or as Standard (the
editor), `Solid` came out as `6ROLG`.

Both readers now take the post-table name of the glyph a code selects, when
that name has a Unicode value, as MuPDF does; the editor's reverse map makes
typing set the same glyphs. Fonts flagged Nonsymbolic, fonts with a named
base encoding, and fonts the editor already treats as Symbol or ZapfDingbats
by name keep their old reading. Vendored hayro-interpret patch (46).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review follow-up. The renderer kept only glyph names it could look up whole,
so a variant such as `a.ss01` fell back to WinAnsi while the editor's
glyph_text dropped the suffix and read `a`. The vendored patch now reads names
by the same Adobe Glyph List rules (suffix dropped, `_` components, uniXXXX
groups, uXXXX to uXXXXXX), giving several characters for a ligature, and
checks /Differences itself now that the step sits outside the name chain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review follow-up. symbolic_glyph_texts tried each offset across every cmap
subtable, while the renderer reads the records in order, each with its
offsets, and only Windows (3,0)/(3,1) and Macintosh or Unicode-platform
encoding-0 records. A font with a (3,0) and a (3,1) cmap could give the editor
another glyph, and so another name, than the page shows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CodeRabbit on #3969: symbolic_glyph_texts read `post` straight from the font, so a
version written with its halves swapped (00 00 00 02 for 2.0, which the renderer
repairs since #2946, vendored hayro-interpret patch (49)) gave the editor no glyph
names while the renderer drew the font. The table is now read as the version it means
first. The test builds the same symbolic font with the swapped version and gets the same
text. Also removes a stray `|` main had left in hayro-interpret's test list, which split
it into a sixth cell.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CodeRabbit on #3969 (nitpick): Post::glyph_name walks a version 2.0 table's name data
from its start for each glyph, and symbolic_glyph_texts asks for up to 256, so an
untrusted font program (up to 16 MiB) could make opening a document slow. The names the
table spells out are now read once (post_custom_names) and looked up by index
(post_name), giving what Post::glyph_name gives; a test checks that for every glyph of
Inter's version 2.0 table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main added hayro-interpret patches 51 and 52 (bounded saved graphics
states, /W2 runs past the largest CID), so this branch's patch is now
53 in vendor/README.md; the code comments that cite it follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC
A single squashed port of the branch's five commits onto current main, which
split ui-egui's lib.rs (app.rs, app/files.rs), canvas.rs (canvas/zoom.rs,
canvas/tests.rs) and control.rs (control/host.rs), replaced the Menu button
with a menu bar and moved the rail and panels into panels/side_panels.rs. The
behaviour and tests are the branch's (ISO 32000-2 Table 147):

- HideToolbar, HideMenubar and HideWindowUI apply in the document's tab, and
  F8 (view.toggle_interface) brings them back; on main the toggle is listed in
  the View menu beside Full Screen and Read Mode, HideMenubar hides the menu
  bar, and HideWindowUI skips the side panels;
- FitWindow measures the spread the view shows, and FitWindow and CenterWindow
  wait only while the document is the window's only one;
- a reloaded document keeps its tab's hidden interface; one-page-at-a-time
  layouts open at fit page;
- the two strings are in every catalog, fa.tsv (added on main since) too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
Review on #3969: for a symbolic TrueType font without an encoding, the
editor overlaid only the codes whose glyph has a name with a Unicode
value on its Standard table. A code whose glyph name has none kept
Standard's reading (0xC4 `tilde`, 0x80 nothing), while extraction falls
back to WinAnsi (vendored hayro-interpret patch (53)): `Ä`, `€`. Such a
code now reads as WinAnsi in the editor as well, so editing and
extraction show the same text.

vendor/README.md: patch (53) names the function it adds,
`EmbeddedKind::symbolic_text`, states the glyph-name rules it shares with
the editor, and lists the pdfcraft-fonts test too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
Review on #5010: MAX_TEMPLATE_TEXT allowed for a UTF-16 packet growing
into UTF-8, but not for the other ways a packet's text can grow past its
bytes. Each byte that isn't valid UTF-8 becomes a three-byte replacement
character, and the packets of an array are joined by newlines, so a
document within the 64 MB packet limit could hand parse a template it
refuses. read_packets now checks the text it assembles, each packet and
its newline before they are added, and the text of a single stream,
against MAX_TEMPLATE_TEXT, and refuses past it as too large; a template
it returns always parses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
Review follow-up on #4378: HiddenInterface::mode_bar skipped the bar only when both the toolbar and the menu bar were hidden. With the native macOS menu bar the bar never draws the menus, so a document setting only HideToolbar (ISO 32000-2 Table 147) left an empty 48 px bar above the page. The bar now goes when the tools are hidden and the menus are hidden or native.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
A single squashed port of the branch's four commits onto current main, which
split annot's lib.rs (info.rs, modify.rs), render's inspect.rs (model.rs,
annotations.rs, tests.rs), engine's lib.rs (comments.rs, edit/run.rs) and
ui-egui's comments.rs (tools/comments/*). The behaviour and tests are the
branch's (ISO 32000-2 §12.5.6.2):

- a group subordinate is part of its primary's comment: listed, selected,
  exported and deleted with it, with the primary's attributes;
- Replace Text writes the caret as the primary and the strikeout as its
  subordinate; earlier groups (strikeout primary, text on the caret) still
  list their text, through a map;
- deleting a group refuses a locked member, and a subordinate whose primary
  is past the depth limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
Review on #4358:
- Make Current Properties Default on a Replace Text group selected the
  caret, the group's primary, and updated the Insert Text tool. A caret whose
  group holds a grouped /IT /StrikeOutTextEdit strikeout now updates the
  Replace Text tool; a caret of its own still updates Insert Text.
  Test: a_replace_text_group_makes_replace_text_defaults.
- fill_group_text let an empty caret take the primary's slot, so a later caret
  with the replacement text was not listed. Only a caret with text lends it.
  Test: an_empty_caret_does_not_hide_the_next_carets_text.

Both tests fail with their fix reverted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
Review follow-up on #4358: the secondary-click path still selected with cx.hit, which misses a group subordinate lying outside its primary's rectangle and cleared the selection, so the menu offered page actions instead of the comment's. It now uses the group-aware cx.click_hit, as the click does (ISO 32000-2 §12.5.6.2). Double-click editing goes through the same group-aware hit as the click since the rebase onto main, where the press owner comes from the module's hit test; the test covers both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
The render inspect tests moved to crates/render/src/inspect/tests.rs on main;
`cargo xtask parity` failed on the old path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
# Conflicts:
#	crates/automation/src/tools.rs
A link annotation listed twice in one page's /Annots, or by two pages, kept its later references after the first one stripped its external action, leaving inert links on the page. Every reference to a link that ends up with no action and no /Dest is now removed. A shared link counts once in external_links.
# Conflicts:
#	crates/automation/src/tools.rs
In the web build a delivered save keeps Apply redactions steps counted, so
Undo of a delivered step left Save writing to the host in place. Save now
asks where while fewer steps are in effect than were delivered, and a new
Apply after such an Undo marks the delivered steps as cut off.
In the web build Save kept sending Save As to the host after the redacted
file was delivered, because the delivered steps stay counted for the
_Redacted name. Save now asks where only for steps applied since the last
delivery.
After Undo past a saved Apply redactions, Save asks where but proposed the
redacted copy's own name, so the dialog's default was the redacted file.
It now proposes the name without _Redacted.
The Save dialog proposes its name when it opens, and the document's bytes
are taken once the user chooses. An Undo past a saved Apply redactions in
between wrote the restored content to the redacted copy the dialog had
proposed. Save now asks again, proposing the name for the restored content.
An Undo of Apply redactions while the dialog that proposed the _Redacted
name was open saved the unredacted content under that name. Save now asks
again whenever an Apply redactions or an Undo of one changes what the
dialog's proposed name was based on.
Create Custom Stamp offers an optional line under the picture, a
template with {name}, {date} and {time} filled in when the stamp is
placed, in the built-in dynamic stamps' format, and kept with the stamp
library. The picture keeps its shape above the line and the stamp widens
to fit the line. stamp_custom takes the same line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Catalan, Sorani, Finnish, Hindi, Indonesian, Korean, Lao, Norwegian Bokmål, Romanian, Albanian and Thai joined main after this branch was written. AI-assisted, as noted in each catalogue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review on #2345 (gabglus): the line was filled by chained replaces, so a
placeholder inside the author's name ("Ada {date}") was filled in too.
stamp_line now walks the template once and expands only the {name},
{date} and {time} it finds there; the author's text is kept as written.
Trimming and the MAX_STAMP_LINE cut are unchanged. The loop advances by
whole characters (strip_prefix, or get() past the next char), so it
cannot panic.

stamp_lines_fill_in_the_name_date_and_time gains the review's case and
a name made only of a placeholder; both fail with the chained replaces.
The UI test checks the saved /PCByLine has no placeholder left (from
gabglus's commit f6760b37).

Co-Authored-By: gabglus <gabglus@hotmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main's #4077 test (a custom stamp fitted inside a box) builds
Edit::AddCustomStamp without the line field this branch adds, so the
engine tests stopped compiling after the rebase.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC
On a page turned 90 or 270 degrees the stamp's rectangle has its sides
swapped in user space, and the appearance is counter-rotated so viewers fit
its turned bounding box to /Rect (ISO 32000-2 12.5.5). The lined layout was
computed in user-space axes, so that fit scaled the two displayed axes
differently: the picture came out squashed and the band stretched.

The layout is now made in the displayed box (the rectangle with its sides
swapped) and the stamp records the page's turn in /PCPictureRotate, so the
appearance carries the counter-rotation itself and a later restyle rebuilds
it the same way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
The test sent a 500-character line, past the schema's maxLength of 200,
and only checked an upper bound on the page text, which also passes when
the line is missing. It now sends a 198-character template whose filled-in
line runs to 264 characters, and checks the saved /PCByLine holds exactly
200.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
In full screen, a page's transition (/Trans, ISO 32000-2 §12.4.4, Table
164) plays when the page comes into view, from the outgoing page's raster
to the incoming one's (render::transition geometry), and a page's display
time (/Dur) advances to the next page once it is up. A still pointer
hides after three seconds and shows again when it moves.

- Transitions play in single-page view without view rotation, once the
  incoming page has rendered; spreads, a rotated view and the Animation
  effects preference set to off change pages at once.
- Display times count from the end of the transition and stop at the
  last page; the reader can turn the page sooner. The document's
  durations are bounded (a transition at most 10 s).

The logic lives in canvas/presenter.rs; canvas.rs keeps its state on
DocView and calls it after drawing the pages, and the Animation effects
preference reaches it through PageEnv.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
A left click goes forward a page in full screen; a right-click now goes
back one, and full screen shows no context menu, as it shows no tools
(Adobe Acrobat Help, 2019, "Full Screen preferences", p. 69: "Left Click
To Go Forward One Page; Right Click To Go Back One Page").

Enter goes forward a page and Shift+Enter now goes back; it went forward
too (Adobe Acrobat Help, 2019, "Read a document in Full Screen mode",
p. 66: "To go to the previous page, press Shift+Enter").

parity: view.full-screen and view.full-screen-presentation cite the new
tests and say what is still missing; organize.page-transitions is now
played. docs/ui-parity.md row and revision history updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
- Replacing the document clears the presenter: no transition, page or
  timer of the old document carries over.
- A transition into a page that failed to render ends at once instead of
  waiting for a texture that never comes.
- A display time that expires on the last spread asks for a redraw only
  when the page actually moved, so the last spread doesn't redraw every
  frame.
- A still pointer hides through a cursor claim on the top layer, which
  every tool reaches, so it hides over a link too, and its tip with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
- A click on a link, form field or comment turns no page in full screen:
  the pointer stays active to click links and open notes (Adobe Acrobat
  Help, 2019, p. 65).
- Shift+Ctrl+Page Up / Up Arrow go to the first page and Shift+Ctrl+Page
  Down / Down Arrow to the last, as Home and End (p. 83).
- A transition waiting for its incoming page looks again every 50 ms
  rather than asking for every frame; the render cache wakes the window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
Review on #5316:
- Full screen started a page's /Dur (ISO 32000-2 §12.4.4.1) when the page
  became current, so a slow render could use up its display time while the
  page still said Rendering…. The time now starts once the page's raster has
  arrived, or at once for a page the renderer can't draw.
  Test: a_display_time_starts_once_the_page_has_rendered, which fails with the
  fix reverted. a_display_time_on_the_last_spread_does_not_redraw_every_frame
  now gives its page a raster, so it still reaches the display time.
- full_screen_plays_a_page_transition_and_honours_the_display_time also checks
  the Wipe's painted output: a textured strip of page 2 as tall as the page and
  only part of its width. It fails when the presenter stops painting the
  incoming page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
pdfcraft.exe and pdfcraft-cli.exe imported windows.data.pdf.dll and
prntvpt.dll at load time (through craft-print's printing), so a trimmed
Windows installation without them refused to start the program at all.
The executables now delay-load the two DLLs, and the spool adapters
check that they exist before calling in: without them the printer list
is empty and a print job fails with a message pointing to Save as PDF.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review follow-up on #2701: the test no longer requires the DLLs in the
host's System32 (the trimmed installations this supports lack them).
Instead a test-only override points the lookup at an empty folder and
submit, submit_with_progress and printers are called through: printing
is refused naming the DLL and no printers are listed, before any call
into the delay-loaded DLLs. The job names a printer that doesn't exist
and the bytes aren't a PDF, so a missing guard can't reach a real one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
On opening, the engine laid the form out again after every initialize or
calculate script that changed presence, access or instances. On a large form
one layout takes about the whole 3 s open budget, so the remaining initialize
scripts were skipped, and that half-initialized form was what saving kept: each
later opening hid more of it, and a page shown at the first opening, filled in,
was hidden and its data reset by the next one.

The scripts read the live form (template, data and overrides), not the pages,
and XFA 3.3 fires initialize after data binding, before layout (pp. 388, 390,
407). On opening, a needed layout is now recorded and done once in finish().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBVyQEwtmEw3mS1MoAiwrK
On a large dynamic XFA form, every script of an event copied the whole form
twice before it ran (the engine's live form to the script engine's tree, then
that tree to the flattened form the host works on), and every resetData call
rewrote and rescanned the whole document. On a 1,300-script form opening spent
its 3 s budget on these copies and on twenty resets, and skipped the last 850
initialize and calculate scripts.

The engine now sends the scripts of an event one shared tree (Arc) until the
live form changes in any way, and the script thread keeps the flattened form
it built from that tree; each script starts from a copy whose text is shared
(Arc<str>), so it costs a fraction of a flatten. Each script still gets its
own copy: nothing one script changes reaches the next except through the
engine. Resets in a row in one script's effects are applied as one reset of
the names together.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBVyQEwtmEw3mS1MoAiwrK
Resets in a row are applied together; the names were deduplicated by
scanning the kept list for each new one, quadratic in the names a script
passes (its effects may carry hundreds of thousands within the payload
cap). The run now keeps a set beside the ordered list, and a reset of the
whole form still takes in the rest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBVyQEwtmEw3mS1MoAiwrK
resetData resolved its targets through the generated fields, so a field of
a row an opening script added, with no widget until the form is laid out
again, kept the value a later script gave it, and the final layout saved
it. The reset now also walks the live form and puts the template's default
back in the data of every matching field with no widget yet (review).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
A check box field with kids whose on states are A and B could only ever
tick A. With A ticked, clicking B cleared the field, so B's tick flashed
and vanished, and with nothing ticked, clicking B ticked A. The click sent a
field-wide Check(!on), and write_value always turned on the first kid's on
state, even for a value naming B.

§12.7.5.2.3 makes the field's /V the on state of the kid that is on (/AS
deciding where they differ), and Table 230 lets kids be checked
independently.

- A click on a kid that is off sends its on-state name: it (and any kid
  sharing that state) turns on and the others off. A click on a kid that is
  on clears the field. "On" is the kid's /AS (Field::widget_on).
- write_value: a named state or /Opt export ticks the kids with that state.
  Check(true) keeps the kid that is on, else takes the first.
- Field::check_box_value maps a stored value to the state it names. Reset to
  /DV, script value changes and FDF/XFDF import use it.
- The display-only fallback ticks only the kids that are on.
- form_fields lists a check box's options and the checked one when its kids
  have more than one on state. form_fill takes the name.

Kids sharing an on state still tick together (EXAMPLE 2).

Tests: forms unit, ui-egui end-to-end clicks, automation form_fill and
xfdf import. Each fails with its fix reverted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC
form_fields listed a check box's options by /Opt export, so kids sharing
the on state Yes with exports x and y showed as two options, though
filling either ticks both (ISO 32000-2 §12.7.5.2.3, EXAMPLE 2). It now
lists one option per distinct on state, named by its export unless two
states share one (Table 230 allows that), then by the state.

`checked` came from /V. It now follows the kid whose /AS is its on
state, which §12.7.5.2.3 makes decide where /AS and /V differ, so it
matches what the page shows.

Test: automation form_fields on shared-state, /AS-over-/V and
shared-export fixtures. Each assertion fails with its fix reverted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC
form_fill reads a name as an on state before an /Opt export, so an export
that equals another kid's on state would tick that kid instead
(ISO 32000-2 §12.7.5.2.3, Table 230). Fall back to the on-state names then.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC
Options are tracked by position as well as export value, so two options
sharing an export value (ISO 32000-2 Table 234, /I) show and pick the right
row; a single-select list given several values holds and shows the first;
/I matches /V item for item, repeats included; values match options in
linear time.

Rebased onto main as one commit: main split the forms crate's lib.rs into
read.rs and values.rs and moved forms_ui.rs into tools/. Folds the branch's
four commits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
- A same-name twin gets the copied /I mapped to its own options by export
  value and text, so options listed in another order keep the option picked;
  one with no match there leaves the twin selecting from /V, never part of /I.
- A position picked twice is written once in /V and /I, so they still match
  item for item.
- Shift-click extends from the row clicked last, kept in the view: with rows
  sharing an export value the value can't say which came last.
- A single-select list loaded from an array /V writes one value when its
  shown row is clicked again.

(review)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
A radio group with one /Opt export value per button and on states named
by position (/0, /1, ...; ISO 32000-2 Table 230) took the export value
"1" as the state /1, so it turned on the second button. Static XFA forms
build every exclusion group this way, with items 1, 2, 3. Asking for
option 1 selected option 2, 2 selected 3, and only the last option came
out right. Clicks wrote the button's position (0, 1) into the XFA data
instead of its item (1, 2), and XFA 3.3 ch. 4 and 17 say that data holds
the member's "on" item.

- Field::state_for_export resolves an export value: the per-widget /Opt
  first, then the on-state names. state_for, which callers holding a
  state (a click) use, is unchanged.
- form_fill resolves a radio option (the export value form_fields lists)
  with it, and so does opening a static XFA form for the data's item. The
  pre-strip comparison against the data compares export values.
- radio_dataset_value writes the selected button's /PCOn, else its /Opt
  entry, else its state name.

On 24 public XFA forms (benefit, disability, pension and visa
applications) a fill test flipped 329 radio groups on main and none with
this change.

Tests (each fails with the fix reverted): a forms unit test, an
automation form_fill test on a pure AcroForm group, and an engine test on
a synthetic static XFA exclusion group (open from data, click, save and
reopen).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBVyQEwtmEw3mS1MoAiwrK
The default configuration's /AS array (usage application dictionaries) was
never read, so a group whose /Usage /View /ViewState is OFF and that a
/View usage application manages still drew on screen.

- hayro-interpret patch (67): OcgState::from_catalog applies the View
  event's auto state after /BaseState, /ON and /OFF and before
  ocg_overrides. Each application with /Event /View and /View in
  /Category recommends each listed group's /ViewState; a group is ON only
  if every recommendation is ON. /Zoom, /User and /Language give no
  recommendation. At most 256 applications, 4096 /OCGs entries and 16
  categories each are read. InterpreterSettings::view_auto_state (on by
  default) switches it.
- render: RenderConfig::for_print turns it off; print as image sets it,
  since printing is not the View event.
- inspect: a layer's initial and visible state apply the same auto state,
  so the Layers panel and doc_info match the page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC
The panel listed every group in /OCGs, flat. Table 99 says only the groups
/Order lists are presented, nested arrays as a tree, and a leading text
string in a nested array as a non-selectable label; in the default
configuration /Order defaults to the empty array.

- render: DocInfo::layer_order, rows of LayerRow { depth, item } where an
  item is a layer (index into DocInfo::layers) or a label. A nested array's
  entries are one level deeper than the layer or label before them. At most
  4096 rows, 32 levels and 256 characters a label; an indirect array is read
  once, so cycles end. DocInfo::layers is unchanged: unlisted groups keep
  their state, draw, print and answer layer_set and set-layer actions.
- ui-egui: the Layers panel draws the rows, indented 16 points a level, a
  label as a heading that can't be toggled; the count is the layers shown.
  A document whose /Order lists none says so.
- automation: doc_info reports layer_order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC
A print preview (a request carrying the print tag, with or without the
canvas's scale and grayscale bits) rendered with the document's on-screen
settings, so a layer the View auto state turns off was hidden in the
preview but printed. Printing is not the View event (ISO 32000-2
8.11.4.4), so the preview now leaves the auto state off like the print.

Also add a tool-level test of doc_info's layer_order rows for a /Order
with a group, a labelled nested array and an unlisted group (1-based
layer index, name, label and depth).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC
A nested /Order array may start with a text string label (ISO 32000-2
Table 99). One indirect string can head any number of direct arrays, and
each row decoded the whole string before cutting it to 256 characters, so
the work grew with rows times string length. Keep each indirect label
decoded once, by object number.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC
Review (CodeRabbit): in two-page view the /Dur timer started once the
current page had rendered, so the spread could turn while its other page
still showed Rendering…; it now waits for every page on screen. The
ui-parity row says full screen shows a fitted page or spread and that
background clicks and keys turn pages while clicks on page objects don't.
The doc comment of presentation_click_state is back on its function.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
The reset of objects an opening script added before they have widgets
missed three kinds of value, and its name matching grew with the product
of objects and names (review):

- Exclusion groups were skipped, so a radio value set and reset in a new
  row stayed in the data. A group now resets to the on value of its
  default button, or to nothing.
- A choice list reset to the text it shows, not the item value it saves.
  The default now goes through Field::saved_value, as the live form
  reads it.
- A multi-select list whose picks the data holds as <value> elements
  compared as empty and could not be written, so its picks survived. A
  new DataOp::Reset compares and replaces those picks (none for an empty
  default); DataOp::Value still leaves nodes with children alone.
- Reset names are indexed (ResetTargets), so matching an object costs
  the same however many names a script passes, and forms::reset looks
  named fields up through sets instead of scanning the list per field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
Setting a field's rotation (Properties > Position, form_set_props) swapped
the widget's width and height around its center, so a tall field placed
along a page edge jumped into a short, wide one, and typing in it showed
the text upright.

ISO 32000-2 Table 192 /MK /R turns the widget annotation relative to the
page; its /Rect stays the area it occupies in default user space. So a
rotation now changes only /MK /R: the rectangle stays, and the regenerated
appearance lays the text out along the turned direction (the box /BBox is
as wide as the field is tall, and /Matrix turns it onto /Rect).

On screen, the in-place editor and the just-committed preview paint the
text turned by /MK /R less the page's /Rotate and the view's rotation, as
the page will show it. The editor still takes the keys and pointer over
the field, and a press or drag places the caret in the turned text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC
An editable combo box (ISO 32000-2 Table 233, Edit) turned by /MK /R
(Table 192) sized its font for the turn but still drew a horizontal
editor, so its text, selection and caret ran across the field while the
committed text ran along it. The combo editor now takes the field's
TextFrame and shares the turned text field's path: an upright layout at
the turned box's width, the pointer mapped back into the upright text,
and the text, selection and caret painted turned. The shared pieces are
pulled out of the text field editor (turned_layouter, show_field_editor,
turned_caret_and_paint) so the two editors cannot drift apart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC
The turned editor's caret placement read egui's pointer state directly
(primary_down, interact_pos, primary_pressed), which the page-pointer
guard refuses in page modules. The editor is an Area over the page, so
it reads a PagePointer for its own response: the press on it is its
own, held until the frame of its release, a touch's lift included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBVyQEwtmEw3mS1MoAiwrK
- form_fill took a JSON number for a radio group as text, so {"title": 1}
  on /Opt [(1) (2)] with on states /0 /1 turned on the second button. A
  number now resolves through state_for_export as the same option written
  as a string does.
- strip_xfa compared the XFA data with the selected button's /Opt entry
  only, but saving writes the button's /PCOn ahead of it. A group with
  /PCOn then reported a lost value on Flatten and XfaToStatic. The data
  now agrees with the field when it holds what saving writes
  (radio_dataset_value, made public) or a value that selects the same
  button (state_for_export).

Tests (each fails with its fix reverted): the automation form_fill radio
test now also fills 1, 2 and 3 as numbers; an engine test strips a group
whose /PCOn equals the state, and one whose /PCOn is neither the state
nor the /Opt entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
A subordinate (/RT /Group) whose /IRT primary is on another page is now a
comment of its own, listed with its own author and text, rather than part
of that group. ISO 32000-2 §12.5.6.2 doesn't limit a group to a page, but
Acrobat (desktop and iPad) and PDF-XChange all keep such a subordinate when
the primary is deleted, and Acrobat desktop lists it on its own; tested on
a synthetic two-page file. pdfcraft_render::link_replies and
pdfcraft_annot::summaries both apply the rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
The click queued the clicked list box's own option positions under the
shared field name, but set_value reads ChoiceIndices in the first field of
that name. A twin listing options that share an export value in another
order therefore gave the first field the option at the same position, not
the one clicked. The UI now maps the twin's picks to the first field's
positions by export value and text (Field::positions_in, the same matching
the engine uses to copy /I to twins), falling back to the export values
when an option has no match there.

(review)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
Deleting any member deletes its group on the page, as ISO 32000-2
§12.5.6.2 has it and PDF-XChange and Foxit do; a subordinate whose primary
is on another page is a comment of its own and stays. The note's Missing
list no longer says such a subordinate can't be selected, and the
cross-page test is its evidence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
Acrobat desktop deletes only the member of a group (/RT /Group) that was
clicked: deleting the primary keeps its subordinates, on its page or another,
and deleting a subordinate keeps the rest of its group. ISO 32000-2 §12.5.6.2
lists deletion among the operations that act on the entire group; this
follows Acrobat. A subordinate left without its primary is a comment of its
own.

Replace Text stays one comment: its strikeout and caret go together from
either, in both orientations (the caret as primary, and the strikeout as
primary, as earlier PdfCraft builds wrote it), as Acrobat deletes the pair.

On the page, a click on a subordinate still selects its group, and Delete
(the key or the menu) acts on the member clicked. The group-primary lookup
that deletion used is gone with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
A double-click on a comment opens its pop-up window as in Acrobat:
author, date, the text edited in place, the replies; the title bar
drags it, x closes it, each an undo step saved in /Popup (/Open, /Rect)
and a note's own /Open. A connector line in the comment's colour joins
the comment to its open window on roll-over or selection. comment_popup
does the same headlessly; comment_list reports the pop-up. Three undo
labels in 22 catalogues (Vietnamese included).

Ported onto main's split modules: set_popup lives in annot's modify.rs
and PopupInfo in info.rs; the pop-up windows are their own module,
ui-egui tools/comments/popups.rs, listed with the page modules that
read the pointer and set the cursor themselves; the hover tip filter is
in comments::describe. PageCx's pointer and redaction look, the
tests/it evidence paths and the Annotation test fixtures follow main
(formerly fcd33e131, c68e5d902, 0a7fb530b, 8af2c338e, 345c1573d), and
labels are logical text now that egui shapes bidi itself.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv2WwdgjmBz33GWkwgNn3Q
Review on #2137. set_popup accepted a pop-up whose /Parent was present but
not a reference, and the comment list (pdfcraft_annot::summaries and the
inspector) reported whatever /Popup named: another annotation's rectangle
and open state. One check, own_popup, now decides for both: /Subtype
/Popup, and /Parent absent or a reference to the comment (ISO 32000-2:2020
12.5.6.14, Table 186: "Optional; shall be an indirect reference").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv2WwdgjmBz33GWkwgNn3Q
The pop-up's reply list kept every annotation in reply to the comment,
so a review status (/StateModel /Review) and the private checkmark reply
Mark writes (/StateModel /Marked) showed as replies with their generated
text. It now lists replies without a /State, as the Comments panel does
(comments_panel.rs).

Raised by CodeRabbit on #2137.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R3niW7kVeT8mi7TpWc8eAJ
These catalogues were added on main after this branch began. AI-assisted,
marked as not reviewed by a native speaker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
Rebased onto main's reference-based threading (#4061, #4566). The pop-up
matched replies by /NM (in_reply_to == name), which need not be unique:
two notes sharing PyMuPDF's fitz-A0 both showed one note's replies, and a
reply to a reply was missing. It now files replies under a comment with
thread_tops over the settled parent links, as comments_panel does, and
has_popup asks Annotation::is_comment rather than in_reply_to (a reply
whose parent has no /NM read as a comment of its own).

Regression test a_pop_up_lists_its_replies_by_reference_when_names_repeat,
seen to fail with the /NM filter restored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC
Review on #2137: when /Annots holds 5 0 R and object 5 is only a
reference to the comment 6 0 R, the pop-up's /Parent names 6 0 R, but
the ownership check compared it with 5 0 R. The comment's pop-up was
then missing from both the on-open listing (render inspect) and the
summaries after an edit (annot). Both now compare the references they
resolve to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
Review on #2137: the cited test opened, moved and closed a pop-up but
never looked at the connector line. The new test reads the frame's
shapes: a line in the comment's colour from the comment's edge to the
window's, nearest points at each end, shown with the pointer on the
comment or the window or with the comment selected, and not otherwise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
Review on #2137 (nitpick): a SetAnnotationPopup edit marked its page
stale, so its tiles were rendered again, its text cache dropped and its
selection cleared, though nothing in the raster changes: the window is
drawn over the page and a /Popup annotation paints nothing. Edits that
also change the comment (a batch) still invalidate as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
The connector from a comment to its open pop-up window was a one-pixel line.
It is now a faint wedge in the comment's colour: a point on the comment's
edge nearest the window, widening to a base on the window's side that faces
the comment, as the common readers draw it. Shown, as before, while the
pointer is on the comment or the window, or the comment is selected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
The pop-up window now reads as the common readers' do:

- a one-line title bar with the comment's kind and author, its date and x,
  in the comment's colour; the body in a lighter tint, with fixed ink so it
  reads in either app theme;
- the comment's text, and each reply's, in a box typed in place;
- the replies as panels in thread order, each inside the panel of the reply
  it answers (/IRT), nesting at most eight deep;
- a Reply button under the comment and each reply, opening a box that
  answers that comment or reply (Enter starts a new line; Post or
  Ctrl/Cmd+Enter posts; Cancel closes it).

The window keeps the size of its pop-up's /Rect in points on screen as the
page zooms, instead of growing with the zoom; the text and replies scroll
inside it, with a scroll bar shown whenever there is more. Dragging its
lower right corner resizes it; the new size is saved as the pop-up's /Rect,
the window's upper left corner kept in place on screen (on a page turned a
quarter too), in one undo step. A /Rect with non-finite numbers gives the
least size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
- The render path reads a pop-up as none unless its /Rect is four numbers,
  as pdfcraft_annot::summaries does; set_popup gives a comment's own pop-up
  without one the place a new pop-up gets, so opening it shows it again.
- The composer's edit and a pop-up's, landing in one frame, are queued
  together in one batch instead of the pop-up's replacing the composer's.
- A pop-up's unsaved text, reply draft, drag and resize follow their comment
  when a deletion moves it, are dropped on undo/redo, and are dropped after
  any other edit that leaves their (page, index) naming another comment
  (pages moved, inserted or deleted).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
Text typed into a pop-up's box and not yet saved is saved when the window's
x is clicked: the click takes the focus from the box and closes the window
in one frame, as one undo step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
A form whose fields don't repeat keeps each template name as it is, one
that looks made up (Total_2) included: reserving the template's names must
not push a field off its own. Seen to fail with the template's names
reserved for every field (Name_2, Total_3, Total_2_2, Date_2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
collect_names recorded an exclusion group's name but not its members'.
The members are drawn as fields with their own names (draw_field), so a
repeated field drawn before the group could take a member's name as its
made-up one (a repeated Cell4 took Cell4_2, and the member Cell4_2 became
Cell4_2_2); the result depended on draw order. The members' names are now
reserved as well, so a member keeps the name the template gives it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
A Names array repeating one name kept the walk going, since only new names
counted. Pairs are now counted, repeats included, up to 16,384, and a name
over 4,096 bytes is left before it is turned into text. The node limit no
longer skips the names of the node the walk has just reached.

Tests for a repeated name, an over-long name, the 4,096th node, a linked
stream that isn't a picture, and an over-large picture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
Nested sets in lr-tb and positioned subforms, an empty set (and one
holding only an empty one) in lr-tb, positioned and tb subforms, a
repeating member of a set, and a repeating set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
Six tests for the review of #5370: every kind of node without a height
is as tall as its content after the measurement moved into
content_height; a flowed cell grows past a small or zero height, keeps
its margins and maxH and is measured across spanned columns; fields,
exclusion groups and positioned or row subforms in a cell keep their
heights; empty, nested and repeating flowed cells and repeating rows;
a cell taller than the page, huge margins and deep nesting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
Review on #5316. No behaviour change: the presenter already cut transitions
when animations are off (by default following the system), bounded /D to
10 s and /Dur to a day, ignored non-finite or negative values, read an
unknown /S as Replace (ISO 32000-2 Table 164) and stopped with full screen.
These tests pin each of those down, and every key and click full screen
answers to. The Wipe test now counts the transition's frames from its first
painted strip, so frames spent waiting for the raster don't count toward it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
A file's huge number became infinite as f32 and reached the window's
place. Both readers (annot summaries and the render inspector) now take
such a rectangle as no pop-up, as one that isn't four numbers; opening it
gives it a new place. Reversed corners were already put in order; tests
now cover both, read from a file and saved through set_popup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
End-to-end tests for a removed run over 64 KiB, more than 20,000 terms, a run over 4,096
characters, and XMP that is corrupt, cut short, behind two filters, over 16 MiB or in another
encoding. Each was seen failing with its guard removed or loosened.

The corrupt and cut-short cases showed that the tolerant decoder returned the part of the packet
before the damage, which was then searched as if it were the whole packet. XMP is now decoded
strictly, and a packet behind more than one filter goes unread.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
Report::still_holding (Elsewhere) counts the places apply leaves as they are that still hold the
removed text: bookmark titles, attachment names, file names and descriptions, the text and authors
of the comments that remain, and XMP on objects other than the catalog and the pages. Nothing is
changed; Remove hidden information removes them. The text is matched as the metadata check
matches it (one Search type now serves both), with its own work limit. When the removed text
couldn't be searched for, every place holding any text counts and `unsearched` says so. The walks
read each outline item and name-tree node once, within caps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
The unread-form branch was tested for a form nested past the depth limit only; a form under a
mark whose filter can't be decoded is removed unread too, and now has an end-to-end test, seen
failing with that branch's flag removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
The document keeps the last apply's Report::still_holding as redaction_elsewhere (re-exported as
RedactionElsewhere), cleared by the next edit, undo or redo as edit_notices is. redact_apply
returns it as still_holding with the four counts, their total and unsearched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
After "Applied N redaction marks…", the same notification names the bookmark titles,
attachments, comments and other metadata streams that still hold the redacted text, and points
to Remove hidden information. New English templates; untranslated text shows in English. The
protect.apply-redactions parity note and evidence say what is reported and what is still left.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
While a page's transition plays, or the page waits for its raster, the
page under the pointer isn't the one shown, yet its links, fields, text
and comments took clicks. Page input now waits until full screen shows
the current page; turning pages (a click, a key) is handled apart and
still works. A press made meanwhile gives nothing on the page an owner,
so its release once the page is in takes nothing either.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
main added a second exhaustive match on RenderWarning (render_warning_text in
crates/automation/src/pages.rs); the merge lets this branch handle it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
A growable lr-tb row group that fits stays, one taller than a page goes on over pages, an rl-tb
one splits as lr-tb does, and one with a height or a positioned one moves whole. A table of
empty multi-line cells with a minimum height fits as many rows on a page as that height allows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
The minH-above-content case now has its own assertions, a fixed h with a minH below it is
checked, the doc comment names IRCC IMM 5257 and cites XFA 3.3 p. 847, and the row test cites
the subform page (p. 847) instead of the exclGroup one (p. 719).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
A prototype shared by many elements was reached again from each of them and its own
reference read again every time, so a long reference (1 MB) on a prototype used by
60,000 fields cost tens of gigabytes of hashing while the written template stayed small.
Each element's reference is now resolved once. A SOM expression longer than 1,024 bytes
names nothing (the longest in 198 local Designer templates is 109 bytes).

Tests: a "billion laughs" template (ten levels, each prototype referencing the level below
ten times) fails as a whole within the output cap and the form is read as written; a long
Designer-style form resolves in full with no warning; the shared long reference; the SOM
length limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
Comment only; no behaviour change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
A /DA string comes from the file. Reviewing how the PR reads it:

- An operand that is not a finite number was taken as one: Rust parses
  "NaN" and "inf", so "NaN NaN NaN rg" reached the appearance stream as
  "NaN NaN NaN rg" (clamping keeps NaN), and "/Helv inf Tf" drew at the
  400 pt cap. parse_da now ignores such an operand with its operator.
- A Tf without its size ("/TiRo Tf") lost its font: da_font_name read
  the name two tokens before Tf only. The name is now read whether or
  not a size sits between it and Tf; the size falls back to 12 pt.
- A zero or negative size keeps the default 12 pt (zero is auto-size
  only for form fields, §12.7.4.3; a text box has none), now stated in
  parse_da's comment.

Tests (each fails with its fix reverted): a text_style unit test over
missing, zero, negative, NaN, infinite, huge and non-numeric sizes,
missing Tf, non-finite and out-of-range colours, garbage and non-UTF-8
bytes; and an appearance test that draws boxes from such /DA strings,
including a font in /DR and one no /DR lists, and checks the stream's
font, size and colour and that no NaN or inf reaches it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
A caption with no font and no reserve beside a 24pt and a 6pt field takes its text's width in
each field's font (four times as much at 24pt), and one line of that font above the box. A
caption font naming only a size keeps the field's family, weight and posture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
The test module now quotes ISO 32000-2 §10.7.4 on which pixel a point on
a boundary belongs to (i = floor(x), j = floor(y); shapes include their
floor sides but not their ceiling sides), so the row a line on a pixel
boundary marks follows from the text. A new test runs zero-height and
zero-width lines, as a fill and as a clip, on a boundary, off one, and with
their ends inside pixels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
- render_warning_text (crates/automation/src/pages.rs, on main since this
  branch was cut) matched RenderWarning exhaustively and did not build
  with the new variant. It now names it, in the CLI's words, so
  page_render and render_preview report the skip. A workspace search
  finds no other match on RenderWarning outside pdfcraft-render.
- An Indexed lookup is a string or a stream (8.6.6.3). Indexed::new and
  is_malformed_indexed tried it as a stream first; a failed stream read
  of a reference consumed the reference, so a string lookup given by
  reference was never found and the image was skipped. The lookup is now
  read once and dispatched by type, in both places.
- hival is judged before the base: an out-of-range hival rules the space
  out even when the base cannot be read (the table size still needs the
  base, so a usable hival keeps today's handling there).

The size check is unchanged: hival is range-checked to 0..=255 before
(hival + 1) * m is formed with checked_add/checked_mul.

Tests (each seen failing with a fault injected): bounds at DeviceCMYK,
DeviceGray and hival 255 (exact and one short); hivals past u32/u64/any
integer, negative and fractional (with unchecked arithmetic and the range
widened the test panics with "attempt to multiply with overflow"); a
lookup string by reference (fails with the old stream-then-string read);
an unreadable base with hival 300; an undecodable lookup stream; and
page_render reporting the skip (without the new arm the crate does not
build, E0004).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
form_fill turned a JSON number for a radio group into text with
Number::to_string, which spells a float with a fraction part: 1.0, 1e0
and 0.1e1 became "1.0", and serde_json parses -0 as the float -0.0
("-0.0"). None names the option "1". number_option now spells a whole
number below 2^53 as that integer (1.0 -> "1", -0 -> "0"); integers keep
their exact spelling; any other number keeps JSON's spelling (1.5,
1e300), so it names only an option exported as exactly that text, and is
otherwise refused with the options listed.

The precedence when an on-state name equals another button's export
value (export value first, then on-state names; ISO 32000-2 Table 230
lets state names be positions) is now stated in state_for_export, at the
call site and in form_fill's tool description, with the number rules.

Test (fails with Number::to_string restored): the ambiguous pair (the
first button exports "1", the second's state is /1) filled with 1, 1.0,
1e0, 0.1e1, "1", -0, 2e0, 2.000 and 3.0 selects the right button; "1.0",
1.5, -1, 1e300, u64::MAX and 9007199254740993.0 are refused with the
options listed and the selection kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
radio_dataset_value returned the first widget whose on state or /PCOn
equalled the selected state. With states /0 /1 and /PCOn items 1 2,
selecting /1 matched the first widget's /PCOn "1" and wrote 1 instead of
2, and strip_xfa's comparison, which uses the same function, could then
miss a lost value. The selection is an on-state name, so every widget's
state is now compared first and /PCOn only after.

Test (fails with the one-pass match restored, writing "1" for /1): the
static radio fixture with /PCOn 1 2 writes 1 for /0, 2 for /1, 2 for the
item 2, the value itself when nothing matches, and nothing with no
selection.

This changes the XFA data saved for such groups.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
The script engine's copies of property trees are keyed by the address of
the tree. Four tests pin what keeps that sound: later snapshots share one
copy; a freed tree's entry holds its allocation until the next use drops
it, so a tree made at the same address never gets the old copy; an entry
made from another tree is not returned; and the map does not grow with
trees that are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
callout_side gave every position a side, but a knee exactly on the left,
right or top edge, or on a corner, took the bottom side, and the leader
line was drawn across the box. A knee on the outline (or within 0.01 pt
inside it, as the page hands positions back as f32) now meets the side
whose edge it lies on, a corner the left or right side as beside the box.
Inside the box, or with a coordinate that isn't a number, it meets the
bottom side. The rule is written out on callout_side and every tie is
tested.

Tests also pin the /CL invariant (ISO 32000-2 Table 177: four or six
numbers): four- and six-number callouts keep their count through the
arrow point, knee, box move, box resize, whole move and save/reopen, and
ten malformed /CL values are refused, left as they were by a resize and
shifted in place by a move.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
The shared clamp leaves a heightless subform's minH out, but a row's own minH is already in
its content height: cells() raises the row to it (XFA 3.3 p. 847). The test pins that a row
with 0.2in of content and minH="3in" outside a table, in a tb and an lr-tb parent, is 3in tall.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
The bar's place was clamped for a 520 pt bar, so with longer labels
(German, about 900 pt) a selection on the right pushed it over the side
panel. It is now clamped by the width it was laid out at last frame, with
520 pt only as the first guess. The body of the Area is re-indented by
rustfmt because its first line got shorter.
Deleting a group's primary (delete_annotation, lift_annotation) or
clearing a redaction mark with a group member (redact::clear_marks) now
removes /IRT from each subordinate that stays and keeps its /RT /Group,
as Acrobat desktop does. The kept member no longer refers to the deleted
annotation, so a full save leaves the deleted one's content out. It is
part of the same edit: one undo restores the group, /IRT included.
lift_annotation's copy-only uses (the page beneath a dragged stamp or
signature) only render the copy, so the change there is harmless.

Each /IRT walk this PR relies on gets a test past its bound and a loop:
group_primaries on an unsettled loop (render), comment_summary under a
100-deep chain of subordinates and a self-listed loop (engine), and
deletion over a 200-deep thread and reply and group loops (annot).

Review on #4358.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
Round trip through the session for the static radio fixture with /PCOn
items 1 2 next to /Opt [(1) (2)] and position states /0 /1: a click on
either button saves that button's own item to the datasets, reopening
turns the same button on with nothing rewritten, and taking the XFA out
notes no lost data. With radio_dataset_value as before dbea0793e the
second button saves "1" (left: Some("1"), right: Some("2")).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
Saving writes the selected button's /PCOn, else its /Opt entry, else its
state name (radio_dataset_value). Opening read the data as an /Opt export
value first, so with /PCOn items 4 1 next to /Opt [(1) (2)] the second
button saved 1 and reopened as the first, whose /Opt entry is 1, and
taking the XFA out compared against that reading. radio_state_for_item
now inverts the writer: the button whose written item equals the data.
Data no button writes (from another viewer) is read as before, as an
export value, then a state name. The widget walk is shared with the
writer.

Test (fails with the reader as before: the second button reopens as
["0"]): the /PCOn 4 1 fixture saves 1 for the second button and 4 for
the first, each reopens on the clicked button and strips with no lost
data note; data 2 and 0 from elsewhere still select /1 and /0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
roxmltree's ParsingOptions on main is no longer Copy, so the options used for the
template and again for the expanded template are built for each parse.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
Conflict in crates/engine/src/document.rs: main turned `edit_notices` into
`Vec<EditNotice>`; the PR's `redaction_elsewhere` field is kept beside it in
this merge, and the next commit makes it an `EditNotice` variant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
Main's `EditNotice` is "something the last edit did that the user should hear
about", set by `Session::apply` and cleared by the next edit, undo and redo,
which is what the PR's separate `redaction_elsewhere` field did by hand. It is
now `EditNotice::RedactionElsewhere(Elsewhere)`, pushed when a place is counted
or the text couldn't be searched for: tools list it in `warnings` with the
other notices, the interface says it through the same path, and
`Document::redaction_elsewhere()` gives `redact_apply` and the Apply dialog the
counts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
#2539 review: a heading tracked with TJ numbers ([(B) -300 (L) -300 ...] TJ)
was collected as "B L U E F I N", which neither held "Bluefin" nor had a word
of three letters, and a value spelled "Blue fin" or "BLUE-FIN" didn't hold the
term "bluefin". A TJ gap can't be told from a word space, so the glyph ends stay
as they are; instead every term with three letters or digits is also looked
for by its letters and digits alone, and a value is part of a run when its
letters and digits are, starting and ending at the run's word edges ("True"
still isn't part of "untrue"). It over-matches only towards removal.

Tests: a_heading_tracked_with_tj_numbers_matches_the_metadata,
a_value_spaced_or_hyphenated_otherwise_matches (both failed before: /Title
kept, metadata 0).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
#2539 review: a decomposed "Müller" in a UTF-16BE /Title, a U+FB01 ligature
from a ToUnicode map and full-width letters never matched their plain forms.
Both sides are now normalized to NFKC and lower case (unicode-normalization,
already in the build through pdfcraft-crypt's stringprep, so no new crate).
Normalized text more than four times its input's length (U+FDFA and the like)
isn't searched: it counts as holding the text.

Tests: unicode_forms_of_the_removed_text_match (failed before: Title, Subject
and Keywords kept), a_value_that_normalizes_to_far_more_text_counts_as_holding_it
(failed with the growth limit raised to 100: 0 removed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
#2539 review: ISO 32000-2 §7.9.2.2.1 defines text strings in PDFDocEncoding,
UTF-16BE (FE FF) and UTF-8 (EF BB BF) only, so an FF FE string was read
through PDFDocEncoding and Cyrillic or CJK text in it could never match.
It is now read as UTF-16LE for the search (fail-closed), in the /Info scrub
and in the bookmark, attachment and comment counts.

Test: a_utf16le_string_is_searched_as_utf16le (failed before: 0 /Info
entries removed; with only the count reverted, bookmarks 0 instead of 1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
#2539 review: the scrub removes the catalog's XMP packet whole when it holds
the removed text, and with it the file's PDF/A and PDF/UA claims, which
Report didn't say. The identification (pdfaid part, conformance, amd, rev;
pdfuaid part, rev) is now read by namespace (roxmltree, already in the build
through pdfcraft-xfa), each value kept only when it is the short code it must
be and doesn't hold the removed text (one-character codes always), and
written back in a packet holding nothing else, with a fixed pdfuaid extension
schema under PDF/A-1 to -3 (left out, with the PDF/UA claim, if the redacted
words are among its words). Report::identification says, per standard,
None / Kept / Lost; the engine passes it on as
EditNotice::RedactionIdentification, the Apply notification says it after the
count, and redact_apply returns identification { pdfa, pdfua }.

Tests (redact): the_identification_of_a_removed_packet_is_written_back,
a_packet_without_identification_is_only_removed,
an_identification_that_cannot_be_kept_is_reported_lost,
unsearched_text_keeps_only_one_character_codes,
the_extension_schema_is_left_out_when_it_uses_a_redacted_word; engine
applied_redactions_report_the_pdfa_identification_written_back; automation
redact_apply_reports_the_identification_written_back; ui-egui
applying_says_the_pdfa_identification_was_written_back. With the write-back
replaced by "nothing kept", the redact tests fail (Kept expected, None
found); with the notice not pushed, the engine, automation and UI tests fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
Letters-and-digits matching, NFKC, FF FE strings as UTF-16LE, and the
PDF/A / PDF/UA identification written back or reported lost; what still
isn't done (remaining /Info entries aren't mirrored into the new packet).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
Patch (60) skipped such an image. A reviewer's side-by-side tests on #4393
(seven cases against main and Chrome's viewer) showed the skip dropped
colours other readers draw, and regressed a real hival that main draws.
Indexed::new now recovers instead:
- a lookup table shorter than m x (hival + 1) bytes, missing, or not a
  string or decodable stream is padded with zero bytes;
- a hival past 255 is taken as 255;
- a real hival is truncated toward zero (as upstream read it).
The space records that it was recovered. ImageXObject::new raises
MalformedIndexedImage from the space it built; a cs/CS colour in such a
space reports ColorFallback, as the upstream DeviceGray fallback did. A
hival below zero or not a number has no reading: the space falls back as
upstream, and the image reports the warning. Index values past hival
stay clamped (8.6.6.3).

The separate is_malformed_indexed check is gone, so no lookup table is
read twice: inline images naming an Indexed resource read its table once
through the resource cache (CodeRabbit, interpret/mod.rs:945). The read
count is exposed as InterpreterCache::indexed_lookup_reads.

Tests: indexed_lookup.rs rewritten as draw tests (17, cases A-G and
bounds); raster::tests::indexed_lookup_cache. On b6f93a3ae logic 14 of
the 17 fail (the three that pass are guards: full table, case C, device
name) and the cache test reads [21, 41] instead of [1, 1].

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
Mutation pass on patch (60):
- keying the resource colour-space cache with one key for every space
  survived the earlier tests, which used one Indexed resource a page:
  raster::tests::indexed_lookup_cache::two_indexed_resources_are_cached_apart
  kills it;
- the ColorFallback report for a recovered space set with CS had no
  test (only cs was tested): a stroke case in
  a_fill_or_stroke_colour_in_a_short_table_is_padded_and_reported fails
  with that report removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
# Conflicts:
#	crates/ui-egui/tests/it/main.rs
# Conflicts:
#	parity/acrobat-features.toml
# Conflicts:
#	parity/acrobat-features.toml
# Conflicts:
#	crates/xfa/src/layout.rs
#	crates/xfa/src/layout/common.rs
#	crates/xfa/src/layout/items.rs
#	crates/xfa/src/parse.rs
#	crates/xfa/src/pdf.rs
#	parity/acrobat-features.toml
# Conflicts:
#	parity/acrobat-features.toml
Seven readers were compared on synthetic files. Most draw the entries a
short table holds and black past them, and none draws an image whose
lookup is missing.
- Indexed::new keeps how many entries the table holds in full. Indices
  at or past that are set to black after conversion, so DeviceCMYK,
  ICCBased, Separation, DeviceN and Lab come out black too (zero bytes
  gave white, or blue in Lab). Fill and stroke colours follow the same
  rule. An incomplete last entry counts as missing.
- A lookup that is missing, null, a number, or a stream that does not
  decode (or decodes to nothing although it holds data) is no table.
  ColorSpace::new_allowing_tableless builds such a space marked
  tableless (for the resource cache and ImageXObject::new only), and
  the image is skipped with MalformedIndexedImage. A cs/CS colour in it
  keeps the upstream DeviceGray fallback with ColorFallback. Every other
  caller still gets None, as upstream.
- hival clamping and truncation are unchanged, and so is case C.
- The warning now reads "an image with a malformed Indexed colour table
  may be drawn wrongly or not at all".
- vendor/README.md: the patch (60) row is one line again. 3275f853c
  split it.

Tests: indexed_lookup.rs now has 25 tests, including one per base space
(DeviceGray, DeviceCMYK, Lab, Separation, DeviceN, and ICCBased N=4
drawn through its alternate). Against 6179cf795, 13 fail, including
every base space except DeviceGray, where zero is black. Against
b6f93a3ae, 19 fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
Tests for the mutation pass on c709dd99e:
- a_fill_colour_in_a_space_with_no_table_keeps_the_fallback now covers
  CS/SC too;
- a_shading_in_a_space_with_no_table_is_not_drawn checks that only
  images and the colour operators see a tableless space.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
Redaction starts from the images a run replaces or removes and the forms
and soft masks under them, so a dictionary used by something that left
another way stays, with apply returning Ok: a stamp or widget appearance, a
Type 3 glyph, an /Alternates entry, a /Mask, or an image with /Filter by
reference or /DecodeParms through a reference to a reference. A
byte-identical copy stays too, which is what pdfseparate then pdfunite
leaves, and Remove Hidden Information never takes a dictionary out.

Compare what a full save would keep before and after the run instead, the
same walk write_full makes, now cos::reachable. What left names the
dictionaries, in any entry of its parameters; what stays that names one, or
a copy, is stored again without it; and the walk is taken once more, so a
dictionary still kept refuses the run, whatever keeps it. An image with
/Width, /Height or /Filter by reference is read, and is one bit in the copy,
as it was when drawn. /Alternates stays with what is stored again, and a
stream that isn't JBIG2 refuses rather than being stored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
Trim marks at the trim box's corners, dashed bleed marks at the bleed box,
registration targets, a colour bar and a page-information line, drawn outside
the trim box as a removable page mark (MarkKind::PrinterMarks); the page is
widened to make room when asked and the trim box stays, so the printed page
cuts the same. Edit::AddPrinterMarks (scope Full, undoable) and Remove printer
marks through Edit::RemoveMarks; Dialog::PrinterMarks reachable from the
catalog, the palette and --view printer-marks; tools doc_printer_marks and
doc_remove_marks kind printer_marks; rows in every language; parity
print.printer-marks shipped.

Carries the review fixes:
- ATTRIBUTION.toml: the language files' checksums after the new rows
- Printer marks: drop the unreachable AddPrinterMarks pattern in scope_of
- Tests: printer marks read the document's marks through upstream's marks() (#1434)
- Printer marks: make room inside the crop box and keep the trim box on rerun
- Format with rustfmt
- Printer marks: keep accented labels and make room for the bleed marks
- Printer marks test: the page is letter size, so the bleed marks (33 pt beyond the trim box) fit
- i18n(ui): carry this PR's new wordings in the catalogues that must cover them
- Parity: evidence paths follow the tests moved into tests/it
- Fix the build after merging upstream/main
- fix(printer-marks): refuse numbers out of range, print on every plate

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every colour operator in the pages' content and the Form XObjects it draws is
rewritten in the target space by the standard device formulas, source colours
read through the space in force (edit/src/colour.rs: device, ICCBased, CalRGB/
CalGray, Lab, Indexed, Separation/DeviceN through a Type 2 tint). Preserve black,
pictures too (optimize::recolour_image: 8-bit gray/RGB re-encoded, JPEG stays
JPEG, CMYK lossless), comments' and fields' appearances optionally.
Edit::ConvertColours (one undo step; refuses when nothing changes, so
Session::count_colours counts first), the Convert colours dialog, command
prepress.convert_colors, tool doc_convert_colours, rows in every language
(RGB and CMYK allowed unchanged in the Ukrainian and Kazakh checks), parity
print.convert-colors shipped.

Carries the review fixes:
- ATTRIBUTION.toml: the language files' checksums after the new rows
- Convert colours: keep RGB and CMYK as-is in the Czech and Turkish menu-label checks
- Convert colours: CMYK black start, shared objects copied, streams kept apart, guarded count
- Convert colours tests: drop an unused helper
- Format with rustfmt
- i18n(ui): carry this PR's new wordings in the catalogues that must cover them
- Parity: evidence paths follow the tests moved into tests/it
- fix(colours): comment permission for appearances; page range per document
- fix(convert-colours): say what it is not, and what it leaves behind

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The area Take a Snapshot copies stays outlined with a small menu under it:
Print area… and Save as image… (PNG). Print area… opens Print with
"Selected area (page N, size)" under Pages to Print; the area prints alone,
as a page of that size, with Fit, Actual size, Shrink or a custom scale,
clipped to the area. Multiple, Booklet, Poster and Label stay for pages.

- print: Settings.area (one page's rectangle in display space, y up),
  placed with place_crop under a Size layout; other layouts are refused.
- ui: PrintDraft.area and Which::Area; only Size is offered while the area
  is chosen. Another tool puts the area and its menu away.
- automation: doc_print takes area [x0, y0, x1, y1] from the top-left.
- Five new labels in all 22 catalogs (ATTRIBUTION.toml checksums
  refreshed); parity print.selected-area; ROADMAP progress log.

Requested in #2051.

Carries the review fixes:
- Save as image: save this tab's area, not the last snapshot taken anywhere
- Web downloads: type the file by its extension; keep the snapshot area after Print area
- Clear the copied area of every tab when Snapshot is left
- i18n(ui): carry this PR's new wordings in the catalogues that must cover them
- style: rustfmt after the merge with main
- Use Id::unique for the snapshot area menu (Id::new is deprecated in egui 0.37)
- rustfmt
- print area: refuse an area that isn't finite, and a page out of range first

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ISO 32000-2 §8.5.2.1, Table 58: h "terminates the current subpath.
Appending another segment to the current path shall begin a new subpath,
even if the new segment begins at the endpoint reached by the h
operation." kurbo carries a segment after ClosePath on from the subpath
start without a MoveTo, so resolve_degenerate_subpaths saw "h" then an "l"
back to that point as part of the closed subpath, and the round-cap dot
of that degenerate subpath was lost (main drew it, through the nudge this
PR removed). l, c, v and y after h now start with a MoveTo to the point h
reached.

Review on #4410 (CodeRabbit, vendor/hayro-interpret/src/interpret/mod.rs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
table_rows_of_minimum_height_cells_fill_the_page_at_that_height read the
height of the first widget on the first page only, and counted only the c
cells per page. A shorter last row leaves both the counts and that one
height as they were (checked: with the last row at minH 0.3in the old
assertions pass). It now collects the c and d cells of every row on every
page and asserts each is 28.8pt, with 60 and 20 cells.

Review on #5124 (CodeRabbit, crates/xfa/src/tests.rs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
apply works on a copy, so nothing changes when it refuses, but remove_hidden
isn't transactional: a direct caller's document is part changed by then. The
message said nothing was changed for both. The app's callers work on copies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
radio_widgets read each button's on-state name with from_utf8_lossy,
while the group's value it is compared with comes from pdfcraft_forms,
which writes '#' as #23 and bytes that are not UTF-8 as #XX (name_text).
A state /A#23B (the bytes A#B) or a Shift-JIS /#82#CD then matched no
button: saving wrote the state's text as the item instead of the button's
/Opt entry, and opening returned a state no button has. The states are
now spelled with the same rules (a copy of name_text: this crate can't
depend on pdfcraft-forms).

Also pins that taking the XFA out notes no lost data when the data names
the shown button by its /Opt entry while saving would write its /PCOn.

Review on #4784 (CodeRabbit, crates/xfa/src/data/values.rs and
crates/engine/src/xfa_form.rs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
A Fill & Sign signature stays a removable comment unless Flatten Fill &
Sign when saving is on, which sat only in Preferences. The first save
after a signature or initials is placed now asks: Lock and save flattens
the document's Fill & Sign marks, Keep editable saves as before, Cancel
saves nothing, and Don't ask again turns the answer into the
preference. The Sign menu carries the same switch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review follow-up on #2348: the document stays tracked as holding a placed
signature and "Don't ask again" is remembered only once locking succeeded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review follow-ups on #2348 (benandez): a dialog an answer opened this frame
isn't closed with it; documents with digital signatures aren't asked, since
locking changes them after signing; a placement that was undone asks nothing;
the mark is forgotten when the tab closes; the other catalogues carry the
AI-assisted header; parity evidence lists the new tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review follow-up on #2348: as the native save does, so a later save with the
question turned back on doesn't ask about what was already saved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
benandez's review suggestion on #2348. Right after Fill & Sign places a
signature or initials, a notice says "Signature added. Others can move it
until it's locked." with Lock now, which flattens the marks into the page
(undoable) without saving; the save that follows then has nothing to ask
about. The placed-signature mark is kept, so undoing the lock brings back the
notice and the save-time question, which stays as the fallback. Dismiss hides
the notice until the next signature. No notice on a digitally signed document
or when saves lock anyway. The "Lock into the page when saving" switch is
now once, in the panel's Sign yourself section, no longer in the Sign menu
over the page. The three new strings are in all 22 catalogues (AI-assisted,
not reviewed by native speakers).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Catalan, Sorani, Finnish, Hindi, Indonesian, Korean, Lao, Norwegian Bokmål, Romanian, Albanian and Thai joined main after this branch was written; their coverage tests ask for these strings too. AI-assisted, as noted in each catalogue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review follow-up on #2348: flatten_fill_sign_in refreshed the view after Edit::FlattenFillSign but kept view.comments.selected. The placed signature is usually the selected comment, and once it is part of the page its (page, index) names nothing, or another comment after the indices shift. Both Lock now and the save-time Lock and save go through flatten_fill_sign_in, so both clear it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
File > Revert discards a signature placed since the last save, but its marker stayed, so the
next save asked to lock a signature that was gone whenever the saved file held an earlier
Fill & Sign mark. A successful revert now clears the marker.

Review on #2348 (CodeRabbit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
crates/ui-egui/src/panels/right.rs.orig and crates/automation/src/json.rs.orig
were left by a merge tool in 211718668. Neither is compiled (the modules
load right.rs and json.rs), and right.rs.orig still drew the old flat
Layers list, so a reader searching the tree could take it for the code.

Review on #4396 (CodeRabbit, crates/ui-egui/src/panels/right.rs.orig).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
callout_side's doc says a coordinate that isn't a number takes the bottom
side, but every comparison with NaN is false, so the other coordinate
alone decided: [NaN, y above the box] gave Top, [x left of it, NaN] Left.
A NaN coordinate is now checked before any comparison.

Review on #4094 (CodeRabbit, crates/annot/src/create.rs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
border_width_of, which fits a text box's new text and sizes callouts,
polygons and line endings, read only /BS /W (and turned a negative width
into 0), while the appearance draws with appearance::border_width: /BS /W,
else /Border's third number, 1 for an unusable one. A text box with
/Border [0 0 4] and no /BS was drawn with a 4-pt border but fitted as if
it had a 1-pt one, 6 pt shorter. border_width_of now is that rule.

Review on #4365 (CodeRabbit, crates/annot/src/modify.rs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
disconnected_subpaths_on_one_line_leave_the_gap_between_them_unpainted
checked overlapping subpaths drawn in opposite directions under f* only.
It now checks the W* clip of the same path keeps their union too: with
the clip area pushed under the path's own even-odd rule instead of
non-zero, the overlap goes missing and the new assertion fails.

Review on #4433 (CodeRabbit, crates/render/tests/it/zero_area_fill_clip.rs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
main gave the colour-space constructors a nesting depth (ColorSpace::within) and read the
Indexed lookup once by type (patch (71)). Indexed::new keeps this PR's recovery (hival
truncated and clamped, black past a short table, no table skipped) with main's depth bound;
vendor/README.md takes main's rows with this PR's patch (60) entries after (59).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
Answering the lock question removed the placed-signature mark before the save ran, so a
save that then failed or was cancelled didn't ask again. The answer now only lets the save
that follows go ahead without asking; the mark is removed when a save succeeds, as before.
A reload from disk replaces the document, so it forgets the mark too (a failed reload keeps
it).

Review on #2348 (CodeRabbit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
The pdfcraft-print unit-test executable imported windows.data.pdf.dll and
prntvpt.dll at load time (the windows crate binds them with raw-dylib), so
on a Windows installation without them it could not start, and the tests
of the missing-DLL guard never ran there. crates/print/build.rs now passes
/DELAYLOAD for both and delayimp.lib on MSVC, as the applications' build
scripts do. Link arguments from a build script reach only this package's
own executables, so dependents are unaffected.

dumpbin /imports: before, both DLLs under "the following imports"; after,
under "the following delay load imports". A copy of each test binary with
prntvpt.dll renamed to a DLL that doesn't exist: the old one exits with
0xC0000135 (STATUS_DLL_NOT_FOUND) before main, the new one starts and runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
The focus now carries the focused field's object, so the overlay, the
draft commit and the focus frame use the twin that has the focus rather
than the first field with its name. The arrow keys pick by position
through the same twin mapping a click uses (list_pick_edit), so an arrow
in a twin listing its options in another order picks its own next row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
pdfcraft-xfa keeps its own copy of pdfcraft_forms::name_text (it can't
depend on forms), and a radio group's value only finds its button while the
two agree. xfa_and_forms_spell_names_alike compares them, and checks that
forms' name_bytes reads the copy's spelling back, for UTF-8, '#', Shift-JIS
and stray bytes. With the copy writing lowercase hex it fails at the
Shift-JIS case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
No conflicts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
Conflicts, each resolved by keeping both sides:
- parity/acrobat-features.toml (dynamic XFA entry): main added the rotate
  evidence and the note on draws turning about their anchor; this branch
  added its open-script evidence and notes. Both kept on the one line.
- crates/engine/src/tests.rs and crates/xfa/src/tests.rs: both sides added
  tests at the end of the file. Both kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
Adds three regression tests from wireless89's review of #4791:
- xfa_opening_reset_reaches_a_section_shown_by_an_earlier_script (a text field of a section
  an earlier opening script showed is reset in the widget and the saved data);
- xfa_deferred_choice_resets_preserve_the_reset_value_through_reopen (the same for choice
  lists: multi-select with structured picks and an empty or set default, and a label saved
  as its export value; widget, open datasets and saved datasets checked separately);
- xfa_initialize_scripts_that_hide_subforms_lay_the_form_out_once now edits the check box
  and checks fields, pages and the value through three save/reopen cycles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
Conflict in crates/xfa/src/tests.rs: both sides added tests at the same
place (this branch's radio data tests, main's tests for draws turning about
their anchor). Both kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
Conflict in vendor/README.md (hayro-interpret row): main added patches (71), (72) and (74); this branch adds (65). main's row is kept, with (65) placed after (64) in the patch list and in the tests list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
Conflict in vendor/README.md (hayro-interpret row): main added patches (71), (72) and (74); this branch adds (67). main's row is kept, with (67) placed before (71) in the patch list and in the tests list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
Conflict in crates/xfa/src/tests.rs: both sides added tests at the same place (this branch's table row tests, main's tests for draws turning about their anchor). Both kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
Conflicts:
- vendor/README.md: the hayro and hayro-jbig2 rows are main's (this PR
  does not change them). The hayro-interpret row is main's, with this
  PR's (61) entry inserted after (59) in both the patch column and the
  tests column; no other number moved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
Conflicts:
- crates/annot/src/modify.rs: the create imports; kept this branch's
  callout helpers and main's ink_bounds.
- crates/annot/src/tests.rs: both sides added tests at the same place
  (the callout tests here, main's freehand highlight test); kept both.
- crates/automation/tests/it/automation.rs, crates/ui-egui/tests/it/comments.rs,
  crates/ui-egui/src/tools/comments/tests.rs: both sides added tests or
  helpers at the same place; kept both, each with its own closing brace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
Conflicts:
- crates/annot/src/modify.rs: main (faebcfd26) made the same change to
  border_width_of (appearance::border_width) and added ink_rect_for_width
  after it; kept main's body and new function, and this branch's doc comment.
- crates/annot/src/tests.rs: both sides added tests at the same place; kept
  this branch's text-box tests and main's freehand-highlight test, each with
  its own closing braces.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
A group member (/RT /Group, as Replace Text's strikeout and caret are) is
part of its comment, not a reply (ISO 32000-2 §12.5.6.2), so the window
lists no group member among the replies. For Replace Text as main writes it
(the replacement on the caret, grouped under an empty strikeout, #5688), the
window shows and edits the caret's text, the proposal's.

A second test pins that a double-click on the proposal opens one window, the
primary's, in both layouts; the page's hit test already picks only the
group's primary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
Conflicts, each where both sides added at the same place; both kept:
- crates/annot/src/tests.rs, crates/automation/tests/it/automation.rs and
  crates/ui-egui/tests/it/comments.rs: this branch's pop-up tests and main's
  new tests.
- crates/ui-egui/src/i18n/{ca,fi,ro,vi}.tsv: this branch's pop-up lines and
  main's image-paste and Home lines. No key is added twice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
For Replace Text as main writes it (#5688), the window shows and edits the
caret's text, but the caret is the group's subordinate: locking the comment
sets /Locked on the strikeout, the primary, and the caret's own flag left the
text editable from the window. The text box is now read-only when either is
locked. The tests' comments no longer call this a review point.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
No conflicts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
A growable lr-tb/rl-tb subform split at a content area's bottom now breaks
the flowed subforms round it there (break_frames), as a break between their
children does, so a bordered or filled section keeps a closed part on each
page instead of losing its border.

An unnamed growable multi-line field is measured with its template text.
A nameless field takes no part in data binding (XFA 3.3 p. 194), and its
SOM context ends in its parent, so its data is not looked up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
No conflicts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
When nothing that names the dictionary is stuck and something else refers
to it (a private key, say), the refusal said 1 item(s) couldn't be stored
without it. It now counts 0 and says something else refers to the data.

remove_hidden's refusal gets a test: the comment has gone by then, and the
text doesn't say nothing was changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
A kept image that named the dictionary added every dictionary it names to
what had to leave, in one pass. An image using only one of those, earlier
in the file, had been passed over, so it kept that dictionary and the
redaction refused, saying something else referred to the data. In the
other order it was stored again. Now only the names that matched have to
leave, so another dictionary stays with what uses it, in either order.

The variant's doc says what is counted: something that names the data,
JBIG2 or not.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
Conflicts:
- crates/engine/src/comments.rs (comment_kind): main added the
  Freehand Highlight arm, this branch the Replace Text arm; both kept.
- crates/automation/tests/it/automation.rs and
  crates/ui-egui/tests/it/comments.rs: both sides appended tests at the
  end of the file; both kept, each test closed on its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
Conflicts:
- crates/engine/src/tests.rs: both sides added tests at the end of the
  file. Kept this branch's three stamp-line tests and main's page-deletion
  save and image-transform tests; the closing braces after the markers are
  shared, so the branch's last test gets its own.
- crates/ui-egui/src/i18n/{ca,fi,hi,id,ko,nb,ro,sq,th,vi}.tsv: both sides
  added rows at the same place. Kept both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
Conflict:
- crates/ui-egui/src/canvas/tests.rs: both sides added tests at the end of
  the file (the presenter tests here, the image-handle rotation test on
  main); both kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
Review on #4664: the caption tests covered a size-only caption font, but
not a caption that sets one posture property while its field sets the
other. A bold caption on an italic field is now drawn bold and italic.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
Conflicts:
- crates/xfa/README.md: main added the sentence on turned draws where this
  branch added the one on caption fonts; both kept, main's first.
- parity/acrobat-features.toml (dynamic XFA layout entry): main's evidence
  and notes kept, with this branch's evidence test appended and its
  "captions (in their field's font unless they name their own)" note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
Review on #4791: the test's only field outside the reset subforms is
written by the script after the resets, so a reset that reached it too
would go unseen. A field with typed data that no script touches now
keeps it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
No conflicts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
/FDecodeParms are the parameters of data in another file, which a reader
may draw instead of the stream's own. A dictionary that only a removed
image named there stayed in the file with Ok. It is now read with
/DecodeParms and /DP, and an image whose data is in another file counts
as one that can't be stored without the dictionary: readers draw that
file, not a copy stored here.

images_restored counts what was stored again. A dictionary that names
itself as its own globals (malformed) leaves with what used it but can't
be stored, and was counted.

The table's /Mask route gets a row in the routes test, as it was only
measured.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
A /F that is null, by reference to null or to no object, is as if absent,
so an image with one is stored again, not refused as data in another file.
The check is image::data_elsewhere.

A copy stored again kept /FFilter and /FDecodeParms, which named now
reads, so the dictionary stayed through the copy and the run refused,
blaming something else. A cleared copy kept those and /DP. Both drop them,
the cleared copy only when its data isn't in another file.

A dictionary that names itself isn't counted as something that uses one
when the run is refused, as it already wasn't when it goes on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
/F, /Width, /Height, /Filter and its entries, /ColorSpace and an ICC
profile were resolved one reference deep, while /DecodeParms and
/JBIG2Globals follow a chain (followed). So /F through a reference to a
reference to null counted as data in another file and refused the run,
and a /ColorSpace chained to /DeviceRGB wasn't known to have three
components, so a JBIG2 image naming it was stored as one bit, where main
refused it. All now go through followed, which a ring ends.

image::components serves clear too, so an image whose colour space is
given that way is now cleared, where it was removed whole; clear's
/Decode for a stencil mask, read one reference deep, follows the chain
as well, so a chained [1 0] isn't missed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
# Conflicts:
#	crates/ui-egui/tests/it/comments.rs
content() decoded a dictionary through with_direct_parms, which reads
/DecodeParms and its entries one reference deep, while named() follows
the chain. So a copy of the dictionary whose parameters came through a
reference to a reference decoded to other bytes, wasn't known for a copy,
and stayed in the file with Ok. readable() now gives /DecodeParms, each
entry and each value through followed, /JBIG2Globals aside, so a copy
decodes as a reader that follows references decodes it.

With the copy known, the image naming it has to be stored again, and
PdfCraft decodes that image's symbols with the copy's parameters one
reference deep, so it can't: the run is refused, where it went on with
the copy in the file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
direct_parms copied every value it followed, and direct() every /Filter
entry, so each reference to one large array copied it again: about 5.5 MB
a reference, 11 GB from a file of about 225 KB. Now, as
Document::with_direct_parms does, a size is a number, /Filter a name or
an array of names, and direct_parms() keeps of each entry the numbers and
flags decoders read (DECODE_PARMS_KEYS, now public in cos) and
/JBIG2Globals as given, for as many entries as there are filters.
Anything else is null.

A kept image whose own /DecodeParms come through a chain is stored again
with them, which 2eddb1bc9 made so: what_stays tests it now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
Three ways a small file could still make a redaction run long or large:

- direct_parms kept /JBIG2Globals as given, so a direct array there was
  copied for each entry (427 MiB from 32 KB). It is kept only when it is a
  reference, as Document::with_direct_parms keeps it; a direct value is
  no stream, so no decoder reads it.
- The entries copied were bounded only by /Filter's length, and the work
  repeated for every stream that uses a dictionary: time grew with the
  square of the file (117 s from 249 KB). A /Filter longer than any
  decoder takes (MAX_CHAIN, and a /Crypt) is read as no filter, so its
  parameters aren't read either; no decoder reads such a stream anyway.
- named() read every entry of every stream's parameters, through up to
  33 references each, with nothing shared between streams. Names keeps
  what each value the file refers to names, for the whole run, so streams
  sharing an array or an entry cost it once; unshare adds a shared list,
  and finds what it matches, once too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
Names kept what a value names by its first reference, so streams naming
one array each through an object of their own that refers to it missed
the memo every time: quadratic time, and now memory too (8000 such
streams with 8000 entries, 1.4 MB: 30 s, 504 MiB). It is keyed by the
object the references end at, so another reference, or a chain, finds it
(the same file: 0.29 s, 7 MiB, measured by the review). matches and
added use the same key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
f5aa20f7a keyed what Names remembers by the object references end at,
but followed them all before looking: every hit fetched the object
again. With two large objects that push each other out of cos's caches,
each fetch decoded 17 MiB, and a 66 KiB file took 26 s where it took
0.6 s. Each reference is now looked up before it is fetched, and what
it leads to is remembered under every reference on the way, with the
key of the last: a hit fetches nothing, an alias or a chain only the
references before what is remembered. The test counts fetches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
No conflicts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
Conflicts:
- crates/ui-egui/src/workflows/js_ui.rs: main moved the Preferences dialog to
  crates/ui-egui/src/dialogs/preferences.rs (cards with search). Took main's re-export and
  carried this branch's "Otherwise, ask on the first save after a signature is placed"
  checkbox into card_fill_and_sign, disabled while flattening is on as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
Conflicts:
- vendor/README.md (hayro-interpret row): kept main's row; main does not
  use patch number (53), so this PR's (53) entries go in numeric position
  (before (55)) in both the Patches and Test columns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
Conflicts:
- vendor/README.md (hayro-interpret row): kept main's row; main does not
  use patch number (67), so this PR's (67) entries go in numeric position
  (before (71)) in both the Patches and Test columns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
Conflicts:
- crates/ui-egui/src/canvas/xform.rs: both sides added a method after
  user_rect (this PR's link_contains, main's user_rect_f64); kept both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
Conflicts:
- ATTRIBUTION.md: generated file; regenerated from the merged ATTRIBUTION.toml with
  `cargo xtask assets --write`.
- crates/ui-egui/src/canvas/tests.rs: both sides appended tests; kept both.
- crates/ui-egui/src/i18n/*.tsv (36 catalogues): both sides appended; kept both, main's
  lines first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
Reviews kept finding ways a small file could make this run long or large:
readable() fetched the same objects again for each image that names a
dictionary (~3.9 s each when two large objects push each other out of
cos's caches), and the memo of names grew with distinct arrays times
their entries (~3.7 GB from 2 MiB). Nothing bounded the run as a whole.

Everything unshare reads now goes through one Run: what a reference
leads to is fetched at most once a run for each way it is read (a size, a
filter, parameters, an entry, a flag, /F, a colour space, an ICC
profile), looked up before it is fetched and kept only as what is read
of it. Objects fetched, names remembered, and what is decoded (a unit for
each 4 KiB) count against RUN_BUDGET (2^22), and past it the run is
refused with SharedJbig2Budget. A scan reads a few objects a page, so a
book of thousands of pages sharing one dictionary stays well within it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
No conflicts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyG234xeYkJfr41E6iP
- vendor/README.md (hayro-interpret row): main added patch (73); kept main's row and inserted this PR's (67) entries in numeric position in both columns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyG234xeYkJfr41E6iP
No conflicts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyG234xeYkJfr41E6iP
Conflicts:
- crates/xfa/src/tests.rs: both sides added tests at the end of the file
  (this branch's flowed table-cell and content-height tests; main's
  rotated-draw tests). Kept both, closing the branch's last test before
  main's helpers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyG234xeYkJfr41E6iP
- crates/ui-egui/src/canvas.rs (apply_frame_requests): main passes the leftmost page edge to quick_bar; this branch hides the quick bar when the document hides its toolbars. Kept both: the branch's HideToolbar condition with main's page_left call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyG234xeYkJfr41E6iP
Conflicts:
- crates/xfa/src/tests.rs: both sides appended tests at the end of the
  file; kept this branch's row minH test and main's draw/anchor tests,
  closing the branch's test before main's block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyG234xeYkJfr41E6iP
Conflicts:
- vendor/README.md (hayro-interpret row): kept main's row, including its
  new (73) and the extended (11)/(31) notes, and re-inserted this PR's (53)
  entries (unused on main) in numeric position in both columns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyG234xeYkJfr41E6iP
Conflicts:
- crates/xfa/src/tests.rs: both sides appended tests at the end of the
  file; kept this branch's unique-name tests and main's draw/anchor
  tests, closing the branch's last test before main's block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyG234xeYkJfr41E6iP
Conflicts:
- crates/xfa/src/tests.rs: both sides added tests at the end of the file
  (this branch's XFAImages name-tree tests; main's rotated-draw tests).
  Kept both, closing the branch's last test before main's helpers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyG234xeYkJfr41E6iP
Conflicts:
- crates/engine/src/tests.rs: both sides appended tests at the end of
  the file; kept this branch's page-master tests and main's page and
  image edit tests, closing the branch's last test before main's block.
- crates/xfa/src/tests.rs: the same, with main's draw/anchor tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyG234xeYkJfr41E6iP
Conflicts:
- crates/xfa/src/tests.rs: both sides appended tests at the end of the
  file; kept this branch's subformSet tests and main's draw/anchor
  tests, closing the branch's last test before main's block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyG234xeYkJfr41E6iP
Conflicts:
- crates/xfa/src/layout/measure.rs (positioned width and height): this
  branch measures each child under its own SOM index (size_at with
  sibs.at(i)); main reads the extent through standing_box so a turned
  draw counts as the box it stands in. Kept both: size_at for the size,
  standing_box(w, h).right() / .bottom() for the extent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyG234xeYkJfr41E6iP
Conflicts:
- parity/acrobat-features.toml (form.xfa-dynamic): main added the
  rotated-draw test to the front of the evidence and a sentence to the
  front of the notes; this branch appended the prototype tests to the
  evidence and put "prototypes first: ..." into the layout sentence of
  the notes. Kept both on main's lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyG234xeYkJfr41E6iP
Conflicts:
- crates/xfa/src/parse.rs: main added quarter_turns before parse_common
  (and rotate in its body); this branch gave parse_common a warnings
  parameter for the property trees. Kept quarter_turns and the branch's
  signature; the body holds both props and rotate.
- crates/xfa/src/tests.rs: both sides added tests at the end of the file
  (this branch's packet-size test; main's rotated-draw tests). Kept both.
- parity/acrobat-features.toml (form.xfa-dynamic): main put the
  rotated-draw test at the front of the evidence and a sentence at the
  front of the notes; kept this branch's lines with main's additions
  prepended.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyG234xeYkJfr41E6iP
A review of fee25bfe4 found three costs the budget didn't count:

- A /JBIG2Globals reference in parameters given directly was fetched
  again for every entry of every stream (2,000 entries, 53 KB: 40 s, two
  thousand units of four million). What a reference leads to is now
  remembered for the run under it, and a chain met part way takes what
  is known from there.
- A /Filter name was kept whatever it was, so a 32 MiB name referred to
  nine times was held nine times and copied for each image (725 MiB from
  2.8 KB). Only a name a decoder knows is kept; any other is no filter,
  and a name longer than any decoder's isn't looked at.
- A decode that failed spent nothing, so a dictionary one byte past its
  limit escaped the budget. A failure now costs as much as it was allowed
  to decode: a dictionary MAX_JBIG2_GLOBALS, an image its allowance.

The README says what the budget counts, and that past it the redaction
is refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
After a find step, a wheel scroll and then a ⌘-wheel zoom anchor on the
pointer, not back on the old match (FredApps on #4139).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GbuSKAS2k5248LGFEapMUR
Conflicts:
- crates/render/src/raster.rs (RenderConfig): main added `jbig2_work`
  next to this branch's `for_print`; both fields are kept.
- vendor/README.md (hayro-interpret row): kept main's row (patches up to
  (74), including its new (73)) and put this branch's (67) entries back
  in their place in both columns. Main's code now uses (67) for its
  knockout-group patch, so the next commit renumbers this branch's patch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyG234xeYkJfr41E6iP
Main's knockout-group patch now uses (67) in vendor/hayro-interpret, so
this branch's patch takes the next number free on main and in the open
reply-appearance patch (75): (76) in vendor/README.md's two columns and in
every `PdfCraft patch` comment of the View auto state (interpret/mod.rs,
ocg.rs, render's inspect/resources.rs). No code change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyG234xeYkJfr41E6iP
The run and byte limits were per page, and a glyph's /ToUnicode text can
be 256 UTF-16 units, so a short content stream could keep gigabytes of
removed text across pages. The document now keeps at most 16 MiB; past
that the rest is not kept and the metadata check fails closed, as for any
other text it couldn't keep (CodeRabbit's retained concern on #2539).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyG234xeYkJfr41E6iP
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyG234xeYkJfr41E6iP
Conflicts:
- crates/redact/src/lib.rs: main added the run's ImageBudget to Applying
  and Scope::new; kept both sides (the removed-text fields and the image
  budget), with main's refused-images check before the destructuring.
- crates/redact/src/tests.rs (auto-merged): this PR's two tests that build
  a Scope or an Applying now pass main's ImageBudget.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyG234xeYkJfr41E6iP
A review of ecf028965 measured that decoding an image undoes the filters
of each dictionary it names again when cos doesn't hold their bytes (20
images naming a 32 MiB dictionary: 1.2 s at 2 units each). content()
now remembers what each dictionary decoded to, or its limit when it
couldn't, and an image is charged that before it is decoded.

Names gathered from many entries are held as a set as they are read, so
a million entries naming one chain hold it once. The claim that each
object is fetched once a run for each way it is read says its exception:
a /JBIG2Globals chain named part way is followed again from there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
`form_fill` with each option `form_fields` reports ticks the kid of that on state, also where
the other kid's `/Opt` export is that on state (ISO 32000-2 §12.7.5.2.3, Table 230).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyG234xeYkJfr41E6iP
No conflicts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyG234xeYkJfr41E6iP
Main (#5569) decodes every image through cos's ImageBudget, which counts the
JBIG2 decoder's own work and the globals it reads, through main's own
restore_shared_jbig2 and image::without_globals for the images that share a
redacted image's /JBIG2Globals too. This branch replaces both with
globals::unshare, so the merge keeps unshare and drops them.

without_dictionary now decodes through ImageBudget::decode instead of
Document::decoded_image_within, which main made private. That closes the gap
the PR noted: the decoder's own work (up to hayro-jbig2's 2^30 steps an image)
was charged one unit of the run budget. apply passes its run's allowance, what
clearing left of it, as main's restore did. Past it apply is refused with
ImagesPastAllowance, as main's restore was. remove_hidden gives unshare an
allowance of its own (sanitize::unshare_unmarked), and past it is refused with
SharedJbig2Budget: "redact fewer pages at a time" means nothing there, and
Optimize's discard runs it too. SharedJbig2Budget's text now says finding and
storing again.

Tests: main's allowance test runs on unshare, with ImageBudget::with_work(0)
refused as ImagesPastAllowance, through unshare_unmarked as SharedJbig2Budget,
and u64::MAX storing one image. Test helpers decode through ImageBudget, since
Document::decoded_image is no longer public.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GbuSKAS2k5248LGFEapMUR
Conflicts:
- vendor/README.md (hayro-interpret row): kept main's row (which adds
  patch (73)) and inserted this PR's (65) entries in numeric position in
  both the Patches and Test columns; (65) is unused on main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyG234xeYkJfr41E6iP
Conflicts:
- vendor/README.md: kept main's hayro-interpret row and inserted this
  branch's (60) entries in numeric position in both columns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyG234xeYkJfr41E6iP
Conflicts:
- vendor/README.md (hayro-interpret row): kept main's row (which adds
  patch (73)) and inserted this PR's (61) entries in numeric position in
  both the Patches and Test columns; (61) is unused on main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyG234xeYkJfr41E6iP
Mutation testing of the PR against main left three mutants of image.rs alive:
the ICCBased arm of components deleted (an ICC image is then removed whole),
and data_elsewhere made false or inverted (a cleared copy then drops, or keeps,
/FFilter and /FDecodeParms whatever /F says).

an_icc_based_image_is_cleared_in_place_through_any_references clears an image
of one component and of three, its profile direct and through a reference to
a reference, and checks the samples. a_cleared_copy_keeps_parameters_for_data_
in_another_file_only_with_one checks a /F string, direct or by reference,
keeps them, and a null /F, a reference to null or to no object, or none, drops
them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
content(), which reads each JBIG2 dictionary to know copies of it, charged the
run budget for what its filters made at the end, a unit for each 4 KiB. That
says nothing of the work: three Flates can inflate a 204-byte stream to
32 MiB, a 1-bit TIFF predictor run over all of it, and ASCIIHex skip it as
white space to nothing. The Resources review of f802198f4 measured 1.33 s for
one unit, apply returning Ok after 26.6 s with 20 of them in 11 KB, about 50 to
70 minutes a MB, never refused; remove_hidden and Optimize's discard too.

It now decodes through the run's ImageBudget (decode_filters), which pays for
what every stage reads and makes, as the images stored again already are. Past
it the run is refused with ImagesPastAllowance, which remove_hidden gives as
SharedJbig2Budget. The run budget still counts the decoded size.

a_dictionary_pays_for_every_stage_of_its_filters: two Flates of a MiB of zero
bytes under ASCIIHex spend one unit of the run and more than a MiB of the
allowance; with a MiB of allowance content() and unshare are refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
Since b1f221611 the dictionaries read to know copies of them are decoded
through the run's ImageBudget, every filter stage paid, as the images stored
again are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
Since b1f221611 the dictionary is read within the allowance too, so the test
that gave unshare no allowance was refused reading the dictionary, before the
image was decoded: turning the image's refusal into "can't be read" left every
test green (Claims review of 5bef9b0e7). The test now also gives exactly what
reading the dictionary costs, so the image is what is refused.

The same review: the README lists the other images that can't be stored
again (more than one component, no size) and names ImageBudget::decode, not
Document::decoded_image_within, which #5569 made private;
SharedJbig2Budget's doc and remove_hidden's comment name the dictionaries read
to know copies too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
The README said apply "refuses and changes nothing" and that remove_hidden
takes the dictionaries out "the same way", which reads as if a refused
remove_hidden changed nothing too. It has changed the document by then
(a_refused_removal_of_hidden_information_doesnt_say_nothing_changed: the
comment goes before the refusal), as its doc says, so a caller passes a copy it
can drop. Raised by CodeRabbit on #5698.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
CommentView remembered the group member a press on the page picked, so
Delete acts on the member clicked, as Acrobat desktop does. The memory
outlived the selection it came from: after choosing the group's card in
the Comments panel, a deletion, an undo or a selection made by a tool,
Delete still took the member clicked earlier.

Every selection now goes through CommentView::select(), which clears the
remembered member; pick() (a press on the page: the Select tool's press
and click, and the context-menu press) is the only place that sets it.
The panel's cards and their Delete, Delete and Backspace, Escape, the
context menus (Delete, Flatten comments), deletion and undo
(annotation_deleted, history_stepped), the drawing tools, the measuring
panel and the control channel use select().

Test: choosing_the_groups_card_makes_delete_act_on_the_card_not_the_member_clicked
(ui-egui). With select() leaving the member in place it fails: the Circle
clicked earlier goes instead of the Square the card shows
(left: ["Square"], right: ["Circle"]).

CodeRabbit review on #4358.

(cherry picked from commit a24b5ae4e8590be640028f501c9fe87cfdf679d2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyG234xeYkJfr41E6iP
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
# Conflicts:
#	crates/automation/src/tools.rs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
The test #5954 added on main double-clicks a sticky note and expects the
edit composer. On this branch a double-click on a note opens its pop-up
window instead, so for a note the test now checks that the pop-up opened,
then opens the edit composer with the page menu's "Edit text…" and goes on
as before: the composer still trims trailing whitespace from a non-typewriter
comment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
main moved the interface translations to craft-i18n (#6055) and removed
crates/ui-egui/src/i18n/*.tsv; this branch's catalog rows go with them
and are left for craft-i18n. Untranslated labels show in English.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
main moved the interface translations to craft-i18n (#6055) and removed
crates/ui-egui/src/i18n/*.tsv; this branch's catalog rows go with them
and are left for craft-i18n. Untranslated labels show in English.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
main moved the interface translations to craft-i18n (#6055) and removed
crates/ui-egui/src/i18n/*.tsv; this branch's catalog rows go with them
and are left for craft-i18n. Untranslated labels show in English.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
main moved the interface translations to craft-i18n (#6055) and removed
crates/ui-egui/src/i18n/*.tsv; this branch's catalog rows go with them
and are left for craft-i18n. Untranslated labels show in English.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
# Conflicts:
#	crates/ui-egui/src/i18n/ar.tsv
#	crates/ui-egui/src/i18n/bg.tsv
#	crates/ui-egui/src/i18n/ca.tsv
#	crates/ui-egui/src/i18n/cs.tsv
#	crates/ui-egui/src/i18n/de.tsv
#	crates/ui-egui/src/i18n/el.tsv
#	crates/ui-egui/src/i18n/es.tsv
#	crates/ui-egui/src/i18n/fi.tsv
#	crates/ui-egui/src/i18n/fr.tsv
#	crates/ui-egui/src/i18n/hi.tsv
#	crates/ui-egui/src/i18n/hu.tsv
#	crates/ui-egui/src/i18n/id.tsv
#	crates/ui-egui/src/i18n/it.tsv
#	crates/ui-egui/src/i18n/ja.tsv
#	crates/ui-egui/src/i18n/kk.tsv
#	crates/ui-egui/src/i18n/ko.tsv
#	crates/ui-egui/src/i18n/lt.tsv
#	crates/ui-egui/src/i18n/pl.tsv
#	crates/ui-egui/src/i18n/pt-br.tsv
#	crates/ui-egui/src/i18n/ro.tsv
#	crates/ui-egui/src/i18n/ru.tsv
#	crates/ui-egui/src/i18n/sl.tsv
#	crates/ui-egui/src/i18n/sq.tsv
#	crates/ui-egui/src/i18n/te.tsv
#	crates/ui-egui/src/i18n/tr.tsv
#	crates/ui-egui/src/i18n/uk.tsv
#	crates/ui-egui/src/i18n/vi.tsv
#	crates/ui-egui/src/i18n/zh-hans.tsv
#	crates/ui-egui/src/i18n/zh-hant.tsv
# Conflicts:
#	crates/ui-egui/src/i18n/ar.tsv
#	crates/ui-egui/src/i18n/bg.tsv
#	crates/ui-egui/src/i18n/ca.tsv
#	crates/ui-egui/src/i18n/cs.tsv
#	crates/ui-egui/src/i18n/de.tsv
#	crates/ui-egui/src/i18n/el.tsv
#	crates/ui-egui/src/i18n/es.tsv
#	crates/ui-egui/src/i18n/fi.tsv
#	crates/ui-egui/src/i18n/fr.tsv
#	crates/ui-egui/src/i18n/hi.tsv
#	crates/ui-egui/src/i18n/hu.tsv
#	crates/ui-egui/src/i18n/id.tsv
#	crates/ui-egui/src/i18n/it.tsv
#	crates/ui-egui/src/i18n/ja.tsv
#	crates/ui-egui/src/i18n/kk.tsv
#	crates/ui-egui/src/i18n/ko.tsv
#	crates/ui-egui/src/i18n/lt.tsv
#	crates/ui-egui/src/i18n/pl.tsv
#	crates/ui-egui/src/i18n/pt-br.tsv
#	crates/ui-egui/src/i18n/ro.tsv
#	crates/ui-egui/src/i18n/ru.tsv
#	crates/ui-egui/src/i18n/sl.tsv
#	crates/ui-egui/src/i18n/sq.tsv
#	crates/ui-egui/src/i18n/te.tsv
#	crates/ui-egui/src/i18n/tr.tsv
#	crates/ui-egui/src/i18n/uk.tsv
#	crates/ui-egui/src/i18n/vi.tsv
#	crates/ui-egui/src/i18n/zh-hans.tsv
#	crates/ui-egui/src/i18n/zh-hant.tsv
# Conflicts:
#	crates/ui-egui/src/i18n/ar.tsv
#	crates/ui-egui/src/i18n/bg.tsv
#	crates/ui-egui/src/i18n/ca.tsv
#	crates/ui-egui/src/i18n/cs.tsv
#	crates/ui-egui/src/i18n/de.tsv
#	crates/ui-egui/src/i18n/el.tsv
#	crates/ui-egui/src/i18n/es.tsv
#	crates/ui-egui/src/i18n/fi.tsv
#	crates/ui-egui/src/i18n/fr.tsv
#	crates/ui-egui/src/i18n/hi.tsv
#	crates/ui-egui/src/i18n/hu.tsv
#	crates/ui-egui/src/i18n/id.tsv
#	crates/ui-egui/src/i18n/it.tsv
#	crates/ui-egui/src/i18n/ja.tsv
#	crates/ui-egui/src/i18n/kk.tsv
#	crates/ui-egui/src/i18n/ko.tsv
#	crates/ui-egui/src/i18n/lt.tsv
#	crates/ui-egui/src/i18n/pl.tsv
#	crates/ui-egui/src/i18n/pt-br.tsv
#	crates/ui-egui/src/i18n/ro.tsv
#	crates/ui-egui/src/i18n/ru.tsv
#	crates/ui-egui/src/i18n/sl.tsv
#	crates/ui-egui/src/i18n/sq.tsv
#	crates/ui-egui/src/i18n/te.tsv
#	crates/ui-egui/src/i18n/tr.tsv
#	crates/ui-egui/src/i18n/uk.tsv
#	crates/ui-egui/src/i18n/vi.tsv
#	crates/ui-egui/src/i18n/zh-hans.tsv
#	crates/ui-egui/src/i18n/zh-hant.tsv
# Conflicts:
#	crates/ui-egui/src/canvas/zoom_snap.rs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in #5548 (crypt filters by reference):
- writer.rs: write_full keeps this branch's walk(), which reachable()
  shares, and walk() now also follows the references of a direct
  /Encrypt, as #5548 made write_full do; collect_refs is pub(crate) for
  #5548's encryption_members;
- document.rs re-exports both DECODE_PARMS_KEYS and Held;
- document/tests.rs keeps both sides' new tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Since #5548 cos reads an object that holds only a reference as what it
names. A shared dictionary named through such a chain, and a copy whose
parameters are given through one, can now be decoded, so the run can
store the image again instead of refusing. Both tests now accept either
outcome for those cases and, when the run goes on, check that the
dictionary isn't kept and LEAK_GLOBALS isn't in the saved file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in #5910 (shared JBIG2 dictionaries in Optimize). redact/image.rs
keeps MAX_PIXELS as MAX_REDACTED_IMAGE_PIXELS, now pub(crate) for this
branch's globals.rs, with its doc pointing at without_dictionary, which
replaces without_globals here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This branch removes Document::jbig2_globals_ref, which #5910's test
used. The test now counts streams whose dictionary holds JBIG2Globals in
any spelling, which also covers the arrays and references the old
helper didn't read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	crates/forms/src/read.rs
# Conflicts:
#	crates/engine/src/tests.rs
#	crates/forms/src/values.rs
# Conflicts:
#	crates/ui-egui/src/canvas.rs
#	crates/ui-egui/src/tools/comments/input.rs
# Conflicts:
#	crates/ui-egui/src/canvas.rs
# Conflicts:
#	crates/ui-egui/src/canvas.rs
# Conflicts:
#	crates/ui-egui/src/tools/forms_ui.rs
# Conflicts:
#	crates/ui-egui/src/tools/forms_ui.rs
# Conflicts:
#	crates/ui-egui/src/tools/comments/input.rs
#	crates/ui-egui/src/tools/comments/tests.rs
# Conflicts:
#	crates/ui-egui/src/tools/comments/geometry.rs
#	crates/ui-egui/src/tools/comments/input.rs
#	crates/ui-egui/src/tools/comments/paint.rs
# Conflicts:
#	crates/ui-egui/src/canvas/page_pointer/tests.rs
#	crates/ui-egui/src/tools/comments/paint.rs
# Conflicts:
#	crates/engine/src/lib.rs
main now checks that only listed functions ask which comment lies under a
point. The group lookup is `click_target` (a subordinate stands for its
primary, ISO 32000-2 12.5.6.2), listed with `picked`; a right-click acts on
the comment the router already found instead of looking again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
The search for a signed field's appearance spent the read limit queuing its kids needed, so a long /Kids could hide a signed kid. And a field first reached at the depth limit was never read again from a shorter path, so the fields under it were lost; it is now read again when reached shallower, at most once per depth, within the same limits.
# Conflicts:
#	crates/xfa/src/tests.rs
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Metrics::from_dict decodes up to four streams of a font (its Encoding and
ToUnicode CMaps, its program, its /CIDToGIDMap), each within a limit of
its own but afresh on every call, so a caller reading many fonts that
name one large stream decodes it once per font with no total.

Metrics::from_dict_within charges each of those decodes to the caller's
DecodeBudget, and past_allowance says when one was left unread because
the allowance was spent, so a caller can refuse rather than work from
guessed widths, code lengths or meanings. A stream past its own limit is
left unread as before and doesn't count as past the allowance.
from_dict reads with no total, as before.

embedded_sfnt_within, which the editor's font listing shares one
allowance across, read the metrics that adapt a program's cmap outside
that allowance: their ToUnicode and the program again. It now charges
them, skips a composite font before reading its metrics (it never used
them), and adapts no copy from metrics read past the allowance.

Part of #5755.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
A DecodeBudget bounds what a stream's filter stages make together, and
the first commit passed it each stream's own limit, so a /CIDToGIDMap of
65,536 CIDs behind ASCIIHex and Flate (131,072 bytes, with the hex
stage's output on top) went unread, where a decode without an allowance
bounds each stage alone and read it. Every Metrics::from_dict caller saw
the font as missing those glyphs. The 16 MiB limits on CMaps and
programs had the same edge.

Each stage may now make up to the stream's own limit, as before, and
the allowance pays for all of them; what the stream decodes to stays
within its limit.

Found by the Consumers review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
A /CIDToGIDMap, CMap or program whose /DecodeParms are an indirect
object, or an array holding one, was decoded without them, as on main:
behind a PNG predictor its tag bytes stayed in, so the map read wrong
and the CMap or program not at all. Each of the five metrics reads and
the editor's program read now go through Document::with_direct_parms,
which shares the stream's data and copies its dictionary only when a
parameter is indirect.

Reads::decode's comment now says what the stage bound does: a stage may
make more than the stream's limit where a decode without an allowance
stops it, and a stream past its limit counts as refused when what is
left can't pay for its stages.

Found by the Legal forms review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
Full screen veils a page until its raster is in, and a click on a veiled
page turns it. The test clicked after six frames; on a slow CI runner the
raster wasn't in yet, so the click on the field turned the page. It now
waits (up to 600 frames) until DocView::presentation_settled says the page
is wholly in view.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
# Conflicts:
#	crates/automation/tests/it/main.rs
# Conflicts:
#	crates/annot/src/appearance.rs
# Conflicts:
#	crates/annot/src/appearance.rs
# Conflicts:
#	crates/ui-egui/src/canvas.rs
#	crates/ui-egui/src/canvas/area.rs
#	crates/ui-egui/src/canvas/page_pointer/tests.rs
#	crates/ui-egui/src/tools/forms_ui.rs
The Claims review of fd4b698c7:

- The predictor test's rows are tagged Up, not None: read without the
  predictor, None rows leave only zero bytes, which a CMap's parser
  skips, so its ToUnicode part passed without the fix.
- embedded_sfnt_within's comment and its test's doc gave guessed
  meanings as the reason no copy is adapted; it is the metrics read past
  the allowance, the widths' read of the program again included.
- The full-map test is named for the filters it covers, and the docs of
  Reads::decode, MAX_CMAP_BYTES and the own-limit test carry the
  conditions they hold under.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
# Conflicts:
#	crates/print/README.md
# Conflicts:
#	crates/ui-egui/src/canvas/page_pointer/tests.rs
#	crates/ui-egui/src/tools/forms_ui.rs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
# Conflicts:
#	crates/automation/tests/it/automation.rs
# Conflicts:
#	crates/engine/src/js.rs
# Conflicts:
#	crates/ui-egui/tests/it/main.rs
# Conflicts:
#	crates/xfdf/src/tests.rs
vendor/README.md: main's rows kept; this branch's hayro-interpret patch renumbered (77), as main
now uses (64) and (67); the code comments follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
vendor/README.md: main's rows kept; this branch's hayro-interpret patch is (65), the number its
code comments use, as main now uses (64).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
vendor/README.md conflicted across the vendored rows: taken from main, with
this PR's symbolic-TrueType glyph-name patch added to the hayro-interpret
row as patch (79), after main's highest (75) and the open PRs' 76-78.
Code and test references (true_type.rs, fonts pdf.rs, render tests) say
(79) too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
vendor/README.md: main's rows kept; this branch's hayro-interpret patch is (61), the number its
code comments use, as main now uses (64).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
Conflicts in crates/annot (appearance.rs, info.rs, modify.rs): main added a
text box border colour of its own (`RG` in /DA, `da_border`), oval and
circle borders, per-part opacity and snug circles; this PR draws and
restyles the text in the face /DA, /DR and /DS give (`text_style`).
Both are kept: the text is laid out with the face's widths and centred in
an oval; restyling (colour, size, and now set_border_color) keeps the face,
the font resource and the border colour in /DA (`restyle_text_bordered`).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
vendor/README.md conflicted: taken from main, with this PR's Indexed
lookup patch added to the hayro-interpret row as patch (80), past main's
highest (75) and the numbers open PRs use (76-79). Its code comments
(cache.rs, color.rs, context.rs, interpret/mod.rs, x_object.rs) and the
render tests that name it say (80) too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
crates/ui-egui/src/canvas.rs: main's row_on_screen for the page menu kept, with this branch's
page_context_menu (no menu while presenting). canvas/navigation.rs: main's comment on → / ←
scrolling sideways; command comes from this branch's earlier (command, shift). docs/ui-parity.md:
this branch's status line (the later date) and both revision rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
vendor/README.md conflicted: taken from main, with this PR's View auto
state patch added to the hayro-interpret row as patch (76), the number its
code already uses (main's highest is 75).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
Conflict in crates/engine/src/edit/run.rs: main added the PasteComments
arm next to AddCustomStamp; both are kept, AddCustomStamp with this PR's
text line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
Conflicts in crates/annot/src/lib.rs (exports: main's border colour, border
shape and part opacity setters next to this PR's set_popup) and the comment
tool's input.rs and paint.rs (imports; a double-click now selects with
main's multi-selection `select_only` before opening the pop-up). The
pop-up connector shows for any selected comment (`is_selected`), as main
keeps several selected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
Conflicts: exports and imports (annot info.rs, engine lib.rs, the comment
tool's input.rs and paint.rs) take both sides; automation keeps main's
border-shape and opacity fields in comment_list and this PR's callout
points, and comment_edit's description has main's border colour and
opacity text with this PR's callout text box and arrow point and knee.
Main moved the end of a select gesture into `finish_select_gesture`
(multi-selection moves); this PR's callout handling (a drag on the text
box moves the box alone, a resize uses the box's frame, a reshape goes
through `reshape_for`) now lives there, and `changing_points` keeps main's
batch arm next to this PR's resize arms. In annot's tests this PR's
callout helper is `callout_box_of`, since main now imports
`modify::text_box_of` there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
Main added multi-selection to the comment tool (`selected` plus `also`,
`select_only`, `toggle`, `select_range`, `clear_selection`, one Delete for
all selected); this PR picks a group's member under the pointer
(`member`, `pick`, `target`). Both are kept: main's selection API resets
the picked member, `select` and `pick` go through it, a plain click or a
drag that starts a new selection picks the member, and Delete (key and
menu) takes the picked member alone while its group is the whole
selection, else every selected comment. Exports in annot lib.rs take both
sides; Comment Properties keeps main's typed-text default with this PR's
`tool_for_default` signature; other call sites take main's selection calls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
Export to Word/HTML/PowerPoint/RTF reads a page's text through
reading_lines/reading_blocks, which walks form XObjects without any
notion of a clip. So text a form's /BBox clips away -- never drawn --
was still read and exported, while Find, text extraction and the
renderer already honour it.

Give reading_lines a stack of the active forms' /BBox (page space,
under each form's own matrix): a run whose box falls outside any of
them is not added to the page's lines. Nested forms each check their
own box independently, which is equivalent to intersecting them for
deciding whether a run is outside at least one.

description both say Office export is separate. This covers only a
form's own /BBox, not arbitrary re W n page clips or curved clips,
matching the scope #3628 itself drew for the renderer's own
extraction device.

hostile_form_xobjects_are_read_once used a 9x9 placeholder /BBox while
drawing its own test text far outside it; the fix correctly clips that
away. Widened the box to one that holds the test's text -- the test is
about loop and resource handling, not clipping.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Extends the form-/BBox clip from the previous commit: a page (or a
form) can clip with a plain content-stream re/W/n too (ISO 32000-2
§8.5.4), not only through a form's /BBox. The renderer already honours
both; Office export honoured neither until the previous commit, and
still missed this one.

Ts gains a clip field (the active rectangular clip, page space),
tracked by the existing q/Q save/restore (Ts already saves and
restores through there, so the clip needs no extra plumbing for that).
reading_lines recognizes the re ... W/W* ... n idiom and narrows the
clip when it completes; a form drawn inside an active clip inherits
it, on top of its own /BBox.

text_lines/text_blocks (the interactive editor) call the same
interpret() but consume its Shown records through their own separate
loop in text_lines_in, which never looks at state.clip -- this stays
scoped to reading_lines/reading_blocks (Office export, and whatever
else reads a page the way a reader sees it), not editing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Review of #5075, four findings, each with a test seen failing first:

- A /BBox with anything but four numbers is no bound. filter_map read
  [0 /Bad 0 50 50] as [0 0 50 50], invented a clip and hid real text.
- A clipping path of several rectangles clipped to the last one only,
  hiding text seen through the others. It now clips to the box around
  them all, which holds every one of them, so nothing they show is
  lost; a path with an unreadable rectangle clips nothing.
- Clips in force were each tested on their own, so a run crossing two
  clips that lie apart (a page clip and a form's /BBox, or nested
  forms' /BBoxes) overlapped each and was read though nothing of it
  shows. Clips now intersect.
- A turned clip was reduced to its upright bounding box, whose corners
  it does not cover, so text there was read. A clip is now the convex
  region the rectangle's corners make under its matrix.

So the graphics state's clip becomes a convex Region (page space). A
form's /BBox is intersected into the clip it inherits at its Do (its
content starts with a stack of its own, so it cannot lift it), which
replaces the separate stack of form boxes. Regions intersect by
Sutherland-Hodgman and a run shows when the separating-axis test finds
no gap; a region past 32 corners becomes its bounding box, so a stream
that sets clip after clip stays bounded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Second review of #5075, five findings, each with a case seen failing on
the previous commit:

- A path of several rectangles clipped to the box around them, so text
  in the gap between them was read though nothing of it shows. A clip is
  now the union of its parts, one convex region per rectangle; clips
  intersect part by part (a path of more than 16 keeps the box around
  them, which still holds them all).
- A form's /BBox one point by 1e-10 fell under a fixed area threshold
  and was taken for no box. Whether a region has area is now its area
  against the square of its perimeter, the same at any scale.
- At 1e12 points from the origin the shoelace products lost the area to
  rounding, and the box was dropped. The area is measured from the
  first corner.
- Clips meeting only along an edge intersected to a region of no area
  that a run across the edge still showed through; an intersection with
  no area, or with an empty region on either side, is now empty.
- A clip of no area was taken for no clip, and everything under it was
  read. It has no inside (ISO 32000-2 8.5.4), so it now hides all; only
  a clip whose corners or measure are not finite is left unread.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	crates/automation/tests/it/automation.rs
# Conflicts:
#	crates/automation/tests/it/automation.rs
# Conflicts:
#	Cargo.lock
#	crates/redact/Cargo.toml
#	crates/redact/src/interp.rs
#	crates/redact/src/lib.rs
When a redaction removes or replaces an image or form under a mark, it
takes the name out of the page's resources unless the page still draws
it. It looked only at the page's own `Do` operators. A form with no
/Resources of its own draws through the page's, so when such a form
outside the mark drew the same image, the page lost the name and the
form drew nothing there.

The page now keeps a name that a form drawing through its resources
still draws, found as the cleanup of the fonts and properties these
forms use already finds them. As that cleanup does, it refuses the page
when such a form can't be read. On a page with fonts or properties to
clean up that was already the case; on a page with neither, the name
used to go.

A rewritten form had the same fault one level down. It dropped the names
it replaced before `prune_form_resources` ran. That function already
keeps what a form drawing through these resources still draws, so the
rewritten form now leaves the decision to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
A form whose /Resources is null, or a reference to an object that isn't
there, has no resources of its own. The interpreter, the walk the
cleanup uses and the Type 3 cleanup all take it so. `content_draws` took
it as having its own. It decides at the end of a redaction whether a
removed image is still drawn through a page's or a surviving form's
resources, so it dropped the name the page had just kept for such a
form, and the form drew nothing there.

The page's check refuses a page whose content it can't read in full.
That includes bytes the parser can't read in a stream the redaction
leaves as it was, for Remove Hidden Information as for Apply
Redactions. Tests now pin that, and the error no longer calls the
content "property resources".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
A form with resources of its own draws through those, so neither check
of what is still drawn may follow it: the page's (prune) and the
unlinking one at the end of a redaction (content_draws). Nothing pinned
either. A test now has a form on another page naming a different image
the same way, and the redacted image still leaves the dictionary both
pages inherit, which pins the unlinking check. Another calls the page's
check directly with such a form, since in a whole redaction the
unlinking check would drop the name anyway.

The walk of what content draws through its resources refuses content
of more than 2,000,000 operators, counting those of the forms it
follows. A test now pins that boundary for both counts.

Remove Hidden Information keeps a name that a form without resources,
left as it was, still draws, as Apply Redactions does. A test now pins
that with hidden text, which a form loses or keeps depending on where
it is drawn.

The doc comment of prune now says when it refuses, with examples of
what Stream::decoded refuses, and that corrupt data in such a form is
read up to the damage, which may be nothing, not refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
To tell which resources content still draws, redaction reads the forms
it follows, and the Type 3 glyph programs, with `Stream::decoded`, which
reads `/DecodeParms` only when direct. Behind a predictor whose
parameters are an indirect object, a form read as the predictor's tag
bytes and row differences instead of its operators. So:

- the check of what a page still draws (`resource_ops`), the unlinking
  check (`content_draws`) and the unlinking from surviving forms'
  resources (`unlink_from_forms`) didn't see such a form draw an image
  again, and the image went from resources it is still drawn through;
- the Type 3 cleanup didn't see such a form, drawn or not, show a glyph,
  and dropped the glyph's program;
- it refused the redaction over a glyph program behind such a predictor,
  which it couldn't parse.

All six reads now go through `Document::with_direct_parms`. Each has a
test, the form or glyph behind a predictor whose parameters are given by
reference, that fails with that read reverted:
- an_image_a_form_with_parameters_by_reference_draws_again_is_kept
  (`resource_ops` and `content_draws`);
- an_image_a_surviving_form_with_parameters_by_reference_draws_is_kept;
- a_type3_glyph_a_form_with_parameters_by_reference_shows_is_kept;
- a_type3_glyph_an_undrawn_form_with_parameters_by_reference_shows_is_kept;
- an_image_a_type3_glyph_with_parameters_by_reference_draws_is_kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
The walk of the forms that content draws through its resources
(`resource_ops`) decoded each one outside the page's decode allowance:
up to 1 GiB a form, read again by each cleanup that walks them, on every
page. The interpreter skips a form outside the marks, so for such a form
this walk is the only read.

`resource_ops` now charges the bytes it decodes to the `DecodeBudget` it
is given. The content walk hands back what it left of the page's
allowance (`Scope::into_budget`), and the cleanups after it spend that:
the check of what the page still draws (`prune`), the properties and the
Type 3 fonts, in Apply Redactions and in Remove Hidden Information. A
rewritten form's cleanups spend the walk's allowance as they go. The
unlinking pass's check of the properties, which walks every page that
shares a resource dictionary, gives each page's content an allowance of
its own.

Only bytes are charged, not form entries: each cleanup reads the same
forms again, so a page drawing many small forms would run out of entries
having decoded little.

Tests, each failing with the charge reverted, and the last three with
their own part of it reverted (the hand-back on either page, the
rewritten form's cleanups):
- what_content_draws_through_its_resources_is_read_within_its_allowance
  (a 2,000-byte form read with 2,000 bytes left, refused with 1,999);
- the_cleanups_after_the_content_walk_spend_what_it_left_of_the_pages_allowance;
- the_cleanups_of_a_rewritten_form_spend_the_pages_allowance;
- removing_hidden_text_spends_what_the_content_walk_left_of_the_pages_allowance.
In each of the last three a form without resources of its own is read
three times: refused at 100 MiB, more than the page's 256 MiB, and not
at 50 MiB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
On a page, the check of which names the rewritten content still draws
(`prune`) ran before the rewritten XObjects were added to the page's
/XObject dictionary, so it couldn't follow a rewritten form under its
new name. A rewritten form without resources of its own gets a copy of
the page's, pruned to what it draws, and draws through that. It keeps
none of its own only when the copy loses nothing, which takes a form
that draws itself, here outside the mark. Such a form draws through the
page's resources, and the page lost the names it draws there: the
original form, and an image under the mark that it draws again outside
it through an unchanged form.

The page now installs them first, as a rewritten form does with the
forms it rewrites. Remove Hidden Information's page does the same. There
every form is entered, so a form that draws itself is followed to the
depth limit and every copy above it keeps resources of its own (measured
with such a form); its order changes so that the two read alike.

Test an_image_a_rewritten_form_draws_again_through_the_pages_resources_is_kept
fails with Apply Redactions' old order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
the_cleanups_of_a_rewritten_form_spend_the_pages_allowance passed with
prune_form_resources doing nothing (cargo-mutants). The rewritten form
then kept its caller's resources unchanged, had none of its own, and the
page's check read the large form a third time through it. The rewritten
form now has resources of its own, which the page's cleanups don't
follow, so the three reads are the walk's and the rewritten form's two
cleanups'. It fails with prune_form_resources doing nothing, and with
each of the rewritten form's cleanups given an allowance of its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
resource_ops parsed the content and each form it follows whole before
checking its 2,000,000-operator limit, and content_draws parsed whole
streams with no limit at all. A parsed operator takes about 120 bytes,
and a form of `n` operators outside the mark cost 78 to 138 bytes of
memory per byte of content. Measured as the growth of the process's
peak: 629 MiB at 8 MiB, 1,253 MiB at 16 MiB and 4,415 MiB at 32 MiB, so
tens of GiB at the 256 MiB the page's allowance admits, from a file of
a few hundred KiB. The page's check of what it still
draws (prune), added in this branch, reads such forms on pages with no
fonts or properties too. On main the font and property cleanups read
them, and the unlinking check (content_draws) on any page that shares a
dictionary with a removed image, so the content_draws half fixes what
main does too.

Both now read with pdfcraft_content::ops, one operator at a time.
resource_ops holds at most one past its limit, and content_draws holds
no operators, as it only looks for Do. Measured as the peak resident memory of
the whole process (dev profile): the page's check over a form of 8, 16
and 64 MiB of `n` takes 311, 327 and 422 MiB, and the unlinking check
over one of 16 MiB takes 69 MiB, against 1,269 before (2,527 MiB at
32 MiB).

This bounds the operators held, not their operands: the parser still
holds the operands that no operator ends, so content of operands alone
costs about 30 bytes per byte (974 MiB for 32 MiB of `0`). Bounding
those is pdfcraft-content's, for every reader of content.

It also drops resource_ops' check that it followed no more than
2,000,000 forms. Each form it follows is a Do among the operators it
holds, which the operator limit already bounds, so that check could
never fail; cargo-mutants found it untested.

Test the_forms_followed_through_resources_are_read_one_operator_at_a_time
(tests/operator_memory.rs, a binary of its own) measures how much the
process's peak grows over 32 MiB of `n` operators through each check, on
Linux, and fails with either read reverted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
# Conflicts:
#	crates/automation/tests/it/automation.rs
The walk read a form it enters, one whose box crosses a mark, with
DecodeBudget::decode_strict, which reads /DecodeParms only when direct.
Behind a predictor whose parameters are an indirect object, the form
read as the predictor's tag bytes and row differences. The walk found
nothing under the mark in it and left it as it was, still drawing what
lay under the mark: on main, text there stayed, and so did an image
only the form drew. Once the page's check read such a form with its
parameters (f227d63ab), an image leaked too when the page drew it under
the mark as well: the check saw the form draw it and kept the page's
name for it, where before it took the name away.

The walk now reads its forms through Document::with_direct_parms, as
the cleanups after it do. The page's own streams, and the redaction
proof's reads (verify/util.rs), still take /DecodeParms only when
direct, as on main; #6289 changes the page's.

Test what_a_form_with_parameters_by_reference_draws_under_the_mark_is_removed,
with text, and with an image drawn under the mark by the page and the
form and by the form alone, fails with the read reverted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
The Type 3 cleanup at the end of a redaction (unlink_type3) scans every
page and every form it reaches for the glyphs they show, and reads the
programs of the glyphs it keeps for the resources they use. Both parsed
each stream whole before checking for bytes they couldn't read, outside
the page's allowance and up to 1 GiB a stream. So a form with resources
of its own outside the mark, which no other reader follows, cost about
79 bytes of memory per byte of content: the peak grew by 632 MiB for
8 MiB of `n` operators. On main too, wherever a Type 3 font survives in
a page's or a form's resources.

Both now read with pdfcraft_content::ops, one operator at a time, and
refuse for unreadable bytes once they have read the whole stream, as
before. The scan only reads, and either error refuses the whole
operation, so acting on operators before that check changes nothing.

The memory test gains a case for each:
the_forms_followed_through_resources_are_read_one_operator_at_a_time
fails with either read reverted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
The Type 3 cleanup refuses a redaction when its scan of a page or form,
or its read of a kept glyph's program, meets bytes the parser can't
read. Nothing tested either refusal: cargo-mutants found both checks
could be dropped with every test passing, once 8bf63c879 moved them
after the loop.

unreadable_bytes_in_a_form_the_type3_cleanup_scans_refuse_the_redaction
leaves a string open in a form only that scan reads (resources of its
own, outside the mark), hiding the glyph the form shows after it, and
unreadable_bytes_in_a_kept_type3_glyph_program_refuse_the_redaction
leaves one open in the program of the glyph the page keeps, hiding the
image it draws. Each fails with its check disabled; with the checks off,
the glyph loses its program and the font its image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
The two tests of a content stream the redaction leaves as it was, holding bytes the parser can't
read, accepted any syntax error. They now take only the refusal of the check of what content
still draws through its resources, so a refusal from another check, or with other wording,
fails them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
The editor's read of a font program bounded all its filter stages together by the program's
16 MiB limit, as main does: a 12 MiB program behind ASCIIHex and Flate, its Flate data about
8 MiB, was left unread, though behind Flate alone it reads. It now takes the limit once for each
stage, as the metrics reads do, which the stages share and any one of them may use, and then
holds the program itself to 16 MiB. Behind eight filters a read may so decode up to 128 MiB,
where main stopped at 16 MiB.

Once the allowance is spent, the metrics reads refuse a stream with data, and the editor's
program read passes a program over, before resolving its /DecodeParms, as the decode itself
would refuse it, so each font left doesn't pay for that work the allowance doesn't count. What
is read and what is refused are as before, an empty stream still read rather than refused.

Tests: an_editors_program_reads_behind_ascii_hex_and_flate_within_its_limit, and
an_empty_stream_reads_with_nothing_left.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
The glyph list specification (ISO 32000-2 §9.10.2) writes the hex digits
of its `uniXXXX` and `uXXXX`-`uXXXXXX` forms in uppercase, `uni` taking
groups of exactly four and `u` four to six, outside the surrogate range.
`glyph_name_text` (hayro-interpret patch 79) took lowercase digits too, so
a glyph named `uni1eae` read as U+1EAE; it now names nothing and the code
reads as WinAnsi, as for any glyph without a name. The editor's reading of
the same `post` names in pdfcraft-fonts applies the same rule, so both
agree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
# Conflicts:
#	crates/automation/src/tools.rs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
# Conflicts:
#	crates/ui-egui/src/canvas.rs
#	crates/ui-egui/src/canvas/page_input.rs
The I-beam reads whether a press would go to a link from the hover hit
(main); link_ui::hit already tests a link's QuadPoints (xf.link_contains),
so the text layer's own QuadPoints check is dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
A page full screen doesn't show yet still takes nothing, with main's
right_click_on predicate; full screen still opens no page menu
(page_context_menu) around main's menu_for-driven menu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
Full screen takes no press on a page whose raster is not in yet; under
load six frames were not always enough, and the double-click turned the
page instead of opening the note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
# Conflicts:
#	crates/engine/src/open.rs
# Conflicts:
#	crates/ui-egui/src/canvas/page_input.rs
# Conflicts:
#	crates/ui-egui/src/editing.rs
Main's signature-state redraw in apply_edit pushed the function over the
cognitive-complexity limit with the pop-up exception inline; the rule moves
into one_page_changed unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
Main's page-pointer guard (NOT_PAGE_DRAGS) refuses any-button drag starts in
page modules; a right-button drag on a pop-up's bar no longer moves it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Keeps both the comment pop-up test and main's ungroup tests in
automation.rs. Carries the shared fix for main's rules.rs build break
(content_streams now returns ContentStream; join via text::joined).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
Resolve the conflicts in crates/engine/src/lib.rs (keep both the
redaction-identification and user-regex re-exports) and Cargo.lock
(main's, plus the redact dependencies). Drop the branch's copy of the
test helper pdf_of, which main now defines identically. Fix main's
pdfcraft-edit build: rules.rs joins its content streams through
crate::text::joined now that content_streams returns ContentStream.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
Conflict: crates/automation/tests/it/automation.rs (kept the group test and main's ungroup tests).
Also carries the one-line fix for main's crates/edit/src/rules.rs (content_streams() now returns
ContentStream): page_rules joins its streams with crate::text::joined.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
Keeps both the callout-parts test and main's ungroup tests in
automation.rs. Carries the shared fix for main's rules.rs build break
(content_streams now returns ContentStream; join via text::joined).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
Main's OCR Verify screen walks the words to review with F8 and Shift+F8;
the registry's view.toggle_interface shortcut now skips while that screen
is open, so both keep working after the merge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
# Conflicts:
#	crates/engine/src/session/editing.rs
# Conflicts:
#	crates/engine/src/edit/run.rs
# Conflicts:
#	crates/annot/src/info.rs
# Conflicts:
#	crates/annot/src/appearance.rs
#	crates/annot/src/draw.rs
view.render-annotation-appearances: main now draws Thai in text boxes from the Thai face
(Type 3 glyphs beside Helvetica). A text box keeps the standard face its /DA or /DS names for
the rest of its text: lines wrap and are measured in that face with Thai shaped
(appearance::text_width_in), its font resource sits beside the Thai fonts, and after a Thai run
the text goes back to that face rather than /Helv. set_contents keeps both main's Thai refusal
and the drawn style, and fitted_box sizes the box to the Thai ink in the drawn face.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
# Conflicts:
#	crates/automation/src/tools.rs
# Conflicts:
#	crates/automation/src/tools.rs
# Conflicts:
#	crates/ui-egui/src/app/input.rs
# Conflicts:
#	crates/engine/src/xfa.rs
#	crates/xfa/src/parse.rs
# Conflicts:
#	crates/xfa/src/layout/pages.rs
#	parity/acrobat-features.toml
# Conflicts:
#	parity/acrobat-features.toml
# Conflicts:
#	crates/ui-egui/src/canvas/navigation.rs
# Conflicts:
#	crates/xfa/src/lib.rs
#	parity/acrobat-features.toml
# Conflicts:
#	crates/render/src/inspect/core.rs
# Conflicts:
#	parity/acrobat-features.toml
Pass main's SOM scope to draw_value_text and keep the image href pattern.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
Keeps main's recovery renewal after applying redactions alongside the
combined notification, and reads XMP and rich-text streams with their
/DecodeParms given by reference made direct, as main's stream readers do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
Union main's embedded-field exports and data_path import with the page-area ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
Keep collect_names beside main's template-based collect_values and embed helpers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
crates/engine/src/print_raster.rs: the print raster keeps `for_print` (the View
auto state is not applied when printing) and main's Print-event layer states.
vendor/README.md: hayro-interpret patch (76) and its tests follow main's new
patch (89); main's hayro (31) and hayro-jbig2 (17) entries are kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
Main's #6595 made a soft hyphen invisible and a line ending in one run on, so
the line-end hyphen readings now cover HYPHEN-MINUS and HYPHEN only, pass over
invisible characters, and keep main's zero width space word boundaries in each
reading. The reference oracle combines both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
Conflict: layout/flow.rs imports take both MAX_INSTANCES (this PR) and
MAX_MEASURES (main). Main's new check-box width arm in measure() now
passes the &Ctx this PR's per-instance measure takes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
Keeps main's vendor ledger and adds hayro-interpret patch (79) to it; the
symbolic TrueType program is read through doc.with_direct_parms, as main now
reads every font stream.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
Main's DataOp::Value now replaces a multi-select list's value-group picks in the
packet and in the data copy, so the reset writes through it and the separate
DataOp::Reset (and Want::picks) is dropped. The reset keeps the PR's indexed
targets and its pass over fields without widgets, beside main's floating-field
pass (which the unshown pass leaves embedded fields to), and finish() lays out
again for either an owed relayout or a changed embedded value. Parity notes and
evidence take both sides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
Parity: main's floating-field evidence and notes with the caption-font test and wording.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
Keeps main's vendor ledger with hayro-interpret patch (80) added, and both
sides' test modules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
Keeps main's vendor ledger with hayro-interpret patch (61) added, main's
BlendMode import beside the patch's Point and Cow in path.rs, and both sides'
stroke test modules (main's stroke_degenerate_cubic and stroke_zero_end_tangent
fix vello_common's stroker; this patch shapes subpaths in hayro-interpret).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
Keeps main's vendor ledger with hayro-interpret patch (65) added, and main's
BlendMode import beside the patch's kurbo imports in path.rs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
Keeps main's vendor ledger with hayro-interpret patch (77) added, and both
sides' render test modules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
Conflict in parity/acrobat-features.toml (organize.initial-view): the
evidence list is the union of both sides (main's 25%/100% magnification
tests and the branch's viewer_prefs tests); the notes keep main's text
with the branch's HideToolbar/FitWindow sentence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
Main's Replace Text (#5689) makes the caret the group's primary, as this
branch does. Kept: this branch's member-by-member deletion and release of
orphaned subordinates, with main's seed() (the strikeout also seeds its
caret) and its pair restyle; the writer returns the caret (the group's
comment) and leaves the strikeout without /Contents. Main's tests read the
strikeout from the caret's group instead of its replies, and the automation
test reads the strikeout's own colour from an XFDF export, since
comment_list gives a group its primary's attributes only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
Conflicts: xfa Template keeps main's embedded/embedded_paths and this
PR's cache (parse() sets all three); engine xfa.rs tests keep both
sides' tests; xfa README combines main's sheet numbers with this PR's
property objects; parity form.xfa-dynamic keeps this PR's notes plus
main's floating-field sentences and both sides' evidence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
# Conflicts:
#	crates/render/src/inspect/core.rs
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Keeps main's vendor ledger (now with patches 82-102) and adds hayro-interpret
patch (61) to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
Keeps main's vendor ledger (now with patches 82-102) with hayro-interpret
patch (65) added, and both sides' render test modules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Keeps main's vendor ledger (now with patches 82-102) with hayro-interpret
patch (77) added, and both sides' render test modules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
Main's #6569 (hayro-interpret patch 101) reads a symbolic TrueType font that
names no glyphs and keeps the Macintosh order as Mac OS Roman; this branch's
patch (79) reads one that names its glyphs by those names. Both are kept: the
renderer struct holds both fields, and the editor's unicode_map applies the
glyph-name reading only to a font whose post table names glyphs, then the Mac
OS Roman one, so each font takes the reading the renderer gives it. The ledger
keeps main's rows with (79) added; the editor reuses descriptor_flags and
MAX_SFNT.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
Main's #6663 added InterpreterWarning/RenderWarning::Type3WidthsMissing (patch
90) and a decoded-image cache that records warnings as flags (patch 91). Both
warning variants are kept in every match (renderer, automation, CLI), and
MalformedIndexedImage takes flag 32 in warning_bit/warnings_in. The ledger
keeps main's rows with (80) added; both sides' raster test modules are kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
Main moved a link's action reading into Inspector::action_target, shared
with bookmarks, and made the link/attachment text budget start with the
outline. Resolution:
- annotations.rs: link() calls action_target and then resolves a URI
  target against /URI /Base, spending what the base adds from the same
  budget (or keeping the raw address); QuadPoints reading unchanged.
  The budget doc comment covers bookmarks, links, quads and attachments.
- core.rs: keeps the branch's uri_base read; the outline goes through
  main's budgeted outline_window (the branch's old call is dropped).
- tools.rs: link_list keeps main's "to_page with its zoom" and the
  branch's resolved_url / active_areas text.
relative_uri_actions_resolve_against_the_catalog_base re-proven red with
the resolution reverted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
values.rs: keep the position-tracking choice matching and match each value
with main's option_named (export value first, then display text).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
xfa data.rs: keep main's fields_with_ids re-export and the PR's radio
value re-exports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
Clean merge; brings in main's fix for the ui-egui dialog and security-notice
tests that failed this branch's gate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
parity: keep main's form.xfa-layout notes and evidence, with the
prototype sentence and tests added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
Clean merge; brings in main's fix for the ui-egui dialog and security-notice
tests that failed this branch's gate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
Clean merge; brings in main's fix for the ui-egui dialog and security-notice
tests that failed this branch's gate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
# Conflicts:
#	crates/a11y/src/autotag.rs
Keeps main's Callout font size preference with the shared callout_knee(),
main's on_text() beside the callout box resizing, and both new inspector
helpers.
Main's new engine test builds a pdfcraft_forms::Field literal; give it
the selected and single_value_is_array fields this branch adds.
Main took hayro-interpret patch (76) for non-embedded Arial and Times New Roman
CIDs, so the View auto state becomes patch (103) in the ledger and the code
comments. The view.layers-panel note no longer lists the Print event's auto
state as missing: main applies it when printing (print.print-ready-pdf).
Take main's consumed Shift+Ctrl first/last-page shortcuts and go_to_end, keeping
this branch's early return after them and its Shift+Enter back in full screen.
The view.find parity entry keeps main's new notes and evidence (combining
marks and Thai or Indic word gaps, #6517) with this branch's ligature and
line-end hyphen sentence and tests added.
props: a text box reports its drawn text style, other comments main's drawn_color
(grey, CMYK). Parity: main's evidence and notes with this branch's text-box and
redaction-outline sentences and tests.
Conflict: xfa layout.rs keeps main's MAX_DRAW_CHARS draw-phase budget
and this PR's MAX_INSTANCES of 1,000 in place of main's 50.
crates/forms/src/values.rs: the read imports take both listed_indices and
selected_options (list selection) and main's listing_fields_named (radio
fields sharing a name).
# Conflicts:
#	crates/xfa/src/layout/pages.rs
# Conflicts:
#	crates/ui-egui/src/app.rs
crates/render/Cargo.toml and Cargo.lock: pdfcraft-render takes both url (the
/URI /Base resolution) and main's unicode-bidi.
# Conflicts:
#	crates/automation/src/tools.rs
# Conflicts:
#	crates/automation/src/tools.rs
Moves the egui crates from 6d518fc7 to the tip of the fork's
migration/egui-037-20261024 line. It adds two patches:

- E22: the fallback JavaScript highlighter no longer colours
  TypeScript-only keywords (storytold/egui#24).
- E23: dragging a Resize corner keeps a direction that isn't
  resizable (storytold/egui#25).

The rest is lint and PATCHES.md only. Default fonts are unchanged,
so the egui-icons provenance link only moves to the new rev. The
vendor graph test and vendor/README.md follow the pin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	crates/redact/src/tests.rs
# Conflicts:
#	crates/ui-egui/src/tools/forms_ui.rs
# Conflicts:
#	crates/ui-egui/tests/it/view.rs
#	docs/ui-parity.md
# Conflicts:
#	crates/automation/src/tools.rs
#	crates/automation/tests/it/automation.rs
#	crates/forms/src/lib.rs
#	crates/forms/src/tests.rs
#	crates/forms/src/values.rs
#	crates/ui-egui/src/tools/forms_ui.rs
#	crates/ui-egui/tests/it/forms.rs
#	parity/acrobat-features.toml
# Conflicts:
#	crates/automation/src/redact.rs
Main reworked the redaction cleanups this branch changes: #5969 reads
each inherited form once for all names (`names_content_draws`), #5924
prunes the forms a surviving Type 3 glyph draws and passes the page to
`prune_type3_from_resources`, and #6474 reads a page's annotation
appearances toward one operator total and one byte allowance.

- `interp/prune.rs`: `names_content_draws` keeps main's one read per
  form and takes this branch's three changes to `content_draws`: it
  reads operators one at a time, holding no stream's whole and decoding
  each form only in its turn; it reads forms with `/DecodeParms` given
  by reference; and a `/Resources` that isn't a dictionary is none of
  the form's own. It charges no allowance, as `content_draws` didn't:
  it runs after the pages' walks, over every holder, where no page's
  allowance is left to spend.
- `type3.rs`: `prune_type3_from_resources` takes both the page's
  allowance and main's `page`. The appearances main reads are charged
  to that allowance, which the content and its forms spend too, in
  place of a fresh one of their own, so one page has one allowance.
  Main's `record_used` serves this branch's one-operator-at-a-time read
  of glyph programs, and `scan_stream_object`, which took over
  `scan_xobject`'s read, reads with `/DecodeParms` by reference.
  `prune_font_forms` gives each form's walk the allowance of a page, as
  `prune_retired_properties` does for each content.
- `interp.rs`, `lib.rs`, `sanitize.rs`: both arguments at each call.
- `edit/src/rules.rs`: the refusal of incomplete content stays, with
  main's `text::joined`.
- Tests: both sides' kept, in `edit/src/tests.rs` and
  `redact/src/tests/surviving_references.rs`. A new one,
  `a_page_s_appearances_spend_what_its_content_left_of_the_allowance`,
  pins the shared allowance: 1 MiB of content and an appearance within
  half a MiB of the allowance are refused together, though the
  appearance alone fits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
# Conflicts:
#	crates/render/src/text/search.rs
#	crates/render/tests/it/search.rs
# Conflicts:
#	crates/print/src/tests.rs
# Conflicts:
#	crates/xfa/src/lib.rs
#	crates/xfa/src/model.rs
#	crates/xfa/src/tests.rs
# Conflicts:
#	crates/automation/src/tools.rs
#	crates/automation/tests/it/automation.rs
#	crates/ui-egui/tests/it/document_links.rs
# Conflicts:
#	crates/automation/src/tools.rs
#	crates/automation/tests/it/automation.rs
#	crates/xfa/src/tests.rs
#	parity/acrobat-features.toml
Conflicts, and how they were resolved:

- cos writer.rs and lib.rs: main now numbers a full save in `full_save_order` and exports
  `reachable_objects`, the object numbers a full save writes, for the pattern guard. This
  branch had added the same walk as `walk` and `reachable`. Main's is kept, with its alias
  handling, and this branch's is dropped: `globals::kept` is now `reachable_objects`, so the
  JBIG2 cleanup and the pattern guard read one walk. `kept` returns the walk's error (a save
  that can't number its objects), which redaction and Remove Hidden Information pass on. The
  cos test of what is reachable, and two redaction tests, read `reachable_objects` instead.
  The exports keep this branch's `DECODE_PARMS_KEYS` and main's `TextChars`,
  `civil_from_days` and `new_file_id`.
- redact lib.rs: main unlinks pruned patterns from `/Pages` nodes after `unlink_doomed`; this
  branch replaced `restore_shared_jbig2` with `globals::unshare`. Both stay, the unlink first:
  an image a pruned pattern's cell drew leaves the file with it, and is a user of a dictionary
  that left too.
- redact sanitize.rs: main's `strip_outline_actions` and this branch's `unshare_unmarked` both
  stay. At the end of `remove_hidden`, `unshare_unmarked` runs after main's `unlink_undrawn`,
  so an image unlinked for being drawn nowhere takes a shared dictionary with it, and is
  followed by main's `require_full_save_with_new_id`.
- redact image.rs: `MAX_PIXELS` is `pub(crate)` on both sides; the comment names
  `globals::without_dictionary`, where this branch moved `without_globals`.

Tests: `an_undrawn_image_unlinked_takes_a_shared_jbig2_dictionary_too` (Remove Hidden
Information, through `unlink_undrawn`) and
`an_image_a_pruned_patterns_cell_drew_takes_a_shared_jbig2_dictionary_too` (a redaction,
through `unlink_pruned_patterns`) pin the order of the two new routes before `unshare`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
main's Thai work (#6249, #3492) and the Type 3 and symbolic TrueType
metrics changed the code this PR bounds. Each conflict, and each read main
added that this PR's allowance must cover:

- crates/fonts/src/pdf.rs, the editor's program read: this PR's read is
  kept, its stage-scaled limit and its check for an allowance already
  spent; main's read had /DecodeParms by reference too, which it has.
- The editor's loop: main rebuilds a cmap for a composite subset without
  one from its ToUnicode and /CIDToGIDMap, where this PR skipped Type 0.
  main's composite support stays, with the metrics read within the
  editor's allowance (`from_dict_within`) and no copy adapted from metrics
  read past it, as for a simple font. The /CIDToGIDMap the cmap is rebuilt
  from (`composite_glyph_maps`) reads as the font's other streams do
  (`Reads::decode`): within the allowance, behind several filters too.
- Reads main added to the metrics read: a Type 3 font's glyph
  descriptions (`type3_glyph_box`), and the program read that tells a
  symbolic TrueType font's codes as Mac OS Roman
  (`symbolic_mac_roman_texts`). Both are now paid for from the allowance
  the metrics are read within, and a refusal sets `past_allowance`. The
  glyph descriptions keep their own limits (64 KiB each, 4 MiB a font).
- Constants and helpers both sides added beside one another, and the
  tests both appended, are all kept. Callers main added of
  `unicode_map` pass a reading without a total, as `from_dict` does.
- crates/edit/src/rules.rs: the refusal of a page whose content didn't
  all decode stays, and the operators come from main's `text::joined`.

Tests: `adapting_a_programs_cmap_is_paid_for` now expects a composite
font's ToUnicode, and a simple font's program read again for the Mac OS
Roman reading, to be paid for. Four are new:
`adapting_a_composite_subsets_cmap_is_paid_for`,
`a_full_cid_map_rebuilds_a_composite_subsets_cmap_behind_two_filters`,
`type3_glyph_boxes_are_paid_for_from_the_allowance` and
`the_symbolic_mac_roman_reading_is_paid_for`. The fixture of main's test
of a composite subset without a cmap moves into `cmapless_composite`,
which both use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
# Conflicts:
#	crates/automation/src/tools.rs
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
# Conflicts:
#	crates/xfa/src/layout/flow.rs
#	parity/acrobat-features.toml
# Conflicts:
#	crates/engine/src/edit/run.rs
# Conflicts:
#	crates/ui-egui/src/app.rs
#	crates/xfa/src/lib.rs
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
# Conflicts:
#	vendor/README.md
# Conflicts:
#	crates/automation/src/comments.rs
#	crates/automation/tests/it/automation.rs
#	crates/render/src/inspect.rs
#	crates/render/src/inspect/annotations.rs
#	crates/render/src/lib.rs
#	crates/ui-egui/src/panels/comments_panel.rs
# Conflicts:
#	crates/automation/src/tools.rs
#	crates/forms/src/lib.rs
#	crates/forms/src/tests.rs
#	crates/forms/src/values.rs
#	crates/ui-egui/src/tools/forms_ui.rs
#	crates/ui-egui/tests/it/forms.rs
# Conflicts:
#	crates/automation/tests/it/automation.rs
#	crates/edit/src/lib.rs
#	crates/edit/src/tests.rs
#	crates/engine/src/commands.rs
#	crates/engine/src/lib.rs
#	crates/optimize/tests/optimize.rs
#	crates/ui-egui/src/app.rs
#	crates/ui-egui/src/dialogs.rs
#	crates/ui-egui/src/lib.rs
#	crates/ui-egui/src/state.rs
#	crates/ui-egui/tests/it/editing.rs
# Conflicts:
#	vendor/README.md
# Conflicts:
#	crates/xfa/src/layout.rs
#	crates/xfa/src/pdf/strip.rs
#	parity/acrobat-features.toml
# Conflicts:
#	crates/annot/src/tests.rs
# Conflicts:
#	crates/ui-egui/src/tools/forms_ui.rs
#	crates/ui-egui/tests/it/forms.rs
#	parity/acrobat-features.toml
# Conflicts:
#	crates/render/src/inspect.rs
#	crates/render/src/lib.rs
#	vendor/README.md
# Conflicts:
#	vendor/README.md
# Conflicts:
#	crates/ui-egui/src/app/files.rs
#	crates/ui-egui/src/canvas/bars.rs
#	crates/ui-egui/src/editing/save.rs
# Conflicts:
#	crates/annot/src/info.rs
#	crates/annot/src/modify.rs
#	crates/annot/src/tests.rs
#	crates/automation/src/tools.rs
#	crates/automation/tests/it/automation.rs
#	crates/ui-egui/tests/it/comments.rs
# Conflicts:
#	crates/ui-egui/src/canvas/page_input.rs
# Conflicts:
#	crates/engine/src/edit/run.rs
#	crates/engine/src/js.rs
#	crates/ui-egui/src/editing.rs
#	crates/ui-egui/src/tools/forms_ui.rs
#	parity/acrobat-features.toml
# Conflicts:
#	vendor/README.md
#	vendor/hayro-interpret/src/interpret/path.rs
# Conflicts:
#	vendor/README.md
# Conflicts:
#	crates/ui-egui/src/canvas/tests.rs
# Conflicts:
#	crates/a11y/src/autotag.rs
# Conflicts:
#	crates/engine/src/js.rs
#	crates/engine/src/tests.rs
#	crates/xfa/src/fixtures.rs
#	parity/acrobat-features.toml
# Conflicts:
#	crates/ui-egui/src/canvas/selection.rs
# Conflicts:
#	crates/edit/src/text/lines.rs
# Conflicts:
#	crates/ui-egui/src/canvas/find.rs
#	crates/ui-egui/src/canvas/tests.rs
# Conflicts:
#	crates/print/src/tests.rs
# Conflicts:
#	ROADMAP.md
#	crates/annot/src/info.rs
#	crates/annot/src/lib.rs
#	crates/annot/src/modify.rs
#	crates/annot/src/tests.rs
#	crates/automation/src/comments.rs
#	crates/automation/tests/it/automation.rs
#	crates/engine/src/comments.rs
#	crates/render/src/inspect.rs
#	crates/render/src/inspect/annotations.rs
#	crates/render/src/inspect/model.rs
#	crates/render/src/inspect/tests.rs
#	crates/render/src/lib.rs
#	crates/ui-egui/src/tools/comments/input.rs
#	crates/ui-egui/src/tools/comments/paint.rs
#	crates/ui-egui/src/tools/comments/tests.rs
#	crates/ui-egui/tests/it/comments.rs
# Conflicts:
#	crates/xfa/src/layout/draw.rs
#	crates/xfa/src/layout/field.rs
#	crates/xfa/src/layout/items.rs
#	parity/acrobat-features.toml
# Conflicts:
#	crates/engine/src/commands.rs
#	crates/ui-egui/src/commands.rs
# Conflicts:
#	ROADMAP.md
#	crates/annot/src/lib.rs
# Conflicts:
#	parity/acrobat-features.toml
# Conflicts:
#	crates/render/src/inspect.rs
#	crates/render/src/lib.rs
# Conflicts:
#	crates/xfa/src/layout/measure.rs
#	parity/acrobat-features.toml
# Conflicts:
#	crates/automation/src/dispatch.rs
#	crates/automation/src/json.rs
#	crates/automation/src/tools.rs
#	crates/engine/src/edit.rs
#	crates/engine/src/edit/labels.rs
#	crates/engine/src/edit/run.rs
#	crates/organize/src/import/carry.rs
#	crates/ui-egui/src/dialogs.rs
# Conflicts:
#	crates/ui-egui/src/canvas/tests.rs
Three reads of the Type 3 cleanup came in from main in the merge, and
took `/DecodeParms` only when direct: the forms a surviving glyph draws
(`prune_font_forms`), a page's appearances without resources of their
own, and the soft mask groups without them (`add_soft_mask_ops`). Behind
a predictor whose parameters are an indirect object, each read the
predictor's tag bytes and row differences, which lex as operators that
use no resource, so nothing refused. The form's resources then lost the
image it draws, and the page lost the Type 3 font only the appearance
or the soft mask selects, with its glyphs. Each is now read with them
too, as this branch's other reads in the cleanup are; the appearances
still against the page's allowance.

Tests, each the by-reference form of one already on main:
- `an_image_a_type3_glyphs_form_with_parameters_by_reference_draws_is_kept`
- `a_type3_font_only_an_appearance_with_parameters_by_reference_selects_stays_on_the_page`
- `a_type3_font_only_a_soft_mask_with_parameters_by_reference_selects_stays_on_the_page`

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
# Conflicts:
#	crates/engine/src/xfa.rs
#	crates/forms/src/tests.rs
#	crates/xfa/src/data.rs
#	crates/xfa/src/lib.rs
#	crates/xfa/src/tests.rs
#	parity/acrobat-features.toml
# Conflicts:
#	crates/automation/src/tools.rs
# Conflicts:
#	crates/a11y/src/tests.rs
# Conflicts:
#	crates/forms/src/af/tests.rs
Comment literals in tests gain the callout text box and pop-up fields, and the review-state map is formatted.
# Conflicts:
#	crates/engine/src/xfa.rs
#	crates/js/src/xfa/host.rs
#	crates/xfa/src/packets.rs
#	crates/xfa/src/parse.rs
#	crates/xfa/src/tests.rs
#	parity/acrobat-features.toml
Format the attachment-key numbering kept from both sides of #5508's merge.
# Conflicts:
#	crates/xfa/src/pdf.rs
#	crates/xfa/src/tests.rs
# Conflicts:
#	crates/xfa/src/layout/flow.rs
#	crates/xfa/src/layout/measure.rs
#	crates/xfa/src/tests.rs
# Conflicts:
#	crates/xfa/src/tests.rs
# Conflicts:
#	crates/xfa/src/layout/common.rs
#	crates/xfa/src/tests.rs
# Conflicts:
#	crates/engine/src/xfa.rs
#	crates/xfa/src/layout/pages.rs
#	crates/xfa/src/lib.rs
#	crates/xfa/src/script.rs
#	crates/xfa/src/tests.rs
Main's #1143 (863af864a), merged in 77b9cfa31, makes cos read a reference whose generation isn't
its object's as null. The walk that finds what shares a /JBIG2Globals dictionary with what left
fetched through `Document::get`, but remembered what it read by object number alone, so a
reference of another generation and the object's own one shared what the first of them read:

- What a parameters value names (`Run::names_in`): `/DecodeParms [10 1 R 10 0 R]` on an image
  that left, object 10 naming dictionary 9, read 10 1 R as null, named nothing, and remembered
  that for object 10. The 10 0 R after it found it, so nothing named 9, and the dictionary
  stayed in the file with the image another page kept.
- A size, filters or parameters an image is stored again with (`Run::follow`): an image whose
  `/Width 11 1 R` reads as null can't be stored again, and refused the run alone. Once another
  image had read 11 0 R, it took that width and was stored with it.

What an image names is now read by object number, whatever generation the reference gives
(`Run::fetch_numbered`), in `names_in` and in the `/JBIG2Globals` chain alike: a reader that
doesn't compare generations draws with that object, so what an image that left names this way
may hold its shapes. That is how the chain already named `9 1 R` (the "another generation" way
out), and how both read before #1143. What is read by number is remembered by number. The
values an image is stored again with are read as cos reads them, and remembered by number and
generation, so a reference of another generation reads its own way.

`kept`'s comment said a reference reads the object of its number whatever its generation, which
is no longer so for a full save. It says how `reachable_objects` reads one now. The README and
`Run`'s comments say which reads take the number and which the generation too.

Tests:
- `a_redacted_scans_shared_jbig2_dictionary_leaves_the_file` gains three ways out:
  - parameters through another generation, then their own;
  - parameters through another generation alone;
  - a `/JBIG2Globals` reference to a reference of another generation.
- `a_size_through_another_generation_isnt_the_one_its_own_reference_reads` stores no image
  with the width another reference read. It refuses instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
# Conflicts:
#	crates/xfa/src/layout/measure.rs
#	crates/xfa/src/parse.rs
#	crates/xfa/src/tests.rs
# Conflicts:
#	crates/xfa/src/layout/measure.rs
#	crates/xfa/src/tests.rs
# Conflicts:
#	crates/xfa/src/fixtures.rs
#	crates/xfa/src/tests.rs
Land renderer patches, layer auto state, auto-tag artifacts, export clips and print page lookup (#3969, #4393, #4396, #4410, #4433, #4459, #4159, #5075, #5973)
Land forms, links, find and redaction-save fixes (#4496, #4532, #3567, #3773, #4470, #4474, #4497, #5658)
- engine: the on_changes test's XfaRun literals set highlight; the script
  runner reads its template before borrowing the live form for to_js; has_event
  reads the paged live form, as the rest of the runner does, so focus scripts
  find a page-master copy by its own SOM path.
- js: property reads compare and copy the shared (Arc<str>) node text, and the
  property tests build XHost with its current signature.
- xfa: the Emitter test literal sets groups and used; a field split that is
  undone also restores the names handed out; rustfmt on a merged import.
- parity: drop the use/usehref gap from form.xfa-dynamic, as prototypes are now
  resolved before the template is parsed.
Main's #1143 (863af864a), merged in 77b9cfa31, has cos accept both generations of a slot whose
cross-reference row and object header disagree: `8 0` in the row and `8 1 obj` in the file, a
reference of either reads the object. `unshare` stored an image again with `Document::set` under
the row's generation, which keeps that one alone. A page that named the image by its header's,
`/Im 8 1 R`, then read null, in the document and after a full save: a redaction on another page
took the image off a page no mark reached.

It now stores it with `Document::set_existing`, which keeps both. It was the one object this
PR's code writes back after reading.

Test: `an_image_stored_again_keeps_the_generation_its_header_gives` patches the second page's
image to `8 1 obj` behind a row of generation 0, and its page to `/Im 8 1 R`. After a redaction
on the first page, that page still draws the scan, in the document and after `write_full` and
reopening, and the dictionary is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
The merge gave `prune_font_forms`' walk of each Form a kept glyph
draws the allowance of a page, which its call of `resource_ops` now
needs; no test pinned it. In
`the_form_a_kept_type3_glyph_draws_is_walked_within_the_allowance_of_a_page`
such a Form draws two forms without resources of their own, each past
half of the allowance, and the redaction is refused with the decoder's
limit; with two small ones it goes on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
One conflict, in redact sanitize.rs. Main's #6021 (930b44131) split `remove_hidden` in two:
- it now counts only what the chosen categories need (`scan_only`);
- it hands the removal to `remove_counted`, which tests also call with `every_page`.

This branch had added one line to the old body: `kept_before`, what a full save keeps before
anything is removed. Main's side is taken whole, and that line is now the first thing
`remove_counted` does after finding the catalog, before any change to the document. So every
caller of `remove_counted` has it. `unshare_unmarked` stays last, after `unlink_undrawn`, and
merged cleanly there. The only early return after a change is `NothingToApply` when nothing
was removed and nothing unlinked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
main's #3962 (symbolic TrueType glyph names, 63a6f8ace and nearby) reads a
symbolic TrueType font's codes by the `post` names of the glyphs its cmap
selects, before the Mac OS Roman reading:

- crates/fonts/src/pdf.rs, `unicode_map`: both readings stay, in main's
  order, each given the metrics read's allowance. The program read main
  added (`symbolic_glyph_texts`) goes through `Reads::decode`, as
  `symbolic_mac_roman_texts` does since the last merge: paid for from the
  allowance, `past_allowance` set when it can't be, behind several
  filters within the stream's own limit.
- crates/fonts/src/pdf/tests.rs: both sides added tests at the same place;
  all are kept.

Tests: `adapting_a_programs_cmap_is_paid_for` now expects the program to
be paid for a fourth time, and `the_symbolic_mac_roman_reading_is_paid_for`
becomes `the_symbolic_readings_of_a_program_are_paid_for`, paying for both
readings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
A second merge of main, at b6cbf9bb3. Nothing needed resolving.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
The Resources review of 8eead8ecd. The program read and the metrics reads
passed their limit times the number of filter stages as one total, which
any one stage could use: a FontFile2 of Flate making 128 MiB of
whitespace, under seven ASCIIHex stages (a 130 KB file), made all of it
before being refused, about 0.1 s a font where `main`'s bound stopped it
at 16 MiB. `edit`'s `matching_thai_outline` reads such a program for each
of a page's fonts with no total, and every `Metrics::from_dict` up to
three times.

Now each stage is held to the limit, as a decode without an allowance
holds it, and the stages together to the limit for each of them, so every
stream the stage scaling was for still reads: a full `/CIDToGIDMap` or a
12 MiB program behind ASCIIHex and Flate. The two bounds take an entry
point of their own: `pdfcraft_filters::decode_tolerant_counted_each`,
which `run` already supports, and `DecodeBudget::decode_stages_within`,
which `Reads::decode` and the editor's program read use. A stage past the
limit is charged what the stages before it decoded and the limit; past
the stages' total, or past what is left, it is charged as
`decode_within` charges. The program, the last stage, is so within the
limit without a check of its own.

Tests: `each_stage_is_held_to_its_own_bound_within_the_total` in
`pdfcraft-filters` (a Flate stage stopped at its bound hands the next
stage nothing), `each_stage_is_held_to_the_limit` in `pdfcraft-cos`, and
in `pdfcraft-fonts` `an_editors_program_read_holds_each_stage_to_its_limit`
and `each_stage_of_a_metrics_stream_is_held_to_its_limit`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
The Resources review of 8eead8ecd:

- `embedded_sfnt_within` read a composite font's metrics within the
  editor's allowance before `composite_glyph_maps` refused any `/Encoding`
  but Identity-H, so an Identity-V or UniJIS font spent its ToUnicode and
  `/CIDToGIDMap` for nothing. Its `/Encoding` is now checked first; a
  program with a cmap of its own is kept as it is, as when the rebuild
  finds nothing.
- The Thai edit's allowance, `MAX_THAI_PROGRAMS`, now also pays for the
  CMaps and glyph maps its subsets' metrics and codes are read from: its
  doc and its refusal say "font data", not "font programs".
- `a_symbolic_truetype_font_without_an_encoding_reads_its_glyph_names`
  also reads its font, whose `post` table names its glyphs, within an
  allowance: the program is paid for by the glyph-name reading, by the
  Mac OS Roman one and for the widths, and a byte short of the first the
  names go unread.

Tests: `a_composite_font_that_cant_be_adapted_spends_only_its_program`,
and `adapting_a_programs_cmap_is_paid_for` again expects a composite font
whose codes aren't Identity-H to pay for its program only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
Mutation testing of 063f8629e missed eight mutants in
`DecodeBudget::decode_stages_within`: nothing pinned what a read
stopped at a stage's limit, at the stages' total or at what is left of
the allowance charges, which limit it reports, or where one case gives
way to the next.

`a_stage_past_its_limit_charges_what_came_before_and_the_limit` reads
each way, and each boundary on both sides: a byte more room than the hex
stage and the limit (charged them, a byte left), exactly that (past the
allowance: all of it spent, its limit reported), a hex stage making
exactly the limit so the Flate stage reaches the stages' total with more
left (charged the total), with exactly the total left of a larger
allowance and with less (spent, the allowance's limit reported), and,
with nothing left, a stream with data that decodes to nothing (refused)
and an empty one (read).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
# Conflicts:
#	crates/render/src/inspect.rs
#	crates/render/src/lib.rs
#	crates/ui-egui/src/canvas/page_pointer/tests.rs
#	crates/ui-egui/tests/it/comments.rs
# Conflicts:
#	crates/automation/src/redact.rs
#	crates/automation/src/tools.rs
#	crates/print/src/lib.rs
#	crates/render/src/inspect.rs
#	crates/render/src/lib.rs
# Conflicts:
#	crates/automation/src/tools.rs
#	crates/automation/tests/it/automation.rs
#	crates/forms/src/tests.rs
#	crates/ui-egui/src/tools/forms_ui.rs
#	parity/acrobat-features.toml
The forms editor's test draft sets the focus's obj and list, which main added, and the focus scripts look up the focused field with focused_field so a same-name twin is read as main reads it.
Land comment groups, appearance defaults, callout parts, pop-ups and stamp lines (#4358, #4365, #4094, #2137, #2345)
# Conflicts:
#	crates/engine/src/tests.rs
# Conflicts:
#	crates/render/src/inspect.rs
#	crates/render/src/lib.rs
Land XFA layout, scripting and form fixes (#3589, #4336, #4359, #4517, #4537, #4608, #4662, #4664, #4784, #4791, #5010, #5122, #5124, #5153, #5225, #5368, #5369, #5370, #5393)
# Conflicts:
#	crates/ui-egui/src/app/files.rs
#	crates/ui-egui/src/tools/forms_ui.rs
Land print production, viewer, attachment and optimizer changes (#1918, #1998, #2539, #2348, #2701, #3806, #4139, #4378, #4401, #4763, #5259, #5508, #5316, #4434, #4440, #6092, #6098)
In `a_shared_jbig2_dictionary_that_cant_leave_refuses_the_redaction`, the case of a copy of
the dictionary whose `/Filter` is given by reference may end `Ok`, the image stored again
without it. That branch checked only that the dictionary, object 9, left, and that its marker
was nowhere in the file. But `assert_gone` can't decode the copy, object 10, with its filter by
reference, and its bytes are Flate, so a run that never found the copy, and left it in the
file, passed. The branch now checks that object 10 left too, through `reachable_objects`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
The Legal forms review of 8fb534ad4. A Type 0 font's /Encoding may be
given by reference (a name or a CMap stream, either indirect), and the
metrics read resolve it: an indirect /Identity-H reads two-byte codes.
The editor's check before reading a composite font's metrics, and
`main`'s `composite_glyph_maps` and `recovered_thai_map`, read it with
`Dict::name`, which doesn't resolve: such a font counted as another
encoding, so the editor adapted no copy of it and Repair Thai maps
passed it over. All three now read it through `identity_h`, which
resolves it as the metrics do.

Tests: `a_composite_fonts_encoding_given_by_reference_is_read` (the
editor adapts a copy of a composite subset with `/Encoding 5 0 R`, object
5 `/Identity-H`, that maps 'Ω', and its metrics read two-byte codes), and
`recovered_thai_map_checks_every_kept_glyph_against_the_font_program`
recovers the same map with the font's /Encoding by reference.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
A third merge, of ba28458ad, with nothing to resolve. main's commits since
b6cbf9bb3 add no font program, metrics or CMap read in pdfcraft-fonts or
the editor's font code for this PR's allowance to cover.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
A third merge of main, at ba28458ad. Main's #2448 (in #6714) collects
the text a redaction removes from the walk's scope, which this branch
also reads after the walk for what it left of the page's allowance.

- `interp.rs`: `Scope` keeps this branch's `into_budget` and gains
  main's `with_removed_room`, `flush` and `removed`.
- `lib.rs`: the page's walk starts with main's room for removed text,
  and after it the scope's removed text is collected as on main, then
  what it left of the page's allowance is taken for the cleanups, last,
  as taking it consumes the scope.

Main's new code reads no content stream or form in the walk or the
cleanups, so nothing of this branch's allowance or one-operator reads
needed carrying into it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
One conflict, in redact lib.rs: the module list, where main added `elsewhere` and
`identification` (#6714) and this branch `globals`. All three stay, in order.

Main's #2448 work also added a sixth step to `apply`, after the verification and the pattern
guard: `metadata::scrub` removes the `/Info` entries and the catalog's and pages' XMP that repeat
the removed text. An image that only such a packet refers to leaves the file then, after
`unshare` had looked for what left, so its shared dictionary stayed. `unshare` now runs after
the scrub, last of what can take something out. Storing images again changes no page's content
and no pattern cell, so the verification and the pattern guard before it check the same pages
they did. The crate's doc says the dictionary goes last. Remove Hidden Information's order is
unchanged: main's changes to `sanitize.rs` only read (`owned_values`).

Test: `an_image_only_the_scrubbed_metadata_refers_to_takes_a_shared_jbig2_dictionary_too`. An
image under the mark is also named by the catalog's XMP, which repeats the text removed beside
it. After the redaction the packet is gone, the image on the other page is stored again, and
the dictionary is gone too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
The Consumers review of ba28458ad..b7224cbc9:

- `recovered_thai_map` read the descendant's /CIDToGIDMap with
  `Dict::name`, on the line after e9142b03b's /Encoding check, so a map
  given by reference made Repair Thai maps pass the font over, and so
  did a missing one, which is Identity (ISO 32000-2 Table 115), as
  `composite_glyph_maps`, `LegacyThaiFont` and the renderer read it. It
  now resolves the map and takes a missing one as Identity.
- `MAX_THAI_PROGRAMS` says what it pays for: the programs, and the CMaps
  and glyph maps read to adapt their cmaps. The metrics read to find and
  measure the subsets aren't charged to it. Its refusal ("MiB of font
  data") still says what it counts.

Test: `recovered_thai_map_checks_every_kept_glyph_against_the_font_program`
recovers the same map with the font's /CIDToGIDMap given by reference and
with none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The Claims review of the description at b7224cbc9. `Reads::decode` said a
stream past its limit is unread, not refused, "when what is left can pay
for its stages". What `DecodeBudget::decode_stages_within` does, and
`a_stage_past_its_limit_charges_what_came_before_and_the_limit` pins:
with more left than what the stages before the stopped one made and the
limit, it is charged those two; otherwise, with more left than the
stages' total (behind one filter, the limit), it is charged that total;
with no more left than that total, it spends all of it and counts as
refused.

`MAX_THAI_PROGRAMS` also names the second read of a subset's program,
for which of its glyphs have outlines, among what it pays for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
The Consumers review of 64dfe1fd4. The README said a surviving form loses
the resource entries for removed XObjects its own content does not draw.
It also keeps those a form without resources of its own, drawn through
them, still draws, and since this PR a redacted page keeps them the same
way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
Since 0a00862e9, what an image is stored again with (its size, filters and parameters) is
remembered by number and generation, so a value reached through a reference of another
generation is fetched again rather than taken from what the reference of its object's own
generation read. The README's list of what may be read again now says so. What an image names
is still remembered by object number, so a dictionary named that way isn't read again.

The same sentence said a chain of more than 32 references may be read again. `names_in` follows
33 references before it gives up on a chain, as `a_chain_of_references_is_named_to_the_limit`
says, so it is more than 33.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
The Legal forms review of b7224cbc9..4d6ff291d:

- Nothing pinned that `recovered_thai_map`'s rewritten guard still
  refuses a /CIDToGIDMap that isn't Identity: deleting it left every
  Thai test passing. `recovered_thai_map_checks_every_kept_glyph_against_
  the_font_program` now asserts no map for a map stream that moves glyphs
  and for another name.
- The guard's comment and the test's cited Identity as the default of
  ISO 32000-2 Table 115. It is the default of PDF 1.x (ISO 32000-1);
  PDF 2.0 gives it none and requires the map with a /FontFile2. Both now
  say so, and that the renderer takes a missing map as Identity too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
Editor, export and redaction must not read or rewrite text that the page view and Find never see, which a mismatched cap allows on large streams.

Lower MAX_DECODED to 256 MiB to match the renderer cap, so every Stream::decoded-based content reader stops at the same byte and oversized streams error instead of exposing later text.

Test: decoded_stops_at_renderer_cap_of_256_mib failed before the change and passes after.

Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com>
CodeRabbit's review of 674a20899:

- Each budgeted read of a font's stream went through
  `Document::with_direct_parms`, which copies the whole stream when its
  /DecodeParms is given by reference: an unrelated value in the
  dictionary, however large, was copied again for every read.
  `DecodeBudget::decode_stages_within` now takes the document and reads
  through `Document::decoding_view`, as `decode_strict` and
  `decode_with_damage` do: the data is shared, and only /Filter and the
  parameters made direct are copied. `Reads::decode` and the editor's
  program read use it, a Type 3 font's glyph descriptions read through
  `decode_with_damage`, and so does Repair Thai maps' program read.
- A Type 3 font's glyph descriptions resolved one description's
  parameters a font before the spent allowance refused it. They now ask
  `DecodeBudget::refuses` first, as `decode_stages_within` does for the
  others: once the allowance is spent a stream with data is refused, and
  noted, before its parameters are resolved.
- `DecodeBudget::refused` counts the decodes refused for want of the
  allowance, where `remaining() == 0` can't tell a refusal from an
  allowance spent to the byte.

Test: `reading_a_fonts_streams_copies_no_dictionary` (`pdfcraft-fonts`,
a test binary of its own): a font whose program and ToUnicode hold an
unrelated array of a million numbers and /DecodeParms by reference is
read eight times by the editor's program read and the metrics read,
and the process's peak grows by less than half of what one array
holds. That a Type 3 font refuses before resolving parameters changes
no result, only the time it takes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
CodeRabbit's review of 674a20899:

- The Thai edit refused when nothing was left of its allowance, so an
  edit whose reads spent it to the byte was refused. It now refuses when
  a read was refused for want of it (`DecodeBudget::refused`).
- `LegacyThaiFont::from_pdf` read its metrics with `from_dict`, outside
  the edit's allowance, and so did the edit for each subset it measures.
  Both now read them within it (`from_dict_within`), and metrics read
  past it adapt nothing: `from_pdf` refuses the font before reading its
  program, and the edit leaves the subset unread and is refused. The
  edit's scan of every font on the page for its face name stays outside
  the allowance, as on `main`: charged, each alias of a font would pay
  again, and other faces would spend what the face's subsets need.
  `MAX_THAI_PROGRAMS` says what it pays for.
- When the `/CIDToGIDMap` a THSarabunPSK subset's Thai mappings are
  rebuilt from was left unread for want of the editor's allowance,
  `composite_glyph_maps` gave nothing, and a subset with a Latin-only
  cmap of its own was listed as it was, without its Thai mappings. The
  editor now lists no copy of it, as for metrics read past the
  allowance.

Tests: `a_thai_edit_that_spends_its_allowance_to_the_byte_goes_through`
(`pdfcraft-edit`: a subset costing exactly the 64 MiB is edited, and
one three bytes past it is refused),
`a_legacy_fonts_metrics_are_paid_for_from_the_edits_allowance` and
`a_map_left_unread_for_the_allowance_lists_no_copy` (`pdfcraft-fonts`).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
* M10: the Print dialog remembers two-sided, grayscale, collate, sizing and comments & forms (#2001)

Persist the choices that describe how this computer prints and restore
them on the next launch, under the names doc_print takes; copies, pages,
layout, orientation and the printer still start fresh for each job.
Those names now live in one place, pdfcraft_print::names, used by the
tool and the settings file. Settings are untrusted: an unknown name or a
custom scale outside the dialog's 1..=1000 % keeps the default.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* M10: the Print dialog also remembers a booklet's binding side (#2707)

Acrobat keeps the booklet binding (left or right) between prints;
PdfCraft started at Left every time. It is now kept with the other
remembered print choices, named as doc_print names it, and an unknown
saved value keeps the default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M10: Print starts each job's copies, pages, layout and orientation afresh (#2001)

CodeRabbit on #2103: the README promised that copies, pages, layout and orientation
start fresh, but open_print carried them over through `..keep` within a session.
ISO 32000-2 Table 147 initialises these per document when the print dialog opens
(NumCopies "typically 1", PrintPageRange), so each opening now resets them to the
defaults (Selected pages still win when thumbnails are picked); two-sided,
grayscale, collate, sizing, Comments & forms, binding, the paper and the printer
picked last carry over as before. Two-sided names now include "printer" (leave it to
the printer, main's default since #2422), so that choice is remembered too.
main's paper-persistence test expected grayscale not to persist; it is one of the
remembered preferences.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M10: a new print job also resets its layout's own options and leaves Label (#2001)

CodeRabbit on #2103: resetting the layout to Size kept pages per sheet, order, borders,
booklet subset and the poster options through `..keep`, so choosing Multiple again
brought back, say, a cut-and-stack order; and a job printed on Label went back to Size
without giving back the paper Label replaced. open_print now leaves Label through
set_handling (#3564) and resets the layout's own options with the rest of the job's
choices; the binding and sizing stay remembered. The README now says what resets on
each opening and what carries over.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M10: the Print dialog also remembers Print as image and its resolution (#6392)

A printer that needs pictures keeps getting them after a restart: as_image and
dpi are saved with the other print preferences and restored, a resolution the
dialog doesn't offer keeping the default 300 dpi. The bad-settings checks move
to their own test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: mtlcl410 <chuanlin@niar.org.tw>
Add Folio (8.5 x 13 in / 612 x 936 pt) and F4 (210 x 330 mm / 595.28 x 935.43 pt)
to the PAPERS preset table and map them to standard PWG 5101.1 media names
(na_foolscap_8.5x13in and om_folio_210x330mm) in cups_media.

Closes #4777.

Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
* fix(print): fall back to the lpoptions listing when the PPD is unreadable

cupsd keeps /etc/cups/ppd/<queue>.ppd at 0640 root:cups on Arch and
similar setups, so an ordinary desktop user cannot open it. The option
reader treated that permission error as "no options", and Print ▸
Properties… said "This printer offers no settings of its own" for a
queue whose driver options cupsd was serving all along through
lpoptions.

When the PPD is missing, unreadable, or parses to no options, the list is
now built from `lpoptions -p <queue> -l`. The options Craft handles
itself are skipped, as the PPD parser does, and the starred choice is the
fallback default.

Fixes #4699

* fix(print): skip CUPS custom-value templates in the lpoptions fallback

`lpoptions -l` prints `Custom`, `Custom.<TYPE>` (`Custom.PASSCODE`,
`Custom.STRING`, …) or `Custom.WIDTHxHEIGHT` in place of the choices of an
option whose value the user has to type. Nothing in the dialog collects that
value, so offering the template sent its literal text to the driver. Those
entries are now dropped, and a starred template never becomes the default.

The choice cap is also tested before the duplicate scan, so a listing past
`MAX_LPOPTIONS_CHOICES` no longer walks the stored choices on every token.

Refs #4699

* fix(print): refuse a destination that starts with a dash

`lpoptions` parses its argv with getopt, so `-p -E -l` reads `-E` as an
option rather than as the destination; `--` cannot help, because it ends
option parsing before the `-p` that names the queue. Reject a leading
`-` on the queue and on the instance component beside the existing name
checks, before the PPD is opened or the command is spawned.

* fix(print): read the listing by its last colon, and stop inventing a default

`list_group()` prints the option as `"%s/%s:"` from the PPD's keyword and
translated text and each choice as a bare `" %s"`, so a colon can sit in
the text but not in a choice keyword. Splitting at the first colon turned
the rest of the text ("Supporto: Tipo") into invented choices; split at
the last one instead.

When the choice CUPS marks is a custom-value template, the parser dropped
it and stood the first retained choice in as the default. The UI then
showed a value the job does not use, because only options changed away
from the default are submitted. Leave the default empty in that case:
there is nothing the dialog could collect, and an invented choice is
worse than no selection.

---------

Co-authored-by: Jarvis (Hermes) <jarvis@localhost>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Its page index would name another page after pages were deleted or moved.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* M10: grayscale with Print as image prints on a driver that keeps no grayscale (#5806)

With Print as image and grayscale, PdfCraft renders the pages as grey
pictures itself, yet the job still asked the driver for grayscale. A
driver that keeps no grayscale setting, Microsoft Print to PDF among
them, made craft-print refuse the job ("the printer's driver did not
retain the requested grayscale; nothing was sent"), so even the grey
pictures never printed. The job now asks the driver for grayscale only
when the pages aren't grey pictures already
(print::driver_needs_grayscale), in the dialog and in doc_print.

The print.grayscale parity note and the doc_print as_image description
say so, and the note names what is still missing: vector pages to such
a driver are refused rather than printed in colour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M10: the grey-pictures test checks that the pictures are grey too (#5806)

The test checked that grey pictures don't ask the driver for grayscale,
but not that the pictures are grey. A change that dropped grayscale from
the dialog's raster settings, while the job still skipped the driver,
would have printed in colour with the test passing. It now asserts both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: austin62jake <340374338+austin62jake@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
After a print to a printer, its paper, page sizing (Fit, Actual size,
Shrink, Custom %) and two-sided choice are remembered for that printer and
brought back when it is chosen. The printer printed to last is preselected
after a restart while it is listed, else the system default. Saved as
print_printers, most recent first, at most 32; malformed entries are skipped.
A FormCalc script under a time limit is watched twice: the caller waits
`timeout` for it, and the interpreter reads its own deadline, set to the
same `timeout`, every 256 steps. Which one notices first is down to the
scheduler, and the two reported different things: the caller's outcome is
abandoned with no changes (the form's scripts go off), while the
interpreter's kept every change the script made before the deadline.

For a click script that loops on addInstance, that difference is 1000 rows
(the most one run may add). Since repeating subforms lay out up to 1000
rows instead of 50 (#4608, landed with #6716), keeping them means laying
out a 1000-row form again, twice, which takes most of a minute in the test
build. The engine test hostile_formcalc_scripts_cannot_hang_or_bloat_the_document
took 3.0 s when the caller noticed first and 46-61 s when the script did,
so it failed its 60 s bound at random.

Now a run stopped by its own clock reports what an abandoned run does: the
same error, no changes, abandoned. XfaSession treats that outcome like a
wait that ran out (the session is dead after it). Limits other than time
(steps, loops, depth) keep their changes as before.

Co-authored-by: FredApps <FredApps@users.noreply.github.com>
* fix(print): M10 — Windows ya no cambia las letras de las fuentes TrueType incrustadas (#3132)

Un subconjunto TrueType incrustado numera sus glifos de nuevo (en el
subconjunto de Arial de ReportLab el espacio es el glifo 11; en la Arial
instalada, el 3). Al imprimir en Windows, Windows.Data.Pdf → Direct2D →
XPS conservaba esos números de glifo pero los dibujaba con la fuente
instalada del mismo nombre: «Revisión» salía como «Wj{nxn» y la «z» en
negrita como «Ä», tanto en las colas Brother como en la impresora Adobe PDF.

La copia que va a la cola de Windows renombra ahora cada programa
TrueType/OpenType incrustado como PdfCraftPrint<n>, en su tabla name
y en el PDF (/BaseFont, /FontName; el subconjunto conserva su etiqueta),
para que ninguna fuente instalada responda a ese nombre. Glifos,
codificaciones y /ToUnicode no cambian. CUPS y Guardar como PDF reciben
el PDF tal cual.

Tests: extremo a extremo con impose (la página se renderiza idéntica
antes y después, y el test falla sin el arreglo), fuentes que comparten
programa, Type 0, reescritura por plataforma de la tabla name y programas
dañados sin pánico. Falta comprobarlo en una impresora física con Windows.

Fixes #3132

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(print): M10 — solo se renombran en el PDF las fuentes cuyo programa se reescribe (#3132)

Si PdfCraft no puede leer o reescribir un programa incrustado (no es un
sfnt, está dañado o pasa de 64 MiB), ahora conserva también su /BaseFont
y su /FontName. Un visor que tampoco pueda cargarlo seguirá eligiendo el
sustituto por el nombre original («Arial») en vez de por PdfCraftPrint<n>.
Si no queda ningún programa reescrito, el PDF va a la cola tal cual.

Lo señaló CodeRabbit en el PR #4098. Test nuevo:
a_program_that_cannot_be_rewritten_keeps_its_name_in_the_pdf_too (falla
con el comportamiento anterior).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(print): M10 — el renombrado de fuentes para Windows decodifica contra un presupuesto total (#3132)

Cada programa ya tenía un tope de 64 MiB, pero un PDF con muchos
programas distintos podía sumar mucho trabajo de descompresión al
imprimir. Ahora todo el renombrado comparte un DecodeBudget de 512 MiB,
el mecanismo que main acaba de introducir en print. Agotado el
presupuesto, los programas que faltan conservan su nombre, igual que uno
ilegible.

Test nuevo: programs_past_the_shared_decode_budget_keep_their_names
(falla si se decodifica sin presupuesto).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(print): M10 — también se renombran los CIDFont escritos dentro de su fuente Type 0 (#3132)

Si /DescendantFonts lleva el CIDFont como diccionario en línea (en el
propio Type 0 o en un objeto array aparte), el recorrido no llegaba a
él. Cuando compartía programa con otra fuente, el programa se renombraba,
pero el Type 0, el CIDFont y su descriptor seguían diciendo «Arial», y
Windows podía volver a cruzarlos con la Arial instalada. Ahora el Type 0
se trata en el mismo recorrido, sin el segundo que hacía type0_names: un
CIDFont en línea apunta su programa, y al renombrar se cambian el Type 0,
el CIDFont y su descriptor en línea.

Lo señaló CodeRabbit en el PR #4098. Test nuevo:
a_cidfont_written_inside_its_type0_font_is_renamed_with_it (falla sin el
arreglo).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(print): M10 — renamed_table ya no copia cada cadena de la tabla name (#3132)

renamed_table copiaba el texto de cada registro antes de comprobar el
tamaño del resultado: una tabla name de menos de 1 MB con 65.535
registros apuntando a una misma cadena de 65.535 bytes reservaba unos
4 GiB, y un fallo de reserva cierra el proceso al imprimir en Windows.

Ahora la tabla se lee entera sin copiar ningún texto (cada registro
guarda el offset y la longitud de origen) y después cada cadena distinta
se guarda una sola vez: los registros que compartían cadena la siguen
compartiendo y el almacén nunca pasa de lo que alcanzan los offsets de
16 bits. Una tabla que declara más registros de los que caben antes de
sus cadenas se rechaza de entrada.

Lo encontró RomanLis91 en el PR #4098. Tests nuevos:
records_that_share_one_string_keep_sharing_it (falla con el código
anterior) y a_name_table_claiming_more_records_than_fit_before_its_strings_is_refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix: cap print dialog height to the screen so it can't overflow top/bottom

The Print dialog modal had no height constraint, so on screens where the settings+preview content (especially with the driver's Properties... panel expanded) exceeds available screen height, the dialog overflowed equally off the top and bottom, making Print/Cancel unreachable. Wrap the settings+preview area in a ScrollArea capped to the viewport's available height (read through viewport_rect(), which holds steady across frames unlike raw screen_rect), keeping the title and button row outside so they stay reachable. Includes a kittest regression test.

* Print dialog: size the scroll area from its cap, not from what the modal offers this frame

Opened after a taller dialog, the shared modal remeasures from a height of zero (#5118) and
offers less, so the settings area took the previous dialog's leftover height: Print was 408 pt
after Preferences against 674 pt fresh (dialog_chrome::a_dialog_keeps_its_own_height_after_a_taller_one).
min_scrolled_height(max_h) makes the viewport the cap; auto_shrink still fits shorter contents.
Comments cite #4252 and #5317.

---------

Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
* Print: list printers without asking drivers; ask only about the one picked (#2192)

On Windows, craft-print's printers() reads every driver's capabilities to
report two-sided support. Windows takes about 48 s to give up on a printer
it can't reach, such as a WSD printer on another network, so the Print
dialog waited for each one before it listed anything (#2192).

The dialog now lists printers from craft-print's printer_queues(), which
asks no driver, and asks two_sided() about the printer the user picks, on
a background thread (PdfCraftApp::two_sided_source, set by the desktop app
like printer_source). Each answer is kept for the session, so a printer is
asked about at most once, and one nobody picks is never waited on. The
"doesn't take two-sided settings" note shows once the answer arrives.

The automation printers tool still uses printers(), so it keeps reporting
two_sided.

Needs the craft-libs change that adds craft_print::two_sided.

* Build: take craft-print from storytold/craft-libs#133

Pins craft-print to the head of storytold/craft-libs#133 (b6a3b87), which
adds craft_print::two_sided as one commit on craft-libs main at 5ee809f.
craft-print is otherwise unchanged from 7ee35a9, the rev main pins now; the
other craft-libs crates stay where they are. Fetched through the
storytold/craft-libs URL, so cargo-deny's allow-git is unchanged. To be
replaced by the merge commit once #133 lands.

* Print: run at most four two-sided reads at once

A driver Windows can't reach holds its two-sided read for about 48 s, so
picking printer after printer started a thread for each. At most four now
run at once; past that, the printer picked is asked once a read finishes.

Tests: hanging reads are capped and the picked printer is asked after one
finishes; a list that already says whether a driver takes two-sided
settings is used as it is, with no driver asked.

* Repin craft-print to craft-libs main, where #133 merged (b6b8a55b)

---------

Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
* M10 #5130: resolve Windows printer-default duplex before spooling

* M10 #5130: document printer duplex resolution

* M10 #5130: adopt DocumentDuplex driver fix from craft-print

* M10 #5130: adopt two-sided capability selection fix

* M10 #5130: update documented craft-print revision

* M10 #5130: fall back when Windows duplex defaults cannot be read

* fix(print): satisfy duplex review formatting and refresh shared pin

* Repin craft-print to craft-libs main (#411 and #133 merged); ask only the picked printer's driver for two-sided support

craft-libs b6b8a55b has #411 (DocumentDuplex) and #133 (two_sided(name)). The
Printer default support check now reads that one driver instead of listing
every printer's capabilities at submit time (#2192).

---------

Co-authored-by: robertobulltech <341520377+robertobulltech@users.noreply.github.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
storytold/craft-libs#761 moves winprint to 3c978dfc: winprint.rs#19 draws each page at the
job's resolution instead of ~96 dpi (#4941, #2676), #12 writes QName ticket values as
prefix:local (#3823), #3 accepts whitespace around integer capabilities (#2430). Only the
craft-print and winprint lock entries move, plus winprint's windows-numerics dependency.
# Conflicts:
#	crates/edit/src/tests.rs
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
M7: a font's metrics can be read within a caller's decode allowance (#6239, merged with main)
# Conflicts:
#	parity/acrobat-features.toml
# Conflicts:
#	crates/edit/src/tests.rs
`Reads::decode` and the Type 3 glyph-box read took any failed read, with
nothing left of the allowance after it, for one the allowance refused. The
Thai edit refuses on what the allowance itself counts
(`DecodeBudget::refused`), so the two could disagree: once the edit's
allowance was spent to the byte, an empty stream behind a filter nothing
decodes, which is unread with any allowance, made a font's metrics read
past it with no refusal counted. The edit then skipped that font of the
face and went through without it, which its refusal is there to prevent.

Both now take a read as refused only when `DecodeBudget::refused` grew
during it, so `Metrics::past_allowance` is set exactly when the allowance
counted a refusal, as its doc now says.

Tests:
- `an_empty_stream_reads_with_nothing_left` adds such a stream: it isn't
  past the allowance, and the flag agrees with the count in each case.
- `a_type3_description_unread_on_its_own_is_not_past_the_allowance`: the
  same for a glyph description after one that spends the allowance to the
  byte; a description with data after it is still refused.
- `a_font_of_the_face_unread_on_its_own_once_the_allowance_is_spent_still_refuses_the_edit`:
  the edit above is refused, where it went through.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
The editor's program list (`embedded_sfnt_within`) reads a legacy Thai
font's metrics after its program, and passes over the program when they are
past the allowance. With the program spending the allowance to the byte, an
empty ToUnicode behind a filter nothing decodes no longer passes it over;
a ToUnicode with data is still refused, and the program isn't listed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
`Metrics::past_allowance`, `Reads::decode` and the Type 3 glyph-box read
said a stream that fails on its own data isn't counted as refused even when
it spends the last of the allowance. A stream past its own limit fails with
any allowance, yet with no more left than its stages' total it is counted,
as `Reads::decode` itself says just above. They now name the case that isn't:
a read that fails without the allowance refusing it, such as an empty stream
behind a filter nothing decodes. The program-list test's doc no longer says
more than it checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
fix(edit): preserve source fonts in mixed-script paragraphs (#2778, merged with main)
M8: redaction keeps an image a form without resources still draws (#6341, merged with main)
fix(redact): a shared JBIG2 dictionary leaves with whatever used it, by any route
The image pass visits the images pages draw by name. A picture's stencil
`/Mask` and its `/SMask` are images of their own that pages don't name, so
the monochrome step never reached them (the colour and grey settings
resample an eight-bit soft mask with its picture, but store no mask as
Group 4 or JBIG2). In a mixed-raster scan, a colour layer drawn through a
one-bit mask that holds the text, most of the black-and-white data stayed
as it was written (Flate, say).

After the pictures, each picture's stencil mask and one-bit soft mask, once
however many pictures name it, now goes through the monochrome step at its
size (`decide_mask`): stored again as the compression asks (Group 4, JBIG2)
when that is smaller, its bits as they were, so it masks as it did. It
isn't resampled (its resolution isn't measured) and shares no JBIG2
dictionary (only images named directly do, which redaction can store again).
A stencil pages also draw by name stays one of the pictures; a soft mask they
draw is still stored as a mask. A soft mask of
more bits stays, to be resampled with its picture, which wants it at eight.
The report counts masks as images; Retain leaves them as they are.

The pictures and the masks go through one loop (`work_through`), so masks
are worked out on threads and from the run's allowance as pictures are.

Tests: `a_pictures_one_bit_masks_are_stored_again`,
`a_pictures_mask_stored_as_jbig2_shares_no_dictionary` and, through
`doc_optimize`, `doc_optimize_stores_a_pictures_one_bit_soft_mask_as_ccitt_group_4`.
`a_mask_past_the_runs_allowance_stays_and_is_counted` holds a mask past
the run's allowance to staying, counted as refused, and the progress to
counting masks with pictures.
`a_picture_with_a_stencil_mask_is_downsampled_under_its_mask` now checks
the mask's size and bits rather than its bytes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
# Conflicts:
#	crates/automation/src/printing.rs
#	crates/print/src/lib.rs
#	crates/print/src/tests.rs
#	crates/ui-egui/src/app.rs
#	crates/ui-egui/src/app/files.rs
#	crates/ui-egui/src/app/prefs.rs
#	crates/ui-egui/src/lib.rs
#	crates/ui-egui/src/platform/open_worker/tests.rs
#	crates/ui-egui/src/workflows/print_ui.rs
#	crates/ui-egui/src/workflows/print_ui/body.rs
#	crates/ui-egui/src/workflows/print_ui/draft.rs
#	crates/ui-egui/tests/it/print.rs
* M10: Windows prints a large image whole: drawn as tiles under Windows.Data.Pdf's cap (#5131)

Windows prints each page through Windows.Data.Pdf, which draws any image
of more than 2^22 pixels from a copy shrunk to that size. With the page
at the printer's resolution (craft-libs#396, #5023), a 6000 x 6000 scan
still printed as 2048 x 2048, its one-pixel detail gone.

pdfcraft_optimize::tile_large_images replaces each such image XObject
with a form XObject of the same object number that draws it as tiles of
at most 2000 pixels a side, so whatever drew the image draws the tiles.
Samples are copied as stored (any bit depth and colour space, decode
array, interpolation, colour key); a grey or RGB JPEG is decoded and
stored with Flate; a soft or stencil mask of the image's size is cut
into the same tiles. Tiles overlap by a pixel against drivers that
soften image edges, except under a soft mask, where the overlap would
blend see-through pixels twice. JPEG 2000, CMYK or ambiguous JPEGs,
masks of another size, more than 80 million pixels, short data and
images past the decode allowance are left whole.

pdfcraft_engine::spooler::spooler_copy applies it to the copy that goes
to the spooler on Windows, from the Print dialog's worker and doc_print;
elsewhere, or when the copy can't be read or written, the bytes go as
they are. The saved print-ready PDF is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M10: a test that the spooler gets the tiles on Windows; tiles are up to 2001 px (#5131)

spooler_copy was only tested with prints that pass through unchanged, so
one that skipped tiling on Windows would have passed. A test now checks
that on Windows it sends the tiled copy and elsewhere the bytes as they
are. The optimize README said tiles have at most 2000 pixels a side;
with the overlap they have 2001.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M10: the tiles' samples are StoredSamples, apart from the crate's Samples (#5131)

main has since added a public `Samples` (8-bit grey or RGB, for tools
that repaint an image), so two different things in one crate shared a
name. The tiles keep an image's samples as stored, in its own colour
space and depth, so each tile redraws exactly what it replaces; the
type now says so in its name and doc comment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: austin62jake <340374338+austin62jake@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Brings back #2381 (by regendorf-ux), which #6706 reverted before v0.20.0 because its page-setup
rows made the Print dialog as tall as Preferences and failed the dialog-height test.

- Custom… beside Paper adds a named size in the preferences' unit (1 to 200 inches a side),
  kept with the settings; Remove takes it off the list. doc_print takes sizes such as 8.5x14in.
- #2381's "Remember this paper for <printer>" box and its separate printer-to-paper map are left
  out: since #6779 every printer keeps its paper, sizing and two-sided choice on its own (#2001).
  That setup now keeps the paper by name, so a custom paper is found again however the list
  changes, and one removed since leaves the paper as it is.
- The dialog scrolls within the screen since #6223, so the Custom… row no longer grows it.
- The Print part of the settings restore is its own function (clippy's complexity limit).

Co-authored-by: regendorf-ux <osvaldo.duran09@gmail.com>
Co-authored-by: regendorf-ux <338149330+regendorf-ux@users.noreply.github.com>
iOS Markup keeps a private copy of a page's markup (/AAPL:PPK, /AAPL:PPKHash on the page,
/AAPL:AKExtras on each annotation) and draws from it. Once /Annots changes, that copy is stale:
iOS 26 drew a Markup signature twice. Adding, deleting or answering a comment now drops the copy,
records the drop in the repair log, and leaves a refused edit untouched.

The change lives in lookup.rs (change_annots), called from create.rs (add) and modify.rs (delete,
reply, mark). Each of the four operations has a regression test on an Apple Markup page.

Co-authored-by: BabaKush <101988920+BabaKush@users.noreply.github.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Bump storytold/egui to 725a39fc (lands #6699)
Print a selected area from Take a Snapshot (lands #2491)
CodeRabbit found this on #6805, which landed #6341 on main.
`prune_type3_from_resources` reads what the content draws through its
resources (`resource_ops`) whenever the resources list any font, and only
then looks for a Type 3 font, the only kind it prunes. Since #6341 that read
is charged to what the content walk left of the page's allowance. So a page
with only TrueType fonts and a large form without resources of its own
could be refused, by Apply Redactions or Remove Hidden Information, for a
read that prunes nothing: a 100 MiB form crossing the mark is read by the
walk, the page's check and this read, 300 MiB against the page's 256.

Without a Type 3 font, the read is now charged to an allowance of a page of
its own; with one, to the page's, as before. The read is still made with
any font listed, as CodeRabbit's suggested early return would not: it
refuses bytes the parser can't read in the streams and forms it reads, and
an early return would drop that refusal on every page without a Type 3
font. The walk reads past such bytes, so where nothing else reads the same
content, text a stray `)` hides in a `TJ` array under the mark would stay
while Apply Redactions and Remove Hidden Information reported success
(found in review). The refusal covers only what the cleanups read, so such
bytes still go by elsewhere, here as on main. The read and the
pruning tell a Type 3 font the same way (`is_type3`).

Tests:
- `the_cleanups_after_the_content_walk_spend_what_it_left_of_the_pages_allowance`
  now also redacts the 100 MiB page with no Type 3 font, which goes on; it
  and the two tests like it (a rewritten form's cleanups, Remove Hidden
  Information) list an unused Type 3 font where their font cleanup runs,
  so that it still charges the page's allowance (`list_type3`).
- `text_beside_bytes_the_parser_cannot_read_refuses_the_redaction`: four
  such `TJ` arrays in a page's second stream, one in a form without
  resources, and hidden text beside one, on pages listing only a TrueType
  font, are refused by Apply Redactions and Remove Hidden Information.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
Review of the allowance change. Only Apply's page caller had a row with no
Type 3 font listed. `the_cleanups_of_a_rewritten_form_spend_the_pages_allowance`
and `removing_hidden_text_spends_what_the_content_walk_left_of_the_pages_allowance`
now have one too: at 100 MiB the redaction and the removal go on.

Doc comments that said every cleanup of a page spends the page's allowance
(`prune_type3_from_resources`, `resource_ops`, `prune`) now say the font
cleanup has one of its own when no Type 3 font is listed. The doc of
`text_beside_bytes_the_parser_cannot_read_refuses_the_redaction` says the
cleanups run only when the walk rewrites some of the page, and read what it
rewrote as rewritten. The comment in `prune_type3_from_resources` no longer
calls its read the only check for text: `prune_form_resources` (for a
rewritten form), `prune` (when an image or form is replaced) and
`prune_properties` make the same read when they run, and `unlink_type3`
reads every page when the document still lists a Type 3 font.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
* fix(forms): ignore print requests from automatic field scripts

* docs(contributors): opt in to GitHub profile name sync

---------

Co-authored-by: enriquemelzer-bit <319226743+enriquemelzer-bit@users.noreply.github.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
The redaction walk reads past bytes the content parser can't read: a
stray `)` or `}` in a `TJ` array, say. Text they hide is neither found nor
removed, while other readers draw it: Poppler's `pdftotext` extracts
`[(TAIL) ) (SECRET)] TJ` as TAILSECRET. The only refusal was a side effect
of the reads after the walk, which run only when the walk rewrote
something, read what it rewrote as rewritten, and in the font cleanup's
case only when the resources list a font. So Apply Redactions reported
success and left the text where the walk rewrites nothing (the only text
under the mark in such a `TJ`, or one in a form with resources of its
own), in a stream it rewrites (it wrote the array out as
`(TAIL) (SECRET) TJ`, without the stray byte or the brackets), and on a
page whose font is set only through an ExtGState. Remove Hidden
Information never counted hidden text in such a `TJ`: it found nothing to
remove, or left it beside what it removed.

The walk now records that it met such bytes (`Output::unparsed`, from
`Parsed::skipped`, nested forms included), and Apply Redactions refuses
the page with `RedactError::Unreadable`, as it does an inline image whose
end is a guess. Remove Hidden Information counts such a page in its scan,
so the removal runs and refuses it, as it does content that doesn't
decode; it refuses an inline image whose end is a guess the same way,
which it took on trust before. Apply walks only the forms that reach a
mark, so bytes in a form away from the marks don't make the walk refuse
the page; in a form without resources of its own, the check after the
walk still refuses them when it runs, as before.

Tests (`crates/redact/src/tests/unparsed.rs`): text under a mark in four
broken `TJ` arrays, alone and beside other text; the same in a stream the
walk rewrites; in a form under the mark with resources of its own; on a
page without font resources; hidden text in such a `TJ`, alone and beside
other hidden text, through the scan, Remove Hidden Information and
Sanitize Document; hidden text beside an inline image the renderer can't
end; and a form away from the marks, which is still redacted around. Two
tests in `tests.rs` now expect the walk's refusal; the check after the
walk keeps a case of its own, a form without resources away from the
mark.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
Review of the refusal. `doc_hidden_info` said it lists what Remove Hidden
Information would remove. A page that can't be read in full is counted as
one item of hidden text and of hidden layers, so that the removal runs and
refuses it: since the refusal before this, that includes a page holding
bytes the parser can't read or an inline image whose end is a guess, not
only content streams that don't decode. The description now says so, and
that with form fields removed too, a field's appearance is read the same
way though it isn't counted. The parity note on Apply Redactions lists the
new refusal beside the inline image's.

Removing hidden layers alone refuses such a page in a document without
`/OCProperties` too, which a review asked to narrow: a membership
dictionary there (`/AllOff` of a layer that is on) still hides what it
marks, for the walk and for the app's renderer. The comment at the
refusal in `sanitize.rs` says only what the bytes are shown to hide: text
in a broken `TJ`.

Tests: `bytes_the_parser_cannot_read_refuse_removing_hidden_layers_alone`;
`hidden_text_beside_bytes_the_parser_cannot_read_in_a_field_appearance_is_refused`
(removing form fields with hidden text, and Sanitize Document, which went
on and left it); and `hidden_text_beside_bytes_the_parser_cannot_read_is_refused`
gains hidden text in a broken `TJ` inside a form. The form-away test's doc
says the check after the walk would still refuse a form without resources.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
* M10: retain printed artwork with indirect annotation rectangles

* M10: resolve indirect annotation appearance states when printing

* M10: follow bounded annotation reference chains when printing

---------

Co-authored-by: Kyle Kassen <85040622+KyleKassen@users.noreply.github.com>
* fix(print): keep the localized reset label off the properties panel title

The header of Print ▸ Properties… laid the title out first and gave what
was left of the row to Done and Reset. When the translated "Reset to
printer defaults" needs more room than that, egui keeps placing the two
buttons from the right edge of the child leftwards and paints them back
over the title: in Italian the label is 51 characters against 25 in
English, and both strings end up unreadable (#5545).

Laid out with egui::Sides::shrink_left().truncate instead: the buttons
take their room first and the title takes what is left of the row, cut
with an ellipsis. Nothing moves in English, where the title still fits.

* test(print): pin the header against every catalog and the title's tooltip

The geometry tests behind #5545 tried English, Italian and German at four
widths. They now walk every catalogued language at the row the settings column
really gives the header (470 px less the frame's 10 px margins), so the longest
translation of "Reset to printer defaults" is covered by the run instead of by a
guess at which locale is the worst one.

Two more tests: hovering the elided title opens a tooltip with the full string,
while a title that fits whole opens none; and a row too narrow to give the title
even an ellipsis leaves it out instead of painting it back over the buttons.

That last case is why driver_options_header returns Option<Response>. Bulgarian
(370 px of button), Spanish (365) and Ukrainian (361) fill the row with the two
buttons alone at 450 px: the ellipsis is 12.4 px wide and the clearance was
0.1 px, so a pixel less and the label was painted over the buttons again.

---------

Co-authored-by: Jarvis (Hermes) <jarvis@localhost>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
* fix(ui-egui): keep hotbar picker stationary while resizing

* fix(ui-egui): keep hotbar picker pinned during keyboard resize

Pin the Hotbar tools picker while the Toolbar size slider is adjusted with the arrow keys, until the slider loses focus. Widen the regression test to both drag directions and every layout, add a keyboard test, and document the new helpers.

* fix(ui-egui): closing the hotbar picker ends a size adjustment

close_picker() also clears picker_drag_pos and keyboard_sizing. Main's
close_hidden_picker() closes the picker on passes that don't draw the
hotbar, where show() can't clear them, and the next opening would start
at the held position. Adds closing_the_picker_ends_a_size_adjustment, a
doc comment for picker_drag_pos, and says "picker" where the comment
meant it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: FredApps <FredApps@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* M10: a booklet's both sides follow a driver that keeps its own two-sided setting (#5772)

Booklet ▸ Both sides always asked for two-sided printing on the short
edge (#2707). A driver that doesn't take two-sided settings from other
programs (no two-sided printing, manual two-sided only in its
Properties, Microsoft Print to PDF) then made craft-print refuse the
whole job. The reporter's Epson ET-2870 offers only OneSided, and its
manual two-sided setting is what prints their normal two-sided jobs.

print::booklet_job_duplex asks such a driver for nothing on both
sides, so its own setting applies, as for ordinary printing (#2422)
and as Acrobat enables two-sided for a booklet only "if available".
Unknown support still asks for the short edge; one side on its own
stays one-sided. The Print dialog takes the chosen printer's support
from the printer list (PrintDraft::printer_two_sided, also behind its
"its own setting applies" note), and doc_print reads it only for a
booklet's both sides.

The dialog's booklet test used the machine's printers, and on Windows
the default, Microsoft Print to PDF, now gives the other answer: it
sets its own printers and checks the note too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M10: doc_print's lookup of a booklet printer's two-sided support is tested (#5772)

The booklet test fed job_duplex the printer's answer directly, so a
lookup that missed the default printer or matched the wrong one would
have gone unnoticed. The search over the printer list is now its own
function, two_sided_in, tested with a list of named, default and
unknown printers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M10: a booklet printed to "default" goes to the printer whose driver was asked (#5772)

doc_print decides a booklet's both sides from the printer's two-sided
support. For "default", the job named no printer, so the spooler picked
whichever printer was the default when the job was submitted, after the
document was rendered. The printer found in the list is now the job's
printer, by name, and the lookup test covers a one-sided default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #5903: the booklet test lists its printers through a stand-in source

It set the dialog's printer list by hand while the real list was still loading. On a machine with
no printers (CI's Linux runner) the list that arrived replaced them and reset the printer, so the
dialog the test clicked through was not the one it set up. The printers now come from the
dialog's own source, drivers unasked as #3554 lists them, with each driver's two-sided answer in
the map.

---------

Co-authored-by: austin62jake <340374338+austin62jake@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
The walk read a form's `/Matrix` with `Matrix::from_operands` on the array
as written, so an entry given by reference made it the identity. That is
legal: an array's entries may each be an indirect reference (ISO 32000-2
§7.3.10), and Arlington's `XObjectFormType1` allows the matrix itself to
be one. A form moved onto a mark by such a matrix was then tested against
the wrong place, never walked, and Apply Redactions reported success with
its text still in the file: `/Matrix [1 0 0 1 0 8 0 R]` with `8 0 obj 200`
left SECRET, which Poppler's `pdftotext` places under the mark. Remove
Hidden Information missed text such a matrix moves off the page the same
way. Found in review of the unparsed-bytes refusal.

The walk now reads it with the proof's `matrix_of` (`verify/extract.rs`),
which resolves the array and each entry. A `/Matrix` that isn't six
numbers was the identity too; now where the form draws can't be told, so
Apply refuses the page and the scans count it, as for a form past the
page's budget. `matrix_of` now reads a null `/Matrix`, given directly or
by reference, as the identity: a null entry is no entry (ISO 32000-2
§7.3.9).

Tests (`crates/redact/src/tests/form_matrix.rs`): a form moved onto the
mark by a matrix written direct, as a reference, and with an entry by
reference, redacted; a null matrix, the identity; a matrix that isn't six
numbers, refused, and counted by the verify pass and Remove Hidden
Information's scan; and hidden text a matrix entry by reference moves off
the page, removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
Review of the walk's read of a form's `/Matrix`.

Removing hidden layers alone never asks where content lands, but a form
whose `/Matrix` isn't six numbers was counted in its scan and refused, in
a document without layers too. Such a form is now walked as it stands
there; Apply Redactions, and Remove Hidden Information with hidden text,
which tells hidden text by where it lands, still refuse it.

`matrix_of` resolved and copied every entry before checking there were
six, and the walk reads it at every `Do`. Measured in review (debug
build), 20,000 draws of a form whose
`/Matrix` is 20,000 entries long took 9 s by reference (16 ms before the
walk read entries) and 12 s written directly (2.6 s before, the array
being copied by `resolve`). It now reads an array written directly in
place, and checks the length before resolving any entry.

Tests: `a_form_whose_matrix_is_not_six_numbers_leaves_removing_hidden_layers_alone`,
and `tests/matrix_work.rs` (a binary of its own), whose
`a_long_form_matrix_costs_no_more_than_a_short_one` times 4,000 draws of
a form with a 4,000-entry `/Matrix` against a 7-entry one, written
directly and by reference.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
Review of the walk's read of a form's `/Matrix`. Through `Document::resolve`
it followed a reference to a reference, for the matrix and for each entry,
as the app's renderer does; Poppler doesn't, and reads such a matrix as
the identity and such an entry as 0. With
`/Matrix [1 0 0 1 0 8 0 R]`, `8 0 obj 9 0 R` and `9 0 obj 200`, a mark
where Poppler shows the text applied with success and left it in the file,
where `main`, reading any entry by reference as the identity, removed it.

`matrix_of` now follows each reference one hop, and a reference to a
reference fails as a malformed matrix: where the content draws can't be
told, so Apply refuses the page, as for a matrix that isn't six numbers.
The refusal now says "isn't six numbers given directly or through one
reference", and `matrix_of`'s doc names the appearances it reads, not
patterns. The proof, which reads the matrix of forms and appearances
with it, treats such a matrix as malformed too.

Test: `a_form_whose_matrix_goes_through_a_reference_to_a_reference_is_refused`
(an entry and the whole matrix, with a mark where either reader draws the
text).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
* M10: preserve annotation placement precision (#4995)

* M10: preserve NoRotate and implicit Text print orientation (#5029)

Keep existing printable appearances upright on rotated source pages, including Text annotations whose NoRotate flag is clear.
Preserve original flags and appearance streams, print eligibility and outer page transforms.

* M10: clarify NoRotate verification docs

---------

Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
* Print in grayscale prints on a Windows driver with no grey option (#5806)

Windows refused Print in grayscale ("the printer's driver did not retain the requested
grayscale; nothing was sent") on drivers that list no grey option the ticket can name:
Microsoft Print to PDF, and an HP Photosmart driver whose grey is its own vendor setting.

- On Windows, a grayscale job's copy for the spooler is made grey first with Convert colours
  (all pages, comments and form fields, black kept black, pictures re-encoded where they can
  be), and the job says so (Job::grey_pages). The driver is still asked for grey where it has
  it; one without prints the grey pages instead of refusing them. A copy that can't be read
  goes as it is and asks the driver strictly, as before.
- craft-print 970644b7 (storytold/craft-libs#774) adds Job::grey_pages and asks a driver that
  lists only psk:Grayscale for that, not psk:Monochrome.
- Both the Print dialog and doc_print go through spooler_copy, which now takes the job.

* craft-print 1146cdc4: craft-libs main with #774 (grey pages) and #773 (the CUPS printer list waits at most 15 s, storytold/pdfcraft#6761)
On Windows the printer list reports whether a driver supports two-sided printing, but the job still asked for it anyway, so duplex jobs printed single-sided or misaligned on printers such as the reported Brother model.

Leave duplex unset for a printer reported as not two-sided, so that printer keeps its own setting, while printers that support two-sided or report no capability keep the chosen setting.

Test: duplex_is_not_sent_to_a_printer_without_two_sided failed before the change and passes after.

Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
* Render: an isolated CMYK group of any size blends in CMYK, in bands

hayro patch (27) blended an isolated DeviceCMYK transparency group in CMYK only up to 2^24
device pixels (a 4096 x 4096 square); a larger one was blended in RGB. Print as image renders a
whole page at once, up to 8192 px on the long side, so a page enlarged to 11 x 17 in at 600 dpi
(about 43 MP) blended any group covering most of it in RGB, and a print test file's CMYK blend
patches drew their X's. The same applied to exporting a large page as an image.

A larger group is now blended in bands of whole rows, each within the old limit: both ink passes
and the tint conversion run per band, so their memory stays as before, and only the finished
group (4 bytes a pixel) is kept whole. Every band is blended before any is drawn, so a group a
pass has no tints for is still drawn as before, whole.

The regression test lowers the band to 7 rows (set_cmyk_band_pixels, hidden, per thread like
set_skip_offscreen_paths) and checks the banded group matches the whole one across an
anti-aliased edge, and is CMYK's green where magenta is under black with Difference.

* Test: CMYK green through the profile is (0, 166, 81); check green against RGB's magenta
fix(print): auto-detect printer paper size and add page setup sync (fixes #5861)
* M10: preserve annotation layers in vector printing (#5005)

* #5019 test: a Popup is the annotation that never prints; a Link prints since #5028

---------

Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
#5861 (#6100) reads the chosen printer's papers and default paper in the background, and the
default replaces the dialog's paper unless it was chosen in the dialog (paper_overridden). The
paper a printer's setup restores (#2001, #6779) didn't count as chosen, so on Windows the driver's
default replaced it each time the dialog opened: an office printer last printed on A3 came back
on A4.

apply_setup now marks the paper it restores as chosen. A test drives the dialog with a printer
remembered on A3 whose driver's default is A4.
fix: Continuous Scrolling toggle not working in Preference tab (#6780)
* macOS: a job that names no paper tray asks for Auto Select (craft-print 1ee01a4c, #3752)

craft-print 1ee01a4c (storytold/craft-libs#232) sends `AP_D_InputSlot=` on macOS for a job whose
options name no tray, as the system Print dialog's Paper Feed: Auto Select does. Without it CUPS
used the PPD's default tray, and a printer whose default tray is empty held every job while CUPS
reported it completed (the Xerox AltaLink in #3752).

- The Print dialog's driver options take the queue's defaults through craft-print's
  apply_queue_defaults: option and choice names match as CUPS matches them, and on macOS the
  paper tray starts on Auto Select, which is what the job sends.
- The two tests that pin the exact `lp` arguments expect the Auto Select option on macOS.

* Tests: on macOS a CUPS queue on its driver's default tray shows Auto Select (craft-libs#232)

instances_read_the_queue_ppd_and_their_own_defaults and
a_queue_that_never_answers_is_given_up_at_the_deadline expected the PPD's default tray; on macOS
apply_queue_defaults now offers Auto Select first and as the default there, which is what a job
naming no tray sends. Found by running the print tests on hosted macOS.
Binds Properties dialog drafts to the vendored edit-session crate (byte-identical to PerchAnton/edit-session@033f7c3a, ledger in vendor/README) and adds replayable UI scenarios. Includes a merge of main and a test covering the clean Properties inspection-tab live refresh.
output_probe_paths_preserve_non_utf8_names creates files named with bytes that aren't UTF-8.
APFS keeps only UTF-8 file names, so on macOS creating the probe fails before the check runs and
the test fails; no output can have such a name there. It runs on the other unix systems as before.
Found running the automation tests on hosted macOS.
* feat(js): expose document page boxes

* Normalize page boxes for getPageBox

* Correct page box test expectations and formatting

* Use local page helper in box test

---------

Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
The menu bar folds titles that don't fit into "⋯", or all of them into one "Menu" when not even
the first fits. On hosted macOS, at MIN_WINDOW_SIZE, the bar is the Menu button (the system font is
wider), so the test's File menu item wasn't there and it failed; the main menu is still reachable.
A real macOS window uses the native menu bar.
Pull requests and main run Linux and Windows only; release.yml builds and signs the macOS app
without running the tests, so macOS-only failures went unseen. On 10 Nov 2026 a hand-started run
found three (two print tests and a bates test, fixed in #6905 and #6913) plus one more
(min_window, fixed separately). One macos-latest leg a night, through the shared gate.
An operator takes its operands from the end, and the renderer ignores a
surplus before them: `(x) /OC /MC0 BDC` marks a layer's block, and
`(x) /GS1 gs` selects `/GS1`. Redaction read some of them from the front,
so a surplus stood in for them and what they named went unseen. Found in
review of #6882, where Remove Hidden Information left the text of a
hidden layer in `(x) /OC /MC0 BDC (LAYERSECRET) Tj EMC`, which both the
app's renderer and Poppler hide.

In what Apply Redactions and Remove Hidden Information decide:
- the layers walk read a block's tag and property list from the front,
  and kept a hidden layer's block;
- the cleanup of `/ActualText`, `/Alt`, `/E` and `/T` in a block whose
  paint was removed read (and wrote) the property list from the front,
  and kept the name that still read the removed word;
- the tags cleanup read a block's `MCID` from the front, and left the
  removed text in its structure element's `/ActualText` and `/Alt`; and a
  `TJ`'s array, so a block that still drew was taken for empty and its
  structure element lost the content the page still shows;
- the property cleanups read the name of a `BDC` or `DP` property list
  from the front, and retired a property list the page still draws with;
- the walk read the ExtGState a `gs` selects from the front, so a font set
  only that way was not set, and `cs`/`CS` the same for a pattern space;
- the Type 3 cleanup's reads of `cs`, `gs`, `Tf`, `BDC` and `DP`, which
  decide which resources and fonts are still used.
The redaction proof read `Tc`, `Tw`, `Tz`, `TL`, `Ts`, `Tf`, `"`, `TJ` and
`Do` from the front too. `pdfcraft_content::Op` gains `back`, `name_back`
and `num_back`: operand `k` counted from the end.

Tests (`crates/redact/src/tests/operands_from_the_end.rs`): a hidden
layer's block, `/ActualText` on removed paint, a tag's `MCID`, a tagged
`TJ` that still draws, a property list still drawn with, a font from an
ExtGState, the pattern a `/Pattern cs` keeps, and a Type 3 font with the
ExtGState, colour space, property list and font its glyph uses, each
after a surplus operand. In `tests_verify.rs`,
`the_proof_reads_operands_from_the_end` (`Tf`, `TJ`, and `Do` of a form
and of images), `the_proof_reads_the_text_states_operands_from_the_end`
(`Tc`, `Tw`, `Tz`, `TL`, `Ts` and `"`) and
`an_image_a_type3_glyph_draws_after_a_surplus_operand_is_found`. And
`operands_are_counted_from_the_end` in the content crate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
Review of the operand reads. Path construction was still read from the
front, in the walk (`construct_path`) and in the proof (`extend_path`):
after a surplus number, `9 100 100 m 9 140 100 l ...` placed the square at
x = 9, so a mark over it removed nothing, and an even surplus placed a
square beside a mark under it, which was removed. `m` and `l` now take
their last two numbers, `c` six, `v` and `y` four, and the proof's `re`
four, as the walk's already did.

The tags cleanup read an `MCID` with `Dict::int`, so one given by
reference in a named property list, or in a structure element's `/MCR`,
was missed and the element kept the removed text in `/ActualText` and
`/Alt`. It is read through the reference now.

Tests: `a_paths_points_after_a_surplus_number_are_where_the_renderer_draws_them`
(lines, and curves by `c`, `v` and `y`),
`an_mcid_given_by_reference_loses_what_redaction_removed` (and an `/MCR` of a
form's stream that stays), and
`the_proof_reads_a_paths_points_from_the_end` (lines, `re`, and each of
`c`, `v` and `y` from a point outside the region).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
* Fix #5211: make calculateNow synchronous

* Bound and cache calculation execution

* fix(engine): invalidate field calculation cache

* perf(js): build calculation scripts lazily

* Refresh calculation cache after page insertion

* Load calculations for document script helpers

* Assert imported calculation result

* Initialize formatted field-change state

* Update formatted calculation test expectations

* Include the referenced field in calculation test

---------

Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
The tags cleanup resolved the page's `/Properties` at every named `BDC`,
on main too: 20,000 of them with 5,000 entries took 28 s, as measured in
review (optimized), against 0.07 s with one entry. And the second commit's
`MCID` by reference was read again at every `BDC`: the document keeps an
object larger than its cache only until it reads another, so reading the
`MCID` evicted a property list that large, which was parsed again, all of
it, at the next `BDC` (50 `BDC` took 12.3 s against 0.44 s with the
`MCID` given directly). Now the named property lists are read once for
the stream, in place when they are direct, and each name's `MCID` once.

Tests (`crates/redact/tests/operator_work.rs`, a binary of its own whose
tests run one at a time), each the least of three runs against one entry
or operator, allowing ten times as long plus a second:
`named_property_lists_are_read_once_for_a_page` (5,000 `BDC`, 2,000
entries) and `an_oversized_property_list_is_read_once_for_a_stream` (100
`BDC` naming a list too large to keep).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
Review of the whole change found reads it fixes that no test failed
without: each was put back to main's read alone, and the tests passed.
Each now has a case that fails without it:
- the proof's `Tf` size (text starting outside the region reaches into
  it only at its size) and font (an undecodable font's text leaves the
  region unverifiable), and the strings a `TJ` carries;
- the proof's `l`, `c`, `v` and `y` points after a surplus, from a point
  outside the region, and the walk's `m`, `l`, `c`, `v` and `y` points
  beside a mark, each alone;
- the property list a second page sharing the resources still names;
- a Type 3 font an ExtGState's `/Font` sets, kept by the page and its
  glyph by the font, and one without resources of its own, which keeps
  the page's pattern its glyph may paint with;
- a Type 3 font only a soft mask's group selects, the mask's ExtGState
  named after a surplus;
- the named property lists read once, with each `BDC` naming another
  entry, so that it is the lists, not each name's answer, that are kept.

And the walk's `cs` says why it tells a Pattern space by the name
`/Pattern` alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
adding_custom_paper_overrides_pending_driver_default clicked Custom… as soon as the background
paper read started; on hosted macOS the dialog hadn't drawn its paper row by then and the test
failed (no node "Custom…"). It now waits for the button, up to 10 s, before clicking. Found by the
first nightly macOS run.
* Add push-button caption script methods

* Apply scripted caption with other field changes

* Complete caption field property initializers

* Apply scripted captions to every button widget

* Format caption regression test

* Cover caption getter errors and redo state

* Assert button caption undo state

* Reject button caption writes during format events

* Test empty push-button captions

* Validate push-button caption updates

* Keep no-widget caption test data alive

* Check caption clipping marker

---------

Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
* Fix #5211: persist JavaScript field colours

* Read current document state in field scripts

* Redraw form fields after validation changes

* Format validation redraw regression test

* Keep field list alive in redraw test

* Refresh fields only after same-field scripts

* Format conditional field refresh

* Borrow accepted field value for storage

* Redraw calculated fields from current appearance

* Format JavaScript field state merge

* Restore JavaScript runner document state

* Group JavaScript runner form inputs

* Read current field display state in scripts

---------

Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
The image steps leave an image over 80 million pixels alone, for memory:
decoded at eight bits a pixel or more, it would hold 80 MB a component
and more. An image already of one bit a pixel holds a byte for each 8
samples of a row, so a large black-and-white scan (an A0 page at 600 ppi
has about 560 million pixels) was left as stored though its samples
would take less than 80 MB.

The monochrome step now takes an image of one bit a pixel as stored (an
image mask, or one bit in one colour component), a picture's one-bit mask
included, while its rows hold no more bytes than 80 million pixels of
eight bits (`one_bit_bytes`): up to 640 million pixels when it is wide,
and a strip one sample wide no taller than before. One it would pack
from eight bits keeps the 80 million limit, as it is decoded at eight
bits first. A fax image's code is allowed no more than 80 MB for its
pixels however large the page, and four bytes a row, where it was
allowed about a byte a pixel (`fax_code_allowance`). When planning how
many pictures run at once, a one-bit image counts as the bytes its rows
hold and one more a row, as decoding allows each row's end a few bytes
besides its samples. The optimize README and the parity note say which images the monochrome
step now takes, and the note states JBIG2's limit of 2^28 pixels in all,
as the README does.

`group_4_takes_images_of_one_bit_a_pixel_up_to_the_pixel_limit` now
expects its one-bit image of 10,000 by 8,001 stored again, where it was
left alone. The packing path counts what it decodes, for the tests, as
the one-bit path does.

Tests: `one_bit_images_past_80_million_pixels_are_stored_as_group_4` (a
one-bit image, a picture's stencil mask and a picture's one-bit soft mask
of 9,000 by 8,900), `a_one_bit_image_past_80_million_pixels_is_averaged_with_the_default_settings`,
`an_image_too_large_for_the_jbig2_decoder_stays_as_it_is` (2^28 pixels
stored as JBIG2, a row more left alone, and averaged), and the unit
tests `one_bit_images_are_decoded_up_to_their_own_limit` and
`a_one_bit_picture_counts_as_the_bytes_of_its_rows` (each with a strip one
sample wide, at its limit and past it) and
`a_fax_images_code_is_allowed_80_million_bytes_and_four_a_row` and
`a_zero_wide_image_is_not_decoded`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
main fails to compile (E0592): the merge of #6046 added Stream::decoded_strict_within next to the one already there. Both decode the whole chain bounded by max and map the error to CosError::Filter; the earlier one stays, with #6046's test.
Behaviour-preserving: `[[ ]]` in place of `[ ]` for bash tests (SonarCloud shell:S7688). Every script has a bash shebang; no test uses `-a`/`-o`, escaped parentheses or an unquoted pattern on the right of `=`. `bash -n` passes on each script; the rest is left to CI.

- `packaging/freebsd/package.sh`
## What this changes

`read_xref_chain_materialized` in `crates/cos/src/document/reader.rs` keeps a running total of the rows it merges across the cross-reference chain. Past `MAX_XREF_OBJECTS` (8,388,607) it returns `CosError::Syntax` ("cross-reference chain lists more than 8388607 entries"). The open path then rebuilds the index from object headers and logs the repair, as it already does when a single section decodes past that cap. Before this, the per-section cap and the 10,000-section chain limit multiplied, so the merged map had no overall bound.

Each section adds `sect.len() + odd.len()`: its entries plus the rows it set aside, including those from a hybrid `/XRefStm`. Set-aside rows are kept for the whole chain, and a section can set aside nearly everything it lists. The count is of rows merged, not the map's size, because a chain that relists the same objects costs as much to merge. A section's rows never outnumber what the compact reader counts for it, and that reader stops at 2,000,000 rows. So no chain the compact reader accepts is refused here.

The limit itself lives in `read_xref_chain_materialized_with_limit` (`pub(super)`). The existing entry point, `read_xref_chain_materialized`, calls it with `MAX_XREF_OBJECTS`, so tests can reach the limit in a few rows. Both functions have doc comments.

Fixes #5082

## How you verified it

Three regression tests in `crates/cos/src/document/tests.rs`. The two small-limit tests were suggested by @Harrison-Booth in review.

1. `a_cross_reference_chain_past_the_total_cap_is_refused_and_the_file_reconstructed` runs end to end at the production cap. It builds a synthetic file:
   - a catalog, a page tree with one page, and a chain of three cross-reference streams;
   - the newest stream locates objects 0 to 6;
   - two older Flate-compressed streams each list 4,200,000 rows (`/W [1 0 0]`, disjoint `/Index` ranges). Each is half the per-section cap and too large for the compact reader. Their rows are in use at offset 0, so they are set aside, which keeps the test at a few bytes a row.

   It asserts that `Document::open` logs the cap with "rebuilding from object headers" and then "rebuilt cross-reference index ...". The rebuilt document must keep `/Root 1 0 R` and the `/Page`. `read_xref_chain_materialized` must return `CosError::Syntax` at the oldest stream's offset.
2. `the_chain_row_limit_counts_disjoint_and_relisted_rows` uses three classic tables of 2, 1 and 1 rows. A limit of 4 passes, and a limit of 3 refuses the chain at the oldest table. This holds when the sections list different objects, and when one relists an object a newer section has: that is a map of three entries but four rows merged.
3. `the_chain_row_limit_counts_hybrid_rows_set_aside` uses a hybrid file whose table puts object 3 at an offset while its `/XRefStm` puts it in an object stream. It merges six rows and sets object 3 aside. A limit of 7 passes, and a limit of 6 refuses it.

Seen failing, each by reverting part of the fix in the working tree:

| Revert | Test 1 | Test 2 | Test 3 |
|---|---|---|---|
| no chain check at all | fails (empty repair log) | fails | fails |
| count without `sect.len()` | passes | fails | fails |
| count without `odd.len()` (state before 780764cb) | fails | passes | fails |
| fix restored | passes | passes | passes |

All three run in about a second together.

Commands, all with `-j 2` and a separate `CARGO_TARGET_DIR`:
- `cargo fmt -p pdfcraft-cos --check`: clean
- `cargo clippy -p pdfcraft-cos -j 2 --all-targets --locked -- -D warnings`: clean
- `cargo test -p pdfcraft-cos -j 2 --locked`: 95 unit tests and 39 integration tests pass (2 ignored)

- [x] `cargo fmt --check`
- [x] clippy with `-D warnings` on the crates I changed
- [x] tests pass on the crates I changed
- [x] n/a — `cargo xtask layers` / `cargo xtask assets`: no crate boundary or asset touched

## Never-crash

- [x] No new `unwrap`/`expect`/`panic!`/`unreachable!`/`todo!` outside tests (the offset conversion uses `unwrap_or(usize::MAX)`)
- [x] Checked arithmetic: `saturating_add` per section, `checked_add` for the running total; the change bounds an allocation sized by input
- [x] No `unsafe`
- [x] The unbounded allocation this fixes has a small synthetic regression test

## Provenance

- [x] n/a — no assets of any kind
- [x] n/a — no font files
- [x] No GPL/AGPL code copied in
- [x] Behaviour comes from ISO 32000 (§7.5.8 cross-reference streams, the Annex C object limit) and the crate's existing escalation path
- [x] n/a — no new assets, so no `ATTRIBUTION.toml` entry

## Security

- [x] n/a — no file paths or tool dispatch touched (`--root` confinement unaffected)
- [x] n/a — MCP and control channel untouched
- [x] No new outbound network access

## Parity and reach

- [x] n/a — hardening of the cross-reference reader, not a parity feature; no `parity/` entry changes
- [x] n/a — no new behaviour to expose; every `automation` tool that opens a document gets the cap
- [x] n/a — no documented behaviour changes

## Anything a reviewer should look at first

- The check runs after each section is read. So one section, already under the per-section cap, is decoded before the chain is refused. Peak memory stays bounded by the cap plus one section.
- The row limit is a parameter of a `pub(super)` function so tests can set it small. Production always passes `MAX_XREF_OBJECTS`, and the error text names whichever limit applied.


<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Bug Fixes**
  * Documents with cross-reference chains that exceed the supported total size now report a syntax error at the section where the limit is exceeded.
  * The limit applies across the entire chain, including repeated object numbers and set-aside entries, so individually valid sections cannot exceed the total cap.
  * When opening an affected document, the index can be rebuilt from object headers, allowing available catalog and page content to be recovered.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## What this changes

Batch processing now keeps the caller's "Enable JavaScript" setting. Before, each file was processed in a fresh `Session::new()`, where JavaScript is on, so open-time XFA scripts and "Run a JavaScript" steps ran and their results were saved.

- `actions::run_on` and `ocr::recognize_file` take a `javascript: bool` and call `set_javascript` before opening the file. In `recognize_file` the open now goes through a small private helper, `ocr::open_file`, so the open path can be tested without the recognition models.
- `doc_batch`'s `Recipe` carries the setting into its per-file session and its OCR step.
- Callers pass their own `session.javascript()`: the desktop Action Wizard, desktop multi-file OCR, `action_run`, `ocr_recognize_files` and `doc_batch`.

Also relevant to #5054: an administrator's `DisableJavaScript` policy now reaches these sessions.

Fixes #5080

## How you verified it

Regression tests, adopted from the reviewers' patches, plus one for the OCR open path:

- `crates/engine/src/tests.rs::an_action_runs_a_javascript_step_only_when_javascript_is_on` (croquete1): a "Run a JavaScript" step writes its field with JavaScript on, and `run_on(.., false, ..)` refuses it instead of saving a scripted value. croquete1 reported it failing with the `set_javascript` call in `run_on` removed.
- `crates/automation/tests/it/automation.rs::file_processing_keeps_javascript_off_for_xfa_open_scripts` (Harrison-Booth): with `js_enabled` off, an XFA initialize script that sets `qty` to 42 doesn't reach the output of `action_run`, `doc_batch` or (only when its models are installed) `ocr_recognize_files`. The contributor reported that each propagation removed on its own makes it fail.
- `crates/engine/src/ocr.rs::tests::files_open_for_recognition_with_the_callers_javascript_preference`: opens the scripted XFA fixture through `recognize_file`'s open path with JavaScript off (the initialize script leaves `qty` empty) and on (it sets `qty` to 2). This covers the OCR propagation in runs without the OCR models.

What was run locally, and what wasn't:

- `cargo fmt --check` and `cargo clippy --all-targets -- -D warnings` were clean on `pdfcraft-engine`, `pdfcraft-automation` and `pdfcraft-ui-egui` at the earlier heads. On the latest commit only `cargo fmt --all` was run; clippy was not re-run.
- No tests were run locally, including the new `ocr::tests` one: test builds time out on this machine, and the run was stopped before it compiled. The new test has not been seen failing either. If `set_javascript` is removed, the session keeps its JavaScript-on default, so the off case would read `qty` = 2 like the on case, and the first assertion would fail. Seeing that happen is still owed.
- The tests are left to the maintainers' CI. No CI result is claimed for this PR.

- [x] `cargo fmt --check`
- [ ] clippy with `-D warnings` on the crates I changed (clean at the earlier heads; not re-run on the latest commit)
- [ ] tests pass on the crates I changed (not run locally)
- [x] `cargo xtask layers` and `cargo xtask assets`: N/A. No crate boundaries, dependencies or assets change.

## Never-crash

- [x] No new `unwrap`/`expect`/`panic!`/`unreachable!`/`todo!` outside tests. The new non-test code only passes a `bool` and propagates errors with `?`.
- [x] No indexing or slicing with input-derived positions, no new arithmetic, allocations or recursion.
- [x] No `unsafe`
- [x] Any crash this fixes has a regression test: N/A, this fixes no crash.

## Provenance

- [x] No asset from an Adobe product, and no Adobe icon recreated, traced or imitated (no assets change)
- [x] No font files committed here
- [x] No GPL/AGPL code copied in
- [x] Behaviour comes from the project's own JavaScript preference (`Session::set_javascript`) and issue #5080, not from another implementation's source
- [x] Every new asset has an `ATTRIBUTION.toml` entry: N/A, no new assets

## Security

- [x] `--root` confinement still holds: `action_run` and `ocr_recognize_files` still resolve every input and output through `resolve`, and `doc_batch`'s path handling is unchanged. No new paths are read or written.
- [x] MCP and the UI control channel are untouched.
- [x] No new outbound network access.
- This narrows script execution: with JavaScript off, the per-file sessions of the Action Wizard, multi-file OCR, `action_run`, `ocr_recognize_files` and `doc_batch` no longer run document or step scripts.

## Parity and reach

- [ ] `parity/acrobat-features.toml` not updated: this is a bug fix, and the `misc.batch-processing` entry's status (`partial`) and notes stay true.
- [x] Reachable headlessly: `action_run`, `ocr_recognize_files` and `doc_batch` take the setting from the session, which `js_enabled` sets.
- [x] Docs: N/A. No documented behaviour changes (`docs/cli-batch.md` doesn't describe JavaScript handling).

## Anything a reviewer should look at first

- The existing `run_on` tests pass `true` to keep their behaviour; the tests above cover the JavaScript-off path.
- `recognize_file` now has 7 parameters, the `too_many_arguments` limit.
- The OCR branch of the end-to-end test runs only where the OCR models are installed. The engine unit test covers the OCR open path without them. Provisioning the models in CI is left as a separate decision.


<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Bug Fixes**
  * File actions, batch processing, and OCR now honor the JavaScript setting from your current session for each file.
  * When JavaScript is disabled, document scripts—including scripts that run when a document opens—won’t execute during processing. When enabled, JavaScript steps can run as expected.
  * This behavior applies across automated and in-app workflows. Per-file results and OCR output handling remain unchanged.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
* Print: the printer's own paper sizes in the Paper list, named as the job's media

The work of #2672 on current main as one commit (its earlier four commits are on
MaxFairwinds/pdfcraft `feat/printer-paper-sizes-pre-squash`), moved to the split Print dialog
(workflows/print_ui/{draft,body}.rs) and the single integration-test binaries.

- spool::printer_papers: on CUPS the queue PPD's *PaperDimension entries (named by their labels,
  the *DefaultPageSize one marked), read with the same queue checks and capped PPD read as
  printer_options; paper_of_size finds one by size, as the driver feeds it.
- The Paper list offers the printer's sizes after the standard ones, read each time the dialog
  opens and when the printer changes. It starts on the printer's default size (the standard
  entry when that is a standard size as the driver feeds it), unless a paper was chosen, in
  the dialog or remembered from the last session (#2156), and the printer lists it.
- The job names the chosen sheet as its media by the printer's own keyword, also for a standard
  size the printer has, else by main's media_option.
- printer_papers tool; doc_print takes one of the printer's sizes as paper (paper_choice), and
  with none named uses the printer's default, for "default" the default queue's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Print: name the driver's default keyword for a size it offers twice; fix a parity evidence path

A standard size chosen in the Paper list is sent as the printer's own keyword
for it. When the driver offers that size under two keywords (Letter and
Letter.Fullbleed), the job now names the driver's default one rather than the
first. The printer_papers_are_read_from_the_queue_ppd evidence entry named a
module path that xtask parity cannot resolve.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Print: a chosen printer paper keeps its keyword when the sizes are read again

When the chosen paper was one of the printer's own entries and its size is a
standard one (Letter.Fullbleed), reading the printer's sizes again (the dialog
reopened) matched it by size alone: it became the standard Letter and the job
named the driver's default keyword for that size. The entry is now kept by its
keyword when the printer still lists it at that size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Print: test that a PPD keyword that would break a CUPS option is not offered as a paper

A paper's keyword becomes the job's `media` option. parse_ppd_papers already
leaves out keywords with a space, `=` or a quote; the PPD test now has lines
with each, and fails when that check is loosened.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Print: on CUPS a job on a standard size names the PPD keyword of that size

PdfCraft's 4 x 6 in preset goes out as w288h432, which a driver with its
own keyword for that stock (a 102 x 152 mm label) didn't get mapped. A
cups_media name, a w<w>h<h> name or a PWG self-describing name now maps
to the nearest PPD paper within 5 pt a side, the epsilon CUPS's
_ppdCacheGetPageSize uses.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
## What this changes

lopdf's fallback loader (used by `inspect` when the lazy reader fails, and always by `attachment_data`) parsed every object stream eagerly, capping only decoded bytes. A stream of `0 0 0 …` inside the 256 MiB limit built ~120M `Object`s (10+ GB) and aborted. This bounds what it builds:

- **Per top-level object:** at most 4,194,304 objects (`parser::MAX_OBJECT_NODES`, the same as cos's `MAX_NODES`), counting every array element and dictionary value at any depth. One budget is threaded through `array` / `_dictionary` / `_direct_object`. An object is charged once it is built, so failed `alt` branches cost nothing, and an exhausted budget is a nom `Failure`.
- **Per object stream:** at most 1,000,000 index entries (cos's `/N` clamp), and the index list is no longer allocated from the whole block.
- **Per load:** at most 8,388,608 objects across all object streams, in an atomic `NodeBudget` shared by rayon workers. A member reserves up to its own byte length (every object takes at least one byte) and refunds what it doesn't use, so parallel members can't hold the budget between them. Once the budget is spent, the remaining members are skipped with a warning.
- **`convert` in `crates/render/src/structure.rs`:** the depth limit is now 128, one level per array or dictionary, matching cos's parser. Before, it was 100 with two levels per dictionary, so ~51 nested dictionaries alone forced the lopdf fallback.

Logged as lopdf patch (7) in `vendor/README.md`.

Fixes #5083

## How you verified it

`cargo fmt --check` on `pdfcraft-render` and `cargo clippy -p pdfcraft-render --all-targets -- -D warnings` (which compiles the vendored lopdf). Regression tests in `crates/render/src/inspect/tests.rs`, where lopdf patches are tested in-tree (the vendored lopdf's own tests need its missing `assets/example.pdf`):

- `object_stream_members_over_the_node_cap_are_skipped` (croquete1): a member that is one array of 4.3M elements is skipped at the per-object cap; a small member is kept.
- `object_stream_index_entries_past_the_clamp_are_ignored`: of 1,000,001 distinct members, the one past the 1,000,000-entry clamp is never built.
- `object_streams_of_one_load_share_their_node_budget`, `object_streams_loaded_in_parallel_share_their_node_budget` and `a_malformed_object_stream_member_refunds_its_reservation` (Harrison-Booth): a small shared `NodeBudget` bounds the members of several streams, serially and across threads, and a malformed member refunds what it didn't build. `NodeBudget` and `ObjectStream::new_within` are now public for this.

- [x] `cargo fmt --check`
- [x] clippy with `-D warnings` on the crates I changed
- [x] tests pass on the crates I changed (`cargo test -p pdfcraft-render --lib object_stream`: 9 passed, including the five new tests)

## Never-crash

- [x] No new `unwrap`/`expect`/`panic!`/`unreachable!`/`todo!` outside tests
- [x] No indexing or slicing with input-derived positions; the budget uses `checked_sub` and a `min`-bounded reservation; index entries and objects built are capped; nesting stays bounded (128 in `convert`)
- [x] No `unsafe`
- [x] The abort this fixes has synthetic regression tests (above)

## Provenance

- [x] No assets, fonts or copied code; the limits follow `pdfcraft_cos`'s own caps

## Security

- [x] No change to `--root`, MCP, the control channel or network access

## Parity and reach

- Not applicable: an internal robustness fix to the fallback loader, with no user-facing feature or parity entry. The vendored-patch log (`vendor/README.md`, lopdf (7)) is updated.

## Anything a reviewer should look at first

- `vendor/README.md` now lists the tests above for patch (7).
- Ordinary (not compressed) indirect objects are capped at 4M objects each but aren't charged to the per-load budget; their total is bounded only by file size.
- `get_compressed_object` still decodes and re-parses the whole stream per lookup (pre-existing); it now gets one object's budget.
- Objects nested 101–128 deep now reach the Inspector through the lazy reader rather than being skipped by the fallback. The Inspector's own walkers cap depth at 32 or less.


<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Bug Fixes**
  * Added limits to PDF object parsing and object-stream processing. Loading now parses only selected members, while lookups parse only the requested member. Members that exceed available limits are skipped in a deterministic order.
  * Increased the supported nesting depth for dictionary and object conversion, allowing more deeply nested content to be processed.
  * Documented that conversion failures can trigger eager loading as a fallback.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## Summary

Fixed selected-text comments that were previously shown as separate sticky notes, often covering text and making their target unclear. Comments are now attached directly to highlight annotations, with a small marker and connector beside the highlighted word. Hovering shows only the comment text, while normal highlights and other annotation types keep their existing behavior.

Fixes #3759 

## What this changes

Fixes the confusing behavior when adding comments to selected text.

- Selected-text comments are stored as highlight annotations.
- Commented highlights display a small message marker and connector.
- Hovering a commented highlight shows only the comment text.
- Normal highlights and other annotation types retain their existing display.
- Cancelling a selected-text comment clears its pending selection state.

### Screenshots

### Before

<img width="1763" height="293" alt="Screenshot from 2026-10-21 04-18-37" src="https://github.com/user-attachments/assets/b88cb60a-82e9-4dd6-81ae-053b482c1e81" />


### After

<img width="2474" height="930" alt="image" src="https://github.com/user-attachments/assets/ee5c6c31-6a6a-4dd8-a3c4-baa04d9908f5" />


## How you verified it

- [x] `git diff --check`
- [x] `cargo fmt --check`
- [x] clippy with `-D warnings` on the changed crate
- [x] tests pass on the changed crate
- [x] `cargo xtask layers` and `cargo xtask assets` — not applicable

Manual verification was performed with a PDF containing selectable text:

- Added a comment to a selected word.
- Confirmed the word remains highlighted.
- Confirmed the comment marker appears near the highlighted word.
- Confirmed the popup displays the comment text.
- Confirmed ordinary highlights remain visually distinct.

Cargo tests could not be run in the agent environment.

## Never-crash

- [x] No new `unwrap`/`expect`/`panic!`/`unsafe`
- [x] No new input-derived indexing or unsafe arithmetic
- [x] No crash fix is included in this PR

## Provenance

- [x] No assets added
- [x] No fonts added
- [x] No GPL/AGPL code copied
- [x] Behavior was implemented from the existing PDF annotation model and project behavior
- [x] No new asset requires attribution

## Security

- [x] No path or automation behavior changed
- [x] No MCP or UI control-channel behavior changed
- [x] No outbound network access added

## Parity and reach

- [ ] `parity/acrobat-features.toml` updated — not applicable
- [ ] New automation tool — not applicable
- [ ] Documentation update — not required for this UI bug fix

## Anything a reviewer should look at first

The main behavior change is in the selected-text comment path: it now creates a highlighted annotation carrying the comment instead of an independent sticky note. This preserves the selected text as the comment’s visual anchor while keeping normal sticky-note placement unchanged.

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **New Features**
  * Comments on selected text are saved as highlights, preserving the text location.
  * Choosing the sticky-note tool and clicking elsewhere replaces the selection-anchored composer and places a comment at the new location.
  * Comments with text markup display a marker on the page, including near page edges. Hovering over markers shows the author and comment, and markers remain selectable across page rotations and zoom levels.
* **Bug Fixes**
  * Markers take priority when selecting or hovering over overlapping annotations. Markers are not shown for comments without text or outside the page.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## What this changes

The nesting guard that keeps hostile scripts from overflowing the stack in boa's parser could be put out of phase, and this replaces it with one that can't be. boa parses by recursive descent with no depth limit of its own (boa_parser 0.22 has none to turn on), and a stack overflow aborts the process, since `catch_unwind` can't catch it.

**The problem.** `refuse()` scanned bytes and guessed where strings begin. A quote inside a regular expression put it out of phase with the real strings. The first version of this PR counted opening brackets inside skipped text, which closed the reported `/'/` reproduction. It still let closing brackets inside *real* strings count as code. `x=` followed by 5,000 lines of `(/"/,"))))",` nests 5,000 deep in 65 KB (under the 256 KiB limit), while that scan never went past depth 1. The same works with `'`, and with template literals, which the scan didn't read at all.

**The fix.** The guard now lives in `crates/js/src/nesting.rs`.
- **It doesn't guess.** Whether a `/` is a division or starts a regular expression depends on where the parser is: boa's parser, not its lexer, sets that. So the scan follows every reading at once. At each `/` that isn't a comment, it splits into a division reading and a regular-expression reading, and counts nesting on each. boa's reading is always one of them, so the deepest count is at least boa's real depth. That makes it a sound bound, not a mitigation for one reproduction.
- **It drops readings boa rejects,** at the byte where boa stops with a syntax error: a string or regular expression running into a line break, or a regular expression whose groups don't match. boa runs `regress::try_parse` on each literal as it lexes it. This keeps ordinary divisions such as `Math.round(a / b * 100) / 100` from leaving stray readings that add up to false depth.
- **It reads the rest of the lexical grammar that changes what's code:** strings with escapes and line continuations, comments, template text and `${}` substitutions (which count as a level), the Annex B `<!--` and `-->` comments (followed both ways), and a leading `#!`.
- **It caps the work.** At most 16 readings are followed at once. Readings in the same place are merged first, keeping the larger counts, and past that the script is refused.

The limits are unchanged: 64 brackets, a run of 64 prefix operators, 4,096 conditionals. Every path that hands source to boa goes through `refuse()`: AcroForm scripts, XFA JavaScript, and `CheckedCodeFromStrings`.

Brackets inside strings and comments no longer count at all. That drops the trade-off in this PR's first version, which refused a script whose strings held more than 64 opening brackets in total.

Fixes #5079

## How you verified it

Locally, only formatting was run. I didn't build or test this commit, and I'm relying on CI for the build, clippy and the tests.

- [x] `cargo fmt --check -p pdfcraft-js` passes.
- [ ] clippy with `-D warnings` on `pdfcraft-js`: not run locally.
- [ ] `cargo test -p pdfcraft-js`: not run locally. A local test build was stopped before it compiled anything, so I have no local results for this commit.

What the tests check:
- `a_quote_in_a_regex_cannot_put_the_guard_out_of_phase`: the 5,000-line bypass above, and the same with `'` and backticks. It also runs a script nested exactly to the limit, closed properly.
- `nesting_guard_reads_templates_regexes_and_comments` and `divisions_do_not_add_up_to_a_refusal`: controls that must run.
- `boa_rejects_what_the_nesting_guard_drops`: the boa behaviour the guard relies on.
- The XFA-open test (`session::a_quote_in_a_regex_cannot_hide_nesting_from_an_initialize_script`) now runs the 5,000-line bypass too.
- `ordinary_nesting_still_runs` again expects 1,000 brackets inside strings and comments to run.

Not yet shown: that these tests fail on the previous head (439f86220). By reading the previous scan, the `(/"/,"))))",` script never goes past depth 1 there, but I haven't run that.

## Anything a reviewer should look at first

- `boa_rejects_what_the_nesting_guard_drops` pins the assumptions behind dropping a reading. If a boa upgrade starts accepting one of those inputs, that test fails, and the guard has to keep that reading.
- Out of scope: recursion that isn't bracket nesting, prefix runs or conditionals, such as long `a=a=a=…` chains or `if(1)if(1)…` statements. The guard didn't count those before this PR either. They're worth a separate issue once someone checks whether any of them overflows the stack within 256 KiB.


<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Bug Fixes**
  * Scripts that exceed supported nesting limits are rejected with a “nested too deeply” error, including excessive bracket nesting, prefix operators, and conditional expressions.
  * Nesting checks now account for strings, comments, templates, regular expressions, and other JavaScript syntax, avoiding incorrect results from bracket-like text or ambiguous slash characters.
  * After a nesting-related refusal during initialization, the session remains usable.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
* Print: flatten transparent sheets for GDI (v3) printer drivers

Windows sends a job for a GDI (v3) driver through its XPS-to-GDI
conversion, which fails on soft masks (#6155), prints halos around them
(#5838) or prints transparency as black boxes (#5940). XPS (v4) drivers
print the same pages correctly.

spooler_copy takes a flatten_transparency flag. With it, after greying
and before tiling, each sheet that uses transparency is replaced by an
opaque picture of itself (spooler::flattened): rendered at 300 dpi, lower
for a sheet past 80 MP or 16000 px a side, in tiles of at most
2000 x 2000 px, each an RGB Flate image over white, so no tile reaches
Windows' 2^22 px image cap and tiling leaves them alone. The sheet keeps
its boxes and rotation; its group and annotations go. Sheets without
transparency stay vector.

pdfcraft-preflight gets page_uses_transparency, the PDF/A transparency
search without counting a transparency group on its own, which Word
puts on every page.

Both callers pass false until the GDI-driver check lands.

* Print: flatten transparent sheets only for a GDI (v3) printer driver

spool::xps_driver / spool::gdi_driver read whether the printer's driver renders XPS itself,
through craft-print's new xps_driver (storytold/craft-libs#804, winprint
storytold/winprint.rs#21). The Print dialog and doc_print pass gdi_driver to spooler_copy's
flatten_transparency, so XPS (v4) drivers, PDF printers among them, keep vector sheets.

craft-print pinned to the craft-libs#804 branch, winprint to #21's, until they merge.

* craft-print 60ede99985ef1e5df87a2aca66ed538eb23dec99: craft-libs main with #804 (xps_driver), winprint bc06a7c4 (#21)

* Cargo.lock: the two winprint entries in Cargo's order (by source, after the re-pin)
* AcroForm: integrate persistent timesheet script workflows

* AcroForm: address review caching, worker stacks and live metadata

* M6: address second review of AcroForm script workflows

* M6: handle top-level ASI boundaries and verify cyclic calculation results

* M6: bound script workers and isolate pending completions across edits

* M6: make form resets visible before subsequent script reads

* M6: include calculated dependents in reset comparison baselines

* M6: bound reset writeback work before native completion

* M6: charge expanded reset selections and field name bytes

* M6: bound string conversion bytes and charge reset widgets

* M6: preserve unread defaults and bound prompt text conversion

* M6: bound reset calls early and preserve calculated field identity

* AcroForm: address grouped calculations, field identity and widget ownership

* AcroForm: retire field instances and address native review edge cases

* AcroForm: route comparison Save As and Print to the source document

* M6: dispatch text widget mouse-up scripts

* M6: honor fit bounds for form button icons

* AcroForm: bound calculation work and preserve reset and save integrity

* Harden AcroForm script requests and worker recovery

* Keep form callbacks and focus consistent across edits

* AcroForm: preserve reset ordering and bound edit callbacks

* Fix reset appearance ordering and XFA focus after row removal

* Keep XFA focus indexes and stopped reset appearances consistent

* Document form script execution and reset helpers

* Keep script workers bounded and reject partial callback edits

* Keep form events available after a document script reaches VM limits

* Keep main's first-value Simple calculation and restore the calculateNow helper test

AFSimple_Calculate (JavaScript) and the native Simple calculation count each field's first
value again, as #5304 landed them; summing every selection of a multiple-selection list is
a separate behaviour change. Restores the document-helper calculateNow test and the
AFSimple_Calculate doc comment, and adds native and JavaScript multiple-selection cases.

* Patch the callback field snapshot in place instead of rebuilding it

Each field callback rebuilt all field states (3,129 on the benchmark form), and the
retained realm held the previous snapshot, so stamping instance tokens copied it again.
The runner now keeps its last snapshot and patches only the values and flags that
changed; ScriptSession::stamp_fields stamps it before dispatch, the worker copies only
when a token changes, and the realm drops the job's snapshot when the job ends (field
identity is checked against the cached keys instead).

* An oversized job script is that script's error, not a refused edit

Realm::run refused a script too long or too deep to run as an execution refusal, which
made the engine reject the whole edit (setting a 300 KiB calculation script failed). As on
main it is now a plain script error that keeps the value; the realm is not touched.

* Read a leading #! line as a comment in the chain scan; follow main's refusal and conditional limits in tests

The nesting guard from #5079 now checks brackets, prefixes and conditionals; the document
worker's preflight still bounds arrow-function and right-associative chains and skips a
leading #! line as the guard does. A refused script is an error, not a refused edit, so
the preflight test now checks the edit goes through with the error reported.

---------

Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
The model file's units_in_mm and units_from_cad_file were read and
dropped. Keep them on ModelFile and expose PrcFile::units() ->
Result<Option<ModelUnits>, PrcError>: millimetres per unit and whether
the unit is the CAD system's own or the exporter's default. None when no
tree is stored or the unit is not a positive finite number.

Co-authored-by: KenM76 <KenM76>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
`info` falls back to the catalog's XMP packet, and the tab and window title
read it several times per frame when the document asks for its title to be
shown. A packet (up to 16 MiB decoded) was inflated on every read. The last
decoded packet is now kept per thread by the identity of its stream object:
an edit or another document hands out another object, so it reads afresh.

Co-authored-by: KenM76 <KenM76>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(cos): a clone that edits an object stream or a /Length stops reading what other clones cached

Clones share the object cache and the parsed object streams. #5802 keeps
results that read a clone's edits out of those shared maps, but a clone
could still read entries cached before its edit, by another clone or by
itself: after replacing object stream 4, a member of it still read as the
file's copy, and a stream or object stream framed by an indirect /Length
kept the file's framing after the length was edited.

Each cached load now records the other objects it read (a member's object
stream, an indirect /Length, and what those read in turn), and a document
whose edits include one of them reads the object again. Documents with no
edits read the shared entries as before. This also replaces forgetting a
stream's cached member failures on every edit, which the same check covers
for the editing document without costing other clones their entries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cos): count a reference read as null in what a load read

A reference whose generation doesn't match the file reads as null, but an
edit that sets that generation makes it match. A load that read null there
(an object stream's /DecodeParms by reference, say) now counts the object
as read, so a clone that sets it decodes the stream again instead of
taking the one another clone cached. Reads keep a shared slice, and the
read bound has its own test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(optimize): edit the object stream without breaking it

space_audit_shares_an_object_stream_..._edited_members set the stream's
/First to 999999 and still expected its members to read, which only held
while a stale shared-cache entry answered for them. Mark the stream with
an unrelated key instead: it is still edited, so its members are sized
on their own, and they stay readable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cos): keep an object stream decoded by edited parameters out of the shared cache

Whether an object stream is the loading document's own was settled
before its indirect /DecodeParms were read. A clone that edited them
decoded the stream by its edit and put the result in the cache its
clones share, so the document it came from read the clone's data.
The check now runs after decoding.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: KenM76 <KenM76>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
## What this changes

Adds `Permissions::extract_pages()`: `assemble()` **and** copying (bit 5), as #5081 asks, since extracted pages carry their text and graphics into a file without the source's protection. It is used for Extract, Split, Split by size and every Combine/Insert/Replace/paste source check (`sources::source_document`). Changes within the document (rotate, delete, move, insert) keep `assemble()`.

Since this PR was opened, #6309 made `assemble()` revision-aware on `main`: bit 11 alone at revision 3 and later, bit 4 at revision 2. That already refuses "Any except extracting pages" at revision 3+. This PR builds on it:

- At revision 2, `assemble()` reads bit 4, which "Any except extracting pages" sets, so such a file could still be extracted and split into unprotected copies. Requiring copying closes that.
- New `Needs::PageExtraction` greys out Extract and Split up front. It reuses the existing security-refusal string, so no catalogue changes.
- `Document::allows_page_extraction()`, and an `extract_pages` key in the automation security JSON.
- `page_extract` with `separate: true` extracts and writes one page at a time (`extract_separate`), so it no longer buffers every page. The first page is extracted before `out_dir` is created, so a refused extraction leaves no empty directory. If a later page fails, the files this call wrote are removed, and `out_dir` too if this call created it (review on this PR).
- Insert Pages checks a PDF through the same path as the insert itself: new `Session::insert_source(dest, name, bytes)`, which the UI's insert now calls (review on this PR).
- The `organize.combine` parity notes now say "page extraction".

Fixes #5081

## How you verified it

`cargo fmt --all` is clean. Regression tests (adopted from Harrison-Booth's review patch, plus CodeRabbit's follow-ups):

- `crates/crypt/src/tests.rs::page_extraction_needs_assembly_and_copying`: unit test for `extract_pages()`.
- `crates/engine/src/tests.rs::extraction_permissions_cover_revision_three_to_six_and_leave_no_partial_output`: AnyExceptExtract on RC4-128, AES-128 and AES-256 refuses Extract, Split, Split by size and Combine/Insert/Replace sources without the permissions password, leaves source and destination untouched, and the owner can do everything. The authorized combine output has 2 pages, and a differently encrypted or unprotected destination is refused.
- `crates/engine/src/tests.rs::page_extraction_needs_the_copy_permission_on_every_revision`: a file that withholds only copying (R2 RC4-40 and AES-256) refuses extraction; the owner password lifts it.
- `crates/engine/src/tests.rs::pages_under_the_same_protection_are_inserted_without_page_extraction`: a separately built file under matching protection is inserted, and the result stays restricted.
- `crates/automation/tests/it/automation.rs::refused_extraction_writes_no_split_or_page_output`, and the `extract_pages` checks in `protecting_through_tools`, which now cover three presets, including a user (not the owner) for whom extraction is allowed.

**Only the crypt unit test has been run locally, and it passes.** The engine and automation tests timed out while compiling on this machine and have not been run, nor seen failing without the fix. The one `pdfcraft-ui-egui` call-site change has not been compiled. CI is the first place these build.

- [x] `cargo fmt --check`
- [ ] clippy with `-D warnings` on the crates I changed (not re-run after the rebase)
- [ ] tests pass on the crates I changed (crypt only)

## Anything a reviewer should look at first

- **Rebased onto #6309.** The tests no longer expect "Any except extracting pages" to allow assembly at revision 3+. The same-protection paste tests now use the "Inserting, deleting and rotating pages" preset without copying.
- **Copy (bit 5) is now required**, as the issue suggests. `crates/ui-egui/tests/it/editing.rs::protected_files_that_can_be_combined_are_flagged` used a copy-withheld file as "protected but combinable"; it now uses a print-withheld file, since a no-copy file is no longer a Combine source without the permissions password.
- **Same-document paste**: Paste re-opens the document's own bytes as an Insert source, which the new check would refuse. `sources::open_source_into` accepts a source that withholds extraction but allows assembly only when the destination will be saved under exactly the same protection (file key and `/Encrypt` values), so cut/copy and paste within one protected document still works. Pasting into an unprotected or differently protected document is refused. One limit: after changing security in the same session without saving, a cut can't be pasted back until you save.
- **Not in this PR:** CodeRabbit's `disabled_reason` suggestion for read-only documents predates this change and covers other commands too, so it belongs in a separate UI change.


<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

- **New Features**
  - Page extraction and splitting now follow a dedicated permission check, distinct from page assembly.
  - Pages can be inserted from certain protected PDFs when their protection settings match the destination document.

- **Bug Fixes**
  - Separate-page extraction now cleans up files created during a failed operation and avoids creating an output directory when the first page cannot be extracted.
  - Security details now report whether page extraction is permitted.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## What this changes

**File ▸ Open opens Word, Excel, PowerPoint, OpenDocument and RTF files** as new, unsaved PDFs (`Report.docx` → `Report.pdf`), the "LibreOffice sidecar" that `docs/gaps.md` (gap 8) and `docs/file-format-parity.md` planned for `create.from-office`.

- **Conversion** (`crates/create/src/office.rs`): an installed LibreOffice runs headless as a separate process, in a private temporary folder (the input is always `document.<ext>`, so the user's file name never reaches its command line). It gets a fresh profile with `DisableMacrosExecution`, `MacroSecurityLevel` 3 and `BlockUntrustedRefererLinks` set, and Writer's and Calc's `Content/Update/Link` set to *Never* (2 for Writer and 1 for Calc: the two number their values differently), no console window on Windows (`CREATE_NO_WINDOW`), and is killed after 120 s. The output is size-capped (512 MB), must start `%PDF-`, and must open as a PDF before it is used. LibreOffice is found through `PDFCRAFT_SOFFICE`, the `PATH`, then the usual install folders per OS. Without it, the user is told it's needed rather than "not a readable PDF".
- **Off the frame thread:** File ▸ Open converts on the existing open worker (`ResultKind::Converted`); the frame thread only opens the result.
- **Detection:** `SourceKind::Office` requires an Office extension *and* an OLE, ZIP or RTF signature, so `photos.zip`, or a PDF saved with a `.docx` name, are not misread.
- **Open dialog:** PDF (still the default), Images, Office documents, Text, All files. All files is left out on macOS, where rfd merges every filter into one allowed-types list, and on the web; Office documents are left out on the web. The extension lists are now shared constants in `pdfcraft-create` (`IMAGE_EXTENSIONS`, `TEXT_EXTENSIONS`, `OFFICE_EXTENSIONS`), used by both detection and the picker. The duplicate filter list in `open_dialog` is gone. The two new labels are in all 22 catalogues, with their `ATTRIBUTION.toml` hashes updated.
- **Automation:** `doc_create_multiple`, `doc_combine` and batch `convert` go through `convert_to_pdf`, so they take Office documents too; their descriptions say so.

**Deliberately not in this PR:** Insert pages, Combine files and Create ▸ Multiple files convert on the frame thread, so they now refuse Office documents with a pointer to File ▸ Open (`convert_to_pdf_inline` / `as_pdf_inline`) rather than freezing the window for seconds. Moving them onto a worker is the follow-up. `create.from-office` goes planned → **partial**, with that and the rest listed in its notes.

## How you verified it

On Windows 11 Pro with LibreOffice 26.8.1 installed, per crate:

- [x] `cargo fmt --check`
- [x] `cargo clippy -p pdfcraft-create -p pdfcraft-engine -p pdfcraft-ui-egui -p pdfcraft-automation -j 2 --all-targets -- -D warnings`
- [x] `cargo test -p pdfcraft-create -p pdfcraft-engine --lib` (42 and 276 pass); `-p pdfcraft-automation --test automation` (the combine/create tests); `-p pdfcraft-ui-egui --lib open_worker` and `--test editing --test create`
- [x] `cargo xtask layers`, `cargo xtask assets`, `cargo xtask parity --partial`
- [ ] wasm check: the `wasm32-unknown-unknown` target isn't installed here, so I'm leaving it to CI. The web paths are `cfg`'d and were reviewed by eye.

**Tests seen failing before the fix**, by reverting each part:
- Detection off → `office_documents_are_their_own_kind`, `source_kind_tells_pdfs_images_and_text_apart`, and both engine Office tests fail.
- Worker conversion off → `file_open_converts_an_office_document_on_the_worker` fails with the old user-facing message, *"the file is not a readable PDF: no readable page tree or cross-reference data was found"*.

New tests also cover a converter that hangs (it's stopped; the test binary stands in for LibreOffice), one that writes no PDF, a missing converter, temp-folder cleanup, `file://` URL escaping (spaces, drive letters, UTF-8), and the search order.

**Real conversions, end to end:** `crates/automation/tests/it/office.rs` builds a minimal DOCX, an XLSX (with a formula, `=6*7`), a PPTX and an RTF inside the test, so no binary fixtures are committed. It runs them through `doc_create_multiple` in `separate` mode (each output reopened and its text checked, including `42`) and in `combine` mode (page order and bookmarks), and through `doc_combine` beside a PDF, with a page range on one file. These tests, `libreoffice_converts_a_document_when_installed` and the process-tree cleanup test (5 out of 5 reruns) pass with LibreOffice 26.8.1. The Office tests skip where LibreOffice isn't installed, which includes CI.

Three existing tests used a 4-byte `report.docx` as their "file that can't be converted" example. That's no longer true, so they now use `archive.zip`, which keeps what they test.

## Never-crash

- [x] No new `unwrap`/`expect`/`panic!`/`unreachable!`/`todo!` outside tests
- [x] No input-derived indexing or slicing (`rsplit_once` for the extension); the PDF read back is size-capped
- [x] No `unsafe`. The Windows flag uses `std::os::windows::process::CommandExt`
- [x] A crashing, hanging or misbehaving LibreOffice is an error, never a panic or a stuck app: the worker's `catch_unwind`, a 120 s kill, and output checks

## Provenance

- [x] No Adobe assets; no fonts; no copied code
- [x] LibreOffice (MPL-2.0) is only *run* as an external process, as the docs planned; nothing of it is linked or copied. Its command-line options and the profile keys come from LibreOffice's public documentation.
- [x] The translations are contributor-original, written from the plain meaning of "Office documents" and "All files" ("Office" kept as a product name); hashes updated, `cargo xtask assets` passes

## Security

- [x] `--root` confinement unchanged: automation still resolves and reads paths before converting; only the private temp folder is written outside it
- [x] MCP and the control channel untouched
- [ ] **No new outbound network: please decide.** PdfCraft itself opens no connection. The profile blocks untrusted referer links and sets Writer and Calc never to update linked content on load. I converted eight synthetic files that link to a listener on 127.0.0.1: RTF `INCLUDETEXT` and `INCLUDEPICTURE`, a DOCX external picture, an ODT linked section and linked picture, an ODS table source and an external cell reference, and an ODP linked picture. None made a connection, with or without the link settings. Two things remain open: Impress has no link-update setting, and the process's network access isn't sandboxed (documented in `crates/create/README.md` and `docs/gaps.md`).

## Parity and reach

- [x] `create.from-office` planned → partial, with the evidence tests and what's still missing
- [x] Reachable headlessly (`doc_create_multiple`, `doc_combine`, batch `convert`)
- [x] Docs: `docs/file-format-parity.md` (Word/Excel/PowerPoint and RTF rows), `docs/gaps.md` gap 8, `docs/target-app-parity.md` row J (numbers not re-measured), `docs/roadmap.md` M10, the `ROADMAP.md` log, and the `crates/create` README

## Anything a reviewer should look at first

1. **The network question** above. It's the owner's call.
2. **The frame-thread restraint:** Insert, Combine and Create Multiple refuse Office documents rather than block. Is a worker for those wanted in this PR, or as a follow-up?
* feat(js, forms): field setItems, setAction and a bounded toSource (#269)

Ported from #344 by @DWAA1660 onto the per-document script session.

- `field.setItems(items)` replaces a combo or list box's items (text, or
  [text, export] pairs). The value stays when an item still has it, and
  otherwise becomes the first item's. Written as the field's /Opt (pairs
  only where the export value differs), /I dropped, field redrawn.
- `field.setAction(trigger, script)` installs a JavaScript action for one
  trigger (MouseUp/Down/Enter/Exit, OnFocus/OnBlur, Keystroke, Validate,
  Calculate, Format), replacing only that trigger's action.
- `Object.prototype.toSource()` writes plain data (strings, numbers,
  booleans, null, undefined, arrays, objects) that eval reads back. It
  refuses functions, cycles, more than 64 levels, 10,000 values or 1 MiB
  with an error the script can catch.

Co-authored-by: DWAA1660 <dwatnip123@gmail.com>

* feat(engine): run a form's open, Will Save and Will Print actions and its widgets' pointer and focus scripts (#269)

Ported from #344 by @DWAA1660 onto the per-document script session: each
runs in the document's retained realm, so state the document scripts and
earlier events left is kept.

- The catalog /OpenAction's JavaScript (and its /Next chain, bounded and
  cycle-safe; other action types skipped) runs once on opening, after the
  document scripts, for background opens and reloads too, and again on
  Revert in a fresh realm. Event Doc/Open.
- `Session::run_document_actions` runs /AA /WS (Will Save) or /AA /WP
  (Will Print). A failing action is reported and never stops the save or
  print. doc_save and doc_print run them first and report what they said
  (`will_save`, `will_print`).
- Scripts that run unasked (open, Will Save, Will Print, pointer over,
  focus) may only ask for highlight, zoom, focus or a page; a click's
  Mouse Down / Mouse Up keep their requests.
- `Session::field_trigger` runs a widget's Mouse Enter, Mouse Exit, Mouse
  Down, Mouse Up, Focus or Blur JavaScript (one undoable edit), reading only
  that widget's dictionary. `field_focus` now runs an AcroForm widget's On
  Focus / On Blur as well as XFA enter/exit.
- New tool `js_event`; form_focus's description says AcroForm focus
  scripts run too.

Co-authored-by: DWAA1660 <dwatnip123@gmail.com>

* feat(ui): Will Save before saving, Will Print before printing, and widget pointer scripts in the app (#269, #247)

Ported from #344 by @DWAA1660.

- Save (both paths) runs the document's Will Save actions first, and Print
  its Will Print actions; what they change is saved or printed, and what
  they say is shown.
- The pointer moving onto or off a form widget queues its Mouse Enter /
  Mouse Exit script, a press its Mouse Down. A click on a check box or radio
  button queues its Mouse Up after the toggle, so the script reads the value
  the click stored (#247). The queue runs after the frame's edit.

Co-authored-by: DWAA1660 <dwatnip123@gmail.com>

* docs(parity): document actions and widget triggers are partly supported (#269)

Co-authored-by: DWAA1660 <dwatnip123@gmail.com>

* test: check toSource's text (scripts can't eval it) and the trigger event names (#269)

Co-authored-by: DWAA1660 <dwatnip123@gmail.com>

* test: a check box's Mouse Up reads its new value; keep the fixtures on screen and the realm across an in-place save (#247, #269)

Co-authored-by: DWAA1660 <dwatnip123@gmail.com>

* fix(engine, forms, js, ui): review fixes for document actions and field triggers

- widget trigger actions follow /Next, each action its own edit; errors don't stop the chain
- setAction installs on the field the object stands for when names repeat
- toSource refuses non-plain objects and a __proto__ key
- doc_print checks the destination before Will Print; js_event returns only its own output
- combo/list clicks run Mouse Up; a refused click's Mouse Up doesn't run
- label crops are re-read when Will Print changed the document

Co-authored-by: DWAA1660 <dwatnip123@gmail.com>

* forms: run a pointer trigger on the field it hit and refresh the edited field's draft

A trigger now carries the field's object, so of fields sharing a name the one under the
pointer runs its own script (Session::field_trigger_target); js_event passes the field it
resolved. A trigger script changing the field being edited shows in its editor unless the
user already typed there, as focus scripts do.

Co-authored-by: DWAA1660 <dwatnip123@gmail.com>

* forms: carry the field's identity through focus scripts and js_event

Focus transitions now track the field object, so moving between fields sharing a name is a
move and the field that gained or lost focus runs its own script
(Session::field_focus_target). js_event takes an optional field_id from form_fields to pick
one of fields sharing a name.

Co-authored-by: DWAA1660 <dwatnip123@gmail.com>

---------

Co-authored-by: DWAA1660 <dwatnip123@gmail.com>
M7: a font's metrics are past their allowance only when it refused a read
M11: Optimize stores a picture's one-bit masks as monochrome images
M8: the Type 3 cleanup charges the page only when there is a Type 3 font
M8: the redaction walk reads a form's /Matrix entries by reference
M8: redaction reads an operator's operands from the end, as the renderer does
M11: Optimize stores one-bit images and masks past 80 million pixels as monochrome images
M8: redaction refuses a page whose content it can't fully parse
* fix(redact): apply marks with an indirect /Subtype and judge inline annotations

A Redact annotation whose /Subtype is an indirect reference was passed
over by apply although the audit, which resolves it, reports it as a
mark: the other marks applied and the content under that one stayed.
Annotation dictionaries written in place in /Annots were kept whatever
they covered, and an inline reply or pop-up kept a removed comment
reachable through /IRT or /Parent.

/Subtype is now resolved wherever redaction classifies an annotation.
Inline entries are judged by their rectangle and relationships like
referenced ones, on the marked page and across pages, and the pop-up an
annotation names (/Popup) goes with it.

* address review: an inline widget's /Parent field goes even when no referenced widget names it

* fmt
* forms: deleting a field removes ancestors it leaves without kids

* forms test: full-save check, widget only in the sibling case
Adds step-security/harden-runner (audit, pinned to v2.22.4 like
craft-common-actions) as the first step of each job that did not have it,
so every workflow in the org's public repos reports its egress to
StepSecurity. Audit, not block: block-mode allow lists come from each
job's observed traffic.
Updates: take craft-update from craft-libs main at 6e03f65bf
* fix(optimize): walk shared resource dictionaries and name-tree kids once

* test(optimize): restore the file around the appended tests

* address review: import ObjRef in the optimize audit module
* fix(optimize): the fragment search charges every operator of a group it reads

* address review: conflict markers removed; the fragment search charges every operator it reads to the production allowance

* address review: group reads are charged before a parse failure and count all four operators
* fix(preflight): follow every /Next action and bound colour and intent work

Action chains followed an indirect /Next only when it named an array, and
skipped references inside a /Next array, so a forbidden action one indirect
hop behind an allowed one survived both verification and conversion; an
untyped dictionary with /P and /K was exempt from action checks whatever its
/S. Both are now followed and checked, and arrays are borrowed instead of
deep-copied per chain. Conversion treats a /Subtype /Widget without /Type as
an annotation, and takes forbidden annotations off their pages in one pass.

Device-colour inspection decoded each content stream with the 1 GiB
per-stream default and no total; it now shares one 1 GiB DecodeBudget and
reports unread content instead of treating it as clean. Type 3 CharProcs
shared by many fonts are read once, and declared() reads each output intent
once (at most 8).

* address review: no sRGB intent when colour couldn't all be read; count array reads in action chains; declare every output intent

* address review: cut only an over-long identifier; test with a filter that can't be read

* review: leave untyped structure elements out of the forbidden-action check

* merge main, keep both

* address review: a wide direct /Next array is refused before it is queued; queued references are resolved through aliases
clean_type3_font decoded and parsed every surviving glyph program of a
font, for every font. Many small font dictionaries sharing one
compressed CharProc multiplied that work without multiplying the stored
data, with a fresh allowance for each decode.

The resource names each indirect glyph program uses are now cached by
object across fonts (resource-name interpretation stays per font, since
each font prunes its own /Resources), and all decoding spends one
cleanup-wide DecodeBudget; past it the cleanup fails closed.
* fix(annot): count ink points as strokes resolve and cap the eraser's samples

Resizing and erasing a drawing resolved every /InkList entry without a total, so one
stroke array listed many times expanded freely; the eraser densified every segment into
up to 2,000 samples with no overall limit.

* review: refuse an eraser result that exceeds the stored ink point cap

* review: say the erasure would make the drawing too large

* address review: the eraser counts each stroke's final point against its sample total
* fix(xfa): bound the name and path bytes data nodes and queued instances copy

* review: bound the name bytes of a whole data path and report each cap separately

* address review: the queued-instance allowance counts path segment buffers and the dedup copy

* address review: the queued path's spare capacity is counted
* fix(forms): a read-only radio button is not picked or left through set_value

* address review: use /V as the selection in the read-only radio check, keep reset going

* address review: radio read-only checks judge each button by the state it would get, and cover FieldValue::Widget
Each third-party action that StepSecurity publishes a secure drop-in
replacement for now uses that replacement, pinned to a commit with its
version as a comment. Versions follow what we used: the newest
StepSecurity release in the same major, or the closest major they
publish. dtolnay/rust-toolchain refs that named a toolchain (stable,
nightly, 1.76) become the replacement's `toolchain` input.
* fix(redact): hidden-layer removal reads the default state as viewers do

hidden_layers ignored an indirect /BaseState and, with /BaseState /OFF,
let /ON override /OFF, so a group both lists named survived as visible.
The OCMD and OCG classifier also read /Type directly, so a membership
dictionary with an indirect /Type was never evaluated and its content kept.

The default state is now /BaseState (resolved), then /ON, then /OFF, and
/Type is resolved before classifying a group or membership dictionary.

* address review: follow chained /Type and /BaseState references, hash-set ON/OFF lists

* address review: a visibility expression naming a group through an alias reference fails closed
* fix(sign): judge a shared signature value once per listing; dedupe DSS evidence by object number

* address review: shared signature values are judged by output index without copying the dictionary; a dead evidence reference does not hide a live one

* address review: repeated evidence references are skipped before they are resolved

* address review: each full evidence reference is tried once
* fix(render): charge field, tooltip, layer, bookmark and blank-comment text against shared allowances

* address review: charge comment text once, keep JS and field-action flags past the budgets

* review: count an unlisted field's actions through its whole subtree

* review: mark multi-select values the allowance dropped

* review: count only real actions, walk each field once, keep name selections

* address review: revisit shared field descendants from shallower paths, charge value separators, check every widget for actions

* merge main, keep both
* fix(js): bound array text, month parsing, name registration and list work

- strings() (field value and colour setters, resetForm) copied every array
  entry into its own String with no total: one long string listed a million
  times was copied a million times. The copies together are now held to the
  run's MAX_PAYLOAD byte budget.
- FormCalc parse_date copied and lowercased the rest of the text for every
  month token of the picture, quadratic in both; it reads at most the longest
  month name now.
- XFA global registration checked each child name against a Vec of every
  name defined so far, quadratic in a wide form; it is a HashSet.
- FormCalc gather charged one step for a whole collection and list_push
  charged nothing for a duplicate it dropped, so .all on same-named siblings
  did quadratic work for one step a pair; every node visited costs a step.
- add_instance reserved only the new root's SOM before copying a subtree;
  the copy's nodes, names and SOMs are charged to the byte budget first.

* address review: gather charges each call; check an array entry's length before copying it

* Hold an array entry to its widest escaped size before copying it

The pre-copy check compared the payload budget against a JS string's UTF-16
length, which is shorter than the escaped UTF-8 form it is copied to: a
string of quotes or non-ASCII text could allocate up to six times the
budget before the post-check rejected it.

The check now uses the widest escaped width, so the copy cannot exceed the
budget it is checked against.

* review: measure an array entry's escaped width before refusing it

* address review: count an array entry's escaped width without copying it

* address review: charge retained field text and colours to a run-wide budget

* fmt

* fmt

* merge main, keep both
* fix(engine): run the renderer redaction check on redactions applied inside a batch

* Resolve redaction marks by page object and measure the edited document

The renderer check looked marks up by the page index they had when they
were drawn, so a MovePages later in the same batch pointed them at another
page's text, and it measured glyphs with page geometry from before the edit.

Each mark now carries the page object it was placed on, and the geometry is
read back from the document as the batch wrote it.

* review: skip marks whose page the batch deleted

* review: group marks by page once and refuse an ambiguous page tree

* address review: refuse a batch that deletes a marked page while adding pages

* merge main, keep both

* address review: a batch that applies redactions and then copies a page is refused
fix(edit,a11y): font aliases share the metrics built for their font (#5998)
Some checks failed
ci.yml / fix(edit,a11y): font aliases share the metrics built for their font (#5998) (pull_request) Failing after 0s
freebsd.yml / fix(edit,a11y): font aliases share the metrics built for their font (#5998) (pull_request) Failing after 0s
house-rules.yml / fix(edit,a11y): font aliases share the metrics built for their font (#5998) (pull_request) Failing after 0s
nix.yml / fix(edit,a11y): font aliases share the metrics built for their font (#5998) (pull_request) Failing after 0s
packaging-lint.yml / fix(edit,a11y): font aliases share the metrics built for their font (#5998) (pull_request) Failing after 0s
sonarcloud.yml / fix(edit,a11y): font aliases share the metrics built for their font (#5998) (pull_request) Failing after 0s
ci.yml / fix(edit,a11y): font aliases share the metrics built for their font (#5998) (push) Failing after 0s
freebsd.yml / fix(edit,a11y): font aliases share the metrics built for their font (#5998) (push) Failing after 0s
house-rules.yml / fix(edit,a11y): font aliases share the metrics built for their font (#5998) (push) Failing after 0s
nix.yml / fix(edit,a11y): font aliases share the metrics built for their font (#5998) (push) Failing after 0s
packaging-lint.yml / fix(edit,a11y): font aliases share the metrics built for their font (#5998) (push) Failing after 0s
sonarcloud.yml / fix(edit,a11y): font aliases share the metrics built for their font (#5998) (push) Failing after 0s
1d7a9f9ffe
* fix(edit,a11y): font aliases share the metrics built for their font

Text extraction and heading extraction built and kept font metrics per resource name, so
many names for one font object each held a copy of its Unicode and width tables.

* fmt

* address review: font metrics are cached by the object at the end of the reference chain
alf merged commit 1d7a9f9ffe into main 2026-11-11 22:04:05 +00:00
alf deleted branch chore/sync-upstream-20261111 2026-11-11 22:04:06 +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!22
No description provided.