* M7: Arrange keeps a top-level inline image a unit of its own (#3146)
The parser emits a whole inline image (BI ... ID ... EI) as one BI op, but
content_units scanned on from it for an EI op that never comes, so the unit ran
to the end of the stream. SendBackward on an image after a top-level inline
image then acted like SendToBack, and BringForward/BringToFront on an image in
the swallowed region could do nothing. A BI op is now a unit of its own, like Do
and sh.
* M7: pin Arrange around inline images at every position (#3146)
Check the exact content units and the paint order after each move, not
string positions: several top-level inline images (at the start, side by
side and at the end) are one unit each, BringForward and SendBackward step
past one inline image at a time, BringToFront on the front image is refused
without touching the page, and SendToBack and BringToFront go past inline
images that start or end the stream. A guard checks that an inline image
inside q/Q or marked content stays in that unit. Document content_units.
* M7: Arrange tests check each image's transform through q/Q (#3146)
Review asked for text under and above a scan (already pinned), an image
clipped away entirely, two images, and a form holding the image. The
clipped case fails when the hit test uses the image's box instead of
its visible box; the negative cases fail when any later XObject counts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
Conflicts: images.rs (main folded image_transform_scope into
image_scopes; kept only painted_over) and text/interp.rs (gs arm now
reads params via dict_in_place, then records the soft-mask anchor).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
A soft mask is placed by the CTM in force when its `gs` runs (ISO 32000-2
11.6.5.2). The paragraph lifted over the scan re-selects the graphics state
after its own `cm`, so a mask selected before a translation landed
translated with it and could hide the new text. A re-emitted state that
carries a dictionary /SMask now records its anchor CTM and is selected
there: `cm` to the anchor, `gs`, `cm` back.
The test edits the same paragraph with and without a mask and requires the
same ink; it fails with the re-anchoring disabled (the mask lands 100 pt
right and the text vanishes). Both rewrite paths.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
* 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>
Each `Do` copied the page's `/XObject` dictionary, and each form its `/Resources`, `/Properties`,
`/Font` and `/ExtGState`, or the resources it was drawn with if it had none. So Office export of one
page took time in the square of what it drew: 31 s for 10,000 pictures, about 70 ms of it decoding, 30
s for 10,000 forms with no `/Resources`, and 16 minutes for one form with 100,000 keys in them drawn
50,000 times, in a test build.
`FormCall` keeps the form and `resource_dict` reads a dictionary where it is, with an object of its
own held. 10,000 pictures take about 0.15 s now, 10,000 forms 79 ms, and that form drawn 5,000 times 40 ms. The test holds to 10,000 pictures, not the 2,000 it could afford.
A `gs` reads a direct graphics state in place too, in the text walk and in `page_background`, and a
value of the wrong type isn't copied to find that out. A form that sets a font still reads the font each time it is drawn, as the fonts read
are kept only for that draw: 3.5 ms a draw for one of 100,000 widths.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
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
Conflict in crates/edit/src/text_regions/styled.rs: kept this branch's
`row_step` beside main's `WrappedStyledRows`, and main's marker-aware
`splice_with_marker` splice with the branch's `at` (already computed
before the reach check). main's `wrap` now yields (line, soft, gap), so
the reach's widest line in rewrite.rs reads the line from the tuple.
Gates: fmt, clippy -p pdfcraft-edit -p pdfcraft-engine --all-targets
-D warnings, tests edit 362 / engine 388 / 18, xtask parity.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
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.
A larger range on the first line of a multi-line paragraph rises from the
first line's baseline, not from the old box's bottom, so the reach now
extends one pitch above the moved box's top. Name the 1.2 em line floor.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
With only a range enlarged, row_step is the source leading, so the
styled rewrite's reach rose one source line above its old box while a
120-point range draws far higher, and an image there was missed. The
pitch is now at least 1.2 times the largest size.
The landing test gains a 120-point range on a 12-point OCR line under an
image above the old box; it fails without the floor.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
The styled path writes rows `line_spacing` (1.5 by default) times the
size apart when a size or spacing is set, but its Reach assumed 1.2, so
an image over only the lower rows of a larger rewrite was missed. The
pitch now comes from the row writer's own step (row_step, shared by
both), at the largest size among the paragraph's lines and ranges.
The landing test gains a six-row rewrite at twice the size over an image
covering only its last row; it fails with the 1.2 step.
A case with only line_spacing set covers the other branch.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
The on-top trigger compared later paint with the paragraph's old box. A
rewrite that is moved (BlockStyle::offset), rewrapped wider or grown to
more lines can land under an image the old box is clear of, and stayed
underneath it. Both rewrite paths now test a Reach: the old box (where
the cover goes) plus the moved box grown to the wrap width either way
and down to the drawn line count (the widest measured line when wider), and
up by one line pitch for a larger size; turned text grows by the lines'
diagonal; any non-finite number makes it unbounded. Over-reaching is
harmless: the cover still fills only the old box. The parity notes say
so.
Tests: an_ocr_rewrite_goes_on_top_of_paint_over_where_it_lands (move,
width and line growth, both paths) and a_rewrites_reach_covers_its_move_
width_and_lines. Seen failing with each path reverted to the old box and
with growth zeroed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
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
An unrelated image later on the page no longer moves the paragraph and
its cover on top, where the cover could hide visible content inside the
paragraph's box. An XObject now counts only when it is drawn at or after
the paragraph and its visible box overlaps the paragraph's
(`images::painted_over`, from `page_images`). An inline image or a
shading after the paragraph still counts anywhere, and so does any XObject
when the page's images cannot be listed.
The trigger table now covers an image elsewhere, an image drawn first,
visible text, a filled path, a shading and an inline image. It fails with
the old any-`Do` trigger.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
A table test for `revealed_under_paint`: only invisible text with an
XObject, inline image or shading after its place moves; a filled path,
visible text or nothing after it does not. Seen to fail with the check
reduced to `invisible`.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
An OCR paragraph that may go on top of the page is checked (`check_on_top`)
before either rewrite path changes the document, so a page whose marked
content nests too deep is refused with nothing changed. `lift_on_top` no
longer checks.
Tests: undo restores the hidden layer and the scan, redo, save and reopen,
and a second edit of the moved text all show the edit; a phrase split from
visible text in the same text object shows its edit; a refused rewrite on a
too-deep page leaves the saved bytes unchanged. Each new test was seen to
fail with its part of the fix reverted.
Parity: the note says when the edit is drawn on top and lists what is
still missing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
Some OCR tools write the invisible text layer first and the opaque page
image after it. Edit text rewrote such a phrase in the text's place, so
the cover and the new visible text were painted under the scan: the edit
was saved (text extraction finds it) but nothing showed.
When the rewritten paragraph was invisible and an XObject, inline image
or shading is painted after its place, the cover and the new text now go
on top of the page in their own q/Q (place_tagged, tagged Edited), drawn
from the initial graphics state with the paragraph's CTM. Only the state
the following text expects stays in place. Both rewrite paths (single
style and styled rows) do this. An OCR layer drawn over its scan, the
common case, is rewritten in place as before, so reading order is kept.
The test builds a scan whose OCR word comes before the image and checks
the cover and new text show, for one line and for a typed line break.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
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.