Conflicts:
- crates/edit/src/images.rs, crates/engine/src/doc_state.rs, crates/engine/src/edit/labels.rs,
crates/automation/src/dispatch.rs: main added Transform, Arrange and RemoveCrop next to this
branch's Ungroup; kept all.
- crates/edit/src/lib.rs: kept `mod ungroup;` with main's export list (ArrangeOrder).
- crates/engine/src/edit/run.rs: main moved EditPageImage into run_edit_page_image; carried
the Ungroup arm there, and Ungroup goes through change_image on the added-image path too.
- crates/automation/src/tools.rs: image_edit takes main's description and action list, with
this branch's ungroup added to both.
- crates/ui-egui/src/tools/edit_text_ui/image.rs: main rebuilt the image context menu; took
it and carried the Ungroup item (Form artwork only) before Replace image.
- crates/automation/tests/it/automation.rs, crates/edit/src/tests.rs,
crates/ui-egui/tests/it/editing.rs: both sides appended tests; kept both.
- crates/ui-egui/src/i18n/{ca,es,fi,hi,id,ko,ro,sq}.tsv: both sides appended; kept both,
main's lines first.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
* M7: make page-mark replacement transactional (#5657)
* ROADMAP: drop the header line (the log entry stays)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Fold a leading transform inside the image scope as cm followed by the local transform; an enclosing transform uses the opposite multiplication order. This preserves the complete requested image and crop displacement under an outer scale. The exact crop-arrange-group-move fixture previously moved only five points for a ten-point request; saved geometry and rasters now match an independent direct Transform control across both axes and signed offsets. Full scoped Linux native, wasm and repository gates pass.
Co-Authored-By: Codex GPT-6.1 <noreply@openai.com>
Track the text-state operators with saved drawing state, restoring inherited spacing, scaling, leading, rendering mode, rise and known font before relocated Form artwork. Emit restoration inside BT/ET, and leave a font selected by a replayed ExtGState authoritative over an older direct Tf. The exact inherited 12 Tc fixture failed its saved-PDF raster comparison before this fix; zero-spacing and destination-state controls now preserve rendering. Scoped Linux native, wasm, clippy and formatting checks pass.
Co-Authored-By: Codex GPT-6.1 <noreply@openai.com>
Replay repeated ExtGState names in last-application order. Compare each complete soft-mask value and its establishment matrix instead of only its group reference, and retain cached shared definitions across saved states. Saved-PDF raster regressions cover Alpha versus Luminosity over the same group, repeated aliases at different matrices, and ordinary sequence/single-mask controls. Definition checks also cover backdrop and transfer parameters. Actual baseline and reverted-comparator tests fail; scoped Linux native, wasm, clippy and formatting checks pass.
Co-Authored-By: Codex GPT-6.1 <noreply@openai.com>
Move editor crop scopes with grouped images while retaining fixed author clips and folding repeated transforms. Arrange keeps relocated original artwork in its own stream beside an added item, so later edits or deletion cannot discard it; unusual shared owned streams are refused. Refuse nested marked-content extraction when its tags cannot be preserved. Synthetic regressions and the headless group-move, undo/redo, save/reopen, arrange, delete and render lifecycle fail before these fixes and pass afterward.
Co-Authored-By: Codex GPT-6.1 <noreply@openai.com>
Keep dash, color and extended printing operand payloads shared across q snapshots. Copy-on-write color updates preserve saved values without multiplying large operand arrays by nesting depth. A pointer-sharing regression failed before the change; scoped native tests, clippy and wasm check pass on GGEO-AI02.
Co-Authored-By: Codex GPT-6.1 <noreply@openai.com>
Report active PdfCraft crop markers through PageImage::has_editor_crop and page_images.has_editor_crop so frontends can distinguish removable editor crops from author clipping paths.
Co-Authored-By: Codex GPT-6.1 <noreply@openai.com>
Reuse the existing subset-tag parser through the engine facade and share the fallback formatting state.
Co-Authored-By: Codex GPT-6.1 <noreply@openai.com>
Refuse relocation into transfer, overprint, black-generation, undercolor-removal or halftone state that cannot be restored. Preserve implicit device color spaces before sc/scn and restore rendering intent from both ri and ExtGState RI, including its initial default. Three synthetic regressions fail against the previous implementation; all 411 edit tests and scoped clippy pass on GGEO-AI02.
Co-Authored-By: Codex GPT-6.1 <noreply@openai.com>
Identify crop paths with a marked-content point and preserve ownership through arrange reconstruction. Find the same isolated or outermost image scope as Crop, preserving authored and shared clips. Both regressions fail with the fix reverted; all 410 edit tests, scoped clippy, fmt, layers and assets pass on GGEO-AI02.
Co-Authored-By: Codex GPT-6.1 <noreply@openai.com>
Require both graphics and marked-content boundaries to balance inside source, destination and removed spans. Regression fails before the guard; all 408 edit tests, scoped clippy, fmt, layers and assets pass on GGEO-AI02.
Co-Authored-By: Codex GPT-6.1 <noreply@openai.com>
PR #2155 review item A5 (part 2), plus the unreachable Ok(()) exits in
arrange_image from B11. No behaviour change for well-formed content:
- image_exclusive_scope and image_transform_scope were one scan; they are
now image_scopes, and transform/crop take ImageScopes::transform_scope.
The shared scan resets an open path at Q, as the exclusive one did; the
two only differed on content that leaves a path open across Q.
- arrange_image and remove_image_crop took page and index and listed the
page's images again; change_image now passes the page, its streams and
the image, and neither is public any more.
- content_units' four copied scan loops are one sequence_end.
- DrawingState keeps its line-style, intent and flatness operators in a
table; the w/J/j/M/d guards in compute_inherited_drawing_ops tested ops
extract_scope_state never collects.
- clip_path_to_ops: ImageState never builds a quadratic segment and only
stores finite points, so the QuadTo arm and finite checks were dead; it
now reports a segment it can't write instead of dropping the clip.
- target_destination_unit and determine_arrange_spans are shorter; the
Ok(()) exits for lookups that cannot fail are now errors.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PR #2155 review item A5 (part 1). No behaviour change for callers:
- crop_image, and the arrange_image/crop_image/remove_image_crop/MaskIdentity
re-exports, existed only for tests; tests now go through change_image.
- PageImage.smask was read only by tests; page_images no longer tracks
ExtGState for it. The nested-mask test keeps its rendered-pixel check.
- MaskIdentity's Display, ExtractedScopeState.effective_cm, the
ExtendedGStateTracker overflow_depth (an error ends the scan, so it was
never read) and the EffectiveGState one-line getters were unused.
- merge_dict always gets the ExtGState name, so the hash fallback for an
unnamed inline soft mask was unreachable outside a test.
- cid_to_gid_map_odd_length_is_trimmed_to_even exercised no crate code.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- [P1] Arrange: track and replay inherited non-ExtGState drawing state (colors, line widths, etc.) across q/Q scopes for stencil and form artwork
- [P2] Flips: route ImageEdit::Flip through coordinate-aware transform logic to swap axes when rotated at odd quarter turns (90°/270°)
- [P2] Rotation knob: measure clockwise angle delta from drag start relative to the rotated image center to prevent reapplying existing angle
- [P2] Format panel: connect superscript/subscript controls to line editor and content draft selections, and update parity evidence
- Resolve all 13 merge conflicts between upstream/main and main
- Preserve full image editing suite: OBB, crop, proportional resize, arrange, and context menu actions
- Preserve text editing formatting tools: bullet/numbered lists, subscript/superscript, and line spacing
- Cleanly integrate upstream's canvas modularization, font updates, and styling changes
- Refactor arrange_image and run_edit_page_image helpers to satisfy clippy cognitive complexity limits
- All engine, edit, and UI integration test suites passing cleanly
A transparency group composites its contents in its own group colour space
(ISO 32000-2 11.6.6, Table 145 /CS) and the result is then converted to
its parent's; inlined, the same objects composite in the page group's
space (11.4.7). Colour conversion isn't linear, so translucent content and
even opaque colours can change, and Ungroup promises the same pixels.
A group /CS is now accepted only when it matches the page group's /CS, or
is DeviceRGB on a page that names none (the processor chooses that space;
viewers composite on screen in RGB). A group that names no space inherits
the page's and is fine. The tool description and the parity note say so.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
00688adf read crates/edit/src/ungroup.rs as Windows-1252 and wrote it back
as UTF-8, double-encoding the non-ASCII characters in six doc-comment
lines. Restore them byte for byte (comments only, no code change):
- line 1: "â–¸" -> "▸" (Edit a PDF ▸ Ungroup)
- line 5: "…" -> "…" (re W n … Q)
- line 14: "…" -> "…" twice (/OC … BDC … EMC)
- line 17: "§" -> "§" (ISO 32000-2 §8.7.2)
- line 32: "§" -> "§" (ISO 32000-2 §7.8.3)
- line 38: "→" -> "→" (form name → name)
The rest of the PR diff has no such damage.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC
Review follow-up on #3067: the transparency-group checks read /S and /K
raw, so an indirect /K true let a knockout group be ungrouped, and an
indirect /S /Transparency skipped the knockout, translucency and blend
checks. The /Subtype and /PatternType of nested forms, Type 3 fonts and
tiling patterns were read raw too, so blend modes inside such a form and
inherited resources were missed. All are now resolved first (any value
may be an indirect object, ISO 32000-2 7.3.10).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC
A pattern's matrix maps pattern space to the default space of the content
stream that uses it (ISO 32000-2 §8.7.2). Inside a Form XObject that is the
form's space at the Do; drawn inline on the page it became the page's, so a
form moved by its /Matrix or a cm before the Do lost its patterns' place:
tiles out of phase or at the wrong size, a shading outside its box.
Each pattern the form selects with scn/SCN is now drawn from a copy whose
/Matrix is the pattern's own, then the form's /Matrix, then the CTM at the
Do. The form's own pattern object is unchanged for its other placements, a
pattern selected twice is copied once, and a pattern that can't be copied
that way (unreadable matrix, non-finite result, a tiling stream past the
size limit, more than 256 patterns) is refused rather than drawn elsewhere.
Nested forms and patterns inside patterns keep their own parents.
Tests: ungroup_carries_the_placement_into_the_patterns_it_selects (edit) and
ungrouping_keeps_patterns_where_the_forms_placement_put_them (automation,
four synthetic cases rendered before and after save/reopen), both seen
failing with the fix switched off. The same four cases were checked in
Acrobat Pro DC, where the unfixed output drew differently.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC
page_images numbers a Do among the operators of all of the page's content
streams parsed as one (since #3294). translucent_at took that number as an
index into the Do's own stream, so on a page with more than one stream it
read past the Do: with 'q /Mul gs /Art Do Q' in the second stream it saw
the Q and missed the Multiply the group is drawn with, and ungrouped a
transparency group that composites differently. It now walks the joined
operators up to the Do.
Raised by CodeRabbit on #3067.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R3niW7kVeT8mi7TpWc8eAJ
A Form XObject, Type 3 font or tiling pattern without /Resources of its own
is drawn by the renderer with the resources of whatever draws it
(vendor/hayro-interpret x_object.rs, font/type3.rs, pattern.rs chain them
with Resources::from_parent). Ungroup moves the form's resources into the
page's under new names, so such an item drawn from the page afterwards
would find the page's resources instead: another font, or none.
Ungroup now refuses when the merge renamed anything and the form's content
draws such an item. A form without resources of its own is unaffected (it
already uses the page's), and so are images and shading patterns.
Raised by @Lcoding15 in #3067 (from #3222).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R3niW7kVeT8mi7TpWc8eAJ
Review follow-ups on #3067: unmatched EMC/BDC/BT/ET are refused; a shading
pattern's ExtGState and a tiling pattern's content count toward blending inside
a transparency group; nested forms with their own resources are cached and the
reads have a ceiling of 1024.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv2WwdgjmBz33GWkwgNn3Q
Text drawn inside a Form XObject was one piece of artwork in Edit text &
images: HTML-to-PDF output that wraps a block with a translucent picture
in a transparency group showed a whole table as a single box with no
editable text (#3005).
Ungroup (artwork right-click menu; image_edit action ungroup) replaces
that placement's Do with the form's content, wrapped as
q <Matrix> cm <BBox> re W n ... Q, its resources merged into a copy of the
page's under fresh names where they clash (fonts, XObjects, graphics
states, colour spaces incl. inline images', patterns, shadings,
properties). The form object and its other placements are untouched; a
form in a layer keeps it as /OC ... BDC ... EMC.
Refused rather than changed: knockout groups, transparency groups that
blend inside (used gs, nested forms) or are drawn translucent, blended or
soft-masked at the Do (followed per parameter across q/Q and streams),
tagged form content, content that can't be decoded strictly or parsed,
and unbalanced Q. cos gains Stream::decoded_strict_within for that.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
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.
UPDATE_APP (native builds; craft-update is a native-only dependency) carries our 128 px icon and accent (#b70228, its red, from the icon (#5245)). The updater, the self-update
helper and craft-update's startup prompt all get them through crate::UPDATE_APP; the
prompt draws the icon, the accent and "by ArtCraft" (craft-update's default developer).
craft-update is pinned to storytold/craft-libs main at 1065653e7 (#434).
Clippy fixes for Rust 1.99 in untouched code (same logic):
- crates/edit/src/images.rs
- crates/forms/src/appearance.rs
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.
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.
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.
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.
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.
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 put the paragraph marker around the shared ET, so the neighbour in that text object joined the paragraph and the next edit deleted it.
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.
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.
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.
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.
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.