diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 6a6a939f2..42a054eaa 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -4808,7 +4808,7 @@ fn row_state_page(content: &str) -> Document { "<< /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(), + "<< /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(); @@ -4889,3 +4889,19 @@ fn editing_a_row_keeps_the_layer_opacity_and_scale_of_the_rows_around_it() { 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 81394dd16..cf3eb74f8 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -231,17 +231,64 @@ 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. +/// 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: Vec>, + gs: Gs, } -/// How many `gs` operators a [`Paint`] records; the rest of a run of them is not told apart. -const MAX_PAINT_GS: usize = 32; +/// 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 { @@ -534,8 +581,8 @@ struct Ts { leading: f64, rise: f64, render_mode: i64, - /// The `gs` resource names applied so far (see [`Paint`]). - gs: Vec>, + /// The transparency parameters the `gs` operators have set so far (see [`Paint`]). + gs: Gs, } fn content_streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec)> { @@ -745,7 +792,7 @@ impl Carry { leading: 0.0, rise: 0.0, render_mode: 0, - gs: Vec::new(), + gs: Gs::default(), }; Carry { ts, stack: Vec::new(), marked: Vec::new(), layers: Vec::new() } } @@ -757,12 +804,14 @@ 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>, logical_text: bool, ) -> 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()); @@ -865,10 +914,9 @@ 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) - && ts.gs.len() < MAX_PAINT_GS - { - ts.gs.push(name.to_vec()); + 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" => { @@ -1045,7 +1093,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(); @@ -1053,7 +1100,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result, EditErro // joined, so an operator whose operands end one piece still has them. let pieces = Pieces::join(&content_streams(doc, &p.dict).into_iter().map(|(_, d)| d).collect::>()); let ops = pieces.parse().ops; - let shown = interpret(doc, &ops, &fonts_res, &mut cache, &mut carry, None, true); + let shown = interpret(doc, &ops, &res, &mut cache, &mut carry, None, true); 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 @@ -1296,10 +1343,9 @@ fn reading_lines(doc: &Document, page: usize) -> Result, EditError } /// `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 shown = interpret(w.doc, ops, &fonts_res, &mut cache, carry, Some(&mut invoked), true); + let shown = interpret(w.doc, ops, resources, &mut cache, carry, Some(&mut invoked), true); let mut last = None; let mut last_stream = None; let mut forms = invoked.into_iter().peekable(); @@ -1925,11 +1971,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, false); + let shown = interpret(doc, &ops, &res, &mut HashMap::new(), &mut Carry::new(), None, false); let mut patches = HashMap::new(); for s in shown.into_iter().filter(|s| drops.contains(&s.op)) { if s.state.render_mode >= 4 { @@ -2586,7 +2631,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, false); + let shown = interpret(doc, &ops, &res, &mut HashMap::new(), &mut Carry::new(), None, false); 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.