From 57c6e476db16d79e9ffaff7973410c66bd247bb8 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:00:00 +0000 Subject: [PATCH] Keep text rows in other layers, gs states or Tz out of a paragraph Fixes #3115 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/edit/src/tests.rs | 90 +++++++++++++++++++++++++++++++++ crates/edit/src/text.rs | 46 +++++++++++++++-- crates/edit/src/text_regions.rs | 3 ++ 3 files changed, 135 insertions(+), 4 deletions(-) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 99fae205e..6a6a939f2 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -4799,3 +4799,93 @@ fn flattening_comments_keeps_annotations_that_are_not_comments() { let without_widget: Vec<&str> = all.iter().copied().filter(|s| *s != "Widget").collect(); assert_eq!(left(&reopen(&doc)), without_widget, "flattening fields touches no annotation that isn't a widget"); } + +/// A one-page document whose resources have `/GS0` (30 % opacity) and `/L1`, a layer that is OFF. +fn row_state_page(content: &str) -> Document { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R] /D << /OFF [5 0 R] /Order [5 0 R] >> >> >>".into(), + "<< /Type /Pages /Kids [6 0 R] /Count 1 >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + "<< /Type /ExtGState /ca 0.3 /CA 0.3 >>".into(), + "<< /Type /OCG /Name (Hidden layer) >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 7 0 R /Resources << /Font << /F1 3 0 R >> /ExtGState << /GS0 4 0 R >> /Properties << /L1 5 0 R >> >> >>".into(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() +} + +/// Each shown string on page 0 with the state it is drawn in: inside a `/OC` marked-content +/// sequence, after `/GS0 gs`, and its horizontal scale (percent). +fn shown_states(doc: &Document) -> Vec<(String, bool, bool, f64)> { + let joined = streams(doc, 0).join("\n"); + let (mut oc, mut marks) = (0usize, Vec::new()); + let (mut gs, mut tz, mut stack) = (false, 100.0, Vec::new()); + let mut out = Vec::new(); + for op in pdfcraft_content::parse(joined.as_bytes()).ops { + match op.op.as_slice() { + b"BDC" | b"BMC" => { + let layer = op.name(0) == Some(b"OC".as_slice()); + oc += usize::from(layer); + marks.push(layer); + } + b"EMC" => oc -= usize::from(marks.pop().unwrap()), + b"q" => stack.push((gs, tz)), + b"Q" => (gs, tz) = stack.pop().unwrap(), + b"gs" => gs |= op.name(0) == Some(b"GS0".as_slice()), + b"Tz" => tz = op.num(0).unwrap(), + b"Tj" | b"'" => { + let Some(Object::String(s)) = op.operands.last() else { continue }; + if !s.bytes.is_empty() { + out.push((String::from_utf8_lossy(&s.bytes).into_owned(), oc > 0, gs, tz)); + } + } + _ => {} + } + } + out +} + +/// #3115: a row drawn in a hidden layer, at 30 % opacity or at 80 % horizontal scale keeps that +/// state when another row of the same paragraph is edited; the edited row doesn't take it on. +#[test] +fn editing_a_row_keeps_the_layer_opacity_and_scale_of_the_rows_around_it() { + let row1 = "BT /F1 12 Tf 72 700 Td (Hello world, this is the first line) Tj ET\n"; + let row2 = "BT /F1 12 Tf 72 686 Td (and the second) Tj ET"; + let pages = [ + (format!("{row1}/OC /L1 BDC {row2} EMC"), (true, false, 100.0)), + (format!("{row1}q /GS0 gs {row2} Q"), (false, true, 100.0)), + ("BT /F1 12 Tf 72 700 Td (Hello world, this is the first line) Tj 80 Tz 0 -14 Td (and the second) Tj ET".to_string(), (false, false, 80.0)), + (format!("{row1}{row2}"), (false, false, 100.0)), + ]; + for (n, (content, (oc, gs, tz))) in pages.iter().enumerate() { + let mut doc = row_state_page(content); + let blocks = text::text_blocks(&doc, 0).unwrap(); + let block = blocks.iter().position(|b| b.text.starts_with("Hello")).unwrap(); + let text = blocks[block].text.replacen("Hello", "Jello", 1); + text::replace_block(&mut doc, 0, block, &text).unwrap(); + let shown = shown_states(&reopen(&doc)); + let find = |needle: &str| shown.iter().filter(|s| s.0.contains(needle)).cloned().collect::>(); + let first = find("Jello"); + assert_eq!(first.len(), 1, "page {}: {shown:?}", n + 1); + assert_eq!((first[0].1, first[0].2, first[0].3), (false, false, 100.0), "page {}: the edited row keeps its own state: {shown:?}", n + 1); + let second = find("and the second"); + assert_eq!(second.len(), 1, "page {}: {shown:?}", n + 1); + assert_eq!((second[0].1, second[0].2, second[0].3), (*oc, *gs, *tz), "page {}: the other row keeps its state: {shown:?}", n + 1); + } + // The control: rows in the same state are still one paragraph. + let blocks = text::text_blocks(&row_state_page(&pages[3].0), 0).unwrap(); + assert_eq!(blocks.len(), 1); + assert_eq!(blocks[0].text, "Hello world, this is the first line and the second"); +} diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index e4eaaa641..81394dd16 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -62,6 +62,8 @@ pub struct TextLine { origin: Origin, /// Explicit marked-content scope (when present); distinct scopes must not be guessed into one paragraph. scope: Option, + /// The layers and graphics state parameters it is drawn in; see [`Paint`]. + paint: Paint, /// Right-to-left `/ActualText` already supplies typing order; glyph text still needs bidi /// recovery. Left-to-right ActualText (e.g. a Latin ligature) joins its line as usual. logical_order: bool, @@ -228,6 +230,19 @@ impl TextState { } } +/// What a run is drawn in beyond its text state: the optional content groups of the `/OC` +/// marked-content sequences around it, and the graphics state parameter dictionaries (`gs`) +/// applied before it. A rewrite writes a paragraph in its first run's place, so runs that differ +/// here are never joined: one in a hidden layer or at another opacity would lose it. +#[derive(Clone, Debug, Default, PartialEq)] +struct Paint { + layers: Vec>, + gs: Vec>, +} + +/// How many `gs` operators a [`Paint`] records; the rest of a run of them is not told apart. +const MAX_PAINT_GS: usize = 32; + #[derive(Clone, Debug, PartialEq)] struct Origin { tm: [f64; 6], @@ -519,6 +534,8 @@ struct Ts { leading: f64, rise: f64, render_mode: i64, + /// The `gs` resource names applied so far (see [`Paint`]). + gs: Vec>, } fn content_streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec)> { @@ -661,6 +678,7 @@ struct Shown { visual_order: bool, decodable: bool, scope: Option, + paint: Paint, logical_order: bool, actual_text: bool, } @@ -709,6 +727,8 @@ struct Carry { ts: Ts, stack: Vec, marked: Vec>, + /// For each open marked-content sequence, its optional content group when it is `/OC`. + layers: Vec>>, } impl Carry { @@ -725,8 +745,9 @@ impl Carry { leading: 0.0, rise: 0.0, render_mode: 0, + gs: Vec::new(), }; - Carry { ts, stack: Vec::new(), marked: Vec::new() } + Carry { ts, stack: Vec::new(), marked: Vec::new(), layers: Vec::new() } } } @@ -749,6 +770,7 @@ fn interpret( let (mut tm, mut tlm) = (Matrix::IDENTITY, Matrix::IDENTITY); let mut bt = 0usize; let mut marked = std::mem::take(&mut carry.marked); + let mut layers = std::mem::take(&mut carry.layers); let mut actual: Vec> = Vec::new(); for (i, op) in ops.iter().enumerate() { match op.op.as_slice() { @@ -757,6 +779,9 @@ fn interpret( // artifacts and inner spans, which inherit their enclosing region. let region = matches!(op.name(0), Some(b"P" | b"H" | b"H1" | b"H2" | b"H3" | b"H4" | b"H5" | b"H6" | b"LI" | b"TD" | b"TH")); marked.push(if region { Some(i) } else { marked.last().copied().flatten() }); + // A layer named in the resources is told apart by its name, an inline one by where it opens. + let layer = op.is("BDC") && op.name(0) == Some(b"OC".as_slice()); + layers.push(layer.then(|| op.operands.get(1).and_then(Object::as_name).map_or_else(|| format!("@{i}").into_bytes(), <[u8]>::to_vec))); let logical = op.operands.get(1).and_then(Object::as_dict).and_then(|d| d.get(b"ActualText")).and_then(|s| match s { Object::String(s) if s.bytes.len() <= 4_000_000 => Some(s.to_text()), _ => None, @@ -765,6 +790,7 @@ fn interpret( } b"EMC" => { marked.pop(); + layers.pop(); if let Some(Some((start, open, logical))) = actual.pop() { let tail = out.split_off(start.min(out.len())); let mut items = tail.into_iter(); @@ -838,6 +864,13 @@ fn interpret( b"TL" => ts.leading = op.num(0).unwrap_or(0.0), 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) + && ts.gs.len() < MAX_PAINT_GS + { + ts.gs.push(name.to_vec()); + } + } b"Do" => { if let (Some(list), Some(name)) = (invoked.as_deref_mut(), op.name(0)) { list.push((i, name.to_vec(), ts.clone())); @@ -993,6 +1026,7 @@ fn interpret( visual_order: m.visual_order, decodable, scope: marked.last().copied().flatten(), + paint: Paint { layers: layers.iter().flatten().cloned().collect(), gs: ts.gs.clone() }, logical_order: false, actual_text: false, }); @@ -1003,6 +1037,7 @@ fn interpret( carry.ts = ts; carry.stack = stack; carry.marked = marked; + carry.layers = layers; out } @@ -1036,7 +1071,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result, EditErro let bt = pen.bt; let (near, gap) = pen.continues(&s, if bt == s.bt { 3.0 } else { 1.5 }); piece == si - && lines.last().is_some_and(|l| l.scope == s.scope && l.logical_order == s.logical_order) + && lines.last().is_some_and(|l| l.scope == s.scope && l.paint == s.paint && l.logical_order == s.logical_order) && (bt == s.bt || lines.last().is_some_and(|l| compatible_word(l, &s))) && (s.text.trim().is_empty() || lines.last().is_some_and(|l| same_font(l, &s.font, s.face.as_deref()))) && (s.text.trim().is_empty() @@ -1080,6 +1115,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result, EditErro face: s.face.clone(), stream: si, scope: s.scope, + paint: s.paint.clone(), logical_order: s.logical_order, actual_text: s.actual_text, ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(), @@ -1117,6 +1153,7 @@ fn compatible_word(line: &TextLine, shown: &Shown) -> bool { let same = |a: &[f64], b: &[f64]| a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-6); let (a, b) = (&line.origin.state, TextState::of(&shown.state)); line.scope == shown.scope + && line.paint == shown.paint && same(&line.origin.tm[..4], &shown.tm.0[..4]) && same(&line.origin.ctm, &shown.state.ctm.0) && same(&[a.char_spacing, a.word_spacing, a.scale, a.rise], &[b.char_spacing, b.word_spacing, b.scale, b.rise]) @@ -1129,7 +1166,7 @@ fn compatible_word(line: &TextLine, shown: &Shown) -> bool { fn add_shown(lines: &mut Vec, last: &mut Option, s: Shown, stream: usize) { let joins = last.is_some_and(|pen| { let bt = pen.bt; - lines.last().is_some_and(|l| l.scope == s.scope && l.logical_order == s.logical_order) + lines.last().is_some_and(|l| l.scope == s.scope && l.paint == s.paint && l.logical_order == s.logical_order) && (bt == s.bt || lines.last().is_some_and(|l| compatible_word(l, &s))) && (s.text.trim().is_empty() || lines.last().is_some_and(|l| same_font(l, &s.font, s.face.as_deref()))) && (s.text.trim().is_empty() @@ -1169,6 +1206,7 @@ fn add_shown(lines: &mut Vec, last: &mut Option, s: Shown, stream face: s.face.clone(), stream, scope: s.scope, + paint: s.paint.clone(), logical_order: s.logical_order, actual_text: s.actual_text, ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(), @@ -1286,7 +1324,7 @@ fn reading_lines(doc: &Document, page: usize) -> Result, EditError let Some(form) = form_call(w.doc, resources, name, ts.ctm, &w.path, &mut w.visits) else { return }; let ops = parse(&form.data).ops; // The form starts with the graphics state at its `Do`, its own matrix applied. - let mut carry = Carry { ts: Ts { ctm: form.ctm, ..ts }, stack: Vec::new(), marked: Vec::new() }; + let mut carry = Carry { ts: Ts { ctm: form.ctm, ..ts }, stack: Vec::new(), marked: Vec::new(), layers: Vec::new() }; let stream = w.next_stream; w.next_stream = w.next_stream.saturating_add(1); if let Some(r) = form.obj { diff --git a/crates/edit/src/text_regions.rs b/crates/edit/src/text_regions.rs index 8e34e0bdf..5e4fcd462 100644 --- a/crates/edit/src/text_regions.rs +++ b/crates/edit/src/text_regions.rs @@ -6,6 +6,7 @@ fn same_plane(a: &TextLine, b: &TextLine) -> bool { let at = Matrix(a.origin.tm).then(&Matrix(a.origin.ctm)); let bt = Matrix(b.origin.tm).then(&Matrix(b.origin.ctm)); a.scope == b.scope + && a.paint == b.paint && [0, 2].iter().all(|axis| { let ax = at.0.get(*axis).copied().unwrap_or(0.0); let ay = at.0.get(axis + 1).copied().unwrap_or(0.0); @@ -105,6 +106,8 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec { (!mixed || !(invisible(prev) || invisible(first))) && (mixed || same_style(prev, first)) && same_plane(prev, first) + // The rewrite sets every row in the first one's horizontal scale. + && (prev.origin.state.scale - first.origin.state.scale).abs() < 1e-6 && aligned && first.size.min(prev.size) >= first.size.max(prev.size) * 0.4 && gap > first.size.min(prev.size) * 0.8