From e7ba974fe1ca37b6f3515e4dc847a70867824c3a Mon Sep 17 00:00:00 2001 From: storybored379 Date: Sun, 4 Oct 2026 11:00:31 -0700 Subject: [PATCH] Edit text: a rewritten paragraph keeps its place in the text object it shares (#7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Edit text: a rewritten paragraph keeps its place in the text object it shares A rewritten paragraph was drawn again just before the BT of its text object. When earlier paragraphs shared that BT … ET (as in text-only pages), the paragraph moved ahead of them in the content: reading order changed (screen readers, copy, `pdftotext -raw`) and text_paragraphs renumbered, so after text_edit paragraph 2 a following text_edit paragraph 1 rewrote the already edited paragraph instead of the first one. The text object is now split where the paragraph was, and the second half resumes from the same line matrix and text state. Co-Authored-By: Claude Opus 5.5 * Edit text: read the text before a paragraph without slicing by input positions (never-panic rule) Co-Authored-By: Claude Opus 5.5 * Edit text: test a recoloured paragraph in a shared text object (split + q/Q together) Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: storybored379 Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/edit/src/tests.rs | 48 ++++++++++++++++++++++++++++++++++++++++ crates/edit/src/text.rs | 27 +++++++++++++++++++--- 2 files changed, 72 insertions(+), 3 deletions(-) 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) {