diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index b0ce416d..0cedac78 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -471,6 +471,54 @@ fn a_new_paragraph_colour_does_not_spill_into_the_text_after_it() { assert_eq!(text::text_blocks(&doc, 0).unwrap()[1].text, "Second paragraph"); } +#[test] +fn a_rewritten_paragraph_keeps_its_place_in_a_shared_text_object() { + // Three paragraphs in one BT … ET: rewriting the second keeps the order (paragraph numbers + // and reading order) and every paragraph's position. + let src = "BT /F1 10 Tf 72 700 Td (First paragraph) Tj 0 -40 Td (Second paragraph) Tj 0 -40 Td (Third paragraph) Tj ET"; + let mut doc = text_page(src); + let before = text::text_blocks(&doc, 0).unwrap(); + text::replace_block(&mut doc, 0, 1, "Edited second").unwrap(); + let doc = reopen(&doc); + let after = text::text_blocks(&doc, 0).unwrap(); + let texts: Vec<&str> = after.iter().map(|b| b.text.as_str()).collect(); + assert_eq!(texts, ["First paragraph", "Edited second", "Third paragraph"]); + assert_eq!(after[0].rect, before[0].rect); + assert_eq!(after[2].rect, before[2].rect); + assert!((after[1].rect[0] - before[1].rect[0]).abs() < 0.01 && (after[1].rect[1] - before[1].rect[1]).abs() < 0.01, "{:?}", after[1].rect); +} + +#[test] +fn recolouring_a_paragraph_in_a_shared_text_object_keeps_order_and_nesting() { + // Splitting the text object (to keep the order) and q … Q (to keep the colour in) together: + // q/Q stay outside text objects, and the paragraph after it keeps the original colour. + let src = "BT /F1 10 Tf 72 700 Td (First paragraph) Tj 0 -40 Td (Second paragraph) Tj 0 -40 Td (Third paragraph) Tj ET"; + let mut doc = text_page(src); + let style = text::BlockStyle { color: Some([1.0, 0.0, 0.0]), ..Default::default() }; + text::rewrite_block(&mut doc, 0, 1, None, &style).unwrap(); + let doc = reopen(&doc); + let texts: Vec = text::text_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect(); + assert_eq!(texts, ["First paragraph", "Second paragraph", "Third paragraph"]); + let ops = printcraft_content::parse(&page_content_bytes(&doc, 0)).ops; + let (mut in_text, mut depth, mut red) = (false, 0usize, Vec::new()); + for op in &ops { + match op.op.as_slice() { + b"BT" => in_text = true, + b"ET" => in_text = false, + b"q" | b"Q" => { + assert!(!in_text, "q/Q inside a text object: {ops:?}"); + depth = if op.is("q") { depth + 1 } else { depth.saturating_sub(1) }; + } + b"rg" => red.push(depth), + b"Tj" if op.operands.first().and_then(|o| o.as_string()).is_some_and(|s| s.to_text() == "Third paragraph") => { + assert!(red.iter().all(|d| *d > depth), "red still in force for the third paragraph: {ops:?}"); + } + _ => {} + } + } + assert!(!red.is_empty(), "{ops:?}"); +} + #[test] fn justify_underline_and_spacing() { let src = "BT /F1 10 Tf 12 TL 100 700 Td (One two three four five six seven) Tj T* (eight nine ten eleven twelve) Tj T* (end) Tj ET"; diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index a31cc258..ed0fdecb 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -84,6 +84,8 @@ impl TextState { #[derive(Clone, Debug, PartialEq)] struct Origin { tm: [f64; 6], + /// The text line matrix there (where the next line's Td/T* starts from). + tlm: [f64; 6], /// Text-space units → user space (the scale of the text rendering matrix's y axis). k: f64, baseline: f64, @@ -162,6 +164,7 @@ struct Shown { op: usize, /// The text matrix where it starts (text space), and the state there and at its `BT`. tm: Matrix, + tlm: Matrix, state: Ts, bt_op: usize, bt_state: Ts, @@ -318,6 +321,7 @@ fn interpret(doc: &Document, ops: &[Op], fonts_res: &Dict, cache: &mut HashMap Result, EditErro ops: vec![s.op], origin: Origin { tm: s.tm.0, + tlm: s.tlm.0, k: (s.tm.then(&s.state.ctm).0[2].powi(2) + s.tm.then(&s.state.ctm).0[3].powi(2)).sqrt(), baseline: s.baseline, x: s.start_x, @@ -737,12 +742,28 @@ pub fn rewrite_block(doc: &mut Document, page: usize, block: usize, text: Option block_ops.push(Op::new("Q", vec![])); } block_ops.push(Op::new("Q", vec![])); - // What the text after the paragraph expects: the state at its BT. - block_ops.extend(o.bt_state.ops().into_iter().filter(|op| !op.is("Tf") || o.bt_state.font.is_some())); let drop: std::collections::HashSet = members.iter().flat_map(|l| l.ops.iter().copied()).collect(); + // Text shown earlier in the same text object stays first: the paragraph then goes where it + // was, between two halves of the text object, so it keeps its place in reading order. + let start = first.ops.first().copied().unwrap_or(o.bt_op); + let split = ops + .get(o.bt_op..start) + .unwrap_or_default() + .iter() + .enumerate() + .any(|(i, op)| matches!(op.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"") && !drop.contains(&(o.bt_op + i))); + // What the text after the paragraph expects: the state at its BT (or where it was split). + let after = if split { &o.state } else { &o.bt_state }; + block_ops.extend(after.ops().into_iter().filter(|op| !op.is("Tf") || after.font.is_some())); + if split { + block_ops.insert(0, Op::new("ET", vec![])); + block_ops.push(Op::new("BT", vec![])); + block_ops.push(Op::new("Tm", o.tlm.iter().map(|v| n(*v)).collect())); + } + let at = if split { start } else { o.bt_op }; let mut new_ops = Vec::with_capacity(ops.len() + block_ops.len()); for (i, op) in ops.into_iter().enumerate() { - if i == o.bt_op { + if i == at { new_ops.append(&mut block_ops); } if !drop.contains(&i) {