mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 18:33:22 +00:00
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:
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user