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![]));