Merge PR #3551: Edit text: keep Tr, stroke, line width and gs set inside BT when rewriting a paragraph (#3508, #3509)

This commit is contained in:
aiongg
2026-10-24 06:21:29 +00:00
3 changed files with 418 additions and 27 deletions
+235
View File
@@ -6638,3 +6638,238 @@ 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 (from `w` or an ExtGState's `/LW`) and
/// the ExtGStates selected before it, following `q`/`Q`.
#[derive(Clone, Debug, PartialEq)]
struct Painted {
text: String,
render_mode: i64,
stroke: Vec<String>,
line_width: f64,
gs: Vec<String>,
}
fn painted_shows(doc: &Document) -> Vec<Painted> {
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();
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" => {
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() {
out.push(Painted { text: String::from_utf8_lossy(&s.bytes).into_owned(), ..state.clone() });
}
}
_ => {}
}
}
out
}
/// A page with Helvetica as /F1, /GS1, an ExtGState that paints at 20 % opacity, /GS2, one that
/// 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 /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] >>",
])
}
/// 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::<String>(), "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}");
}
}
/// 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::<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 == ["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::<Vec<_>>(),
[(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::<String>(), 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}");
}
}
/// 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}");
}
}
+166 -17
View File
@@ -223,6 +223,14 @@ struct TextState {
fill: Vec<Op>,
/// 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<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 {
@@ -236,9 +244,53 @@ 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(),
dash: ts.dash.clone(),
line_cap: ts.line_cap,
}
}
/// 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.
///
/// 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<Op> {
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(&current.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())]));
}
// 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.
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 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 {
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<Op> {
let n = pdfcraft_content::num;
@@ -616,6 +668,50 @@ 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<Rc<str>>,
/// The operators that set the current stroke colour (`G`, `RG`, `K`, or `CS` + `SC`/`SCN`).
stroke: Vec<Op>,
/// The line width a `w` or an ExtGState's `/LW` set (`None`: the initial 1.0).
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
/// 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<Vec<u8>>, 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 +1025,11 @@ impl Carry {
gs: Gs::default(),
vertical: None,
unreadable: None,
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 }
}
@@ -1042,6 +1143,14 @@ 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"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) {
@@ -1069,9 +1178,21 @@ 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);
// `/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" => {
@@ -1351,7 +1472,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
@@ -1457,7 +1582,11 @@ fn add_shown(lines: &mut Vec<TextLine>, last: &mut Option<Pen>, 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
});
@@ -1817,6 +1946,24 @@ 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, 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).
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));
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
/// 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.
@@ -2908,6 +3055,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)?);
@@ -2915,6 +3077,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;
@@ -3019,21 +3183,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![]));
+17 -10
View File
@@ -800,6 +800,17 @@ 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 };
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 +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);
// 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();
@@ -996,6 +1012,7 @@ pub(super) fn rewrite_styled_block(
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;
}
@@ -1006,16 +1023,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![]));