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