diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 3168dddca..494c7bf06 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -5372,3 +5372,109 @@ fn a_text_session_commits_what_a_rewrite_writes_whatever_it_laid_out_before() { assert_eq!(pdfcraft_cos::write_full(&by_session, &SaveOptions::default()).unwrap(), before); assert!(session.is_current(&doc) && !session.is_current(&by_session)); } + +/// 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 /GS1 << /Type /ExtGState /ca 0.3 /CA 0.3 /BM /Normal >> /GS2 << /Type /ExtGState /ca 1 /CA 1 >> >> /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"); +} + +/// What keeps rows apart is the graphics state they are drawn in, not the `gs` operators +/// before them: a repeated `gs` without q/Q, or another name with the same settings, is still one +/// paragraph, and a `gs` that changes the opacity or puts it back is not. +#[test] +fn rows_under_repeated_or_equal_gs_states_stay_one_paragraph() { + 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 paragraphs = |content: String| text::text_blocks(&row_state_page(&content), 0).unwrap().len(); + assert_eq!(paragraphs(format!("/GS0 gs {row1}/GS0 gs {row2}")), 1, "the same name twice"); + assert_eq!(paragraphs(format!("/GS0 gs {row1}/GS0 gs /GS0 gs {row2}")), 1, "the same name three times"); + assert_eq!(paragraphs(format!("/GS0 gs {row1}/GS1 gs {row2}")), 1, "two names, the same settings"); + assert_eq!(paragraphs(format!("/GS2 gs {row1}{row2}")), 1, "settings that are the defaults"); + assert_eq!(paragraphs(format!("{row1}/GS0 gs {row2}")), 2, "another opacity"); + assert_eq!(paragraphs(format!("/GS0 gs {row1}/GS2 gs {row2}")), 2, "the opacity put back"); +} diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index f8cd15fc6..6cda909b5 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -65,6 +65,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, @@ -244,6 +246,66 @@ 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 effective graphics state parameters (`gs`) in +/// force. 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: Gs, +} + +/// The transparency parameters of the graphics state: what the `gs` operators have set, resolved, +/// so a repeated `gs` or two names with the same settings leave it as it was. +#[derive(Clone, Debug, PartialEq)] +struct Gs { + /// `ca`, the fill opacity. + fill_alpha: f64, + /// `CA`, the stroke opacity. + stroke_alpha: f64, + /// `BM`, the blend mode (`Compatible` is `Normal`). + blend: Vec, + /// `SMask`, when it is not `None`: the reference of an indirect soft mask, else the dictionary. + soft_mask: Option, +} + +impl Default for Gs { + fn default() -> Self { + Gs { fill_alpha: 1.0, stroke_alpha: 1.0, blend: b"Normal".to_vec(), soft_mask: None } + } +} + +impl Gs { + /// Applies the parameters a graphics state parameter dictionary sets; the others stay. + fn apply(&mut self, doc: &Document, params: &Dict) { + let num = |key: &[u8]| params.get(key).and_then(|o| doc.resolve(o).as_f64()).filter(|v| v.is_finite()); + if let Some(v) = num(b"ca") { + self.fill_alpha = v; + } + if let Some(v) = num(b"CA") { + self.stroke_alpha = v; + } + if let Some(bm) = params.get(b"BM").map(|o| doc.resolve(o)) { + let first = bm + .as_name() + .map(<[u8]>::to_vec) + .or_else(|| bm.as_array().and_then(|a| a.iter().find_map(|o| doc.resolve(o).as_name().map(<[u8]>::to_vec)))); + if let Some(name) = first { + self.blend = if name == b"Compatible" { b"Normal".to_vec() } else { name }; + } + } + if let Some(mask) = params.get(b"SMask") { + self.soft_mask = match (mask.as_ref(), &*doc.resolve(mask)) { + (_, Object::Name(_)) => None, + (Some(r), _) => Some(Object::Ref(r)), + (None, Object::Dict(d)) => Some(Object::Dict(d.clone())), + (None, _) => None, + }; + } + } +} + #[derive(Clone, Debug, PartialEq)] struct Origin { tm: [f64; 6], @@ -535,6 +597,8 @@ struct Ts { leading: f64, rise: f64, render_mode: i64, + /// The transparency parameters the `gs` operators have set so far (see [`Paint`]). + gs: Gs, } fn content_streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec)> { @@ -678,6 +742,7 @@ struct Shown { visual_order: bool, decodable: bool, scope: Option, + paint: Paint, logical_order: bool, actual_text: bool, } @@ -797,6 +862,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 { @@ -813,8 +880,9 @@ impl Carry { leading: 0.0, rise: 0.0, render_mode: 0, + gs: Gs::default(), }; - Carry { ts, stack: Vec::new(), marked: Vec::new() } + Carry { ts, stack: Vec::new(), marked: Vec::new(), layers: Vec::new() } } } @@ -824,13 +892,15 @@ type Invocation = (usize, Vec, Ts); fn interpret( doc: &Document, ops: &[Op], - fonts_res: &Dict, + resources: &Dict, cache: &mut HashMap, (Rc, Option)>, carry: &mut Carry, mut invoked: Option<&mut Vec>, // `Some(properties)`: read `/ActualText`, looking named property lists up in `properties`. logical_text: Option<&Dict>, ) -> Vec { + let fonts_res = resources.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default(); + let states = resources.get(b"ExtGState").map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).unwrap_or_default(); let mut out: Vec = Vec::new(); let mut ts = carry.ts.clone(); let mut at_bt = (0usize, ts.clone()); @@ -838,6 +908,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() { @@ -846,6 +917,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))); // The property list is inline or named in the resources' /Properties (ยง14.6.2). let list = logical_text.and_then(|properties| match op.operands.get(1) { Some(Object::Name(n)) => properties.get(n).and_then(|o| doc.dict(o)), @@ -859,6 +933,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(); @@ -932,6 +1007,12 @@ 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" => { + 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); + } + } b"Do" => { if let (Some(list), Some(name)) = (invoked.as_deref_mut(), op.name(0)) { list.push((i, name.to_vec(), ts.clone())); @@ -1090,6 +1171,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, }); @@ -1100,6 +1182,7 @@ fn interpret( carry.ts = ts; carry.stack = stack; carry.marked = marked; + carry.layers = layers; positioned_tracking(&mut out); out } @@ -1108,7 +1191,6 @@ fn interpret( pub fn text_lines(doc: &Document, page: usize) -> Result, EditError> { let p = page_dict(doc, page)?; let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); - let fonts_res = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default(); let mut cache = HashMap::new(); let mut lines: Vec = Vec::new(); let mut carry = Carry::new(); @@ -1117,7 +1199,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result, EditErro let pieces = Pieces::join(&content_streams(doc, &p.dict).into_iter().map(|(_, d)| d).collect::>()); let ops = pieces.parse().ops; let properties = res.get(b"Properties").and_then(|o| doc.dict(o)).unwrap_or_default(); - let shown = interpret(doc, &ops, &fonts_res, &mut cache, &mut carry, None, Some(&properties)); + let shown = interpret(doc, &ops, &res, &mut cache, &mut carry, None, Some(&properties)); let mut last: Option<(usize, Pen)> = None; // the stream piece and where its last run ended for mut s in shown { // A single RTL glyph's ActualText (e.g. lam-alef) is an inline ligature, not a @@ -1135,7 +1217,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.base_font, s.face.as_deref()))) && (s.text.trim().is_empty() @@ -1180,6 +1262,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, clips: s.state.render_mode >= 4, @@ -1218,6 +1301,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]) @@ -1230,7 +1314,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.base_font, s.face.as_deref()))) && (s.text.trim().is_empty() @@ -1271,6 +1355,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, clips: s.state.render_mode >= 4, @@ -1383,11 +1468,10 @@ fn reading_lines(doc: &Document, page: usize) -> Result<(Vec, bool), E } /// `stream` gives the stream number of the operator at an index of `ops`. fn walk(w: &mut Walk, ops: &[Op], resources: &Dict, carry: &mut Carry, stream: &dyn Fn(usize) -> usize) { - let fonts_res = resources.get(b"Font").map(|f| w.doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default(); let mut cache = HashMap::new(); let mut invoked = Vec::new(); let properties = resources.get(b"Properties").and_then(|o| w.doc.dict(o)).unwrap_or_default(); - let shown = interpret(w.doc, ops, &fonts_res, &mut cache, carry, Some(&mut invoked), Some(&properties)); + let shown = interpret(w.doc, ops, resources, &mut cache, carry, Some(&mut invoked), Some(&properties)); let mut last = None; let mut last_stream = None; let mut forms = invoked.into_iter().peekable(); @@ -1412,7 +1496,7 @@ fn reading_lines(doc: &Document, page: usize) -> Result<(Vec, bool), E let Some(form) = form_call(w.doc, resources, name, ts.ctm, &w.path, &mut w.budget) 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 { @@ -2108,11 +2192,10 @@ pub fn clear_block(doc: &mut Document, page: usize, block: usize) -> Result<(), drops.extend(target.lines.iter().filter_map(|i| lines.get(*i)).flat_map(|l| l.ops.iter().copied())); let p = page_dict(doc, page)?; let res = p.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); - let fonts = res.get(b"Font").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); let streams = content_streams(doc, &p.dict); let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); let ops = pieces.parse().ops; - let shown = interpret(doc, &ops, &fonts, &mut HashMap::new(), &mut Carry::new(), None, None); + let shown = interpret(doc, &ops, &res, &mut HashMap::new(), &mut Carry::new(), None, None); let mut patches = HashMap::new(); for s in shown.into_iter().filter(|s| drops.contains(&s.op)) { if s.state.render_mode >= 4 { @@ -2790,7 +2873,7 @@ pub(crate) fn translate_blocks(doc: &mut Document, page: usize, indexes: &[usize let fonts = res.get(b"Font").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); let ops = pieces.parse().ops; - let shown = interpret(doc, &ops, &fonts, &mut HashMap::new(), &mut Carry::new(), None, None); + let shown = interpret(doc, &ops, &res, &mut HashMap::new(), &mut Carry::new(), None, None); let mut patches = HashMap::new(); // Keep full precision: a tiny rounded text-space change can be magnified by a CTM, // and restoring rise/spacing must not alter following unselected text. diff --git a/crates/edit/src/text_regions.rs b/crates/edit/src/text_regions.rs index a00e42f15..33ed4affc 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); @@ -108,6 +109,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