diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 0549906db..5308ea7c7 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -6638,3 +6638,238 @@ 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 (from `w` or an ExtGState's `/LW`) 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 p = &pdfcraft_model::pages(doc)[0]; + let res = doc.resolve(p.dict.get(b"Resources").unwrap()); + let ext = res.as_dict().unwrap().get(b"ExtGState").map(|g| doc.resolve(g).as_dict().cloned().unwrap()).unwrap_or_default(); + 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" => { + let name = op.name(0).unwrap(); + state.gs.push(String::from_utf8_lossy(name).into_owned()); + let lw = ext.get(name).and_then(|g| doc.resolve(g).as_dict().and_then(|g| g.get(b"LW")).and_then(|w| w.as_f64())); + state.line_width = lw.unwrap_or(state.line_width); + } + 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, /GS1, an ExtGState that paints at 20 % opacity, /GS2, one that +/// sets the line width to 4, /GS3, another at 20 % opacity, and /GS4, at 20 % opacity and dashed. +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 /GS2 7 0 R /GS3 8 0 R /GS4 9 0 R >> >> >>", + &format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", + "<< /Type /ExtGState /ca 0.2 >>", + "<< /Type /ExtGState /LW 4 >>", + "<< /Type /ExtGState /ca 0.2 >>", + "<< /Type /ExtGState /ca 0.2 /D [[6 3] 0] >>", + ]) +} + +/// 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}"); + } +} + +/// A `gs` has no inverse, so one run's ExtGState stayed in force for the runs the styled rewrite +/// drew after it. Two ExtGStates at the same opacity paint the same (see `Paint`), so their runs +/// share a paragraph, but either may set what that doesn't compare (a dash, a line cap): each run +/// is drawn with only the ones its source selected. +#[test] +fn a_styled_rewrite_does_not_carry_one_runs_extgstate_into_the_next() { + // "FAI" ends at 40 + 72 * (0.611 + 0.667 + 0.278) = 152.032; "NT" is blue, so another run. + let content = "q BT /GS3 gs /F1 72 Tf 40 400 Td (FAI) Tj ET Q BT /GS1 gs /F1 72 Tf 0 0 1 rg 152.032 400 Td (NT) Tj ET +"; + for new in ["FAINT", "FAINTS"] { + 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(new), &BlockStyle::default()).unwrap(); + let doc = reopen(&doc); + let shows = painted_shows(&doc); + let joined = streams(&doc, 0).join( + " +", + ); + assert_eq!(shows.iter().map(|s| s.text.as_str()).collect::(), new, "{joined}"); + let gs: Vec<_> = shows.iter().map(|s| (s.text.as_str(), s.gs.as_slice())).collect(); + assert_eq!(gs.first(), Some(&("FAI", ["GS3".to_string()].as_slice())), "{gs:?}: {joined}"); + assert!(gs.iter().skip(1).all(|(_, gs)| *gs == ["GS1"]), "{gs:?}: {joined}"); + } +} + +/// An ExtGState's `/LW` sets the line width as `w` does: `2 w /GS2 gs` (`/LW 4`) outlines at 4, +/// and the rewrite replayed `/GS2 gs` and then `2 w`. `/GS2 gs 1 w` outlines at 1, and the +/// rewrite replayed only `/GS2 gs`, since 1 is the initial width. +#[test] +fn a_rewritten_paragraph_keeps_the_line_width_an_extgstate_set() { + for (content, width) in [ + ("BT /F1 72 Tf 1 Tr 2 w /GS2 gs 40 400 Td (OUTLINE) Tj ET\n", 4.0), + ("BT /F1 72 Tf 1 Tr /GS2 gs 2 w 40 400 Td (OUTLINE) Tj ET\n", 2.0), + ("BT /F1 72 Tf 1 Tr /GS2 gs 1 w 40 400 Td (OUTLINE) Tj ET\n", 1.0), + ] { + assert_eq!(painted_shows(&faint_page(content))[0].line_width, width, "the fixture: {content}"); + for new in ["OUTLINE", "OUTLINED"] { + 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.iter().map(|s| (s.text.as_str(), s.render_mode, s.line_width)).collect::>(), + [(new, 1, width)], + "{content} -> {new}: {}", + streams(&doc, 0).join("\n") + ); + } + } +} + +/// A line is rewritten in the state of its first run, so outline text partway through a text +/// object came back filled: "FAI" filled and "NT" a red outline came back all filled. The change +/// of `Tr` now starts a run. (A `gs` partway already starts a paragraph; see `Paint`.) +#[test] +fn a_rendering_mode_partway_through_a_line_starts_a_run_of_its_own() { + let content = "BT /F1 72 Tf 40 400 Td (FAI) Tj 1 Tr 1 0 0 RG 2 w (NT) Tj ET +"; + for new in ["FAINT", "FAINTS"] { + 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(new), &BlockStyle::default()).unwrap(); + let doc = reopen(&doc); + let shows = painted_shows(&doc); + let joined = streams(&doc, 0).join( + " +", + ); + assert_eq!(shows.iter().map(|s| s.text.as_str()).collect::(), new, "{joined}"); + let modes: Vec<_> = shows.iter().map(|s| (s.text.as_str(), s.render_mode, s.stroke.as_slice(), s.line_width)).collect(); + assert_eq!(modes.first().map(|m| (m.0, m.1)), Some(("FAI", 0)), "{modes:?}: {joined}"); + let outline = ["1 0 0 RG".to_string()]; + assert!(modes.iter().skip(1).all(|m| (m.1, m.2, m.3) == (1, outline.as_slice(), 2.0)), "{modes:?}: {joined}"); + } +} + +/// Two ExtGStates at the same opacity paint text the same (see `Paint`), but one of them dashes +/// its outline: a dashed outline after a solid one in the same text object joined its line and +/// came back solid, drawn with the first run's selections. A different dash now starts a run. +#[test] +fn a_dash_partway_through_an_outlined_line_starts_a_run_of_its_own() { + let content = "BT /GS3 gs /F1 72 Tf 1 Tr 1 0 0 RG 2 w 40 400 Td (FAI) Tj /GS4 gs (NT) Tj ET +"; + for new in ["FAINT", "FAINTS"] { + 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(new), &BlockStyle::default()).unwrap(); + let doc = reopen(&doc); + let shows = painted_shows(&doc); + let joined = streams(&doc, 0).join( + " +", + ); + assert_eq!(shows.iter().map(|s| s.text.as_str()).collect::(), new, "{joined}"); + let gs: Vec<_> = shows.iter().map(|s| (s.text.as_str(), s.gs.as_slice())).collect(); + assert_eq!(gs.first(), Some(&("FAI", ["GS3".to_string()].as_slice())), "{gs:?}: {joined}"); + assert!(gs.iter().skip(1).all(|(_, gs)| gs.last().is_some_and(|g| g == "GS4")), "{gs:?}: {joined}"); + } +} + +/// The dash and cap are compared as the operators set them too: `d` or `J` partway through an +/// outlined line starts a run, and a malformed one (too long, not numbers, out of range) is ignored. +#[test] +fn a_dash_or_cap_operator_partway_through_an_outlined_line_starts_a_run() { + let long = format!("[{}] 0 d", "1 ".repeat(40)); + for (change, runs) in [("[6 3] 0 d", 2), ("1 J", 2), (long.as_str(), 1), ("[(a) 3] 0 d", 1), ("[6 3] /x d", 1), ("7 J", 1), ("J", 1)] { + let content = format!("BT /F1 72 Tf 1 Tr 1 0 0 RG 2 w 40 400 Td (FAI) Tj {change} (NT) Tj ET\n"); + let mut doc = faint_page(&content); + assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].rows[0].len(), runs, "{change}"); + text::rewrite_block(&mut doc, 0, 0, Some("FAINTS"), &BlockStyle::default()).unwrap(); + let shows = painted_shows(&reopen(&doc)); + assert_eq!(shows.iter().map(|s| s.text.as_str()).collect::(), "FAINTS", "{change}"); + } +} diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index d00393531..ff16b63c8 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -223,6 +223,14 @@ 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>, + /// The dash pattern and line cap in force (see [`Ts`]): compared, not restated. + dash: Dash, + line_cap: i64, } impl TextState { @@ -236,9 +244,53 @@ 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(), + dash: ts.dash.clone(), + line_cap: ts.line_cap, } } + /// 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. + /// + /// A `gs` has no inverse, so a selection `current` has and this state doesn't stays in force: + /// run these inside a `q`/`Q` that starts from `current`, one per state drawn. + 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())])); + } + // A reselected ExtGState may set `/LW`, so the width (already the effective one) follows it. + let reselected = !v.is_empty(); + 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 reselected || 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 +668,50 @@ 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` or an ExtGState's `/LW` set (`None`: the initial 1.0). + line_width: Option, + /// The ExtGState resources selected with `gs`, oldest first (see [`select_gs`]). + ext_gstate: Vec>, + /// The dash pattern a `d` or an ExtGState's `/D` set (initially solid: `[] 0`). + dash: Dash, + /// The line cap a `J` or an ExtGState's `/LC` set (initially 0, butt). + line_cap: i64, +} + +/// A dash pattern (ISO 32000-2 §8.4.3.6): the dash array and the phase. +type Dash = (Vec, f64); + +/// The most dash array entries a pattern keeps; a longer one is ignored, as is a malformed one. +const MAX_DASH: usize = 32; + +/// The dash pattern `array phase`, as `d` takes it and an ExtGState's `/D` holds it. +fn dash_pattern(doc: &Document, array: Option<&Object>, phase: Option<&Object>) -> Option { + let array = doc.resolve(array?); + let items = array.as_array().filter(|a| a.len() <= MAX_DASH)?; + let finite = |o: &Object| doc.resolve(o).as_f64().filter(|v| v.is_finite()); + Some((items.iter().map(finite).collect::>>()?, finite(phase?)?)) +} + +/// A line cap style, as `J` takes it and an ExtGState's `/LC` holds it: 0, 1 or 2. +fn line_cap(doc: &Document, cap: Option<&Object>) -> Option { + doc.resolve(cap?).as_int().filter(|c| (0..=2).contains(c)) +} + +/// 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 +1025,11 @@ impl Carry { gs: Gs::default(), vertical: None, unreadable: None, + stroke: Vec::new(), + line_width: None, + ext_gstate: Vec::new(), + dash: (Vec::new(), 0.0), + line_cap: 0, }; Carry { ts, stack: Vec::new(), marked: Vec::new(), layers: Vec::new(), text_left: MAX_PAGE_TEXT } } @@ -1042,6 +1143,14 @@ 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"d" => ts.dash = dash_pattern(doc, op.operands.first(), op.operands.get(1)).unwrap_or_else(|| ts.dash.clone()), + b"J" => ts.line_cap = line_cap(doc, op.operands.first()).unwrap_or(ts.line_cap), b"Tf" => { ts.size = op.num(1).unwrap_or(ts.size); if let Some(name) = op.name(0) { @@ -1069,9 +1178,21 @@ 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); + // `/LW` sets the line width as `w` does (ISO 32000-2 §8.4.5). + let lw = params.get(b"LW").and_then(|w| doc.resolve(w).as_f64()).filter(|w| w.is_finite()); + ts.line_width = lw.or(ts.line_width); + let dash = params.get(b"D").map(|d| doc.resolve(d)); + let dash = dash.as_deref().and_then(Object::as_array); + if let Some(d) = dash.and_then(|d| dash_pattern(doc, d.first(), d.get(1))) { + ts.dash = d; + } + ts.line_cap = line_cap(doc, params.get(b"LC")).unwrap_or(ts.line_cap); } } b"Do" => { @@ -1351,7 +1472,11 @@ fn text_lines_in(doc: &Document, p: &pdfcraft_model::Page, actual_text: bool) -> && (s.text.trim().is_empty() || lines.last().is_some_and(|l| same_font(l, &s.font, &s.base_font, s.face.as_deref()))) && (s.text.trim().is_empty() || lines.last().is_some_and(|l| { - (s.size - l.size).abs() < 0.01 && s.bold == l.bold && s.italic == l.italic && fill_color(&s.state.fill) == l.color + (s.size - l.size).abs() < 0.01 + && s.bold == l.bold + && s.italic == l.italic + && fill_color(&s.state.fill) == l.color + && same_paint(l, &s.state) })) && near // An OCR layer is one text object over the whole scan: its words continue a line @@ -1457,7 +1582,11 @@ fn add_shown(lines: &mut Vec, last: &mut Option, s: Shown, stream && (s.text.trim().is_empty() || lines.last().is_some_and(|l| same_font(l, &s.font, &s.base_font, s.face.as_deref()))) && (s.text.trim().is_empty() || lines.last().is_some_and(|l| { - (s.size - l.size).abs() < 0.01 && s.bold == l.bold && s.italic == l.italic && fill_color(&s.state.fill) == l.color + (s.size - l.size).abs() < 0.01 + && s.bold == l.bold + && s.italic == l.italic + && fill_color(&s.state.fill) == l.color + && same_paint(l, &s.state) })) && pen.continues(&s, if bt == s.bt { 3.0 } else { 1.5 }).0 }); @@ -1817,6 +1946,24 @@ fn same_font(line: &TextLine, font: &[u8], base_font: &str, face: Option<&str>) line.font.as_bytes() == font || same_base_font(&line.base_font, base_font) || (line.face.is_some() && line.face.as_deref() == face) } +/// Whether a run painted with `ts` continues `line` as it is painted: a line is rewritten in the +/// state of its first run, so a `Tr`, stroke colour or line width that changes partway starts +/// another run (see [`TextState::paint_ops`]; the `gs` parameters are compared as [`Paint`]). +/// The stroke colour, width, dash and cap only count for text that is stroked, so a rule drawn +/// between two words doesn't split their line. They are compared as values, wherever they were +/// set: two ExtGStates with the same settings under other names still continue a line. +/// +/// Only the visible modes (`Tr` 0–2) that `paint_ops` restates are compared: a line that goes on +/// as invisible or clipping text stays one line, which the clipping refusal reads whole (#3447). +fn same_paint(line: &TextLine, ts: &Ts) -> bool { + let a = &line.origin.state; + let visible = |m: i64| (0..=2).contains(&m); + let same_mode = a.render_mode == ts.render_mode || !visible(a.render_mode) || !visible(ts.render_mode); + let stroked = matches!((a.render_mode, ts.render_mode), (1 | 2, 1 | 2)); + let outline = a.stroke == ts.stroke && a.line_width == ts.line_width && a.dash == ts.dash && a.line_cap == ts.line_cap; + same_mode && (!stroked || outline) +} + /// Whether two `/BaseFont` names name the same font: a subset's six-letter tag and `+` prefix do /// not change the font (ISO 32000-2 §9.6.4), so `ABCDEF+Arial` and `GHIJKL+Arial` are one font. /// A name that is missing, or only a tag (`ABCDEF+`), names no font and matches nothing. @@ -2908,6 +3055,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)?); @@ -2915,6 +3077,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; @@ -3019,21 +3183,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 71ccbc683..6557a37dc 100644 --- a/crates/edit/src/text_regions.rs +++ b/crates/edit/src/text_regions.rs @@ -800,6 +800,17 @@ 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 }; 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 +921,11 @@ 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); + // Each run in its own graphics state, restating what its source set inside its text + // object (`gs`, stroke, line width, `Tr`; #3508/#3509) from the state the rewrite is + // drawn in: a `gs` has no inverse, so one run's ExtGState would stay for the next. + block_ops.push(Op::new("q", vec![])); + block_ops.extend(state.paint_ops(after)); 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(); @@ -996,6 +1012,7 @@ pub(super) fn rewrite_styled_block( block_ops.push(Op::new("l", vec![n(x1), n(y1)])); block_ops.push(Op::new("S", vec![])); } + block_ops.push(Op::new("Q", vec![])); x += segment_width; pos = end; } @@ -1006,16 +1023,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![]));