Commit Graph
169 Commits
Author SHA1 Message Date
aiongg 55e32759b1 Integration: subset fallback test names the face the build leads with (#2609 × #1760)
#2609 put the Japanese faces ahead of the bundled Inter face and rewrote
the #1760 test to expect Inter, which holds only without craft-fonts.
With CRAFT_FONTS_DIR, BIZ UDPGothic has ő and draws it. The test now
expects the Japanese sans face when the build has one, Inter otherwise.
2026-10-22 04:15:17 +00:00
aiongg 2c13fd9373 Merge r2 track t16: mixed: automation-mcp, crate:misc, crypt-security, export-convert, optimize, preflight-pdfa, save-io
# Conflicts:
#	ATTRIBUTION.toml
#	contributors/people.toml
#	crates/automation/src/comments.rs
#	crates/edit/src/tests.rs
#	crates/engine/src/tests.rs
#	parity/acrobat-features.toml
2026-10-22 02:23:58 +00:00
aiongg dcf2802f96 Merge r2 track t15: mixed: accessibility-tags, dialogs-prefs, javascript, organize-pages
# Conflicts:
#	crates/edit/src/tests.rs
#	crates/engine/src/tests.rs
2026-10-22 02:23:22 +00:00
aiongg 3cf35888d7 Merge r2 track t12: i18n, i18n: new language catalogues
# Conflicts:
#	ATTRIBUTION.toml
2026-10-22 02:21:42 +00:00
aiongg 15f558aaa8 Merge r2 track t11: redaction, mixed: combine, compare, crate:edit, measure, ocr
# Conflicts:
#	ATTRIBUTION.toml
#	crates/automation/src/tools.rs
#	crates/edit/src/lib.rs
#	crates/ui-egui/tests/compare.rs
#	crates/ui-egui/tests/ui_group/marks.rs
2026-10-22 02:21:38 +00:00
aiongg 69e755c439 Merge r2 track t08: fonts
# Conflicts:
#	crates/automation/src/tools.rs
#	vendor/README.md
2026-10-22 02:20:50 +00:00
aiongg 4d5025c6da Merge r2 track t07: text-extract-select
# Conflicts:
#	crates/edit/src/tests.rs
#	crates/render/src/raster.rs
#	vendor/README.md
2026-10-22 02:20:03 +00:00
aiongg a2c46a9298 Merge r2 track t06: annotations-comments
# Conflicts:
#	crates/automation/tests/automation.rs
#	crates/render/src/raster.rs
2026-10-22 02:19:23 +00:00
aiongg 7bf122eea8 Merge r2 track t02: edit-content
# Conflicts:
#	crates/edit/src/tests.rs
#	crates/edit/src/text.rs
#	crates/ui-egui/src/canvas.rs
#	crates/ui-egui/src/edit_text_ui.rs
2026-10-22 02:17:34 +00:00
aiongg 456b96d06b Merge PR #2454: M5: year-first slash dates (yyyy/mm/dd, yyyy/m/d) for Fill & Sign and headers 2026-10-22 01:49:17 +00:00
aiongg 1544fae727 Merge PR #2481: M7: show a shaped run in one text object instead of one per glyph 2026-10-22 01:48:40 +00:00
aiongg 3378e660aa Merge PR #2612: M7: let a list marker open an item and a hanging indent continue it (#2564) 2026-10-22 01:48:38 +00:00
aiongg 41ec3f370e Merge PR #3289: Lay out vertical (Identity-V) text as columns in the edit layer
crates/edit/src/text.rs: interpret takes the page resources (#3310) with #3289's CachedFont cache and Result return; walk/enter return Result and use main's form budget; call sites pass resources and propagate with ?. TextLine and the text state keep both sides' fields (clips, gs; unreadable_mode, vertical, unreadable). The shown run keeps main's tracking: 0.0 with #3289's vertical advance and tm. replace_line checks ensure_editable before main's clipping-text refusal.
crates/edit/src/tests.rs: kept both sides' new tests.
2026-10-22 01:48:38 +00:00
aiongg f58254b57f Merge PR #3310: Keep text rows in other layers, gs states or Tz out of a paragraph
crates/edit/src/text.rs: interpret takes the page resources (#3310) and the named property lists for /ActualText (main's #3301); every call site passes both. In BDC handling, #3310's layer push runs before main's property-list lookup; carry.layers is saved before positioned_tracking.
crates/edit/src/tests.rs: kept both sides' new tests.
2026-10-22 01:47:54 +00:00
aiongg fa1095350a Merge PR #3697: Edit text: keep a line together across subsets of the same font (#3661) 2026-10-22 01:47:18 +00:00
aiongg a456f516d9 Merge PR #2756: M7: a paragraph number keeps naming the paragraph text_paragraphs listed (#2516)
ROADMAP.md: kept both sides' progress-log lines.
crates/edit/src/lib.rs: re-export list is the union (TextSession from #3942; BlockKey, block_overlapping from #2756).
2026-10-22 01:47:18 +00:00
aiongg 3f9b41246a Merge PR #1829: M10: keep dotted-leader contents entries together
ROADMAP.md: progress-log line added at the top
docs/roadmap.md: status line, M10 row note and revision row applied onto main's
parity/acrobat-features.toml: export-docx evidence and notes insertions applied onto main's
crates/edit/src/tests.rs, crates/engine/src/tests.rs: new tests appended after main's
2026-10-22 01:46:30 +00:00
aiongg f4f9e009ad Merge PR #3861: M7: keep a word on its line when its spacing differs by less than a thousandth of an em
crates/edit/src/tests.rs: kept both new tests (#3571's surplus-TJ test and this PR's spacing test).
2026-10-22 01:45:49 +00:00
aiongg 64e49eac48 Merge PR #2487: M7: drop the fallback fonts a text edit leaves unused 2026-10-22 01:45:38 +00:00
aiongg a0e8c2faa2 Merge PR #2609: fix(edit): draw the Cyrillic the Japanese faces lack with the bundled Inter face (#2593)
parity/acrobat-features.toml: edit.edit-text evidence and notes take both sides' additions
2026-10-22 01:45:35 +00:00
aiongg b8d5cdd603 Merge PR #3595: M7: paint a new background over older ones (#3591)
ROADMAP.md: progress-log lines from both sides, newest first.
2026-10-22 01:45:17 +00:00
aiongg da6c3adedc Merge PR #3471: fix(edit): keep a superscript footnote marker in its paragraph's row (#3252) 2026-10-22 01:45:10 +00:00
aiongg c4616b25f7 Merge PR #3621: The image scan skips a path segment before any m instead of panicking (#3581) 2026-10-22 01:45:09 +00:00
aiongg b6fdb80cef Merge PR #3756: feat(edit): Header & Footer can be drawn in Times or Courier (#3754) 2026-10-22 01:45:09 +00:00
aiongg 16af57acac Merge PR #3942: M7: a text editing session for one paragraph 2026-10-22 01:45:01 +00:00
aiongg 9d7ccc9cd2 Merge PR #2723: fix(edit): keep a one-row centred title centred when it grows (#2581) 2026-10-22 01:44:58 +00:00
aiongg d133dfbf75 Merge PR #1989: Edit images: rotate an image by any angle with a knob (#1940) 2026-10-22 01:44:58 +00:00
aiongg 23ad2d6b2b Merge PR #3245: Draw a note without an appearance as a fixed-size icon at the corner of its Rect (#3175) 2026-10-22 01:44:50 +00:00
komo1iddinandClaude Opus 5.5 6495c08afd M7: a text editing session for one paragraph
A TextSession opens a paragraph once: it keeps the document as it was
and the page's lines then. Each draft is laid out on a copy of that
document, so no draft sees another's output, and the commit writes into
the document it was opened on, refusing ("the paragraph changed while it
was being edited") when that document has changed since.

text_edit_layout and rewrite_block now open a session and lay out or
commit once, so there is one path and their output is unchanged. No
caches yet: those come next, each under the same equivalence test.

First step of #2512.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 02:15:47 +08:00
komo1iddinandClaude Opus 5.5 475f375ff4 M7: keep a word on its line when its spacing differs by less than a thousandth of an em
Joining runs into one line compared their character and word spacing to
1e-6. Word documents exported through macOS Quartz write 0.0001 Tc on
some words, so a line like "for company AEROSOL" split into "for
company A" and "EROSOL", two paragraphs that edit separately.

Spacing below a thousandth of an em, the unit glyph widths are given in,
can't be seen. Compare character and word spacing to that; scale and
rise still have to match exactly, and visible spacing still keeps runs
apart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 22:41:17 +08:00
Gert Pellin 01149dea77 M7: header and footer can shrink the page content out of their way (#2140)
HeaderFooter::shrink scales the original page content evenly into the
band the header and footer leave free, centred across the page, in
display space so rotated pages shrink the way they are shown. The scale
is a content stream first inside the q/Q wrapper, tagged as part of the
header and footer, so Remove and Update take it away again and
backgrounds behind the page stay full size. Margins that leave no room
are refused before anything is drawn.
2026-10-21 09:02:09 +02:00
Lombardi2003andClaude Opus 5.5 14b3978344 fix(edit): a header reuses a font name only for its very own dictionary
From review of #3756: a /PCTimes naming Times-Roman with more to it (a
/ToUnicode map, /Widths) passed as the same face and was replaced, so
the page's own text could lose its map. A name is reused only when it
holds exactly the dictionary a header writes, and then it is left as it
is; anything else gets a free suffixed name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 07:56:53 +02:00
Lombardi2003andClaude Opus 5.5 4e8e17c7e6 fix(edit): a header never takes over a font name the page already draws with
From review of #3756: add_resources set /PCTimes (or /PCCour, /PCHelv)
in the page's fonts whatever was there, so a page that already drew
another font by that name would have its own text change face. Headers
and footers now pick the usual name when it is free or already names the
same standard face, otherwise the first free name_1, name_2…, and add
only that face, never replacing an entry. An update finds its own name
again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 01:13:00 +02:00
Lombardi2003andClaude Opus 5.5 fecfc774f2 feat(edit): Header & Footer can be drawn in Times or Courier (#3754)
Headers and footers were Helvetica only. HeaderFooter takes the same
standard-face Family as Add text: the content selects /PCHelv, /PCTimes
or /PCCour, the page gains that font, and centred and right-aligned text
is placed by the chosen face's standard-14 widths. doc_header_footer
takes font, and the dialog's fixed "Font: Helvetica" label becomes a
Helvetica / Times / Courier menu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:48:48 +02:00
Aman Mehtar 1d76fcbc3b Edit text: tests for re-encoded subsets and five-letter prefixes (#3661)
From @mtlcl410's independent run of this PR:

- a second subset of the same font can be re-encoded with `/Differences`, so
  "-l" has different codes from the first subset's; the line still joins,
  reads in coding order, and editing it replaces every run, including the
  re-encoded subset's;
- `ABCDE+Helvetica-Bold` has a five-letter prefix, not the six-letter subset
  tag of ISO 32000-2 §9.6.4, so it stays a different font from
  `Helvetica-Bold` and the runs stay apart.

The first case (and its edit) fails before the subset fix; the second guards
the tag-length rule against stripping any `+` prefix.
2026-10-21 00:46:01 +05:30
Aman Mehtar 4a618e8dcb Edit text: do not group or join by an empty font name (#3661)
Review follow-up. `Metrics::from_dict` leaves `base_font` empty when a font
has no `/BaseFont`, and a bare subset tag such as `/ABCDEF+` left an empty
name after stripping. Two consequences:

- `same_style` split one paragraph into separate reading blocks for the
  exporters when both names were empty, though they grouped before subset
  joining;
- `/ABCDEF+` and `/GHIJKL+` compared equal after stripping, so unrelated
  malformed fonts could join into one line.

`same_base_font` now strips both tags first and requires a non-empty, equal
name, so a missing name or a bare tag matches nothing. `same_style` keeps
grouping lines whose fonts both lack a name, since the resource or fallback
face `same_font` already matched is their whole identity.

Tests: a page in a font without `/BaseFont` still reads as one block (it
split into two before this change), and a bare tag matches no other name.
2026-10-20 22:28:08 +05:30
Aman Mehtar 5eae8d6e90 Edit text: keep a line together across subsets of the same font (#3661)
Word and other writers split one font into several subsets, each a font
resource of its own with its own six-letter tag: "Two-line" can be drawn as
/F1 (Two) /F2 (-l) /F1 (ine) with F1 and F2 both Helvetica-Bold. `text_lines`
ended a line wherever the resource name changed, so Edit text showed three
lines and Word/HTML export wrote three paragraphs -- out of order when the
subsets' boxes differ by a fraction of a point.

A run now continues a line when its `/BaseFont` names the same font as the
line's, ignoring the subset tag (ISO 32000-2 §9.6.4), as well as when it is
the same resource or another subset of our Japanese/Hebrew fallback face.
Block grouping in `text_regions` uses the same comparison, so the exporters
see one block.

Tests: the issue's two-resource case and Word's two-subset case read as one
line and one block; two different fonts still split. Before the fix the first
gave ["Hel", "lo"] and the second ["ab", "ca", "b"].
2026-10-20 21:41:00 +05:30
NetterSilverandClaude Haiku 5.5 25e273a33d Pair /ActualText correctly past 256 levels of marked content
Fixes #3573

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:47:09 +02:00
Rahul Ranjan 631ca00bdd The image scan skips a path segment before any m instead of panicking (#3581)
A content stream that uses l, c, v, y or h before any m or re has no
subpath to extend. The edit-side image scan (crates/edit/src/images.rs)
handed the operators straight to BezPath's line_to/curve_to/close_path,
which panic on an empty path by contract, so object_list, page_images,
image_hit and the Office export's image walk all crashed on such a page,
while the renderer, text extraction, MuPDF and pdftotext skip the
malformed segment and render the rest.

The scan now skips the segment when the path has no elements, mirroring
the v arm's existing guard and kurbo's own condition (debug_assert on
!is_empty). A path started with m or re extends and closes exactly as
before. A clip set by a stray segment (W n on an empty path) takes a
zero bounding box, which kurbo defines as Rect::default, not a panic.

Regression test: each of the five segment operators before any m, then
a well-formed m l h and a re W n clip, all before an image Do - the
image is still listed in every case.

Verified against kurbo 0.13.1's source (line_to/curve_to/close_path
debug_assert on empty; bounding_box unwrap_or_default) and the issue's
repro; no Rust toolchain on this machine, so cargo test was not run
here - saying so plainly for the reviewer.

Co-authored-by: rahul05ranjan <11793455+rahul05ranjan@users.noreply.github.com>
2026-10-20 14:50:30 +05:30
mtlcl410andClaude Opus 5.5 d5002807be M10: a turned fill or clip covers the page only if its corners do (#2992)
Review on #2994: fill rectangles and the clip were kept as their page-space
bounding boxes, so a rectangle turned or sheared by the CTM could pass as
covering the page while leaving its corners out, and a following fill became
Word's page colour. Each rectangle is now kept as its four page-space
corners, the clip as the rectangles it intersects, and the crop box counts as
covered when its corners lie inside each.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv2WwdgjmBz33GWkwgNn3Q
2026-10-20 16:55:20 +08:00
mtlcl410andClaude Opus 5.5 ca0e0b08f3 M10: a fill whose second rectangle cuts a hole is no page background (#2992)
Review follow-ups on #2994: under even-odd any second rectangle, under nonzero
one wound the other way, can leave a hole the Word page colour would cover.
The parity note says the page colour hasn't been opened in Word yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv2WwdgjmBz33GWkwgNn3Q
2026-10-20 16:55:20 +08:00
mtlcl410andClaude Opus 5.5 d85d49a4c6 M10: Export to Word keeps the page background colour (#2992)
A page coloured with Edit a PDF > Background exported to Word as a
white page: export pages carried text and images only, and the Word
section has no page colour.

- pdfcraft-edit `page_background`: the colour of the first thing a
  page paints, when that is an opaque fill (no soft mask, Normal blend)
  in a plain gray, RGB or CMYK colour, of a path with a rectangle
  covering the visible page, not clipped smaller; `None` otherwise.
- `export::Page::background` carries it; the Word writer sets it as
  the document background (`w:background`, plus the
  `w:displayBackgroundShape` setting Word needs to show it) when every
  page shares one non-white colour. Word has one background per
  document, so differing colours are left out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
2026-10-20 16:55:20 +08:00
Hazel Granados e9c53ea718 M7: keep a behind watermark under one on top on an empty page (#3591)
On a page with no content of its own, an on-top watermark is the first
stream in /Contents, so it was counted with the behind-content marks and
a watermark added behind later was drawn over it. Nothing records
whether a watermark is behind, so a new behind mark now goes right after
the last background, which is always below any on-top watermark. A newer
behind watermark goes under older ones again, as before #3591.
2026-10-20 00:09:41 -05:00
Hazel Granados 5beaf88a2a M7: paint a new background over older ones (#3591)
Marks behind the page content were all inserted at the start of
/Contents, so a second background went under the first and the old
colour stayed. Backgrounds now come first, then behind-content
watermarks, each in the order added. A watermark added after a
background no longer disappears under it.
2026-10-19 23:42:25 -05:00
hcchung1 3909bebeec M7: a paragraph number keeps naming the paragraph text_paragraphs listed (#2516)
Paragraph numbers are positions in a list regrouped from geometry after
every edit, so an edit that merged a paragraph into its neighbour made the
next edit by number rewrite another paragraph and lose its text.

After text_paragraphs, text_edit and text_edit_layout find the listed
paragraph again by its text and box, and refuse one that no longer exists on
its own (list the page again). An edit's result names the numbers that are
gone in stale_paragraphs. The inline editor's switch-paragraph click holds
the same key instead of an index. Without a listing a number is a position,
as before. An identity that survives save and reopen is still #2508.
2026-10-20 10:02:06 +08:00
MichaelandClaude Sonnet 5 1ce4965c27 fix: read the last array of a surplus-operand TJ, not the first (#3569)
`[(FIRSTARR)] [(LASTARR)] TJ` is malformed (TJ takes one array
operand), but the operator still shows the one immediately before it,
as the renderer, MuPDF and pdftotext agree. Three readers here counted
operands from the front instead and showed the discarded first array:

- crates/edit/src/text.rs (text_lines/text_paragraphs, and the Office
  export through reading_lines)
- crates/redact/src/interp.rs (search-based redaction's content
  interpreter)
- crates/a11y/src/headings.rs (autotagged heading detection)

So the editor could offer a word the page never draws, a redaction
search could miss it, and a heading could read as text nobody sees.
Take the last array for TJ in all three, matching the `_ =>
op.operands.last()` arm already used for Tj/'/" alongside it.

Added a regression test per crate (`a_tj_with_a_surplus_array_reads_
the_last_one`, `a_tj_surplus_arrays_undrawn_glyphs_are_not_seen_
under_a_mark`, `a_heading_with_a_tj_surplus_array_reads_the_one_the_
page_draws`), confirmed each fails without its fix and passes with it.
cargo test -p pdfcraft-edit (196 passed) -p pdfcraft-redact (81
passed) -p pdfcraft-a11y (44 passed), fmt, clippy -D warnings, xtask
layers and xtask assets all clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-20 08:09:59 +08:00
MichaelandClaude Sonnet 5 c38cdbf0be fix(edit): require a meaningful size difference for the wider tolerance
CodeRabbit review on #3471: even with the gap >= 0.0 overlap guard, two
equal-size rows with no horizontal overlap (not just overlapping ones)
could still land inside the widened 50% baseline tolerance at a 5pt
shift that the original 20% tolerance would have rejected, merging
distinct body-text lines into one row.

The wider tolerance is meant for a run distinctly smaller or larger
than its neighbour (a super/subscript), not two ordinary same-size
rows. Require prev.size.min(line.size) < prev.size.max(line.size) * 0.9
before taking it; equal-size runs keep the original 20% tolerance
regardless of overlap.

Added equal_size_rows_need_the_tighter_tolerance_even_without_overlap,
confirmed it fails without this change (merges into one row/block) and
passes with it, alongside the two existing regression tests from the
earlier rounds of this fix. cargo test -p pdfcraft-edit (198 passed),
fmt, clippy -D warnings, xtask layers and xtask assets all clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-20 02:28:34 +08:00
MichaelandClaude Sonnet 5 fe2ece4675 fix(edit): keep the overlap guard for the wider footnote-marker tolerance
CodeRabbit review on #3471: the widened baseline tolerance (half the
larger run's size, for a same-row footnote marker) could also let two
genuinely stacked rows of ordinary body text join into one row, if
horizontal compression (Tz) narrows the first row enough to put the
second row's start within it — a negative `gap` inside the existing
overlap allowance, with a baseline shift inside the new 50% band but
outside the original 20% one.

Only take the wider tolerance when the two runs don't overlap
horizontally (`gap >= 0.0`), which is true for a footnote marker drawn
right after the text it follows. Overlapping runs keep the original,
tighter 20% tolerance.

Added stacked_rows_that_merely_overlap_do_not_join_into_one_row,
confirmed it fails without this change (merges into one row/block) and
passes with it. cargo test -p pdfcraft-edit (197 passed), fmt, clippy
-D warnings, xtask layers and xtask assets all clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-20 01:48:31 +08:00
Michael 2f70006d60 Merge branch 'main' of https://github.com/storytold/pdfcraft into fix/superscript-footnote-paragraph-split
# Conflicts:
#	crates/edit/src/tests.rs
2026-10-20 01:10:02 +08:00
459379ef9b Edit text: refuse a rewrite of text that is a clipping path (#3447)
* Edit text: refuse a rewrite of text that is a clipping path

Editing text in a Photoshop-exported PDF destroyed the artwork on the page.
Such exports draw text in `7 Tr`: the glyphs are not ink but a mask, and a fill
or image is painted through them. Rewriting the run removed the glyphs and so
the mask, the fill behind flooded its own rectangle, and the file still opened
without a warning (#3429).

Measured on a page whose only text is a `7 Tr` mask over a red rectangle: the
fill covered 3.4% of the page before the edit and 100% after it.

`clear_block` already refuses these runs -- "text used as a clipping path
cannot be previewed for editing" -- but `replace_line` and `rewrite_block` did
not, so the destructive path was the one the editor offered. Both refuse modes
4 to 7 now, and the document is left untouched. Modes 0 to 2 paint ink and are
rewritten as before.

This does not keep the design: preserving it means emitting the replacement
inside the same `BT … ET` with the mode intact, which needs the writer to place
glyphs in a clipping object, as both reporters noted. Refusing turns silent
data loss into an error while that is written.

Found and analysed by @RavenCrowe666 and @mtlcl410, who converged on this fix.

* Check every run the rewrite removes, not the line's first one

The guard read `target.origin.state.render_mode`, the mode the line's first run
set. Two paths got a clipping run past it:

A line's runs need not share a rendering mode. Nothing in the join in
`text_lines` requires one -- it asks for the same stream piece, scope, text
object or neighbouring word, font, size, weight, slant and fill colour -- so a
line that starts as ink can continue as a mask, and the whole line's operators
are what the rewrite removes:

  BT /F1 48 Tf 40 60 Td 0 Tr (TITLE) Tj 7 Tr (MASK) Tj ET

reads as one line "TITLEMASK" whose origin is `0 Tr`. Replacing it passed the
guard and took the mask away with the ink.

`coincident_ops` drops another drawing of the line on the same baseline, so that
the replacement is all that shows -- a fake-bold second copy, an invisible OCR
layer. It matches on text, overlap and baseline, not on rendering mode, so that
copy can be the mask while the line being replaced is ink. Both rewrite paths
could drop it.

`TextLine` now carries whether any of its runs clips, and the guard reads every
line whose operators are in the removal set, the way `clear_block` already
checks every run it drops. In `replace_line_with_limit` the removal set is
computed before the replacement font is generated, so a refusal still leaves the
document untouched.

Both cases have a test; on the previous commit each returns
`Ok(LineEdit { substituted: None })`.

* Edit text: keep existing fixtures on text_page; build the clipping fixture on it

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>
2026-10-19 13:03:54 -04:00