From 6d767cc6fca91b071280ea0f5c6bc80b5322a95f Mon Sep 17 00:00:00 2001 From: cadelass Date: Mon, 19 Oct 2026 22:27:00 +0100 Subject: [PATCH] Edit text: keep Tr, stroke, line width and gs set inside BT when rewriting a paragraph (#3508, #3509) A rewritten paragraph is drawn before its text object and restated only the font, spacing and fill. Anything the text object set before its first show was lost: `1 Tr 1 0 0 RG 2 w` inside BT turned red outline text into filled black text, and `/GS1 gs` inside BT made semi-transparent text opaque, even when the text was unchanged. The interpreter now tracks stroke colour, line width and the ExtGStates selected (bounded to 32 distinct names), and the rewrite restates whatever differs from the state in force where it is drawn. Styled runs restate it per run, too. Co-Authored-By: Claude Opus 5.5 --- crates/edit/src/tests.rs | 107 ++++++++++++++++++++++++++++++++ crates/edit/src/text.rs | 107 +++++++++++++++++++++++++++----- crates/edit/src/text_regions.rs | 35 +++++++---- 3 files changed, 222 insertions(+), 27 deletions(-) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index b1c463a6a..7b46cdfc2 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -6571,3 +6571,110 @@ fn a_flatten_refused_on_a_later_page_changes_nothing() { assert!(matches!(flatten(&mut doc, &[0, 1], true, false), Err(EditError::Invalid(_)))); assert_eq!((*doc.get(first)).clone(), before, "the first page keeps its comment and content"); } + +/// How each glyph-showing operator on page 0 is painted, read the way a renderer reads the page: +/// its text, `Tr`, stroke colour operators, line width and the ExtGStates selected before it, +/// following `q`/`Q`. +#[derive(Clone, Debug, PartialEq)] +struct Painted { + text: String, + render_mode: i64, + stroke: Vec, + line_width: f64, + gs: Vec, +} + +fn painted_shows(doc: &Document) -> Vec { + let (_, ops) = joined_ops(doc); + let mut state = Painted { text: String::new(), render_mode: 0, stroke: Vec::new(), line_width: 1.0, gs: Vec::new() }; + let mut stack = Vec::new(); + let mut out = Vec::new(); + let show = |op: &pdfcraft_content::Op| String::from_utf8_lossy(&pdfcraft_content::serialize_ops(std::slice::from_ref(op))).trim().to_string(); + for op in ops { + match op.op.as_slice() { + b"q" => stack.push(state.clone()), + b"Q" => state = stack.pop().unwrap(), + b"Tr" => state.render_mode = op.operands[0].as_int().unwrap(), + b"G" | b"RG" | b"K" | b"CS" => state.stroke = vec![show(&op)], + b"SC" | b"SCN" => state.stroke.push(show(&op)), + b"w" => state.line_width = op.num(0).unwrap(), + b"gs" => state.gs.push(String::from_utf8_lossy(op.name(0).unwrap()).into_owned()), + b"Tj" => { + let Some(Object::String(s)) = op.operands.last() else { continue }; + if !s.bytes.is_empty() { + out.push(Painted { text: String::from_utf8_lossy(&s.bytes).into_owned(), ..state.clone() }); + } + } + _ => {} + } + } + out +} + +/// A page with Helvetica as /F1 and /GS1, an ExtGState that paints at 20 % opacity. +fn faint_page(content: &str) -> Document { + build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /ExtGState << /GS1 6 0 R >> >> >>", + &format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", + "<< /Type /ExtGState /ca 0.2 >>", + ]) +} + +/// Rewriting a paragraph drew it before its text object, restating only the font, spacing and +/// fill: a `1 Tr 1 0 0 RG 2 w` set inside `BT` was lost, and red outline text came back filled +/// black, even with the text unchanged (#3508). Set before `BT` (the control), it already worked. +#[test] +fn a_rewritten_paragraph_keeps_the_rendering_mode_stroke_and_width_set_inside_its_text_object() { + let outline = |text: &str| Painted { text: text.into(), render_mode: 1, stroke: vec!["1 0 0 RG".into()], line_width: 2.0, gs: Vec::new() }; + for content in ["BT /F1 72 Tf 1 Tr 1 0 0 RG 2 w 40 400 Td (OUTLINE) Tj ET\n", "1 Tr 1 0 0 RG 2 w BT /F1 72 Tf 40 400 Td (OUTLINE) Tj ET\n"] { + assert_eq!(painted_shows(&text_page(content)), [outline("OUTLINE")], "the fixture: {content}"); + for new in ["OUTLINE", "OUTLINED"] { + let mut doc = text_page(content); + text::rewrite_block(&mut doc, 0, 0, Some(new), &BlockStyle::default()).unwrap(); + let doc = reopen(&doc); + assert_eq!(painted_shows(&doc), [outline(new)], "{content} -> {new}: {}", streams(&doc, 0).join("\n")); + } + } +} + +/// The same for transparency: `/GS1 gs` (`/ca 0.2`) inside `BT` was not in force for the +/// rewritten paragraph, so 20 % blue text came back opaque (#3509). +#[test] +fn a_rewritten_paragraph_keeps_the_extgstate_selected_inside_its_text_object() { + for content in ["BT /GS1 gs /F1 72 Tf 0 0 1 rg 40 400 Td (FAINT) Tj ET\n", "/GS1 gs BT /F1 72 Tf 0 0 1 rg 40 400 Td (FAINT) Tj ET\n"] { + for new in ["FAINT", "FAINTER"] { + let mut doc = faint_page(content); + text::rewrite_block(&mut doc, 0, 0, Some(new), &BlockStyle::default()).unwrap(); + let doc = reopen(&doc); + let shows = painted_shows(&doc); + assert_eq!(shows.len(), 1, "{content} -> {new}: {shows:?}"); + assert_eq!( + (shows[0].text.as_str(), shows[0].gs.as_slice()), + (new, ["GS1".to_string()].as_slice()), + "{content} -> {new}: {}", + streams(&doc, 0).join("\n") + ); + } + } +} + +/// A paragraph of differently coloured runs goes through the styled rewrite, which draws each +/// run in its own text object: each keeps the state its source set inside `BT`. +#[test] +fn a_styled_rewrite_keeps_the_paint_state_set_inside_the_text_object() { + let content = "BT /GS1 gs 1 Tr 1 0 0 RG 2 w /F1 72 Tf 40 400 Td (FAI) Tj 0 0 1 rg (NT) Tj ET\n"; + let mut doc = faint_page(content); + assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].rows[0].len(), 2, "two runs on one row"); + text::rewrite_block(&mut doc, 0, 0, Some("FAINTS"), &BlockStyle::default()).unwrap(); + let doc = reopen(&doc); + let shows = painted_shows(&doc); + let joined = streams(&doc, 0).join("\n"); + assert_eq!(shows.iter().map(|s| s.text.as_str()).collect::(), "FAINTS", "{joined}"); + for s in &shows { + assert_eq!((s.render_mode, s.stroke.as_slice(), s.line_width), (1, ["1 0 0 RG".to_string()].as_slice(), 2.0), "{s:?}: {joined}"); + assert_eq!(s.gs, ["GS1"], "{s:?}: {joined}"); + } +} diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 29ac90abc..601970c4d 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -223,6 +223,11 @@ struct TextState { fill: Vec, /// The text rendering mode (`Tr`); 3 is invisible text, as in an OCR layer over a scan. render_mode: i64, + /// The stroke colour operators, the line width and the ExtGStates selected (see [`Ts`]): + /// graphics state the text was painted with that [`TextState::ops`] doesn't restate. + stroke: Vec, + line_width: Option, + ext_gstate: Vec>, } impl TextState { @@ -236,9 +241,46 @@ impl TextState { rise: ts.rise, fill: ts.fill.clone(), render_mode: ts.render_mode, + stroke: ts.stroke.clone(), + line_width: ts.line_width, + ext_gstate: ts.ext_gstate.clone(), } } + /// Operators that change how text is painted from `current` (the state where they run) to + /// this one: the ExtGStates, stroke colour, line width and a visible rendering mode. A + /// rewritten paragraph is drawn before its text object, so whatever that object set before + /// its first show (`/GS1 gs`, `1 Tr 1 0 0 RG 2 w` inside `BT`, #3508/#3509) must be stated + /// again. Empty when nothing differs. + /// + /// Only fill, stroke and fill-then-stroke (`Tr` 0–2) are restated: invisible text is revealed + /// by [`reveal_ops`] and clipping text is refused, so neither is ever drawn this way. + fn paint_ops(&self, current: &TextState) -> Vec { + let n = pdfcraft_content::num; + let mut v = Vec::new(); + // The selections both share stay in force; the ones after them are selected again in order. + let shared = self.ext_gstate.iter().zip(¤t.ext_gstate).take_while(|(a, b)| a == b).count(); + for name in self.ext_gstate.iter().skip(shared) { + v.push(Op::new("gs", vec![Object::Name(name.clone())])); + } + if self.stroke != current.stroke { + if self.stroke.is_empty() { + // The initial stroke colour: black in DeviceGray. + v.push(Op::new("G", vec![n(0.0)])); + } else { + v.extend(self.stroke.iter().cloned()); + } + } + let width = self.line_width.unwrap_or(1.0); + if width != current.line_width.unwrap_or(1.0) { + v.push(Op::new("w", vec![n(width)])); + } + if (0..=2).contains(&self.render_mode) && self.render_mode != current.render_mode { + v.push(Op::new("Tr", vec![Object::Int(self.render_mode)])); + } + v + } + /// Operators that set this state (inside a text object). fn ops(&self) -> Vec { let n = pdfcraft_content::num; @@ -616,6 +658,27 @@ struct Ts { /// Why the font's writing mode couldn't be read (a broken or cyclic CMap). Its text is then /// listed as horizontal, but the lines in it are refused for editing. unreadable: Option>, + /// The operators that set the current stroke colour (`G`, `RG`, `K`, or `CS` + `SC`/`SCN`). + stroke: Vec, + /// The line width a `w` set (`None`: the initial 1.0). + line_width: Option, + /// The ExtGState resources selected with `gs`, oldest first (see [`select_gs`]). + ext_gstate: Vec>, +} + +/// The most `gs` selections a state remembers. Repeating a selection moves it to the end, so +/// this bounds the distinct ExtGStates in force, not the operators a hostile stream repeats. +const MAX_GS: usize = 32; + +/// Record `gs /name` in `list`. A `gs` sets every parameter its dictionary names to an absolute +/// value, so a later selection of the same dictionary overrides the earlier one completely: the +/// earlier entry goes, which keeps replaying the list equivalent to replaying the stream. +fn select_gs(list: &mut Vec>, name: &[u8]) { + list.retain(|n| n != name); + if list.len() >= MAX_GS { + list.remove(0); + } + list.push(name.to_vec()); } /// The page's content streams as listed, each with its decoded data (empty when it doesn't @@ -929,6 +992,9 @@ impl Carry { gs: Gs::default(), vertical: None, unreadable: None, + stroke: Vec::new(), + line_width: None, + ext_gstate: Vec::new(), }; Carry { ts, stack: Vec::new(), marked: Vec::new(), layers: Vec::new(), text_left: MAX_PAGE_TEXT } } @@ -1042,6 +1108,12 @@ fn interpret( ts.fill.retain(|o| o.is("cs")); ts.fill.push(op.clone()); } + b"G" | b"RG" | b"K" | b"CS" => ts.stroke = vec![op.clone()], + b"SC" | b"SCN" => { + ts.stroke.retain(|o| o.is("CS")); + ts.stroke.push(op.clone()); + } + b"w" => ts.line_width = op.num(0).filter(|w| w.is_finite()).or(ts.line_width), b"Tf" => { ts.size = op.num(1).unwrap_or(ts.size); if let Some(name) = op.name(0) { @@ -1069,6 +1141,9 @@ fn interpret( b"Ts" => ts.rise = op.num(0).unwrap_or(0.0), b"Tr" => ts.render_mode = op.operands.first().and_then(Object::as_int).unwrap_or(-1), b"gs" => { + if let Some(name) = op.name(0) { + select_gs(&mut ts.ext_gstate, name); + } let params = op.name(0).and_then(|name| states.get(name)).map(|p| doc.resolve(p)); if let Some(params) = params.as_deref().and_then(Object::as_dict) { ts.gs.apply(doc, params); @@ -2907,6 +2982,21 @@ fn rewrite_block_from( None => size * 1.2, }; let n = pdfcraft_content::num; + // The paragraph's operators go, and so does another drawing on the same baseline (a fake-bold + // second copy, an invisible text layer): the new text is all that may show. + let drop = dropped; + // Text shown earlier in the same text object stays first: the paragraph then goes where its + // last row was, after any backgrounds between the rows, or between two halves of the text object. + let start = last.ops.first().copied().unwrap_or(emit.bt_op); + let split = ops + .get(emit.bt_op..start) + .unwrap_or_default() + .iter() + .enumerate() + .any(|(i, op)| matches!(op.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"") && !drop.contains(&(emit.bt_op + i))); + // What the text after the paragraph expects, and what is in force where the paragraph is + // drawn: the state at its BT (or where it was split). + let after = if split { &emit.state } else { &emit.bt_state }; // In its own graphics state, so a new colour (or anything else) stops at the paragraph. let mut block_ops = vec![Op::new("q", vec![])]; block_ops.extend(reveal_ops(&b, &lines, style, emit.ctm)?); @@ -2914,6 +3004,8 @@ fn rewrite_block_from( if let Some((ax, ay)) = movement { block_ops.push(Op::new("cm", vec![n(1.0), n(0.0), n(0.0), n(1.0), n(ax), n(ay)])); } + // Painted as the paragraph was, including what its text object set before the first show. + block_ops.extend(o_state.paint_ops(after)); block_ops.push(Op::new("BT", vec![])); let mut state = o_state.clone(); state.word_spacing = 0.0; @@ -3018,21 +3110,6 @@ fn rewrite_block_from( block_ops.push(Op::new("Q", vec![])); } block_ops.push(Op::new("Q", vec![])); - // The paragraph's operators go, and so does another drawing on the same baseline (a fake-bold - // second copy, an invisible text layer): the new text is all that may show. - let mut drop = coincident_ops(&lines, &members); - drop.extend(members.iter().flat_map(|l| l.ops.iter().copied())); - // Text shown earlier in the same text object stays first: the paragraph then goes where its - // last row was, after any backgrounds between the rows, or between two halves of the text object. - let start = last.ops.first().copied().unwrap_or(emit.bt_op); - let split = ops - .get(emit.bt_op..start) - .unwrap_or_default() - .iter() - .enumerate() - .any(|(i, op)| matches!(op.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"") && !drop.contains(&(emit.bt_op + i))); - // What the text after the paragraph expects: the state at its BT (or where it was split). - let after = if split { &emit.state } else { &emit.bt_state }; block_ops.extend(after.ops().into_iter().filter(|op| !op.is("Tf") || after.font.is_some())); if split { block_ops.insert(0, Op::new("ET", vec![])); diff --git a/crates/edit/src/text_regions.rs b/crates/edit/src/text_regions.rs index a77559494..57b247638 100644 --- a/crates/edit/src/text_regions.rs +++ b/crates/edit/src/text_regions.rs @@ -800,6 +800,20 @@ pub(super) fn rewrite_styled_block( } }; let o = &lead.origin; + let mut drop = coincident_ops(lines, &members); + drop.extend(members.iter().flat_map(|l| l.ops.iter().copied())); + let start = lead.ops.first().copied().unwrap_or(o.bt_op); + let split = ops + .get(o.bt_op..start) + .unwrap_or_default() + .iter() + .enumerate() + .any(|(i, op)| matches!(op.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"") && !drop.contains(&(o.bt_op + i))); + // What is in force where the rewrite is drawn, and what the text after it expects. + let after = if split { &o.state } else { &o.bt_state }; + // How the block paints text so far: each run restates what its source set inside its text + // object (`gs`, stroke, line width, `Tr`; #3508/#3509) when it differs. + let mut paint = after.clone(); for (row, _) in rows.iter().zip(&kept).filter(|(_, k)| !**k) { let text: String = row.iter().map(|v| v.0).collect(); if pdfcraft_fonts::source_shaping::required(&text) && row.first().is_some_and(|first| row.iter().any(|v| v.1 != first.1 || v.2 != first.2)) { @@ -910,6 +924,9 @@ pub(super) fn rewrite_styled_block( tm[4] += ax; tm[5] += ay; let mut segment_width = segment_advances.get(&(row_index, pos)).copied().unwrap_or(0.0); + block_ops.extend(state.paint_ops(&paint)); + // Only the painting fields are read from `paint`. + paint.clone_from(&state); if let Some((_, shaped, _)) = shaped_runs.get(&(row_index, pos)) { let resource = glyph_resources.get(&(row_index, pos)).ok_or_else(|| EditError::Invalid("shaped font is missing".into()))?; let mut properties = Dict::new(); @@ -990,8 +1007,12 @@ pub(super) fn rewrite_styled_block( let underline_size = size_of(source, item.1) * source.origin.k; let (x0, y0) = back.apply(x - o.ctm[4], baseline - o.ctm[5] - underline_size * 0.12); let (x1, y1) = back.apply(x + segment_width - o.ctm[4], baseline - o.ctm[5] - underline_size * 0.12); - block_ops.push(Op::new("RG", vec![n(r), n(g), n(b)])); - block_ops.push(Op::new("w", vec![n(underline_size * 0.06 / o.ctm[2].hypot(o.ctm[3]).max(1e-6))])); + let stroke = Op::new("RG", vec![n(r), n(g), n(b)]); + let width = underline_size * 0.06 / o.ctm[2].hypot(o.ctm[3]).max(1e-6); + block_ops.push(stroke.clone()); + block_ops.push(Op::new("w", vec![n(width)])); + paint.stroke = vec![stroke]; + paint.line_width = Some(width); block_ops.push(Op::new("m", vec![n(x0), n(y0)])); block_ops.push(Op::new("l", vec![n(x1), n(y1)])); block_ops.push(Op::new("S", vec![])); @@ -1006,16 +1027,6 @@ pub(super) fn rewrite_styled_block( }; } block_ops.push(Op::new("Q", vec![])); - let mut drop = coincident_ops(lines, &members); - drop.extend(members.iter().flat_map(|l| l.ops.iter().copied())); - let start = lead.ops.first().copied().unwrap_or(o.bt_op); - let split = ops - .get(o.bt_op..start) - .unwrap_or_default() - .iter() - .enumerate() - .any(|(i, op)| matches!(op.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"") && !drop.contains(&(o.bt_op + i))); - let after = if split { &o.state } else { &o.bt_state }; block_ops.extend(after.ops()); if split { block_ops.insert(0, Op::new("ET", vec![]));