From 6d767cc6fca91b071280ea0f5c6bc80b5322a95f Mon Sep 17 00:00:00 2001 From: cadelass Date: Mon, 19 Oct 2026 22:27:00 +0100 Subject: [PATCH 1/3] 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![])); From 9163f29090c3051d6e54934383c0cfbae2f95108 Mon Sep 17 00:00:00 2001 From: "luis.pereira" Date: Thu, 22 Oct 2026 08:44:59 +0100 Subject: [PATCH 2/3] Edit text: per-run q/Q, /LW from gs, and a Tr partway through a line starts a run (#3508, #3509) Review follow-up for #3551, rebased on the gs grouping of #3310: - The styled rewrite draws each run in its own q/Q from the state where the rewrite is drawn: a gs has no inverse, so one run's ExtGState stayed in force for the runs after it. Two ExtGStates at the same opacity share a paragraph (Paint), but either may set what Paint doesn't compare. - The gs arm applies an ExtGState's /LW to the line width; paint_ops restates the effective width after any replayed gs. - A run whose visible Tr (or, for stroked text, stroke colour and width) differs no longer joins the line before it, so a line is not rewritten in its first run's state. Clipping and invisible modes still join, as the #3447 refusal reads the whole line; gs changes are already kept apart by Paint. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/edit/src/tests.rs | 98 +++++++++++++++++++++++++++++++-- crates/edit/src/text.rs | 44 +++++++++++++-- crates/edit/src/text_regions.rs | 20 +++---- 3 files changed, 141 insertions(+), 21 deletions(-) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 7b46cdfc2..20a6a7b64 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -6573,8 +6573,8 @@ fn a_flatten_refused_on_a_later_page_changes_nothing() { } /// 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`. +/// 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, @@ -6586,6 +6586,9 @@ struct Painted { 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(); @@ -6598,7 +6601,12 @@ fn painted_shows(doc: &Document) -> Vec { 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"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() { @@ -6611,15 +6619,18 @@ fn painted_shows(doc: &Document) -> Vec { out } -/// A page with Helvetica as /F1 and /GS1, an ExtGState that paints at 20 % opacity. +/// A page with Helvetica as /F1, /GS1, an ExtGState that paints at 20 % opacity, /GS2, one that +/// sets the line width to 4, and /GS3, another 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 >> >> >>", + "<< /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 >> >> >>", &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 >>", ]) } @@ -6678,3 +6689,80 @@ fn a_styled_rewrite_keeps_the_paint_state_set_inside_the_text_object() { 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}"); + } +} diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 601970c4d..faa69c95a 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -255,6 +255,9 @@ impl TextState { /// /// 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(); @@ -263,6 +266,8 @@ impl TextState { 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. @@ -272,7 +277,7 @@ impl TextState { } } let width = self.line_width.unwrap_or(1.0); - if width != current.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 { @@ -660,7 +665,7 @@ struct Ts { 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). + /// 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>, @@ -1147,6 +1152,9 @@ fn interpret( 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); } } b"Do" => { @@ -1426,7 +1434,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 @@ -1532,7 +1544,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 }); @@ -1892,6 +1908,26 @@ 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 and width only count for text that is stroked, so a rule drawn between two +/// words doesn't split their 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). +/// +/// Known gap: two ExtGStates with the same transparency but another parameter that [`Paint`] +/// doesn't compare (a dash `/D`, a line cap `/LC`) still join one line, which is then rewritten +/// with the first run's selections. Comparing the names here would split lines #3310 keeps whole. +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)); + same_mode && (!stroked || (a.stroke == ts.stroke && a.line_width == ts.line_width)) +} + /// 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. diff --git a/crates/edit/src/text_regions.rs b/crates/edit/src/text_regions.rs index 57b247638..06bed248b 100644 --- a/crates/edit/src/text_regions.rs +++ b/crates/edit/src/text_regions.rs @@ -811,9 +811,6 @@ pub(super) fn rewrite_styled_block( .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)) { @@ -924,9 +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); - block_ops.extend(state.paint_ops(&paint)); - // Only the painting fields are read from `paint`. - paint.clone_from(&state); + // 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(); @@ -1007,16 +1006,13 @@ 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); - 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("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))])); 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![])); } + block_ops.push(Op::new("Q", vec![])); x += segment_width; pos = end; } From 60985e119cddf1e9cfb655aaa44ce700cccd16ea Mon Sep 17 00:00:00 2001 From: "luis.pereira" Date: Fri, 23 Oct 2026 09:35:03 +0100 Subject: [PATCH 3/3] Edit text: a dash or line cap partway through an outlined line starts a run (#3508) Review follow-up for #3551: two ExtGStates at the same opacity are the same Paint, so a dashed outline after a solid one joined its line and was rewritten with the first run's selections. Ts tracks the effective dash pattern and line cap, from d and J and from an ExtGState's /D and /LC, and same_paint compares them as values for stroked text, so states with the same settings under other names still continue a line. A dash array longer than 32 entries, or with an operand that isn't a finite number, and a cap outside 0-2 are ignored. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/edit/src/tests.rs | 44 +++++++++++++++++++++++++++++++++-- crates/edit/src/text.rs | 50 ++++++++++++++++++++++++++++++++++------ 2 files changed, 85 insertions(+), 9 deletions(-) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 20a6a7b64..31e2777dd 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -6620,17 +6620,18 @@ fn painted_shows(doc: &Document) -> Vec { } /// A page with Helvetica as /F1, /GS1, an ExtGState that paints at 20 % opacity, /GS2, one that -/// sets the line width to 4, and /GS3, another at 20 % opacity. +/// 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 >> >> >>", + "<< /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] >>", ]) } @@ -6766,3 +6767,42 @@ fn a_rendering_mode_partway_through_a_line_starts_a_run_of_its_own() { 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 faa69c95a..1871f700d 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -228,6 +228,9 @@ struct TextState { 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 { @@ -244,6 +247,8 @@ impl TextState { stroke: ts.stroke.clone(), line_width: ts.line_width, ext_gstate: ts.ext_gstate.clone(), + dash: ts.dash.clone(), + line_cap: ts.line_cap, } } @@ -669,6 +674,29 @@ struct Ts { 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 @@ -1000,6 +1028,8 @@ impl Carry { 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 } } @@ -1119,6 +1149,8 @@ fn interpret( 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) { @@ -1155,6 +1187,12 @@ fn interpret( // `/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" => { @@ -1911,21 +1949,19 @@ fn same_font(line: &TextLine, font: &[u8], base_font: &str, face: Option<&str>) /// 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 and width only count for text that is stroked, so a rule drawn between two -/// words doesn't split their line. +/// 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). -/// -/// Known gap: two ExtGStates with the same transparency but another parameter that [`Paint`] -/// doesn't compare (a dash `/D`, a line cap `/LC`) still join one line, which is then rewritten -/// with the first run's selections. Comparing the names here would split lines #3310 keeps whole. 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)); - same_mode && (!stroked || (a.stroke == ts.stroke && a.line_width == ts.line_width)) + 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