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) <noreply@anthropic.com>
This commit is contained in:
luis.pereira
2026-10-23 09:35:03 +01:00
parent 9163f29090
commit 60985e119c
2 changed files with 85 additions and 9 deletions
+42 -2
View File
@@ -6620,17 +6620,18 @@ fn painted_shows(doc: &Document) -> Vec<Painted> {
}
/// 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::<String>(), 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::<String>(), "FAINTS", "{change}");
}
}
+43 -7
View File
@@ -228,6 +228,9 @@ struct TextState {
stroke: Vec<Op>,
line_width: Option<f64>,
ext_gstate: Vec<Vec<u8>>,
/// 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<f64>,
/// The ExtGState resources selected with `gs`, oldest first (see [`select_gs`]).
ext_gstate: Vec<Vec<u8>>,
/// 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>, 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<Dash> {
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::<Option<Vec<_>>>()?, 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<i64> {
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