Keep text rows in other layers, gs states or Tz out of a paragraph

Fixes #3115

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
NetterSilver
2026-10-18 18:00:00 +00:00
co-authored by Claude Haiku 5.5
parent 932bf9c6bf
commit 57c6e476db
3 changed files with 135 additions and 4 deletions
+90
View File
@@ -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<String> = 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::<Vec<_>>();
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");
}
+42 -4
View File
@@ -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<usize>,
/// 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<Vec<u8>>,
gs: Vec<Vec<u8>>,
}
/// 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<Vec<u8>>,
}
fn content_streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec<u8>)> {
@@ -661,6 +678,7 @@ struct Shown {
visual_order: bool,
decodable: bool,
scope: Option<usize>,
paint: Paint,
logical_order: bool,
actual_text: bool,
}
@@ -709,6 +727,8 @@ struct Carry {
ts: Ts,
stack: Vec<Ts>,
marked: Vec<Option<usize>>,
/// For each open marked-content sequence, its optional content group when it is `/OC`.
layers: Vec<Option<Vec<u8>>>,
}
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<Option<(usize, usize, String)>> = 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<Vec<TextLine>, 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<Vec<TextLine>, 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<TextLine>, last: &mut Option<Pen>, 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<TextLine>, last: &mut Option<Pen>, 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<Vec<TextLine>, 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 {
+3
View File
@@ -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<TextBlock> {
(!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