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
co-authored by Claude Opus 5.5
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}");
}
}