chore: sync upstream (326 Commits) #13

Merged
alf merged 326 commits from chore/sync-upstream-20261031 into main 2026-10-31 17:02:02 +00:00
Owner

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

  • f137a289 Merge pull request #5522 from aiongg/docs-tidy
  • 4a5f9ced Merge pull request #5527 from aiongg/update-result-dialog
  • 635a8401 updates: show check results with craft-update's CheckResultDialog
  • da4cc2c1 docs: tidy wording
  • 44ddb348 docs: tidy agent, contributor and asset docs
  • 24bb8530 Release 0.16.1
  • 35c187ff Flatpak bundle: skip the debuginfo pass manifest-wide (runner has no eu-strip)
  • 8f168550 Release 0.16.0
  • 4b709168 Flatpak bundle: skip the debuginfo pass for cups-client (runner has no eu-strip)
  • 65ff3a00 Merge pull request #4172 from songzhihui/fix/windows-cjk-viewer
  • ec33924e fix(flatpak): allow reading launcher version pins (#5490)
  • a57095c8 M2: assert exact GBK glyph rasters for installed Chinese families
  • 9447cdc8 M2: merge main and resolve CJK viewer integration conflicts
  • fa5f9f8c Merge pull request #5426 from FredApps/chore/parity-integration-20261030
  • 67727724 test: accept preserved CCITT filter arrays in lossless regression
Upstream-Sync von `upstream/main` (326 Commits). Fast-Forward. - f137a289 Merge pull request #5522 from aiongg/docs-tidy - 4a5f9ced Merge pull request #5527 from aiongg/update-result-dialog - 635a8401 updates: show check results with craft-update's CheckResultDialog - da4cc2c1 docs: tidy wording - 44ddb348 docs: tidy agent, contributor and asset docs - 24bb8530 Release 0.16.1 - 35c187ff Flatpak bundle: skip the debuginfo pass manifest-wide (runner has no eu-strip) - 8f168550 Release 0.16.0 - 4b709168 Flatpak bundle: skip the debuginfo pass for cups-client (runner has no eu-strip) - 65ff3a00 Merge pull request #4172 from songzhihui/fix/windows-cjk-viewer - ec33924e fix(flatpak): allow reading launcher version pins (#5490) - a57095c8 M2: assert exact GBK glyph rasters for installed Chinese families - 9447cdc8 M2: merge main and resolve CJK viewer integration conflicts - fa5f9f8c Merge pull request #5426 from FredApps/chore/parity-integration-20261030 - 67727724 test: accept preserved CCITT filter arrays in lossless regression
alf added 326 commits 2026-10-31 17:02:00 +00:00
A CID font of an Adobe CJK collection (Japan1, GB1, CNS1, Korea1) that the PDF
doesn't embed drew nothing unless craft-fonts had a Japanese face: text boxes
from Acrobat (Kozuka Gothic) and Pages (SongtiTC) were blank. After craft-fonts,
the renderer now uses one face already installed on the machine, read at
runtime and never bundled. The face of a multi-region collection (PingFang SC
or TC) is chosen by region. Noto CJK and Source Han are not candidates, and
PDFCRAFT_SYSTEM_FONTS=0 turns it off.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
# Conflicts:
#	ROADMAP.md
Refresh PR #1952 against c1a41cc5, preserving its focused changes and current upstream fixes.
# Conflicts:
#	ROADMAP.md
Add Background draws behind the page content, so on a page that first
fills itself with opaque white (browsers' Save as PDF, print drivers) the
background was hidden completely and the tool looked broken.

- pdfcraft-edit: new `backdrop` module. A strictly detected backdrop (one
  axis-aligned `re`, opaque white in a device colour space, painted before
  anything else and covering the crop box) is replaced by turning that one
  fill into `n` in a per-page copy of its stream; the original stream is
  left untouched for other pages. Remove background restores it, unless
  the copy was edited since. Page-sized pictures (scans) are reported only.
- Background gains `replace_backdrop` (default on); the default colour is
  a visible light yellow instead of near-white.
- Dialog: offers to replace the backdrop, warns about scanned pages,
  checks pages a few per frame, preview draws text over the colour.
- Automation: `doc_background` takes `replace_backdrop`; new read-only
  `doc_backdrops` lists pages with a backdrop.

Acrobat's behaviour on such pages is unobserved; recorded as our choice
in parity/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The six new dialog strings (the backdrop offer, its note, the scan
warning and "Checking pages…") were missing from every catalogue, which
the *_covers_ui_literals tests require. Languages with several plural
forms use the "label: {n}" pattern so any count reads correctly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The 22 interface catalogues are registered assets; adding the Add
Background strings changed their SHA-256. Provenance is unchanged
(contributor-original translations).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Preserve already-merged upstream work and add the focus-loss/Escape regression. Exact candidate tree qualified with workspace tests, 31 WASM checks and a separate successful dependency audit.
Review of #2815: `page_of` built every page to return one, and the Add
Background dialog checked 24 pages a frame that way, so a frame cost
24 page-tree walks (93 ms at 5,000 pages, 385 ms at 20,000).

- `page_backdrops(doc, pages)` walks the tree once for a batch; the
  dialog checks one batch a frame and `doc_backdrops` one batch in all.
  A page past the end answers None without hiding the others (the
  viewer's page count can disagree with the page tree's).
- `scan` and `replace` take the page; `add_background` no longer walks
  the tree twice more per page to replace a backdrop.
- Tests: 4,000 pages checked in one batch under a generous bound (17.6 s
  with the old lookup), batch order and missing pages, and a background
  over several pages sharing streams, replaced and restored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	ATTRIBUTION.toml
# Conflicts:
#	ROADMAP.md
Preserve both roadmap histories and current upstream XML/XFDF work.
Keep the tested sidebar implementation unchanged.
# Conflicts:
#	crates/edit/src/lib.rs
# Conflicts:
#	ROADMAP.md
# Conflicts:
#	ATTRIBUTION.toml
Review of #2815 (CodeRabbit):

- A content stream whose bytes live in an external file (`/F`) draws
  those, not its inline bytes, so it is never taken for a backdrop.
- The copy keeps the original stream dictionary, unknown entries
  included, less `Length` and `DL` (Flate re-encodes it); it had started
  from an empty dictionary.
- Every `/Contents` slot that lists the original stream gets the copy: a
  page listing the same stream twice painted the white fill again over
  the background.
- The Background dialog starts with replacing on each time it opens.
  Update restores backdrops before re-applying, so a replaced page shows
  no fill and no checkbox, and a choice left off from an earlier dialog
  hid the background again unseen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	ROADMAP.md
#	crates/ui-egui/tests/ui_group/marks.rs
# Conflicts:
#	ROADMAP.md
# Conflicts:
#	ROADMAP.md
CodeRabbit: the fallback cache was keyed by (script, weight) and every key read
and kept its own copy of the file, so PingFang.ttc (tens of megabytes, a
candidate of several scripts and both weights) could be held up to four times.
The bytes now come from a process-wide cache keyed by path, remembering files
that cannot be used too; the per-script cache keeps a handle and a face index.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
CodeRabbit: on recent macOS the PingFang collection is not in
/System/Library/Fonts but in the downloaded font assets
(/System/Library/AssetsV2/com_apple_MobileAsset_Font*/<id>.asset/AssetData), so
the fixed path missed it, and the PingFang test returned early without checking
anything on such a machine. The macOS candidates now include the AssetData
folders of those assets (listed once, at most 512 per kind), and a test
searches the assets independently and requires the candidate list to contain
what it finds.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
FredApps: AGENTS.md §1.4 allows reading an installed font at runtime only as
the interface's last fallback and says nothing from it may enter a PDF or
package, while the renderer fallback also served exports and printing.

RenderConfig gains system_fonts, off by default. The engine sets it only on the
pool that draws the viewer (display_config); exports, printing, OCR and the
automation tools render with the document's own configuration, so nothing drawn
with an installed font is saved or printed. Tests: the render of a non-embedded
CNS1, GB1 and Korea1 font is blank without the flag and drawn with it; the
viewer's pool draws such a page and an export of it does not (it fails if the
flag is on everywhere). No Adobe-named font can be a candidate.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Proposed for the maintainers to accept or drop (separate commit): the page
viewer, on screen only, may use one installed face for a CID font of an Adobe
CJK collection that a PDF does not embed. Exports, printing, OCR and the
automation tools never use it; nothing drawn with it is saved or printed; no
Noto CJK, Source Han or Adobe family is a candidate.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
CodeRabbit: the engine test skipped when the viewer's render was blank, so a
viewer pool that lost the installed-font fallback would have skipped instead of
failing. Whether this machine has a face that can draw the page is now asked of
a renderer with RenderConfig::system_fonts switched on by hand, and the viewer's
render must then draw it. Without the fallback on the viewer's pool the test
fails.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Conflicts:
- crates/edit/src/lib.rs: keep both `backdrop` (this branch) and main's new
  `background` module (#2992, page background colour for Export to Word).
- crates/engine/src/lib.rs, crates/ui-egui/src/marks_ui.rs: union of the
  imports (main added BlockKey, block_overlapping and FontFamily).
- ATTRIBUTION.toml: catalogue hashes recomputed.

Main added eleven catalogues since this branch was cut (ca, ckb, fi, hi, id,
ko, lo, nb, ro, sq, th), and their coverage tests require every UI literal, so
the six Add Background backdrop messages are translated in them too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	parity/acrobat-features.toml
# Conflicts:
#	ATTRIBUTION.toml
Ctrl+Z is left to a focused text field, so in the paragraph editor it
went to the box's own Undo history. That history still held the
paragraph's text from before the last formatting change, so Ctrl+Z put
it back into a box laid out for the page after the change, a layout
the page never had, and the document kept the change. The box shared
its id across paragraphs too, so it could put back another paragraph's
text.

The history of the paragraph editor and the added-text box now starts
at what the page has. Undo and Redo keep the keys only while the box
holds typing the page doesn't have; then they reach the document. An
open box is applied (as clicking away applies it) and closed before an
Undo or Redo step, so it never shows a state the document has left;
text it can't apply keeps it open and stops the step.
With typing undone in a paragraph or added text box, its text matches
the page again, so Undo and Redo went to the document. Redo then closed
the box and redid a document change (or nothing), instead of putting the
typing back as the box's own Redo did before. And Shift+Cmd+Z is also
Cmd+Z to the Undo shortcut, so even a Redo the box kept was taken as a
document Undo.

Redo stays with the box while its own history has typing to put back
(the test egui makes when it handles the key), and Shift+Cmd+Z is never
a document Undo.
Finishing a box trimmed its text before comparing it with the item, so
an unchanged box whose text was saved with trailing spaces finished as
an edit. Before an Undo or Redo step that edit was applied, clearing
the document's Redo. A box matching its item is now no edit.
# Conflicts:
#	ATTRIBUTION.toml
Opening a centred or right-aligned Add text box drew its text at the left
edge, a layout the box never had, and Ctrl+Z inside the box (which goes to
the text, not the document) left it showing that way. The editor now lays
the text out with the box's alignment. Justified text is still shown
left-aligned while editing (TextEdit can't justify).
Ctrl+Z is left to a focused text field, so in the paragraph editor it
went to the box's own Undo history. That history still held the
paragraph's text from before the last formatting change, so Ctrl+Z put
it back into a box laid out for the page after the change, a layout
the page never had, and the document kept the change. The box shared
its id across paragraphs too, so it could put back another paragraph's
text.

The history of the paragraph editor and the added-text box now starts
at what the page has. Undo and Redo keep the keys only while the box
holds typing the page doesn't have; then they reach the document. An
open box is applied (as clicking away applies it) and closed before an
Undo or Redo step, so it never shows a state the document has left;
text it can't apply keeps it open and stops the step.
Format text's alignment always re-aligned the whole paragraph or added
text box, and clicking it closed an added text box, losing the
selection, so one line couldn't be aligned. With text selected,
alignment now applies to the lines (text between line breaks) the
selection touches; with only the caret, to the whole box as before.

- Added text stores each line's alignment in its /PCAdded parameters
  (/Aligns, null where a line follows /Align). A justified line's last
  row is not stretched.
- An edited paragraph sends its rows' alignments with each rewrite
  while it is open (BlockStyle::aligns), in both the single-style and
  the styled-region rewrite.
- Both editors draw each line by its own alignment, and the alignment
  buttons show the selected line's. An added text box stays open while
  Format text is used, its style applied at once; clicks are judged by
  where they were pressed, so a touch or pen tap on the panel doesn't
  close either editor.
- Automation: `aligns` on page_add_text, content_update and text_edit.
Centring one highlighted line of a paragraph moves it out of the
paragraph's rows, so the next alignment of it saw a one-row paragraph
and aligned it in its own box: Left left it where it was, and since a
single row grows to the page's margin, each Centre moved it further
right. An alignment for a one-row paragraph is now laid out across the
column of text it sits in (the left edge the page's other text uses, to
the furthest right those rows reach), or its own box when no column
holds it.
With typing undone in a paragraph or added text box, its text matches
the page again, so Undo and Redo went to the document. Redo then closed
the box and redid a document change (or nothing), instead of putting the
typing back as the box's own Redo did before. And Shift+Cmd+Z is also
Cmd+Z to the Undo shortcut, so even a Redo the box kept was taken as a
document Undo.

Redo stays with the box while its own history has typing to put back
(the test egui makes when it handles the key), and Shift+Cmd+Z is never
a document Undo.
The editor is at least 60 points wide on screen, so a short box can be
clicked into. With the box's alignment applied, right-aligned or centred
text in a box narrower than that was placed against the wider editor's
edge, past the box's right edge. The editor now reaches past a narrow
box away from the edge its text keeps, so the text stays where the page
draws it.
Without new text, text_edit rewrote a paragraph from its text joined
with spaces, so its rows were rewrapped into one and only the first
entry of `aligns`, which counts rows, had any effect. With `aligns`
given, the paragraph's own rows (its editor text) are kept, as in the
app.
The editor of a box narrower than its 60-point minimum was anchored by
the box's own alignment. A box whose lines are all right-aligned (or
centred) on their own while the box stays Left wasn't anchored, so its
text was placed against the wider editor's edge, past the box. The
lines are now compared with each other and the editor keeps the edge
they share.
Finishing a box trimmed its text before comparing it with the item, so
an unchanged box whose text was saved with trailing spaces finished as
an edit. Before an Undo or Redo step that edit was applied, clearing
the document's Redo. A box matching its item is now no edit.
alignment_frame found the column by the left edge most of the page's other
rows share. On a page of two columns, a title over the column with fewer rows
took the other column's edge, failed to fit in it, and was aligned in its own
box. Only rows reaching across the title now count.

Tests: the two-column case; a two-colour title (the styled-region path) is
framed too; a justified paragraph sent with its rows split by line breaks, as
the editor sends it, still fills every row but the last.
- A row aligned on its own lost its alignment when the paragraph was then
  given a colour, size or font: the rewrite left the rows' alignments out
  once they had been applied. They now go with every rewrite.
- egui drops a box's selection while the box is unfocused, so after one
  alignment or format a second choice went to the whole paragraph or box.
  The selection is given back when formatting returns the focus.
- Redo from the menu or toolbar applied the box's typing first, a new edit
  that dropped the document's Redo. It now redoes in the box, as the key does.
The paragraph box, and the added-text box, each keep one Undo history under
one egui id, shared by every tab. A box cleared it when it opened on new
text, but a box in another tab on the same text (two copies of a document)
kept it: typing taken back with Undo in one tab came back with Redo in the
other. The document that last used the history is now kept beside it, and
the history is cleared whenever another document's box takes it over.

Tests: the paragraph and added-text cases, each with two copies of a
document open.
The paragraph box, and the added-text box, each keep one Undo history under
one egui id, shared by every tab. A box cleared it when it opened on new
text, but a box in another tab on the same text (two copies of a document)
kept it: typing taken back with Undo in one tab came back with Redo in the
other, from the key or from the menu. The document that last used the
history is now kept beside it, and the history is cleared whenever another
document's box takes it over.

Tests: the paragraph and added-text cases, each with two copies of a
document open; the paragraph case tries the menu's Redo too.
"A picture covers this page (a scan?)" didn't say which page, so on a
range of several pages the user couldn't tell where the background
would stay hidden. Both messages now list the pages as 1-based runs
("Page 3", "Pages 1-3, 6"), stopping with "…" after eight runs.
The four strings are updated in every catalogue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
c7715a09 rewrote the Add Background strings in all 33 catalogues; their
SHA-256 entries in ATTRIBUTION.toml still named the old files, which
the_real_manifest_passes rejects. Provenance is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	ATTRIBUTION.toml
The tag on a page's backdrop copy, `/PCBackdrop << /Original ref /At /Len >>`, kept the
page's original content stream reachable. After Add background, then Redact (or Remove
Hidden Information, or Edit text) and a full save, the text taken off the page was still in
the file, in the original stream the tag pointed at. Text extraction didn't show it; reading
the file's bytes did. Restore also gave up after any edit, so the fill stayed off without a
word.

The tag is now `/PCBackdrop << /Index n /Op (f) /Before <SHA-256> /Was [num gen] >>`: how many
of the copy's operators come before the `n`, the fill it stands in for, a hash of those
operators by value (redaction writes the stream out again with other spacing), and the
original's number as two integers, which keep nothing alive.

Remove background turns the fill back on in the copy when the operators before it still
hash the same and a scan with the fill restored finds it painted first, covering the page.
Edits and redactions after the fill are kept. When the original stream is still there with
the same bytes, the page points at it again. Otherwise the copy stays as it is, and
Remove background says which pages keep their own backdrop off: `EditNotice::BackdropsLeftOff`
(engine), a toast in the app, `warnings` from `doc_remove_marks`. `edit_notices` holds
`EditNotice` now, with the undrawn-text notice as one of its kinds. The two new UI strings
are in all 33 catalogues.

`sha2` (already in the lock file) becomes a dependency of `pdfcraft-edit`.

Tests, each seen failing with its piece of the fix reverted:
- `the_tag_holds_no_reference_and_a_full_save_keeps_no_second_copy_of_the_page` and
  `removed_text_leaves_the_file_when_a_background_replaced_the_pages_backdrop` (automation,
  redaction and Remove Hidden Information through a full save): fail with `/Original` back.
- `a_fill_that_would_cover_content_painted_before_it_stays_off`: fails without the re-scan.
- `a_copy_written_out_again_with_other_spacing_still_comes_back` and the automation test:
  fail when the raw bytes are hashed instead of the operators.
- `an_edit_after_the_fill_is_kept_and_the_backdrop_still_comes_back`: fails under the old
  rule (only an unchanged copy comes back).
- `remove_background_names_the_pages_whose_own_backdrop_stays_off` (engine),
  `remove_background_warns_about_pages_whose_own_backdrop_stays_off` (automation),
  `remove_background_says_which_pages_keep_their_own_backdrop_off` (UI): fail without the
  notice.
- `a_change_before_the_fill_leaves_it_off_and_names_the_page`,
  `a_tag_that_does_not_add_up_is_left_alone` (hostile and old-format tags, never a panic),
  `operators_hash_by_value_not_by_spelling`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Redo from the menu or toolbar closed the box first, which applied its
typing: a new edit that dropped the document's waiting Redo, and the
Redo itself then failed. The step was gone. Redo now goes to the box as
the key does: what Undo took back there comes back, and typing the page
doesn't have yet stays as it is, with the document's Redo waiting.

A new text box the document refused was put back without the request
to select the item once it exists, which stayed behind. It is put back
with the box.
Redo from the menu or toolbar closed the box first, which applied its
typing: a new edit that dropped the document's waiting Redo, and the
Redo itself then failed. The step was gone. Redo now goes to the box as
the key does: what Undo took back there comes back, and typing the page
doesn't have yet stays as it is, with the document's Redo waiting.

A new text box the document refused was put back without the request
to select the item once it exists, which stayed behind. It is put back
with the box.

Carried from #4179 (632ba10e). This branch already routed the menu's
Redo to the box; the test from #4179 covers what its own
redo_from_the_menu_puts_the_typing_back_in_the_box did, so that one
goes.
# Conflicts:
#	crates/ui-egui/src/marks_ui.rs
#	crates/ui-egui/tests/it/marks.rs
#	parity/acrobat-features.toml
Conflicts:
- content_ui.rs: main's added-text editor (alignment, character
  spacing) kept whole; the box takes input_id() instead of an id_salt,
  and its Undo history is reset per document by forget_typing_before.
- tests/it/editing.rs: both sides' new tests kept.

#3546's TextDraft literals (editing.rs, edit_text_ui.rs tests) take
typing_from: None.
Main brought #4178 (already on this branch), #4217 (edited text keeps
every space typed), #4266 (added text takes character spacing) and
#3546 (Save commits the open text boxes).

Conflicts:
- edit/added.rs: AddedText has both aligns and char_spacing. Lines are
  measured with the spaced width (latin_width) and placed by their own
  paragraph's alignment, with Tc set before them; both are read back.
- edit/text.rs: wrap keeps #4217's spacing and still returns each
  line's row, so a row aligned on its own keeps its alignment; a
  justified row stretches over its interior spaces only.
- ui-egui/content_ui.rs: the box keeps its per-paragraph layouter,
  which now draws character spacing (spaced_job), with input_id() and
  the per-document typing history.
- automation: aligns and char_spacing are both text-only options.
- Tests from both sides kept; #4178's two editor tests, present on both
  sides, kept once, and mode_tab's doc comment put back.

The TextDraft literals from #3546 take selection and typing_from, and
an engine test's AddedText takes aligns. New test:
added_text_paragraphs_align_by_their_spaced_width, seen failing with
the unspaced width.
Edit ▸ Redo and the toolbar button followed only the document's Redo.
After Undo in a paragraph or added text box took the typing back, with
no document step to redo, they stayed disabled and only the key could
put the typing back. Redo is now also enabled when the box open on the
page has a Redo of its own: the step redo_in_page_box takes. It doesn't
ask for the box's focus, which the menu has taken by then.
Edit ▸ Redo and the toolbar button followed only the document's Redo.
After Undo in a paragraph or added text box took the typing back, with
no document step to redo, they stayed disabled and only the key could
put the typing back. Redo is now also enabled when the box open on the
page has a Redo of its own: the step redo_in_page_box takes. It doesn't
ask for the box's focus, which the menu has taken by then.
# Conflicts:
#	crates/ui-egui/src/commands.rs
#	crates/ui-egui/src/marks_ui.rs
Conflict in automation/tests/it/automation.rs: this PR's two aligns
tests and main's editable combo box test (#4451) were added at the same
place; all three are kept.
The renderer reports when a page's picture is incomplete or approximate
(content cut at the page's safety budget, a colour painted in a fallback
colour), but only `pdfcraft-cli render` showed it. `page_render` and
`render_preview` returned the PNG alone, so a script or MCP client could
not tell that the image was not the whole page.

Both tools now return a JSON part after the PNG when the page has
warnings: `{ "page": 1, "warnings": [...] }`, with the sentences the CLI
prints. Warnings are strings, like every other tool's `warnings`, so
`command_run` can add its own to the same list. A page without warnings
still returns the PNG alone, so existing callers see no change.

The viewer notice the issue also asks for is left for a separate change.
The dialog previews a colour as a tint of the page's thumbnail (main's #3009). It tinted every
page, so with Replace unticked a page that keeps its own white backdrop previewed coloured,
though OK leaves it white. A page a picture covers (a scan) previewed coloured too.

background_tint() now shows no colour where the page will still hide the background after OK:
a scan, or a white backdrop with Replace unticked. It reads the page from the dialog's backdrop
check (BackdropCheck::found, for the current version of the document only); a page the check
hasn't reached yet previews coloured, as before.

Tests, both seen failing with the fix reverted:
- marks_ui::tests::the_preview_shows_a_background_only_where_ok_lets_it_show (the rule);
- it::marks::the_background_preview_follows_the_replace_checkbox (what the dialog paints, per
  page, ticked and unticked).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Edit ▸ Undo with typing in a paragraph or added text box that the page
doesn't have yet applied the typing (a new edit, which dropped the
document's waiting Redo), closed the box and undid the typing all at
once; the key takes it back a step at a time in the box. Menu Undo now
goes to the box first, as Redo does, and is enabled while the box holds
such typing. Undo and Redo share typing_step for the box's own history.
Merge main; set typing_from in two tests from #4441
If the copy's editor had closed before or during Redo, the check for the
other tab's typing passed on an empty text. It now requires the editor
to be open before Redo and after it.
Main took integration batch 6 (#4566). Conflict in edit_text_ui.rs: the
overlay keeps this PR's view_id and main's switch to interact_pos for
clicks outside the document.
Main took integration batch 6 (#4566). Conflict in edit_text_ui.rs: the
overlay keeps this PR's view_id with main's interact_pos comment. The
two TextDraft literals from #4441's tests take selection and
typing_from.
Edit ▸ Undo with typing in a paragraph or added text box that the page
doesn't have yet applied the typing (a new edit, which dropped the
document's waiting Redo), closed the box and undid the typing all at
once; the key takes it back a step at a time in the box. Menu Undo now
goes to the box first, as Redo does, and is enabled while the box holds
such typing. Undo and Redo share typing_step for the box's own history.
egui's typing history keeps only text and caret, so Undo bringing back
a deleted row or line (added text, or a paragraph) gave it the
alignment of the row before it, and finishing the box could save that.
Each page box now keeps the texts it has shown through its own typing,
with every row's alignment then (follow_typing): Undo or Redo back to
one of them restores those alignments, and a new text follows the edit
with remap. An alignment picked since makes the older ones stale; they
are dropped, so Undo never takes back a pick. The record is cleared
with the box's typing history and holds 100 texts, as egui's does. It
covers typing, the keys and the menu's Undo and Redo.

On this branch the two-tab test also runs the menu's Redo in the copy,
where the box, with no Redo of its own, passes the step on and closes;
that step no longer requires the editor to stay open.

Also carries #4179's stronger two-tab test (dd47c4f9): the copy's
paragraph must be open before the key Redo and after it.
A nonempty aligns counted as a style change, so every row of the
paragraph was rebuilt from the text state: rows left unselected lost
their source operators, and with TJ word gaps their words moved by
about a point and a half while the row's outer width stayed the same.
aligns no longer makes an edit non-plain; a row whose own line has an
alignment override is rewritten, and the others keep their operators.
Review of #2815 (FredApps): two ways the detector took real content for a
page's own white backdrop and replaced it by default.

- A `gs` sets only the keys its dictionary has, but each one replaced the
  whole opacity decision and read every key it left out as its default,
  so `/Transparent gs /LineOnly gs 1 g ... re f` (ca 0.5, then only a
  line width) counted as opaque. Fill alpha, soft mask and blend mode are
  now kept one by one, through `gs` and `q`/`Q`.
- A transfer function was never read: TR or TR2 mapping 1 to 0 paints
  `1 g` black, and the fill was still replaced. The transfer function is
  tracked too, TR2 over TR in the same dictionary, and only /Identity
  (and /Default for TR2) counts; anything else leaves the fill alone.

Tests: nine graphics states that must keep the fill (each also checks Add
Background leaves the content as it was) and nine that still find the
backdrop (keys set back, TR2 /Default over a TR function, q/Q restoring),
plus an end-to-end render of the reviewer's translucent case under a red
background. All the keep cases fail on the code before this change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tabs share one text box, and its Undo history was cleared whenever
another document's box took it: opening a paragraph in tab B wiped tab
A's typing steps, so Edit ▸ Undo back in A found none and closed A's
paragraph instead of taking the typing back. The box being left now
keeps a copy of its history and caret aside, under its document, and
the same box gets it back on its return; a box new to the text box
still starts with none, so typing still never reaches another tab. The
copy is a deep one: a state's history is shared between its clones.
The tabs share one text box, and its Undo history was cleared whenever
another document's box took it: opening a paragraph in tab B wiped tab
A's typing steps, so Edit ▸ Undo back in A found none and closed A's
paragraph instead of taking the typing back. The box being left now
keeps a copy of its history and caret aside, under its document, and
the same box gets it back on its return; a box new to the text box
still starts with none, so typing still never reaches another tab. The
copy is a deep one: a state's history is shared between its clones.

Carried from #4179 (abc378c0). On this branch the rows' alignments by
text go aside and come back with the history, so Undo after the trip
still restores a deleted row's alignment.
Three holes in the shared text box's history:

- Formatting applied with the paragraph open left typing_from as it
  was, so when the format left the text unchanged the box kept its
  history, and Redo put typing undone before the format back on top of
  it. applied() now resets typing_from, so the next claim clears it.
- Undo or Redo from the menu in the same frame as a tab switch ran
  before the box was drawn, on the history of the other tab's box: Undo
  put tab B's text into tab A's box, and Redo put A's undone typing into
  B's. undo_in_page_box and redo_in_page_box now make the box claim the
  text box first (claim_typing), as drawing it does.
- A box new to the text box cleared only the last box's history and
  kept its caret and selection; it now starts from a default state.

claim_typing on LineEditor and TextDraft is now the one way a box takes
the text box, for drawing and for the menu.
Three holes in the shared text box's history:

- Formatting applied with the paragraph open left typing_from as it
  was, so when the format left the text unchanged the box kept its
  history, and Redo put typing undone before the format back on top of
  it. applied() now resets typing_from, so the next claim clears it.
- Undo or Redo from the menu in the same frame as a tab switch ran
  before the box was drawn, on the history of the other tab's box: Undo
  put tab B's text into tab A's box, and Redo put A's undone typing into
  B's. undo_in_page_box and redo_in_page_box now make the box claim the
  text box first (claim_typing), as drawing it does.
- A box new to the text box cleared only the last box's history and
  kept its caret and selection; it now starts from a default state.

claim_typing on LineEditor and TextDraft is now the one way a box takes
the text box, for drawing and for the menu.

Carried from #4179 (aa326f37). On this branch the rows' alignments by
text restart with the history, so a Redo past a format can't bring
back an alignment from before it.
In the Comments panel a reply had no menu, and the card's Delete removes
the whole thread, so one wrong reply couldn't go without the comment and
every other reply. On the selected card each reply now has its own "…"
(Edit, Copy text, Delete), as Adobe's help describes. Delete takes that
reply with its pop-up and the replies to it, as comment_delete already
did headlessly. The comment and its other replies stay, in one undo step.
A locked reply's Delete is greyed out. Status badges and a Replace Text
caret get no menu.

Deleting moves what follows it up in /Annots. Before, any delete cleared
the selection, and the panel's inline editor stayed keyed to an index
that could now be another comment. CommentView::annotation_deleted now
finds both where they moved, from their place among what stays and what
they read, or clears them when they went too or the page changed
unexpectedly.

The new string is in every catalogue. Parity notes, the roadmap row and
comment_delete's description say a reply can be deleted on its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main moved to egui 0.37 (#4591) and renamed string ids to Id::unique.
Conflict in edit_text_ui.rs: the paragraph box keeps the shared input
id, now Id::unique; the added-text box's shared id follows.
/ID is SHA-256 of the seed under the label id, not an unkeyed DefaultHasher digest. The salts test is a characterization; failing closed when randomness is missing stays the regression guard.

Co-authored-by: jonathanwxh-cell <jonathanwxh-cell@users.noreply.github.com>
Main moved to egui 0.37 (#4591) and took the menus change (#4752).
Conflicts:
- edit_text_ui.rs: the paragraph box keeps the shared input id, now
  Id::unique; the added-text box's shared id and the unit tests' ids
  follow.
- content_ui.rs: the added-text box keeps its per-paragraph layouter
  (which places each line itself, so main's .align(halign) is left out)
  and takes main's inset, margin(4, 2), and its "Added text"
  accessibility label.
restyle_draft applies a format change to an open added-text box in
place, so the page then has the box's text. Unlike a paragraph's
applied(), it left typing_from as it was: when the restyle left the
text as it was, the box kept its history, and Redo put typing undone
before the restyle back on top of it, to be saved on finishing. It now
resets typing_from after a successful edit, so the next claim clears
the history (and the rows' alignments by text with it).
# Conflicts:
#	crates/engine/src/lib.rs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The only content conflict was crates/engine/src/lib.rs: main moved
EditCtx and run_edit into edit.rs, while this branch taught Protect
to draw keys and salts from getrandom and fail closed. Keep main's
module split and apply that CSPRNG behaviour on EditCtx in edit.rs.
The regression test imports EditCtx and run_edit from the edit module.

Co-authored-by: jonathanwxh-cell <jonathanwxh-cell@users.noreply.github.com>
In Edit text & images a click inside a paragraph opens it for typing, and selecting the whole
box meant aiming for its thin frame: a press a few pixels inside put the caret in the text
instead (#2203). Esc in the inline editor now leaves the text and selects the paragraph's box,
as Acrobat does, so Esc then Delete removes it, the arrow keys nudge it and its frame drags it.
Esc still discards an unapplied draft.

The editor asks `ObjectSelection` to select the paragraph, and the next `prepare`, which has the
document's inventory, selects it after its own Escape check, so the Esc that closed the editor
cannot clear the new selection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two content conflicts, both from main splitting the files this branch edits.

crates/cos/src/document.rs: main moved Document into document/*.rs.
Keep that split. A missing /ID is still pdfcraft_crypt::file_id of the
encryption seed (document/open.rs); generated_id is gone. The regression
lives in document/tests.rs.

crates/engine/src/edit.rs: main moved EditCtx and run_edit into
edit/run.rs. Keep that split. Protect still draws its seed and default
owner password from getrandom on EditCtx and fails closed
(EditError::Protection) when the CSPRNG errors or returns all zeros.

Co-authored-by: jonathanwxh-cell <jonathanwxh-cell@users.noreply.github.com>
# Conflicts:
#	crates/automation/src/lib.rs
#	crates/edit/src/lib.rs
#	crates/engine/src/reexports.rs
#	crates/engine/src/session.rs
#	crates/ui-egui/src/lib.rs
Keep the print module split and reapply the /Contents space join in
imposition::decoded_contents, matching the renderer and the organize pruner.

Co-authored-by: jonathanwxh-cell <jonathanwxh-cell@users.noreply.github.com>
# Conflicts:
#	crates/automation/src/lib.rs
# Conflicts:
#	crates/render/src/text.rs
Main split and refactored the text-rewrite code this PR changes
(automation dispatch.rs, edit added/ and text/, text_regions/styled.rs).
Conflicts in added/store.rs, text/rewrite.rs and text_regions/styled.rs
take main's structure, with per-row alignment applied in it:

- rewrite.rs: LineWriter keeps the paragraph's alignment and the rows'
  own (align, aligns); each line looks up its row's alignment
  (align_of) for justify and for its offset. Alignment::offset takes the
  row's alignment. Wrapped lines carry their row, as wrap returns it.
- styled.rs: wrap_styled_rows also returns each row's draft line
  (row_lines), which the kept-rows check counts aligns by.
- Imports for the moved modules (alignment_frame, BlockStyle,
  align_code, align_from, content in dispatch.rs).
main split comments.rs into tools/comments/: annotation_deleted,
after_delete and on_page go to state.rs, their test to tests.rs. main
pulled the post-edit selection into select_after_edit, which now takes the
deleted page's annotations from before the delete. ROADMAP.md and
docs/roadmap.md keep both entries, main's newer one first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The test moved there with main's split of comments.rs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The scrollbar floats over the right edge of the tool list and covered the
right-hand rounded corners of a hovered row. The list now keeps an 18 px
gutter, so the highlight has the same 15 px gap to the panel edge on the
left and to the scrollbar on the right.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Deleting a comment or a reply took every reply to it, locked or not: only
the target's own Locked flag was checked. A locked reply can't be deleted
on its own, so delete_annotation now refuses while anything that would go
with the target is locked (pop-ups follow their comment).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The locked-reply check in delete_annotation also reached stamp_preview
and signature_image, which only take the annotation off a copy to draw
the page beneath it: an unlocked stamp with a locked reply lost its drag
preview. They now use lift_annotation, which checks no locks.

The Comments panel's Delete (comment and reply menus) and the page's
context menu are greyed out while a reply below is locked
(comments::locked_below), rather than refusing after the click.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The menus greyed Delete out from the listing's reply links while the
engine refused on the PDF's own /IRT and /Parent links, so the two could
disagree. pdfcraft_annot::deletable is now the one check: delete_annotation
runs it, and Document::annotation_deletable gives the Comments panel and
the page's context menu the same answer before they offer Delete.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A malformed token ended the content stream where it stood, so the renderer,
text extraction, Find and search-based redaction all lost the rest of the
page -- while the edit reader, MuPDF and pdftotext read it (#3565, #3568). A
redaction that cannot see the text below the bad token cannot remove it,
which is the part that matters.

Each malformed shape is stepped over instead: an unterminated string, a
number that is only a sign or a dot, a name with a bad hex escape, an
unbalanced dictionary or array. Content after an inline image whose data
holds `EI` is still read, under a bounded lookahead.

Rebased onto main, 1,898 commits on: the tokenizer is still
vendor/hayro-syntax/src/content/mod.rs and applied as it was, and render's
raster.rs is split, so the two tests went to render/raster/tests.rs.
# Conflicts:
#	crates/engine/src/tests.rs
#	crates/ui-egui/src/dialogs/marks_ui.rs
#	parity/acrobat-features.toml
Previous view and Next view had only Cmd+[ / Cmd+] (Ctrl elsewhere), and
Alt+Left turned the page, since the arrow keys ignore Alt. The document
view now takes Alt+Left / Alt+Right (Option on macOS) for them, as Acrobat
and browsers do, before the arrows turn the page. Following a link
already records where it was followed from.

With a picture or objects selected in Edit text & images, the arrow-key
nudge ran first and also matched a press with Alt, so Alt+Left moved the
selection. It now takes only arrows pressed alone or with Shift, judged
by each key press's own modifiers rather than the frame's.

Help > Keyboard shortcuts lists the pair under the existing Previous view
/ Next view labels.
The comment nudge matched arrows with egui's key patterns, which let an
extra Alt through, so with a comment selected Alt+Left moved it a pixel
instead of going back. It now takes only arrows pressed alone or with
Cmd/Ctrl or Shift, judged by each key press's own modifiers, as the
picture and object nudge does.
The title now reads "<name> • — PdfCraft" while the active document
has unsaved changes, as the other Craft apps write it, and goes back to
"<name> — PdfCraft" once saved.

eframe's web runner ignores ViewportCommand::Title, so the web build
never named the open document. It now also sets the page's
document.title, which an embedding host reads to confirm which document
opened and whether it has unsaved changes.
The dirty mark now follows has_unsaved_work, the same test the close
prompt uses, so text typed into a field or text box shows the mark before
it is applied. The web page's title, which an embedding host reads to tell
which file opened, now always names the file, even when the document asks
to show its own title; the native window title is unchanged.
Prepare's arrow nudge read the frame's Alt, so an Alt+arrow whose Alt was
let go in the same frame nudged the field instead. Each press's own
modifiers now decide, as for objects, pictures and comments.
eframe runs only App::logic while a window or browser tab is hidden, so a
?file= download that finished then opened the document without the titles
changing. The titles are now set at the end of logic as well as in ui.
Co-authored-by: jonathanwxh-cell <jonathanwxh-cell@users.noreply.github.com>
Moving a rectangle, an oval or any other comment showed only its blue
frame going, the shape staying where it was until release. The comment is
now rendered alone once as the drag starts (Document::comment_layer: a
read-only copy with only that comment, no page content) and laid over the
page where it goes; the page beneath keeps showing it where it was until
the move is applied, as a CAD program's move does. Rendering the page
without it would cost a whole page's render at every drag on a heavy plan.
Stamps and Fill & Sign marks keep their own layers. annot: isolate_annotations
for several comments of a page, so a group can be drawn together.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A NoRotate comment on a quarter-turned page isn't drawn in its /Rect; its layer's tile and
its offset now come from the box it is shown in, as the frame it follows does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main now gives page modules one pointer per frame (#2685); the comment drawn where it goes
follows `cx.ptr.pos`, as its frame does, instead of egui's hover position.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When Document::comment_layer gave nothing (a comment with nothing to draw, or
a document that can't be read), prepare_move asked again on every pointer
frame of the drag, and each time it copies and writes out the whole
document. The failure is now remembered for that drag (edit generation, page,
index); the next drag asks again.

A render that fails no longer drops the layer either: only its pixels go, so
the frame alone follows the pointer as before, the failed scale isn't waited
for, and another scale renders the same layer again without making it anew.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: jonathanwxh-cell <jonathanwxh-cell@users.noreply.github.com>

# Conflicts:
#	crates/render/src/text/tests.rs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	crates/automation/tests/it/main.rs
alignment_frame measured a one-row paragraph's alignment in the frame
of the nearest overlapping block. A full-width heading closer to the
title than the title's own body column gave it a page-wide frame, so
centring or right-aligning the title moved it out of its column. It now
considers every overlapping block whose column holds the row and takes
the narrowest, the nearest on a tie, so a page-wide heading gives the
title no frame while a narrower column that holds it does. When no
column holds the row it is still measured in its own box.

The comment about page-wide headers named an exclusion the filter
never made; the doc comment now says what is chosen and why.
Two holes in alignment_frame:

- A column's right edge came from the runs that start at its left
  edge, so a row of several styles ended at its first run, and the
  column could fail to hold a title wider than that run. It now takes
  the right edge of each complete row (row_rect).
- A row with no column to align in was framed by its own box, which
  the rewrite also used as the width its text wraps at: changing an
  isolated row's text together with an alignment wrapped it at its old
  width, word by word. When the text changes and no column holds the
  row, alignment_frame now returns None, so the row keeps the width it
  has without the alignment. With the text unchanged the own box is
  still the frame, so aligning twice is still a no-op.
A gap of 2.4 times the font size was still under the 2.5 times join limit, so two paragraphs became one and editing the first deleted the second. Once a paragraph has two rows, a wider gap than its own leading stays the next paragraph.
The join limit follows the text object's TL when it has one, checked against the measured baseline gap. A rewrite of every row is marked so a new row one leading above the next paragraph does not absorb it.
Three or more rows at one repeated step stay one paragraph when the file has no TL, which is how LibreOffice writes each line. A rewrite that leaves a source row still marks the whole paragraph, so the neighbour survives a second edit. Dragging the box width keeps the line breaks.
A row left in the source stays in the last paragraph. An extra marker is not placed across two marked-content regions.
A newline that was typed stays. A wrap is marked so a later width change can reflow it, and a short unmarked line stays broken.
An unmarked paragraph was closing its marker at the following line's BT, which is after that line's BDC. The close now follows this paragraph's last ET.
A partial rewrite put the paragraph marker around the shared ET, so the neighbour in that text object joined the paragraph and the next edit deleted it.
The paragraph-key helper has to carry reflow text, which a width change uses. ClosePath on an empty subpath is a match guard so the edit crate still passes clippy.
A partial rewrite of the last paragraph opened the marker at the shared BT, so the paragraph before it was inside the region.
Editing either end of one text object, then saving and editing again, has to leave the other paragraph in place. The library tests did not drive text_edit and doc_save.
wrap()'s own test for mixed exotic whitespace fed through it directly, bypassing the caller's CR/CRLF normalization, needed its expected value updated for #4199's keep-every-space design, landed on main after this branch's base.
craft_menu writes "⋯" (U+22EF) itself for the menu of titles that don't fit, through the app's own label callback. No font this app ships or falls back to has that glyph, and egui 0.37's MissingGlyphPolicy::Panic crashes on it as soon as the bar is narrow enough to need it: every editing test that narrows the window while a paragraph is open hit this. The ordinary ellipsis the rest of the app already uses everywhere else is available and reads the same.
wrap() already dropped the whole gap at a wrap point (#4199); rejoining it always guessed a single space back, so two or more typed spaces silently became one, and a Chinese/Japanese word split (which has no space of its own) could gain a spurious one. wrap() now records how many separator characters it consumed at each wrap (0 for a CJK split); the /PCSoft marker carries it as /N (a file saved before this, or by another writer, reads as 1); reflow_text and the mixed-style row editor insert exactly that many spaces back instead of assuming one.
The wrap's space count has to survive an incremental save and a full rewrite, and a width-only text_edit has to reflow with that count. A recorded editor session narrows the box and widens it again.
Four holes CodeRabbit found on 648dd428:

- alignment_frame framed a one-row paragraph when any alignment entry
  was set, including one past the draft's rows, and then moved rows
  with no alignment of their own to the column's edge. It now takes the
  draft and frames only when every draft row is aligned, by its own
  alignment or the paragraph's.
- remap could not tell which of two same paragraphs an edit took
  ("A\nA" to "A"), and kept the deleted one's alignment. remap_at takes
  the caret where the edit ended, and the typing path (follow_typing,
  both editors) passes it from the box's state. The caret only chooses
  between readings that fit both texts.
- Both editors judged a focus loss by where a click landed, so focusing
  a Format text control by keyboard or a screen reader (no pointer)
  closed the box before the alignment was chosen. They now also judge
  by where the keyboard went (focus_pos): focus outside the document
  view, or on the added-text box's own buttons, keeps the box open as a
  click there does.

Each change has a test seen failing before it.
Escape cancelled a comment gesture, Prepare's marquee, measuring and the camera tool, each in its
own module; with the Sign area, crop, marquee zoom, a redaction box, the Link tool's rectangle,
a paragraph or picture drag, added content, an object move or a text selection it did nothing,
and the release then drew, placed or opened what the drag had begun (the step-4 probe: Escape
mid-rectangle still opened the signing dialog).

document_area now handles Escape once, before the pages: while the pages hold a press, it drops
every pointer gesture (DocView::cancel_pointer_gestures, as automation's cancel does) and marks
the press cancelled. Until the button is up the page tools take no input from it, so its
release neither clicks (a stamp, a Fill & Sign mark) nor ends a drag. Escape without a press
keeps its meanings (close the editor, clear the selection, back to the Select tool).

Un-ignores escape_cancels_a_signature_rectangle. Two new tests: Escape mid-press keeps the
release from placing a stamp, and Escape mid-drag abandons a crop rectangle. All three fail
with the cancel taken out; the stamp test also fails with the cancel kept and the release let
through.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The page router (canvas/page_router.rs): a page module answers "what of mine is under this
point?" in one hit test, which gives a Hit (an owner, a layer, a cursor, a tip). On the frame of
a press, the hits at the press point decide who it belongs to, by PR 2's table (the highest
layer the tool reaches), and the router holds that capture until the button is up and its
release has been seen; automation's cancel and Escape drop it. The pointer's hits claim the
cursor and the tip, after the tools that are not converted yet, as before.

Comments: comments::hit is the module's only hit test, with each tool's filter (Select: point
handles, resize handles, then the comment; Fill & Sign: the same over its marks only; shape
tools: the selected comment's handles only). The Select tool's drag, click and double click act
on the comment and part the press was captured on, instead of testing the press origin and the
release point again; fill_grabs and select_grabs ask the router while the button is down. The
topmost comment is the one drawn last (/Annots order), not the last in inspection's top-edge
order: over stacked comments the tip named the top one while a click selected the one
underneath. Text markup declines the drag, so a click on a highlight selects it and a drag
across it still selects the text.

Links: the link hover is a hit (hand and target), and a click follows the link its press was
on. Text selection starts unless the press is held by something that keeps its drag (a link, a
comment that moves); it tested for a link under the pointer at the moment egui called the press
a drag, so a drag from text onto a link selected nothing.

Un-ignores a_click_on_stacked_comments_selects_the_one_drawn_on_top; new test
a_drag_from_text_onto_a_link_selects_the_text. Both fail with their fix reverted (inspection
order for the topmost comment; the link test at the pointer).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Sign area, marquee zoom and snapshot, crop and redaction boxes, and the stamps, act where
nothing drawn over the page holds the press (the page router has no owner for it), rather than
wherever the press lands on the page. Fill & Sign already asks the router (fill_grabs).

Their drags start on the primary button and end with its release, however egui reported it:
its drag stop, or the button found up. They started on any button, so a right-drag drew a
signature rectangle and opened the signing dialog (and drew a crop or a marquee), and the end
waited for a pointer position, so a drag whose release came without one never ended.

New test a_right_drag_draws_no_signature_rectangle, seen failing with the drag started on any
button.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Forms: forms_ui::hit is the fields' only hit test (cursor and press). The press opens a text
field (#2213) and its click acts on the field the press was on, from the page router, instead
of the field under the release point: a press on one check box released over the one beside it
ticked the second. Now it ticks neither, as a button pressed and released off itself does
nothing. Forms no longer wait on comments::select_grabs: a comment that moves (a note, a shape,
a signature, a Fill & Sign mark) hits on the Placed layer, over form fields, so it still takes a
press over a field (a signature can be dragged off the field it covers), and text markup stays
on the Comment layer, under them, so a click on a box under a highlight ticks the box.
select_grabs goes.

Prepare Form: prepare::hit (the selected field's corner handles, then a field, on Placed so a
field being moved stays over the comments as before) gives the cursor, and the drag, click and
double click act on what the press was on; a press held by something else is not Prepare's.

New tests: a_press_on_one_check_box_released_over_another_ticks_neither (fails without this
commit: the second box is ticked) and a_check_box_under_a_highlight_takes_the_click (fails with
markup on the Placed layer).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Edit text's paragraph edges were tested twice: by edit_text_ui for the cursor, on the page's
paragraphs, and by object_ui for the press, on its own object inventory (grabs_edge). Where the
two inventories disagree, the resize arrow shows and a press starts an invisible area selection
instead (#2276, the review's step-3 probe). The open paragraph's frame showed the Move cursor
4 px out and took the press 6 px out.

Each of these parts now has one hit test, in the page router's table, and acts only on the
press it was captured on:
- edit_text_ui::hit: a paragraph's edges (Handle), its body (Text; Editor for the open one).
- edit_text_ui::picture_hit and picture_handle_hit: a picture's body on the Text layer, asked
  before the paragraphs, so text on top of artwork takes the press; the selected picture's
  corners and rotate knob on Handle, asked after them, so they win over text.
- object_ui::hit: the objects selected together (Placed) and the open paragraph's frame
  (Handle), with one reach (FRAME_REACH, 6 px) for the cursor and the press. An area selection
  starts only where nobody's hit claims the press.
- content_ui::hit: added text and pictures (Placed; the selected one's corners on Handle), over
  form fields; Add text starts a new box only where nobody's hit claims the press.
image_owns_pointer and content_ui::select_grabs go, with the edge test object_ui kept a copy of
(grabs_edge). The page's hits move into canvas::PageHits.

Un-ignores a_paragraph_edge_resizes_wherever_it_shows_the_resize_cursor: it passes on main
already (the inventories agree for its paragraph since the probe was written), so it stays as a
guard rather than as proof. New test an_open_paragraphs_frame_shows_move_wherever_a_press_takes_the_box
fails on the previous commit (the cursor is Default there).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A "Page presses" non-negotiable in CLAUDE.md, beside "Page input" and "Page cursor": a page
module decides what of its own is under a point in one hit test (a Hit from
canvas/page_router.rs) that gives its cursor, its tip and its press, and acts on a press only
when the router says it is its own, never by testing the press point again or by asking
another module; Escape cancels any press the pages hold.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From a review of this branch:
- Escape mid-drag with a text markup tool (Highlight, Underline, Strikeout, Squiggly, Replace
  Text) or Redact over text left the half-made selection on the page: the cancel took the Escape
  that would have cleared it. It is dropped now. The marks made from a selection at its end
  (after the page tools, which the release blocking did not reach) also wait for a cancelled
  press's button; egui ends its own drag on Escape without a drag stop, so they were not made
  in practice, but nothing but egui stopped them.
- The open paragraph's frame took the press above and below the text only: its sides still gave
  edge grips (a closed paragraph's rewrap handles) on the Editor layer, over the frame. Edges now
  belong to closed paragraphs only.
- A press in the frame right after a release kept the last press's owner until a page claimed
  it, so a drag from the margin beside a page could move the comment just clicked. A new press
  now drops the last owner before the pages give it theirs.
- A release and a new press in one frame no longer leave the new press cancelled.
- Added content is picked up with the tools that act on it (Select, Add text, Edit text), not with
  every tool while Edit a PDF is open.
- The layer docs in page_cursor.rs say what lies on each layer now.

New tests an_open_paragraphs_frame_takes_the_box_beside_its_text and
a_drag_from_beside_the_page_right_after_a_click_leaves_the_comment_alone (the release and the
next press in one frame) fail on the previous commit. escape_cancels_a_highlight_being_dragged_over_text
(with a control: the drag highlights without Escape) passes there too, for the reason above, and
stays as a guard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No conflicts. main's changes to the files this branch touches (the page
cursor router in canvas.rs, hover tips in tools/comments, new tools and
document methods) don't meet this branch's hunks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A delete moves the panel's inline editor along with its comment or reply,
but Undo and Redo cleared only the selection. Either can put annotations
back or take them away, so the editor stayed keyed to an index that could
now be another reply: with B's editor open, delete reply A, Undo, and Save
wrote B's draft over A. history_step now closes the editor (and its
unsaved text) with the selection. Found by FredApps in review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With Add text, the page router's hits included Edit text's pictures and paragraph edges, which
nothing acts on with Add text, and Add text starts a box only where nobody owns the press: on a
scanned page (a picture filling it) no click placed a box, a click within a paragraph's edge
grip beside it did nothing, and the cursor over a picture was the hand. Add text now has only
the paragraphs' bodies, over their text, where it finishes the box being typed (over_source).

New test add_text_places_a_box_on_a_picture_and_beside_a_paragraph, seen failing on the
previous commit. From CodeRabbit's review of #5300.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review is right that `Stack::push` always returned `Some(())`, so `overflowed`
never became true and the whole-instruction discard was dead code.

Making it live breaks the page. `an_operator_with_hundreds_of_operands_does_
not_end_the_content` (#4260) puts 300 stray operands before `0 1 0 rg` and
asserts the `rg` still reads its own three: with the discard alive the fill
stays the previous red, measured as `left: [255, 0, 0]` against
`right: [0, 255, 0]`.

The ring buffer already gives the right answer. It keeps the newest operands,
which are the ones nearest the operator -- its own -- and no operator takes
anywhere near 256 of them: `scn` takes one per colourant and ISO 32000 allows a
DeviceN space 32. So the strays fall off the back and the operator is applied to
its own operands, which is what Acrobat and Chromium do. Dropping the
instruction would lose content that is there to be read.

The dead branch and the flag are gone, `push` no longer returns a result it
cannot produce, and a test pins what the buffer does: the strays are `1` and the
operator's own operand is `0`, so the two answers differ. Keeping the oldest
operands instead fails it at 256 strays.
With Add text, the page router's hits kept only the paragraphs' bodies (3d14ffd7), so a click
just inside a closed paragraph's left or right edge, within the edge's grip, had no owner: it
neither opened the paragraph (over_source counts the point as the paragraph's) nor started a new
box. Add text now keeps a paragraph's hit wherever the point lies in a paragraph's box, edges
included, by the same test as over_source (edit_text_ui::over_paragraph), and drops it only
beside the box, where a click starts a new box. The edge's grip shows there again, and a drag
from it rewraps the paragraph, as on main.

New test add_text_opens_a_paragraph_from_just_inside_its_edge, seen failing with this commit's
source reverted. From CodeRabbit's second review of #5300.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A click acted on what its press was on wherever the button came up: a press on a link, a push
button, a signature field or a list box that slipped off it before the release (still a click to
egui, within 6 px) followed the link, ran the button's action, opened signing or picked a list
box's option. On main a click went to what lay under its release. Now it needs both: the
link's or the fields' hit test at the release point must give what the press was on, as a button
pressed and released off itself does nothing. Check boxes and radio buttons already did so, by
their overlay's click-outside test.

New tests a_press_on_a_link_released_off_it_follows_nothing and
a_press_on_a_button_released_off_it_runs_nothing, seen failing with this commit's source
reverted; the check box test shares their slipping_click helper. From CodeRabbit's second review
of #5300.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With Add text the page's own paragraphs took input only while the pointer was over one
(over_source), and their drags end in edit_text_ui::page_input. A paragraph dragged off its box
was left hanging after the release (block_drag), to land wherever the pointer next met a
paragraph; one flicked off in its first move never started, so nothing moved. main has the same
test. Add text now also acts on the paragraph the page router says holds the press, until the
button is up, so the drag ends where it is released, as with Edit text, and Add text's
new-box path does not take the press over.

New test add_text_finishes_a_paragraph_drag_that_leaves_its_box, seen failing before the fix
(the drag still pending after the release); the flick was checked by hand the same way. From
CodeRabbit's third review of #5300.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
pdfcraft_filters::encode_g4 encodes packed 1-bit rows as Group 4 (ITU-T
T.6, /K -1) through the fax crate (MIT, already in the tree via tiff),
with rows capped at 32,767 samples so its 16-bit positions can't
overflow; round trips are checked against hayro-ccitt.

The optimizer (Settings::mono, CCITT G4 by default, so Reduce File Size
too) re-encodes a 1-bit image of one colour component, or an image
mask, stored with no filter or general-purpose ones, when that is
smaller as stored. The samples decode to the same bits, so colour
space, decode array and masks stay. PDF Optimizer gets a Monochrome
Images row and doc_optimize a mono option. optimize.mono-compression
goes from planned to partial (JBIG2 and monochrome downsampling not
yet).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z
A bare /Indexed, /I or /CalGray is no colour space on its own, so it can
only name a resource, which might be RGB: such an image is now left
alone instead of being read as one bit per pixel. doc_optimize refuses a
mono that isn't an object or a compression that isn't a known name,
instead of reading it as the default (from CodeRabbit's review).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z
A misspelled key ({"mono": {"compresson": "retain"}}) left the preset's
setting in place unnoticed; it is now InvalidArgs (from CodeRabbit's
review).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z
Stream::decoded reads only direct parameter dictionaries, so an image
whose /DecodeParms is a reference (whole, or as array entries) was
decoded without its predictor. For a 1-bit image with a PNG predictor
the rows came out shifted by their tag bytes, Group 4 of that was still
smaller, and the picture was replaced by a skewed one; resampling and
merging image strips read the same way.

pdfcraft_cos::Document::with_direct_parms gives the stream with the
parameters its filters use made direct: only the entries at the
filters' places, and none for a chain longer than any decoder takes,
so at most MAX_CHAIN objects are copied. The optimizer's pixels() and
the Group 4 step decode through it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z
Persian came to main after these rows were written, so fa.tsv had neither "Monochrome Images"
nor "CCITT Group 4". They go beside "Grayscale Images", worded like the Arabic and Kurdish rows,
with the Persian digit the catalogue uses elsewhere ("AES ۱۲۸-بیتی").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
Document::with_direct_parms resolved an indirect /DecodeParms and its
array entries but not the numbers inside (/Predictor 9 0 R), which
Dict::int doesn't follow. A 1-bit image with such a predictor was
decoded without it, its rows shifted by their tag bytes, and Group 4 of
that stored in its place; resampling read images the same way. It now
keeps, of each parameters dictionary, the numbers and flags the decoders
read, resolved, and /JBIG2Globals as a reference. Only those small
values are copied (an entry that is no dictionary is null), so a file
naming one big object from every key and filter can't have it copied a
hundred times.

The change is #4384's, moved down so that every PR stacked on this one
reads such parameters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
The decoders leave /Crypt out wherever it stands (Stream::filters), so
a chain of /Crypt and eight other filters decodes, but with_direct_parms
counted nine and left its parameters as they were: a predictor given by
reference was then skipped. Only the other filters count now, and a
/Crypt entry is null in the copy, as no decoder reads it. Tests cover
that chain and one exactly as long as a decoder takes.

From reviews with the pdfcraft-review skill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
A 1-bit image was decoded for Group 4 up to MAX_DECODED (1 GiB), so a
few kilobytes of Flate could cost a gigabyte, and the same held for the
samples of a colour or grey image read to resample it (pixels). Both now
stop at twice the samples plus a byte a row and 4 KiB, as redaction
does; data past that can only be a decompression bomb, and the image is
left alone.

From reviews with the pdfcraft-review skill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
A test now covers what qualifies and what doesn't: an ICC profile of
one component and a DeviceN of one colourant convert, three components
and two colourants (more than a bit a pixel) don't; an image mask
converts with /BitsPerComponent 1 given, not with 8; an image of exactly
80 million pixels converts, one row more doesn't. The byte totals are
checked exactly. Each of those rules could be broken before with no
test failing (cargo mutants). one_component's comment now says /G
counts with DeviceGray, as the code does.

From reviews with the pdfcraft-review skill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
- Redaction doesn't decode CCITT, so a mark over part of an image the
  optimizer stored as Group 4 removes the whole image (nothing leaks),
  and Export ▸ Images refuses it. The optimize README and the
  mono-compression parity note say so. Later PRs of this stack add both
  (#4335 export, #4384 redaction).
- doc_reduce's description, Engine::reduced_bytes and docs/cli-batch.md
  now list the black-and-white step; docs/target-app-parity.md's
  optimize row gives the measured counts and no longer lists CCITT as
  missing; the M11 milestone row lists it as done.

From reviews with the pdfcraft-review skill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
The optimizer's images decode no further than twice their samples plus
a byte a row, meant for ASCIIHex text under Flate; but hex text broken
into lines, as it usually is, is a little more than twice, so such an
image was left alone as if it were a decompression bomb (a 300 dpi
letter scan stayed Flate). It is three bytes for each now, a separator
for every two digits, and a test stores both images as hex text in
lines of 64 digits.

From reviews with the pdfcraft-review skill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
- Not only Export ▸ Images refuses a CCITT image until #4335: so does
  everything that exports an image through it. Save Image As refuses
  it, Copy Image fails, and Word, PowerPoint and HTML export leave the
  picture out, with the page not reported. The optimize README and the
  mono-compression parity note name them.
- optimize.mono-compression is partial now, so docs/target-app-parity.md
  counts it on the table's own measurement: K Optimize and standards 15
  shipped and 11 partial of 58 (35.3%), breadth 57.0% (423 + 75 of 808),
  mainstream 63.3% (292 + 45 of 497), with a status line and a revision
  row. ROADMAP.md's breadth rows and its K row say the same.

From reviews with the pdfcraft-review skill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
docs/pdf-spec-parity.md's §7.4 row still said there was no CCITT
encoding; it names the Optimizer's Group 4 encoder now, with a status
line and a revision row.

From reviews with the pdfcraft-review skill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
pdfcraft_filters::encode_jbig2_generic writes 1-bit rows as a JBIG2
stream for JBIG2Decode (embedded organisation: a page information
segment and an immediate lossless generic region, no globals): template
0 with its nominal AT pixels, typical prediction, and the MQ arithmetic
coder (mq.rs), written from the JBIG committee's ISO/IEC 14492 Final
Committee Draft. The coder reproduces its Annex H.2 test sequence byte
for byte; output round-trips through hayro-jbig2, and Poppler renders
it the same as the original.

The optimizer's Monochrome Images gets JBIG2 (lossless) beside CCITT
Group 4 (still the default), doc_optimize mono jbig2; a document below
PDF 1.4 gets /Version /1.4 in its catalog. On 300 dpi text JBIG2 is
about a third smaller than Group 4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z
JBIG2 images, and JBIG2 stencil masks, were reported as "can't be exported
yet", so an image the Optimizer stores as JBIG2 would stop exporting. They
are now decoded with hayro-jbig2 (already in the tree through hayro), with
their /JBIG2Globals from a DecodeParms dictionary or array, as JBIG2Decode
gives them: black as 0 (ISO 32000-2 §7.4.7). Unreadable globals, a JBIG2
page of another width, or fewer rows than the image skip the image; rows
past its height are dropped, so the output is bounded by the checked size.

docs/pdf-spec-parity.md §7.4 no longer says there is no JBIG2 encoding.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z
fa.tsv lacked the row: Persian came to main after it was written. It goes after "CCITT Group 4",
worded like the Arabic and Kurdish rows. id.tsv had it as an English copy beside rows it
translates; it now says "tanpa kehilangan", as de, tr and vi translate "lossless".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
Export ▸ Images and Save Image As decoded an image with
Stream::decoded, which reads /DecodeParms only when it is direct. An
image whose PNG predictor came by reference (the dictionary, an entry
of an array, or the numbers inside) kept its row tags in the samples,
and the picture came out shifted. The same was true of an indexed
image's lookup table given as a stream, and of a JBIG2 image's
/JBIG2Globals stream, which then couldn't be read, so the image was
skipped. All three now decode through Document::with_direct_parms
(#4304), as the optimizer does, and a test exports each. Found by
CodeRabbit on #4805; the image decode was already this way on main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
The optimizer README's and optimize.mono-compression's "not yet" lists
said rows wider than 32,767 samples aren't stored. That is Group 4's
limit (MAX_G4_COLUMNS); JBIG2 takes rows of up to 1,048,576
(MAX_JBIG2_COLUMNS). Found by CodeRabbit on #4805.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
The export checked an image's /Width × /Height against its 100-million-
pixel budget, and the JBIG2 page's width against /Width, but not the
page's height. The decoder decodes the whole page, rows past the
image's height too, and takes pages of up to about 2^28 pixels: a
20,000 × 4 image over a 20,000 × 10,000 JBIG2 page cost 200 million
pixels of decoding to export four rows. The page itself is now held to
the budget before it is decoded. Found by CodeRabbit on #4320.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
When the optimizer stores an image as JBIG2 (PDF 1.4), it raises the
catalog's /Version for an older file. It read /Version only when it was
direct: a catalog saying 1.7 by reference looked like no version at all,
and was set to /1.4, lowering the document. It is now resolved first.
Found in pre-push review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
Export ▸ Images skips an image whose JBIG2 page has another width or
more pixels than an export takes; a JBIG2 stencil mask refused the same
way is left out and the image exported opaque, as JPX and CCITT masks
are, which the export test now checks. The README and parity note said
only "is skipped". And redaction doesn't decode JBIG2 or Group 4 yet,
so a mark over part of an image the optimizer stored either way removes
the whole image; the optimizer README and parity note now say so (#4384
adds Group 4). Found in pre-push review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
With compatibility retain, doc_optimize reports the later of the
header's version and the catalog's /Version. It read /Version only when
it was direct, so a catalog saying 1.6 by reference was reported as the
header's 1.4. It is now resolved first, as the optimizer reads it
(a88c8afb). The bug was already on main; found in pre-push review as a
sibling of that fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
Copy Image and Copy Image in Original Format go through Export's
decoder, which reads JBIG2 now, so the view.copy-image parity note no
longer lists JBIG2 as missing. docs/target-app-parity.md's optimize row
lists JBIG2 symbol coding and lossy JBIG2 as missing instead of
monochrome JBIG2, with a status line and a revision row.

From reviews with the pdfcraft-review skill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
A scan saved as a 1-bit PNG decodes to eight-bit grey and was stored
that way, larger than the PNG it came from; a bilevel TIFF was stored at
one bit but with Flate. Now any image of only pure black and white
(1-bit, eight-bit grey, a two-colour palette, RGB without a profile) is
stored at one bit per pixel, as CCITT Group 4 or Flate, whichever is
smaller as stored. A grey profile stays; RGB with a profile keeps its
colours.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z
An eight-bit grey image whose samples are all 0 or 255 is packed to one
bit per pixel and then stored as Monochrome Images asks (Group 4, or
JBIG2 with #4320), when that is smaller as stored; its decode array
stays. Indexed images, a colour-key /Mask and Retain are left alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z
Group 4 images (now written by Create PDF and the optimizer) would
otherwise stop exporting: they were reported as "can't be exported
yet". A CCITT fax image (its only filter) is decoded through
hayro-ccitt with its /DecodeParms (K, Columns, Rows, EndOfBlock,
EndOfLine, EncodedByteAlign, BlackIs1), masks included; /Columns has to
be the image's width, so a hostile value can't size the buffers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z
A CCITT image whose /DecodeParms is a one-element array (common beside
/Filter [/CCITTFaxDecode]) was decoded with the default parameters: K 0
and 1728 columns. Wider scans were skipped, and a fax 1728 wide could be
misread. The parameters are now read from a dictionary or the one in an
array (ISO 32000-2 §7.4.2). The module doc no longer lists CCITT as
undecodable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z
A CCITT image whose /K, /Columns or /Rows is an indirect integer was read
with the defaults (K 0, 1728 columns), and one whose /Rows is below its
/Height was cut short and skipped. The numbers are now resolved, and the
image is decoded up to its /Height rows whatever /Rows says, still bounded
by the size the export checked (review of #4335).

The doc comment of the optimizer's bilevel step now says what Retain
leaves to the grey settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z
A fax image with /Filter [/ASCII85Decode /CCITTFaxDecode], say, was
skipped: only a lone CCITT filter went to the CCITT decoder. Now the
filters before it are undone first, and its parameters are the
/DecodeParms entry at its own place in the array (ISO 32000-2 §7.4.1),
not the first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z
Stream::decoded reads only direct parameter dictionaries, so an image
behind [/FlateDecode /CCITTFaxDecode] whose Flate entry is a reference
lost its predictor, and the CCITT decoder got the predictor's bytes; a
plain Flate image with its predictor given by reference was misread the
same way. Export now decodes through
pdfcraft_cos::Document::with_direct_parms (#4304), as the optimizer
does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z
Packing eight-bit black-and-white grey to one bit decoded with
Stream::decoded, which ignores /DecodeParms given by reference. With PNG
predictor 10 each row then kept its tag byte, a 0, so the shifted rows
still passed as black and white and were packed and stored skewed. It
now decodes through Document::with_direct_parms (#4304), and the test
of images with their parameters by reference covers this case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z
An eight-bit grey image of only black and white was packed to one bit
and stored, and the grey settings never saw it. For a dithered scan that
is bigger than what Reduce File Size made of it before, downsampled to
JPEG: 1-bit Flate can't shrink the dots (a 1200 × 1500 dither at 300 ppi
was 157 KB packed, 67 KB as before). Both are made now, and the smaller
as stored wins, counting a soft mask resampled with the image. Clean
text still packs to one bit. The packing also decodes no further than
its samples can need, as the other image steps do now.

From reviews with the pdfcraft-review skill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
- An indexed image's lookup table given as a stream was decoded without
  its /DecodeParms when they came by reference, so a PNG predictor's tag
  bytes ran into the colours (red and blue came out [2,255,0] and black).
  It decodes through with_direct_parms now, as the samples do. This was
  already so on main; #4320 makes the same change.
- Tests for what this PR claims and no test held: Group 3 one-
  dimensional data exports as its picture (mapping /K 0 to Group 4 gave
  all white and passed); CCITT soft and stencil masks export as the same
  masks in Flate (decoding them plainly dropped them and passed); a
  Flate image's predictor given by reference reads as direct; and a
  1-bit TIFF is stored as Group 4 (the TIFF test said "a 1-bit one" but
  wrote eight bits; its comment now says what it writes).

From reviews with the pdfcraft-review skill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
Redaction doesn't decode CCITT yet, so a mark over part of a picture
Create PDF stored as Group 4 removes the whole picture (nothing leaks);
before this PR such pictures were eight-bit Flate and had only the
covered pixels cleared. The create README and the image-to-PDF parity
note say so; #4384 adds CCITT to redaction. The workspace's comment on
hayro-ccitt says Export uses it, not only tests.

From reviews with the pdfcraft-review skill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
- Eight-bit black-and-white text under a noisy soft mask: the grey
  settings win, as they shrink the mask far more than packing saves.
  Counting the packed image without its mask passed every test before.
- The packing test checks the report's byte totals exactly.
- Export reads Group 3 with end-of-line codes, with rows on a byte
  (EncodedByteAlign), with both, and mixed (/K 1, each row tagged
  one-dimensional) with end-of-line codes and without. Decoding /K 1 as
  one-dimensional, or ignoring EncodedByteAlign, now fails a test.
  /EndOfLine is passed to the decoder, but no input here tells it apart:
  the decoder accepts end-of-line codes either way, as ISO 32000-2
  (Table 11) requires.

From reviews with the pdfcraft-review skill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
From reviews with the pdfcraft-review skill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
With /EncodedByteAlign and end-of-line codes, the fill may start each
code on a byte or end it on one (T.4's fill, as TIFF writes it); the
second was misread, and the image refused as short. With end-of-line
codes the decoder now reads unaligned, as the codes mark the rows either
way (probed black-box for /K 0, 1 and 2); codes laid out so under
/EndOfLine false are still misread. The Group 3 test covers both
layouts, /EncodedByteAlign given by reference, and a /BlackIs1 that
inverts the picture given by reference (with_direct_parms resolves both
before the decoder reads them).

From reviews with the pdfcraft-review skill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
- A noisy 640 × 800 soft mask under 1200 × 1500 text: resampled to the
  image's new size it shrinks only a little, by less than the grey
  settings' JPEG adds over the packed image, so packing wins; not
  counting the new mask passed every test before.
- The hex-text half of the bomb test also stores eight-bit
  black-and-white, which must pack, as its comment says.
- The optimize README, the mono-compression parity note and both
  progress logs say the soft mask counts.
- The Copy Image parity note no longer lists CCITT as missing: it
  copies through Export's decoder, which reads CCITT now.

From reviews with the pdfcraft-review skill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
"Export ▸ Images reads byte-aligned Group 3 whichever way its fill
falls" read byte-aligned rows unaligned whenever /EndOfLine was set.
That broke byte-aligned Group 4 with /EndOfLine (refused); read Group 3
that says /EndOfLine but has no codes unaligned, and wrongly; and read
codes with the fill before them, but no /EndOfLine, on bytes: every row,
the wrong picture, nothing said. Now:
- Group 4, and rows not on bytes, read as the parameters say;
- Group 3 rows on bytes are read by the data: T.4 sends an end-of-line
  code before the first row when it sends them at all, and no other code
  starts with more than eight zeros. Data that starts with 11 zeros or
  more (fill included) and a 1 has codes, and is read unaligned, where
  the codes mark the rows whether the fill starts each code on a byte or
  ends it on one; other data is read on bytes, whatever /EndOfLine says.
  A reading that comes up short is reported rather than tried the other
  way, where data cut short would read as other rows.

The Group 3 test exports nine pictures, stripes and eight of random
text-like runs (how a misreading comes out depends on the picture),
coded with T.4's terminating codes at /K 0 and 1: no codes, with
/EndOfLine and without; codes each starting on a byte or ending on one,
with /EndOfLine and without. Each exports as its picture, and each a
byte short is reported; at /K 1, codes with none before the first row
(not as T.4 sends them) export as their picture or are reported.
Byte-aligned Group 4 still reads with and without /EndOfLine. The
create.export-all-images parity note says what is still limited: such
codes may export wrong at /K 0, as the create README says. A rule tried
first, reading both ways and refusing an image that gave every row both
ways differently, failed on the stripes without codes (TIFF's Modified
Huffman layout): its unaligned reading gives every row too, wrongly. So
did one that read the other way when the first reading came up short: it
drew data cut short as other rows. cargo-mutants leaves no mutant of the
new lines alive.

From reviews with the pdfcraft-review skill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
The PDF Optimizer and Reduce File Size downsampled colour and grey images
but not black-and-white ones. Settings::mono is now MonoSettings {
downsample, target_ppi, above_ppi, compression }, 300 ppi above 450 ppi
and CCITT Group 4 by default (Monochrome Images ▸ Downsample to;
doc_optimize mono: { downsample, ppi, above_ppi, compression }).

A 1-bit image or image mask (no filter, general-purpose filters, or CCITT
fax after any of them) and eight-bit grey of only black and white are averaged by
area: each new pixel takes the sample covering more of it, a tie 0, so a
dark line half a new pixel wide stays. They are stored as Group 4, or as
1-bit Flate when that is smaller; with Retain a resampled image keeps its
kind of compression. Images with a soft mask keep their size.

CCITT decoding moves from Export ▸ Images into pdfcraft-filters
(decode_ccitt, CcittParams, through hayro-ccitt), and
pdfcraft_cos::Document::decoded_image reads a fax image's /DecodeParms
for it (undoing filters before CCITT, such as ASCII85Decode, with any
/DecodeParms given by reference made direct first), so export
and the optimizer share one reader, and Group 4 scans can be
downsampled. A 600 ppi text page went from 79 KB to 40 KB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z
A 2 × 2 tie always gave sample 0, black in grey and the painted part of
an image mask with the default decode array. With an inverting one,
[1 0], 0 is white or unpainted, and in a separation 0 is no ink, so a
line half a new pixel wide could vanish. dark_is_one says which sample
is dark from the colour space and decode array: 0 in grey and of a
mask, 1 in a separation or a DeviceN of one colourant, each the other
way round when the decode array inverts; an indexed image keeps entry
0, as its palette, not the decode array, says which is darker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z
decoded_image paired the fax filter with the last /DecodeParms entry,
but Stream::filters leaves /Crypt out wherever it stands, so with
[/CCITTFaxDecode /Crypt] the fax filter got the Crypt entry and lost
its /K, /Columns and /BlackIs1. Its entry is now that of the last
filter but a /Crypt. /Crypt belongs first (ISO 32000-2 §7.4.10), so
only a malformed file had it (from CodeRabbit's review).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The README said an indexed image's palette decides which entry is the
dark one on a tie, but dark_is_one never reads the palette: entry 0
wins whatever its colour. The README, the function's comment and the
parity note now say so (from FredApps' review).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Stream::decoded_within reads /DecodeParms only when it is direct, so an
image whose PNG predictor was given by reference decoded with its row
tags still in the data. The cleared bytes then drifted one byte further
from the covered pixels each row, and the covered samples survived in
the redacted copy, recoverable by applying the predictor. Parameters
are now made direct first (Document::with_direct_parms).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Create PDF and the optimizer now store black-and-white scans as Group 4,
and redaction couldn't decode CCITT, so redacting one word on such a
scan removed the whole page image. A CCITT image (the last filter,
after any others) now decodes through Document::decoded_image and is
cleared in a Flate copy like any other image; one that can't be decoded
in full is still removed whole.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The batch step took Reduce File Size's monochrome settings unchanged, so
`ppi: 600` (or `--ppi 600`) kept colour and grey images at 600 ppi but
halved 600 ppi black-and-white scans to 300. Black-and-white images now
go to the larger of ppi and 300 ppi: a higher ppi keeps their
resolution, and a lower one doesn't take scanned text below 300. The
doc_batch description and docs/cli-batch.md say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Redacting a CCITT image went through decoded_image, which undoes the
  filters before the fax decoder up to MAX_DECODED, so a Flate bomb in
  front of a fax image cost a gigabyte while every other image stops at
  twice its raster. Document::decoded_image_within takes a bound, and
  redaction gives a fax image's code about a byte per pixel. A fax image
  cut short, or behind a bomb, is removed whole; a busy one whose code
  is bigger than twice its raster is still cleared.
- Every image is now decoded through decoded_image_within, which reads
  /DecodeParms through Document::with_direct_parms: given by reference,
  the numbers inside (/Predictor 7 0 R) included. That reading was first
  written here and has moved down to #4304. The predictor test now covers
  the dictionary, an array's entry and the number itself given by
  reference; read without them, the cleared bytes drift off the covered
  pixel and the covered sample stays in the copy.

From reviews with the pdfcraft-review skill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
doc_reduce, the engine's reduced_bytes doc, the optimize README's
presets and the optimize.reduce parity note still said every image
above 225 ppi goes to 150 ppi; black-and-white images go to 300 ppi
above 450 ppi, as CCITT Group 4 when smaller.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A CCITT fax image that wasn't resampled was left as it was, so Group 3
images (the 1-D and 2-D codings fax software writes) stayed Group 3.
Group 4 codes each row against the one above and is usually much
smaller. Now only Group 4 alone skips decoding; Group 3 is stored again
as Group 4 when that is smaller as stored, and Retain still keeps it.

It reads an image's CCITT parameters (its /DecodeParms entry with Table
11's defaults) through pdfcraft_cos::Document::fax_params, from #4384.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z
Redaction clears an image mask's covered samples to the value that
paints nothing, which an inverting /Decode [1 0] flips. It read the
array only when direct, so given by reference (or with its first number
so) the cleared samples painted instead: the covered area came out
inked. That was already so on main, and this PR sends CCITT masks
through it. The array and its first number are resolved now, as export
and the optimizer read them; the fax test covers all three forms.

From reviews with the pdfcraft-review skill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
Monochrome Images only took eight-bit grey of only black and white
besides 1-bit images, so the same scan written as RGB, or as an indexed
image of two palette entries, stayed at eight bits (and RGB went to the
colour settings, as JPEG). Now these are packed to one bit first:

- eight-bit DeviceRGB whose every pixel is black or white becomes
  DeviceGray; a decode array the same for each component becomes that
  pair, another stops it. Calibrated RGB and RGB with a profile stay.
- an indexed image of 2, 4 or 8 bits that uses only palette entries 0
  and 1 keeps its palette, whatever the two colours; a decode array
  other than the default stops it.

Colour-key masks still stop all of them, and Retain leaves them to the
grey and colour settings. They are decoded through /DecodeParms given
by reference too (Document::with_direct_parms), and when downsampled a
tie goes to the dark sample of the image as packed: packed RGB is grey,
with grey's decode pair.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z
- Halftones and dithers: most of their new pixels cover both black and
  white, and taking the sample that covers more turned their tones
  solid (a 50% screen black, a 25% one white). An image whose new pixels
  mostly cover both is now left at its resolution; text covers both only
  along its edges and is still averaged down. downsample_bits says how
  many new pixels cover both.
- A counter for each column: an image wider than a fax row
  (MAX_CCITT_COLUMNS) isn't averaged, so a strip of 80 million columns
  can't make the counters 640 MB.
- A fax image's decoding is bounded at about a byte a pixel, as
  redaction's is (decoded_image_within), not 1 GiB; behind a Flate bomb
  it is left alone.
- An indexed image's tie went to sample 0, which an inverting decode
  array maps to entry 1, though the docs say entry 0. It goes to the
  sample that decodes to entry 0 now (ISO 32000-2 §8.9.5.2).

Each has a test that fails without it.

From reviews with the pdfcraft-review skill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
- Packed to one bit, black-and-white DeviceRGB became DeviceGray. Where
  /DefaultGray or /DefaultRGB is in force (ISO 32000-2 §8.6.5.6), that
  drew it otherwise: through DefaultGray as grey, or no longer through
  DefaultRGB. The page walk now notes the images drawn, on any use, where
  a page's or form's resources (an enclosing one's included) name
  either, and those stay RGB. When a use couldn't be followed, every
  image counts as such.
- One whose soft mask has a /Matte stays RGB too: the matte has one
  number for each of the parent's components, three, and grey has one.
- The optimize README, the mono-compression parity note and doc_reduce's
  description said Group 3 to Group 4 wasn't done yet, that only images
  packed from eight bits could become Flate, and that the colour space
  and decode array always stay. They now match the code.

From reviews with the pdfcraft-review skill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
- Redaction decodes an image no further than twice its raster plus a
  byte a row, meant for ASCIIHex text under Flate; hex text broken into
  lines is a little more, so such an image was removed whole instead of
  cleared (already so on main). It is three bytes for each now, as the
  optimizer allows.
- Tests: grey stored as hex text in lines under Flate, and RGB behind a
  filter array (which a fax image's bound would refuse), are cleared;
  decoded_image_within gives a fax image no more rows than its bound
  holds.

From reviews with the pdfcraft-review skill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
Mutation testing found three behaviours no test held:
- eight-bit black-and-white in calibrated grey, an ICC profile of one
  component or a separation is packed, each keeping its colour space,
  and calibrated RGB of only 0 and 1 isn't (taking every array colour
  space for an indexed one passed every test);
- mixed Group 3 (/K 1, with end-of-line codes) is stored again as
  Group 4 (taking /K above 0 for Group 4 passed);
- a resampled Group 4 image of vertical stripes is stored as 1-bit Flate,
  which is smaller (ruling Flate out for any CCITT image passed).

From reviews with the pdfcraft-review skill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
- The optimize README, the crate doc and both parity notes say a
  halftone or dither, and an image wider than a fax row, isn't averaged
  down.
- The README's "Left alone" list is the colour and grey settings'; image
  masks, decode arrays and CCITT go to Monochrome Images now. Its
  monochrome "Not yet" names an image's own /SMask or /Mask image again
  (only images drawn on a page are visited), as the parity note does;
  the parity note's row limit is Group 4's.
- docs/architecture.md says CCITT decodes through pdfcraft-filters for
  export, redaction and the optimizer, not only the renderer.
- docs/target-app-parity.md no longer lists monochrome downsampling as
  missing.

From reviews with the pdfcraft-review skill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
With end-of-line codes, the CCITT decoder picks a bad row up again at
the next code and reports no damage (an invalid code, a row of too many
pixels: probed as a black box), so such an image is stored again as
Group 4 the way it decodes. Without them, damage stops the decoding,
too few rows come out, and the image is left alone. Telling damage
apart would take a Group 3 parser of our own; the optimize README and
the mono-compression parity note say so instead.

From reviews with the pdfcraft-review skill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
Mutation testing found behaviour no test held, now covered:
- a Group 4 halftone above 450 ppi isn't resampled and stays byte for
  byte, alone by default and behind ASCIIHexDecode with Retain;
- under Retain a resampled Flate scan stays Flate, though Group 4 would
  be smaller (ragged bands);
- sixteen-bit grey of only 0x0000 and 0xFFFF keeps its bytes;
- a busy Group 4 image behind ASCIIHexDecode, whose code is over three
  times its raster, decodes within the fax bound and is stored again as
  Group 4 alone. Redaction's busy-fax test is that busy again: since the
  bound for other images is three times the raster, the old checkerboard
  fitted it, and dropping the fax bound passed.
The redaction README says three times, and doc_optimize's description
says what Retain keeps: CCITT stays CCITT, anything else becomes Flate.

From reviews with the pdfcraft-review skill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
- Packing compared an indexed image's decode array, and RGB's pairs, as
  written: [0 255.0], an entry by reference or a real among RGB's pairs
  stopped it, though they mean the same as integers. Each entry is
  resolved and read as a number now, as is_identity_decode and
  dark_is_one read them; RGB's pair is written back direct.
- A test holds that Retain stores nothing again it doesn't resample: a
  Group 3 halftone above 450 ppi, which isn't averaged down, stays Group
  3 byte for byte (the guard's || as && passed every test).

From reviews with the pdfcraft-review skill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
The rule came into decode_ccitt with the decoding it moved from Export ▸
Images (#4335's "Export reads byte-aligned Group 3 by the codes in its
data"), where the create test checks it through the export. Here it gets
tests of its own:
- 32 seeded text-like pictures, 8 to 207 samples wide, at k 0 and 1, in
  each layout (no codes, with end_of_line and without; codes, each
  starting on a byte or ending on one, with and without). Each decodes to
  its picture and, a byte short, to fewer rows, not to other rows read
  another way.
- Group 4 rows on bytes keep their aligned read, end_of_line or not.
- The any-input property varies end_of_line and encoded_byte_align too.

With the rule this replaced (rows on bytes read unaligned under
end_of_line, aligned otherwise) the test fails; cargo-mutants leaves no
mutant of the rule's lines alive. The filters README says how rows on
bytes are read.

From reviews with the pdfcraft-review skill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
The optimize crate doc and MonoSettings named only 1-bit, CCITT and
eight-bit grey images; doc_optimize's mono said Group 4 only; the M11
milestone row stopped at Group 4 compression. They now include
downsampling (halftones aside), Group 3 to Group 4, 1-bit Flate, and
black-and-white RGB and indexed images that use only palette entries 0
and 1, packed to one bit. That last isn't "two-entry", as the roadmap and
the downsampling parity note had it: an indexed image packs whatever its
palette's size, when its samples are 0 and 1. The mono-compression note
says byte-aligned Group 3 with end-of-line codes but none before the
first row is stored again as PdfCraft draws it, which at /K 0 may be
other rows than were coded.

From reviews with the pdfcraft-review skill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
decode_ccitt reads byte-aligned Group 3 whose data has end-of-line codes
but none before the first row (not as T.4 sends them) on bytes. At /K 0
with the fill ending each code on a byte, about one small picture in
five is then read as other rows; at fax width almost none are, as such
pages come out short and are refused. Export already said so. The
optimizer, when it resamples such an image or stores it again as Group 4
from behind another filter, and redaction, when it clears pixels in one,
write those rows back, and nothing said that.

Black-box, PdfCraft's renderer draws such an image exactly as
decode_ccitt reads it (all 42 misread of 200 seeded pictures, behind
ASCII85Decode too), so the written image is what PdfCraft showed before.
Refusing such data instead (a code after nearly every row but none
first) would also refuse some ordinary Modified Huffman images, whose
rows can meet 11 zeros by chance (0.5 to 5% of small ones in a
black-box probe), so the limit is stated rather than guarded: in
decode_ccitt's doc, the filters, optimize and redact READMEs, and the
core.image-filters and mono-compression notes. The optimize README's and
the mono-compression note's "not yet: Group 3 to Group 4 without
resampling" now say a lone Group 3 image: one behind another filter is
stored again as Group 4.

Also from the review: docs/roadmap.md keeps #4335's status line under
this PR's, rather than replacing it; the filters README's tests and the
core.image-filters evidence name the Group 3 layout test; the filters
crate's description names Group 3 and 4 decoding.

From reviews with the pdfcraft-review skill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
group_3_fax_images_become_group_4 coded its rows only off bytes. It now
also stores again rows on bytes: Modified Huffman (no end-of-line codes),
and codes whose fill ends each on a byte, as TIFF writes them, at /K 0 and
1 without /EndOfLine, which reading by that flag misread (the test fails
with that rule). Each becomes Group 4, smaller, decoding to the picture.

The page is compared with the picture in Flate rather than with itself
before: PdfCraft's bootstrap renderer draws TIFF's layout (the fill
ending each code on a byte) as other rows, at every /K and with
/EndOfLine or not (black box), so storing it as Group 4 corrects what
PdfCraft shows rather than keeping it. (Redaction removes such images
whole, for that reason, since #4384's "redaction removes Group 3 on
bytes whole".)

From reviews with the pdfcraft-review skill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
PdfCraft's bootstrap renderer draws byte-aligned Group 3 whose fill ends
each end-of-line code on a byte (as TIFF writes it) as other rows than
decode_ccitt reads (black box, at every /K, with /EndOfLine or not:
mostly the first row, then blank). Redaction cleared pixels in the
picture decode_ccitt reads, so a mark over what PdfCraft showed needn't
cover what was cleared, and content shown blank stayed in the copy. Such
an image (/K 0 or more, /EncodedByteAlign) is now removed whole, as
every CCITT image was before this PR; Group 3 off bytes and Group 4 are
still cleared.

The image's parameters come from Document::fax_params, the reading of a
fax image's /DecodeParms entry that decoded_image already did, now on
its own (it was to come with #4404, which uses it too). The redaction
README, the module doc, the redaction and core.image-filters notes and
the spec parity table's status line and revision row say so, and the
core.image-filters note now records the renderer's misdrawing.

group_3_on_bytes_is_removed_whole_and_other_fax_images_cleared: a white
16 × 2 picture, checked to decode as such, is removed whole on bytes (no
codes; codes at /K 0; at /K 1 with /EncodedByteAlign by reference) and
cleared off bytes and as Group 4 on bytes. It fails without the guard;
cargo-mutants catches every viable mutant of it.

From reviews with the pdfcraft-review skill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
docs/architecture.md said every image codec decodes through the
bootstrap renderer; Export ▸ Images decodes CCITT (hayro-ccitt) and JPEG
itself. The progress logs' "(a dithered scan)" sat after "soft mask
counted", as if it described the mask, and so did the
optimize.mono-compression note's "(a dithered scan downsampled to JPEG)".

From reviews with the pdfcraft-review skill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
calculation_order flags MissingField (a field deleted from under a sum, a
typo in notation, a script's getField; a group's name and a widget's
number count as there) and BuiltNames (a getField or AFSimple_Calculate
list built at run time, found as af::Site::Built in the same pass as the
literal names). form_calculation_order gives each field's formula and the
two new issues; form_fields shows a custom Calculate script; the
Calculation order page explains both in its row tooltips.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z
The row gets a red step and a "Missing field" caption, as a loop does; a
red card names the formulas and links to the first, in place of the
all-clear; a script that builds names gets a grey "Unchecked reads"
caption and a note; the summary above the page lists a missing field
among the reads and says so, and says when a script names fields as it
runs. The name and formula lines of a row are sized by their own
right-hand text, so a wide caption no longer cuts the name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z
- CalculatedField carries the field object: form_calculation_order and
  the Calculation order page find each row's formula and value by it,
  so two calculated fields sharing a name (a malformed form) each show
  their own.
- An AFSimple_Calculate list counts as written out only when it ends
  the call: "row" + suffix and [...].concat(more) are names built at
  run time, and an array item counts only when it ends at a comma or
  the list's end, so ["row" + i] no longer reads, or renames, "row".
- After a reorder, Undo is offered on the missing-field card instead of
  bringing back the all-clear beside it.

From CodeRabbit's review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z
calculate_arg read the notation first, so {"notation": …, "script": …}
set the notation and dropped the script unnoticed; the script check now
comes first (from CodeRabbit's review).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z
calculate_json wrote the lowercased AFSimple code, so a product or an
average came back as {"op": "prd"} or {"op": "avg"}, which
form_set_props refused as an unknown op. One name per operation now
serves both: calculate_arg looks names up in it (still taking minimum
and maximum), and the listings write it (from CodeRabbit's review).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A read like price.12 counted as found whenever price existed. A widget
number now finds something only below the field's widget count; a
field with one widget, or none, still answers to .0 (from CodeRabbit's
review).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
found accepted price.0 (up to the field's last widget), but links,
recalculate and the scripts' getField all take a name as the field or
the fields under it, so a formula reading price.0 got no value and no
order check while the page said nothing was wrong. found now looks
names up the same way; a widget name is flagged as missing until all of
them resolve it (from CodeRabbit's review).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The page's focus was a name, so with two fields called total (a
malformed form) hovering the second one's row showed the first one's
missing fields in the pill, and outlined and numbered both on the page.
A row and a widget under the pointer now carry their field object: the
pill says that field's reads and issues, and the page outlines it, its
step and the formulas that read it by object. The Prepare selection is
still only a name, so it stands for every field of that name and the
pill says all of their issues (from FredApps's review).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
AFSimple_Calculate("SUM", "a, b", x) gave only a built-name site, so a
rename no longer rewrote a and b (main did) while parse_calculate still
read the sum of a and b: after a rename the sum read missing fields. The
string list now keeps its names when an argument follows, and what
follows is a name built at run time, as for an array (from the review).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
MissingField and BuiltNames are about the formula, not its place in the
order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
A notation beside op and fields set the notation and dropped the rest, a
field name that wasn't a string was left out, a misspelt key was ignored,
and validate {"min": 0, "max": "100"} wrote a min-only range. Each is
now refused and nothing changes. A null bound, as form_fields shows the
one a range doesn't have, is no bound, so its output is taken back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
Both translate the neighbouring Calculation order rows; Finnish had
English copies of the new ones, and Persian came in with main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
steps() takes the widgets by object, so each of two fields sharing a name
gets its own step; a test checks it, unfocused and with a focus (from the
review: no test failed with the step looked up by name).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
A notation is kept in the script's /** BVCALC … EVCALC **/ comment and read
back from there. One holding EVCALC was stored cut short ("a EVCALC b"
read back as "a", and its JavaScript translation didn't parse), one
holding */ ended the comment, and an empty one became "event.value = ;".
form_set_props now refuses each (af::notation_storable: it has to read back
as itself). The test also pins a notation beside op alone and beside
fields alone (from the review).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
parse_calculate trimmed the notation with str::trim, which also took an
escaped space ending its last name: "Qty * Total\ " read back as
"Qty * Total\", a field the form doesn't have, while the script's
JavaScript still read "Total ". It now trims to its first and last
token, as the field-reading scan does. notation_storable compares the
names and operators read back with those given, so a notation ending in
a lone \ (which escapes the space after it) is refused; a proptest reads
back notations of any names (from the review).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
set_props refuses such a notation before writing anything, so the
Calculate tab can't store one either (the tool already refused it). A
refused edit used to close Field Properties and lose everything typed
into it; the dialog now stays open with its changes, as the dialog for
several fields does, and the notice says why (from the review).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
The tooltip given with it, which set_props writes before the calculation,
isn't set either; with the check moved to the calculation the test fails
(from the review).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
ckb and nb get all eleven rows, th and lo the six strings, and sq the five plurals beside its
strings, each under "# Untranslated". Their rows next to these were missing too, so following
them had added nothing.

hi, id, ko, lo and th list one plural form (plural_none), so they get no English copy of the
plurals: one English wording would be used for every count ("1 formulas read ..."). Without an
entry, the English fallback picks the right form, as it already does for the page's other plurals
in those catalogues.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
The test only checked each refusal's message. Without the tool's notation guard, set_props
refuses the same notation with the same message as a failed edit, so the test passed with the
guard removed. It now asserts InvalidArgs for every row, and fails on "a EVCALC b" without the
guard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An image's /JBIG2Globals were decoded with the 1 GiB any stream may decode
to, before the JBIG2 page's size is known, so the page's pixel limit didn't
bound them: a few kilobytes of Flate could make export allocate up to 1 GiB.
They now decode within MAX_GLOBALS (32 MiB); globals past it skip the image,
with the limit in the reason. The test covers one byte over and exactly the
limit, and the image's /DecodeParms direct, by reference and in an array.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
pdfcraft_filters::encode_jbig2 stores each distinct mark of a 1-bit
image (8-connected black pixels, at most 256 a side) once, as a symbol
in a symbol dictionary, and places its exact copies with an immediate
lossless text region that refers to it. Larger marks, and in a second
plan marks that occur once, go to a generic region over their box. It
keeps whichever of that and the generic coding (encode_jbig2_generic,
unchanged) is smaller. Only exact copies share a symbol, so the stream
decodes to the same bits.

Written from the JBIG committee's ISO/IEC 14492 Final Committee Draft:
6.4 and 6.5 (text region and symbol dictionary decoding), 7.2, 7.4.2
and 7.4.3 (segment headers and syntax) and Annex A (the integer coders,
checked against its worked example and Table A.1).

On six pages of rendered 300 dpi text the output is 28% of the generic
coding's size and 17% of Group 4's, at about 100 ms a page; hayro-jbig2
and Poppler (pdfimages) both decode it to the original bits. Scans,
whose copies of a letter are seldom identical, gain little: that needs
refinement coding. A dictionary shared between pages (/JBIG2Globals)
is a further step.

The optimizer's Monochrome Images > JBIG2 (lossless) and doc_optimize
mono jbig2 use it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
Review found behaviour the tests let break unnoticed (each was a
surviving mutant, and each new test fails on it):
- black padding bits: a run reaching past the row would stop symbol
  coding on many scans;
- Annex A's 32-bit range and PREV at exactly 256, where a decision would
  go uncoded: coordinates past 4,435 on a 12,000-pixel-wide and a
  10,050-row page, and the contexts of 4436's coding;
- marks joined at a corner are one mark; 256 pixels a side is a symbol,
  257 is left to the generic region;
- the run and row caps, at and past them.

The roadmap's status line for this change now comes first, and the
"about a third smaller than Group 4 on scans" figure is replaced by what
was measured: 27-33% on the same pages with scan-like noise added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
A second review found two rules tested one way only: a mark 257 tall
(not just wide) is left to the generic region, which also bounds the
bitmap allocated for each mark, and runs joined at the lower-left corner
are one mark. The test now has a 1 x 257 column and an anti-diagonal.
The caps on distinct symbols (65,536) and their pixels (2^24) are tested
at and past them, as the run and row caps are.

The scans figure now names its noise: with 2% or more of the letters'
edge pixels flipped, symbols save at most 8% over the generic region.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
A review found that a dense page came out as a stream PdfCraft's own
renderer refuses, so the page drew blank after optimizing: hayro-jbig2
decodes a text region of at most 10,000 symbol instances, and refuses
32,768 symbols or more. The stream was valid (Poppler decoded it), but
every mark went into one text region and up to 65,536 symbols were kept.
A 300 dpi letter page of the optimizer test's own text crossed it.

A page with more than 10,000 marks is now placed by several text
regions, each over a band of rows and all referring to the one symbol
dictionary, which each region but the last keeps retained (7.2.4). Past
32,767 distinct symbols the image is coded generically.

New tests: 25,000 copies placed by three regions, which decode; the
symbol caps decoded, not just reached; and in the optimizer, a whole
letter page of rendered text that looks the same as JBIG2 symbols (it
drew blank before). The READMEs say "still looked at" for the caps, and
that the scans figure is the six pages' total (20% on one page).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
Review of the previous commit:
- Nothing checked the segment numbers the region split assigns: a
  mutant numbering every region 2 decoded. The many-marks test now
  reads each segment's number, and has a bar too tall to be a symbol,
  so the generic region is numbered after the last text region.
- The decoder's 32,767-symbol limit was checked against all distinct
  marks, before either plan, so an image with more distinct marks lost
  the second plan, which codes only the repeated ones. The limit now
  applies to each plan (MAX_CODED_SYMBOLS); finding marks keeps its
  memory bound of 65,536 (MAX_SYMBOLS), as before the previous commit.
  A test has 32,768 distinct marks above a page of repeated text, which
  encode_jbig2 still symbol codes through the second plan.
- The dense-page optimizer test now checks that it takes several text
  regions, and a compile-time assert keeps segment numbers within a
  byte's reach.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
The optimizer now writes JBIG2 images as a symbol dictionary and text
regions, and Export ▸ Images has to read them back. A page of three
repeated letters, symbol coded (smaller than the generic coding),
exports as its picture. The test is added to create.export-all-images'
parity evidence. Asked for in pre-push review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
docs/target-app-parity.md's optimize row lists lossy JBIG2, refinement
coding and a symbol dictionary shared between images as missing, with a
status line and a revision row.

From reviews with the pdfcraft-review skill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
Packing makes an RGB image grey for every use at once, but /DefaultGray and
/DefaultRGB were checked only where the walk reads: page content and forms.
An annotation's appearance, a pattern, a Type3 glyph or a soft mask's group
drawing the same image can have other default colour spaces, so its colours
could change there.

The walk now records, by object number, every reference in the resources it
reads (each /XObject entry, drawn or not, an /XObject dictionary under its
resources, resources under a page, page-tree node or form), reading the
resources of every page and of every node above one, each page and node a
root. An image the walk reached stays covered unless an object reachable
from the trailer refers to it, or to an object on its way from a page, other
than through those references, or it is held by one that isn't covered; an
eight-bit DeviceRGB image that isn't covered stays RGB. So resources, an
/XObject dictionary or a form a page shares with an appearance keep it RGB,
as does a reference with another generation, while LibreOffice's layout (one
resources object for a page, its /Pages node, and a form inside it that
names it again) still packs. The scan runs only when packing is on and there
is such an image.

The images drawn or listed where the defaults are in force are kept by
object number too, so one named with another generation there stays RGB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Use the unified Format Text panel and current page-input context instead of restoring old monolithic UI code. Preserve main's pointer routing, editor accessibility and preferences; retain colour, save and input regressions. Hosted CI must validate this refreshed head.
Preserve main's continuous text selection, page tools and module split. Carry only this branch's sidebar-focus feature and regression onto main, without resurrecting old common files from criss-cross merge bases. Hosted CI remains required.
Preserve caption editing and current form/Prepare module boundaries. Carry export-only appearance preservation, validation and data round-trip tests to their current paths, and retain current attribution metadata. Hosted CI must validate this refreshed head.
Use a named compact preset arrow beside the field, retain the row alignment/width assertions for the editable control, and open the current colour flyout in the saved-text regression. No accessibility or layout checks are disabled.
Preserve each original PR head and its authorship as a merge parent.
Includes #4304 #4335 #4384 #4404 #4179 #5300 #4719 #4541 #2815 #4620 #3789 #3779 #2960 #5208 #4596 #3636 #4494 #4013 #5134 #2133 #2635.
Conflicting candidates skipped. Combined CI pending; see docs/integration-20261030.md.
Includes #4325 #1952 #4180 #2529 #4320 #4805, preserving their original histories.
Keep paragraph alignment and hard/soft wrap metadata together; retain CCITT/downsampling/RGB ownership guards with bounded JBIG2 export and exact-copy symbol encoding.
Combined interaction regressions added. Validation pending.
Integrate 27 reviewed PDF editing, forms and optimizer PRs
* fix(flatpak): allow reading launcher version pins

* fix(flatpak): allow reading launcher version pins
M2: continue CJK viewer fallback for Windows GBK PDFs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Updates: shared check-result dialog from craft-update
Merge pull request #5522 from aiongg/docs-tidy
Some checks failed
ci.yml / Merge pull request #5522 from aiongg/docs-tidy (pull_request) Failing after 0s
freebsd.yml / Merge pull request #5522 from aiongg/docs-tidy (pull_request) Failing after 0s
nix.yml / Merge pull request #5522 from aiongg/docs-tidy (pull_request) Failing after 0s
packaging-lint.yml / Merge pull request #5522 from aiongg/docs-tidy (pull_request) Failing after 0s
sonarcloud.yml / Merge pull request #5522 from aiongg/docs-tidy (pull_request) Failing after 0s
ci.yml / Merge pull request #5522 from aiongg/docs-tidy (push) Failing after 0s
freebsd.yml / Merge pull request #5522 from aiongg/docs-tidy (push) Failing after 0s
nix.yml / Merge pull request #5522 from aiongg/docs-tidy (push) Failing after 0s
packaging-lint.yml / Merge pull request #5522 from aiongg/docs-tidy (push) Failing after 0s
sonarcloud.yml / Merge pull request #5522 from aiongg/docs-tidy (push) Failing after 0s
f137a289ba
docs: tidy agent, contributor and asset docs
alf merged commit f137a289ba into main 2026-10-31 17:02:02 +00:00
alf deleted branch chore/sync-upstream-20261031 2026-10-31 17:02:02 +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!13
No description provided.