Files
pdfcraft/crates/edit
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
..