mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
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:
co-authored by
Claude Haiku 5.5
parent
932bf9c6bf
commit
57c6e476db
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user