diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index f2033a64de..c8aff30de6 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -9684,6 +9684,102 @@ fn a_clipping_copy_on_the_same_baseline_is_refused() { assert_eq!(text::text_lines(&doc, 0).unwrap().len(), 2, "refused means untouched"); } +/// `'` moves to the next line before it shows its text, and `"` also sets the word and character +/// spacing (ISO 32000-2 §9.4.3, Table 107). Rewriting a paragraph drawn with them drops the +/// operator but must keep what it did: text after it, positioned from that line or using that +/// spacing, stays exactly where and how it was. Both the single-style paragraph path and the +/// region path (a draft with a line break) are checked. +#[test] +fn rewriting_a_paragraph_drawn_with_quote_operators_keeps_their_line_move_and_spacing() { + let cases = [ + // The next line is placed relative to the line `'` moved to. + "BT /F1 12 Tf 14 TL 72 714 Td (Target line here) ' 0 -100 Td (Footer stays put) Tj ET", + // The same with `"`, whose spacing the footer also inherits. + "BT /F1 12 Tf 14 TL 72 714 Td 3 2 (Target line here) \" 0 -100 Td (Footer stays put) Tj ET", + // Spacing set by `"` carries over into the next text object. + "BT /F1 12 Tf 14 TL 72 714 Td 3 2 (Target line here) \" ET BT 72 600 Td (Footer stays put) Tj ET", + // A split text object: text before the paragraph stays first, then the `'` lines go. + "BT /F1 12 Tf 14 TL 72 760 Td (Heading words) Tj 0 -46 Td (Target line here) Tj (more target) ' 0 -100 Td (Footer stays put) Tj ET", + // Split at a `'`: the rewrite's `Tm` there is already past its line move. + "BT /F1 12 Tf 14 TL 72 760 Td (Heading words) Tj 0 -32 Td (Target line here) ' (more target) ' 0 -100 Td (Footer stays put) Tj ET", + ]; + for content in cases { + let footer = |doc: &Document| { + let lines = text::text_lines(doc, 0).unwrap(); + let f = lines.iter().find(|l| l.text == "Footer stays put").unwrap_or_else(|| panic!("no footer in {content}")); + (f.rect, f.ink.clone()) + }; + let original = text_page(content); + let before = footer(&original); + let target = text::text_blocks(&original, 0).unwrap().iter().position(|b| b.text.starts_with("Target")).unwrap(); + for draft in ["NEW TEXT", "NEW\nTEXT"] { + let mut doc = original.clone(); + text::replace_block(&mut doc, 0, target, draft).unwrap(); + let doc = reopen(&doc); + assert_eq!(footer(&doc), before, "{content} with {draft:?}"); + let texts: Vec = text::text_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect(); + assert!(texts.iter().any(|t| t.contains("NEW")) && !texts.iter().any(|t| t.contains("Target")), "{content} with {draft:?}: {texts:?}"); + } + } +} + +/// [`text::replace_line`] also drops copies of the line drawn on the same baseline (a fake-bold +/// overprint). A copy drawn with `'` or `"` must leave that operator's line move and spacing +/// behind (ISO 32000-2 §9.4.3, Table 107), like the line's own first operator does: text after +/// it is positioned from that line or uses that spacing. Each copy is tried as the target, so the +/// one drawn with the quote operator is both the replaced line and the dropped copy. +#[test] +fn replacing_a_line_keeps_what_a_dropped_quote_copy_did() { + let cases = [ + // The copy's `'` moves to the line the footer is placed from. + "BT /F1 12 Tf 14 TL 72 700 Td (Target line here) Tj ET BT 72.3 714 Td (Target line here) ' 0 -100 Td (Footer stays put) Tj ET", + // The copy's `"` sets the spacing the footer inherits in the next text object. + "BT /F1 12 Tf 0 TL 72 700 Td (Target line here) Tj ET BT 72.3 700 Td 3 2 (Target line here) \" ET BT 72 600 Td (Footer stays put) Tj ET", + ]; + for content in cases { + let footer = |doc: &Document| { + let lines = text::text_lines(doc, 0).unwrap(); + let f = lines.iter().find(|l| l.text == "Footer stays put").unwrap_or_else(|| panic!("no footer in {content}")); + (f.rect, f.ink.clone()) + }; + let original = text_page(content); + let before = footer(&original); + let targets: Vec = + text::text_lines(&original, 0).unwrap().iter().enumerate().filter(|(_, l)| l.text == "Target line here").map(|(i, _)| i).collect(); + assert_eq!(targets.len(), 2, "{content}"); + for target in targets { + let mut doc = original.clone(); + text::replace_line(&mut doc, 0, target, "NEW TEXT").unwrap(); + let doc = reopen(&doc); + assert_eq!(footer(&doc), before, "{content} replacing line {target}"); + let texts: Vec = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect(); + assert!(texts.iter().any(|t| t == "NEW TEXT") && !texts.iter().any(|t| t.contains("Target")), "{content}: {texts:?}"); + } + } +} + +/// A copy dropped with the line it overprints moved the pen by its text: a run after it in its +/// text object, placed by that pen, stays where it was (ISO 32000-2 §9.4.4). +#[test] +fn replacing_a_line_keeps_the_advance_of_a_dropped_copy() { + for content in [ + "BT /F1 12 Tf 72 700 Td (Target line here) Tj ET BT 72.3 700 Td (Target line here) Tj [-20000] TJ (Far) Tj ET", + "BT /F1 12 Tf 0 TL 72 700 Td (Target line here) Tj ET BT 72.3 700 Td (Target line here) ' [-20000] TJ (Far) Tj ET", + ] { + let original = text_page(content); + let place = |doc: &Document| text::text_lines(doc, 0).unwrap().into_iter().find(|l| l.text == "Far").map(|l| l.rect); + let before = place(&original).unwrap_or_else(|| panic!("Far is a line of its own in {content}")); + // The line drawn first, so the one followed by Far is the copy. + let target = text::text_lines(&original, 0).unwrap().iter().position(|l| l.text == "Target line here").unwrap(); + let mut doc = original.clone(); + text::replace_line(&mut doc, 0, target, "NEW TEXT").unwrap(); + let doc = reopen(&doc); + let texts: Vec = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect(); + assert!(!texts.iter().any(|t| t.contains("Target")), "{content}: the copy went too: {texts:?}"); + assert_eq!(place(&doc), Some(before), "{content}"); + } +} + /// #2512: a session's drafts are laid out from the document as it was opened, never from each /// other, and its commit writes what a one-shot rewrite writes, whatever was laid out before. #[test] @@ -13610,6 +13706,87 @@ fn review_inside_scope_group_move_matches_direct_transform_under_outer_scale() { } } +/// Each `Tj`/`TJ` moves the text matrix by the width of its text. A run shown later in the same +/// text object without a `Td` or `Tm` of its own (a raised run in another colour, a column after +/// a `TJ` gap) is placed by those advances. Rewriting the paragraph before it must keep them, as +/// deleting a paragraph does: the run must not jump onto the new text. A kept run before the +/// paragraph splits the text object, and what reopens it keeps that run's advance too. +#[test] +fn rewriting_a_paragraph_keeps_later_runs_on_its_baseline_in_place() { + let cases = [ + ("BT /F1 12 Tf 72 700 Td (Target words) Tj 1 0 0 rg 3 Ts (Red) Tj ET", "Red"), + ("BT /F1 12 Tf 72 700 Td (Target words) Tj [-20000] TJ (Far) Tj ET", "Far"), + // Several dropped runs: each one's advance is kept. + ("BT /F1 12 Tf 72 700 Td (Target ) Tj (wor) Tj (ds) Tj [-20000] TJ (Far) Tj ET", "Far"), + // A kept run before the paragraph on its baseline: the text object is split there, and + // the pen it had reached is kept as well as the line matrix. + ("BT /F1 12 Tf 72 700 Td (Left) Tj [-20000] TJ (Target words) Tj [-20000] TJ (Far) Tj ET", "Far"), + ("BT /F1 12 Tf 72 700 Td (Left) Tj [-20000] TJ (Target ) Tj (words) Tj [-20000] TJ (Far) Tj ET", "Far"), + // The same with horizontal scaling and a later line placed from the line matrix. + ("BT /F1 12 Tf 80 Tz 14 TL 72 700 Td (Left) Tj [-20000] TJ (Target words) Tj [-20000] TJ (Far) Tj T* (Next) Tj ET", "Far"), + ("BT /F1 12 Tf 80 Tz 14 TL 72 700 Td (Left) Tj [-20000] TJ (Target words) Tj [-20000] TJ (Far) Tj T* (Next) Tj ET", "Next"), + // A `Q` puts back the text matrix its `q` saved: the `Tm` between them doesn't place `Far`. + ("BT /F1 12 Tf 72 700 Td (Target words) Tj q 1 0 0 1 300 300 Tm Q [-20000] TJ (Far) Tj ET", "Far"), + // A `Q` with nothing saved restores nothing: the advance is still kept. + ("BT /F1 12 Tf 72 700 Td (Target words) Tj Q [-20000] TJ (Far) Tj ET", "Far"), + ]; + for (content, run) in cases { + let original = text_page(content); + let place = |doc: &Document| text::text_lines(doc, 0).unwrap().into_iter().find(|l| l.text == run).map(|l| l.rect); + let before = place(&original).unwrap_or_else(|| panic!("{run} is a line of its own in {content}")); + let target = text::text_blocks(&original, 0).unwrap().iter().position(|b| b.text == "Target words").unwrap(); + for draft in ["NEW TEXT", "NEW\nTEXT"] { + let mut doc = original.clone(); + text::replace_block(&mut doc, 0, target, draft).unwrap(); + let after = place(&reopen(&doc)).unwrap_or_else(|| panic!("{run} is gone: {content} with {draft:?}")); + assert!(after.iter().zip(before).all(|(a, b)| (a - b).abs() < 1e-6), "{content} with {draft:?}: {before:?} -> {after:?}"); + } + } +} + +/// Vertical text advances down the column (ISO 32000-2 §9.4.4): runs dropped by a rewrite leave +/// that advance, so a run later in the same text object stays where it was. +#[test] +fn rewriting_vertical_runs_keeps_a_later_run_in_its_column_in_place() { + // The second with a horizontal scaling (`Tz`), which a vertical advance doesn't take. + for content in [ + "BT /V1 20 Tf 500 720 Td <0041> Tj <0042> Tj [-8000] TJ <0043> Tj ET", + "BT /V1 20 Tf 80 Tz 500 720 Td <0041> Tj <0042> Tj [-8000] TJ <0043> Tj ET", + ] { + vertical_runs_keep_a_later_run_in_place(content); + } +} + +fn vertical_runs_keep_a_later_run_in_place(content: &str) { + let original = vertical_page(content); + let place = |doc: &Document| text::text_lines(doc, 0).unwrap().into_iter().find(|l| l.text == "C").map(|l| l.rect); + let before = place(&original).unwrap_or_else(|| panic!("C is a line of its own: {:?}", text::text_lines(&original, 0))); + let target = text::text_blocks(&original, 0).unwrap().iter().position(|b| b.text == "AB").unwrap(); + for draft in [ + "D", "D +E", + ] { + let mut doc = original.clone(); + text::replace_block(&mut doc, 0, target, draft).unwrap(); + let after = place(&reopen(&doc)).unwrap_or_else(|| panic!("C is gone with {draft:?}")); + assert!(after.iter().zip(before).all(|(a, b)| (a - b).abs() < 1e-6), "{content} with {draft:?}: {before:?} -> {after:?}"); + } +} + +/// The advances read the glyphs without `/ActualText`, which can come to more text than the +/// reading the edit was laid out from. Past the page's text budget that way, no advance is kept +/// and the edit goes ahead, rather than refusing a page the reading allowed. +#[test] +fn dropped_advances_past_the_text_budget_keep_none_rather_than_refuse() { + let doc = text_page("BT /F1 12 Tf 72 700 Td (Drop) Tj [-20000] TJ (Far) Tj ET"); + let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops; + let drop: std::collections::HashSet = ops.iter().position(|o| o.is("Tj")).into_iter().collect(); + let page = pdfcraft_model::pages(&doc)[0].dict.clone(); + let res = page.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); + assert_eq!(text::dropped_advances_within(&doc, &ops, &res, &drop, None).len(), 1, "the advance Far relies on"); + assert!(text::dropped_advances_within(&doc, &ops, &res, &drop, Some(2)).is_empty(), "too much text to read: none"); +} + #[test] fn page_rules_refuse_unread_graphics_state() { let mut dict = Dict::new(); diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index ada90d8484..ca9632061a 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -55,9 +55,11 @@ use fallback::{ use fallback::{add_type3_font, is_type3_fallback}; pub(crate) use fallback::{glyph_path, type3_font_dict, unique_font_name}; use interp::{Origin, Paint, union}; +#[cfg(test)] +pub(crate) use lines::dropped_advances_within; pub(crate) use lines::{ - ContentStream, FormBudget, Read, content_streams, content_streams_within, dict_in_place, drawn_before_damage, first_op_after_damage, form_call, - joined, resource_dict, rewritten_contents, splice, + ContentStream, FormBudget, Read, content_streams, content_streams_within, dict_in_place, drawn_before_damage, dropped_advances, + dropped_side_effects, first_op_after_damage, form_call, joined, reopen_text_object, resource_dict, rewritten_contents, splice, }; pub use lines::{MAX_FORM_DEPTH, MAX_FORM_VISITS, glyph_text_lines, text_lines}; use lines::{page_dict, word_lines}; diff --git a/crates/edit/src/text/blocks.rs b/crates/edit/src/text/blocks.rs index b714bcd592..3ab0240f54 100644 --- a/crates/edit/src/text/blocks.rs +++ b/crates/edit/src/text/blocks.rs @@ -1,5 +1,5 @@ use super::interp::{Carry, interpret}; -use super::lines::{content_streams, joined, page_dict, reading_lines, rewritten_contents, splice, text_lines}; +use super::lines::{content_streams, joined, page_dict, reading_lines, rewritten_contents, show_side_effects, splice, text_lines}; use super::rewrite::{LineEdit, coincident_ops, rewrite_block}; use super::{Document, EditError, HashMap, Object, Op, TextLine, group_blocks, regions}; @@ -315,13 +315,7 @@ pub fn clear_block(doc: &mut Document, page: usize, block: usize) -> Result<(), } let mut replacement = Vec::new(); if let Some(op) = ops.get(s.op) { - if op.is("\"") { - replacement.push(Op::new("Tw", vec![op.operands.first().cloned().unwrap_or(Object::Int(0))])); - replacement.push(Op::new("Tc", vec![op.operands.get(1).cloned().unwrap_or(Object::Int(0))])); - } - if op.is("'") || op.is("\"") { - replacement.push(Op::new("T*", vec![])); - } + replacement.extend(show_side_effects(op)); } replacement.push(Op::new("TJ", vec![Object::Array(vec![pdfcraft_content::num(adjustment)])])); patches.insert(s.op, replacement); diff --git a/crates/edit/src/text/lines.rs b/crates/edit/src/text/lines.rs index 9eadb65107..e1c4b2d312 100644 --- a/crates/edit/src/text/lines.rs +++ b/crates/edit/src/text/lines.rs @@ -115,6 +115,146 @@ pub(crate) fn content_streams_within(doc: &Document, page: &Dict, budget: &mut D Ok(out) } +/// What a text-showing operator does besides showing text (ISO 32000-2 §9.4.3, Table 107): `'` +/// moves to the next line (`T*`), and `"` first sets the word and character spacing (`aw Tw`, +/// `ac Tc`). Text after it relies on both, so a dropped `'` or `"` leaves these in its place. +pub(crate) fn show_side_effects(op: &Op) -> Vec { + let mut out = Vec::new(); + if op.is("\"") { + out.extend(op.num(0).map(|aw| Op::new("Tw", vec![pdfcraft_content::num(aw)]))); + out.extend(op.num(1).map(|ac| Op::new("Tc", vec![pdfcraft_content::num(ac)]))); + } + if op.is("'") || op.is("\"") { + out.push(Op::new("T*", vec![])); + } + out +} + +/// For a paragraph or region rewrite: what dropped operator `i` must leave behind +/// ([`show_side_effects`]), so the text after it keeps its line and spacing. `split_at` is the +/// operator the text object was split at: the rewrite's `Tm` and state there already include +/// that operator's effects. +pub(crate) fn dropped_side_effects(ops: &[Op], i: usize, keep: bool, split_at: Option) -> Vec { + if keep || split_at == Some(i) { + return Vec::new(); + } + ops.get(i).map(show_side_effects).unwrap_or_default() +} + +/// For a paragraph or region rewrite that splits a text object: what reopens it after the +/// rewritten paragraph, so the text after it starts where it did. `tlm` and `tm` are the line and +/// text matrices where the paragraph began, and `size` and `scale` the font size (`Tf`) and +/// horizontal scaling (`Tz` / 100) in force there. A `Tm` restores the line matrix, which a later +/// `Td` or `T*` moves from; a kept run before the paragraph on the same baseline had already +/// advanced the pen beyond it (ISO 32000-2 §9.4.4), so a numeric `TJ` then moves the text matrix +/// alone by that advance. Only an advance along a horizontal baseline is expressible that way; +/// one that isn't (a vertical run's, or one at a zero size) leaves the pen at the line matrix. +pub(crate) fn reopen_text_object(tlm: [f64; 6], tm: [f64; 6], size: Option, scale: f64) -> Vec { + let n = pdfcraft_content::num; + let mut out = vec![Op::new("BT", vec![]), Op::new("Tm", tlm.iter().map(|v| n(*v)).collect())]; + // tm = [1 0 0 1 tx ty] x tlm: solve for the advance (tx, ty) in text space. + let (dx, dy) = (tm[4] - tlm[4], tm[5] - tlm[5]); + let det = tlm[0] * tlm[3] - tlm[1] * tlm[2]; + let tx = (dx * tlm[3] - dy * tlm[2]) / det; + let ty = (dy * tlm[0] - dx * tlm[1]) / det; + let denominator = size.unwrap_or(0.0) * scale; + let adjustment = -1000.0 * tx / denominator; + if tx.is_finite() && ty.is_finite() && tx.abs() > 1e-9 && ty.abs() <= 1e-9 && denominator != 0.0 && adjustment.is_finite() { + out.push(Op::new("TJ", vec![Object::Array(vec![n(adjustment)])])); + } + out +} + +/// For a paragraph or region rewrite: a numeric `TJ` per dropped show operator in `drop`, moving +/// the text matrix as far as that operator's text did without drawing anything (as +/// [`super::clear_block`] does). Text shown later in the same text object without its own `Td` +/// or `Tm`, such as a run in another style or column on the same baseline, then stays where it +/// was. Only an advance such a run relies on is kept: none when the text object ends, or its next +/// kept run starts a line of its own (`Td`, `TD`, `Tm`, `T*`, `'`, `"`), first. An advance a `TJ` +/// can't express (spacing at a zero font size) is left out. `resources` is the page's resource +/// dictionary. This reads the glyphs without `/ActualText`, which can come to more text than the +/// reading the edit was laid out from; a page whose text that way exceeds the page's text budget +/// keeps no advances rather than refusing an edit the reading allowed. +pub(crate) fn dropped_advances(doc: &Document, ops: &[Op], resources: &Dict, drop: &std::collections::HashSet) -> HashMap { + dropped_advances_within(doc, ops, resources, drop, None) +} + +/// [`dropped_advances`] with `text_left` in place of the page's text budget (tests make it small). +pub(crate) fn dropped_advances_within( + doc: &Document, + ops: &[Op], + resources: &Dict, + drop: &std::collections::HashSet, + text_left: Option, +) -> HashMap { + let mut out = HashMap::new(); + let mut carry = Carry::new(); + if let Some(left) = text_left { + carry.text_left = left; + } + let follows = kept_run_follows(ops, drop); + let Ok(shown) = interpret(doc, ops, resources, &mut HashMap::new(), &mut carry, None, None) else { + return out; + }; + for s in shown { + if !drop.contains(&s.op) || s.advance == 0.0 || !follows.get(s.op).copied().unwrap_or(false) { + continue; + } + let denominator = s.state.size * s.state.scale; + let adjustment = -1000.0 * s.advance / denominator; + if denominator != 0.0 && adjustment.is_finite() { + out.entry(s.op).or_insert_with(|| Op::new("TJ", vec![Object::Array(vec![pdfcraft_content::num(adjustment)])])); + } + } + out +} + +/// For each operator: whether a run shown after it in its text object, and not in `drop`, is +/// placed by the pen it left rather than by a line move of its own (see [`dropped_advances`]). +/// A `Q` puts back the text matrices its `q` saved (as [`interpret`] reads it), so a line move +/// between them doesn't count for the text after the `Q`. Leaving an advance in is harmless where +/// nothing turns out to use it. One pass from the end, so a page of many dropped runs stays linear. +fn kept_run_follows(ops: &[Op], drop: &std::collections::HashSet) -> Vec { + // The `q` and `Q` that pair up: [`interpret`] ignores a `Q` with nothing saved, so only a + // `Q` that restores something is a boundary here. + let mut paired = vec![false; ops.len()]; + let mut open: Vec = Vec::new(); + for (i, op) in ops.iter().enumerate() { + if op.is("q") { + open.push(i); + } else if op.is("Q") + && let Some(q) = open.pop() + { + for at in [q, i] { + if let Some(slot) = paired.get_mut(at) { + *slot = true; + } + } + } + } + let mut out = vec![false; ops.len()]; + let mut follows = false; + // What followed each `Q` still open (read backwards), restored at its `q`. + let mut after_restore: Vec = Vec::new(); + for (i, op) in ops.iter().enumerate().rev() { + if let Some(slot) = out.get_mut(i) { + *slot = follows; + } + let pairs = paired.get(i).copied().unwrap_or(false); + match op.op.as_slice() { + b"Q" if pairs => { + after_restore.push(follows); + follows = false; + } + b"q" if pairs => follows = follows || after_restore.pop().unwrap_or(false), + b"ET" | b"BT" | b"Td" | b"TD" | b"Tm" | b"T*" | b"'" | b"\"" => follows = false, + b"Tj" | b"TJ" if !drop.contains(&i) => follows = true, + _ => {} + } + } + out +} + /// The page's streams joined into the one stream they are: an operator may span two of them. pub(crate) fn joined(streams: &[ContentStream]) -> Pieces { Pieces::join(&streams.iter().map(|s| s.data.as_slice()).collect::>()) diff --git a/crates/edit/src/text/rewrite.rs b/crates/edit/src/text/rewrite.rs index c146ffcbc0..1681bc0016 100644 --- a/crates/edit/src/text/rewrite.rs +++ b/crates/edit/src/text/rewrite.rs @@ -4,7 +4,10 @@ use super::fallback::{ source_encodes, source_family, source_program_completes, source_show, source_space, source_width, type3_encode, type3_font, }; use super::interp::{Carry, Origin, TextState, interpret}; -use super::lines::{content_streams, joined, page_dict, rewritten_contents, splice, text_lines}; +use super::lines::{ + content_streams, dropped_advances, dropped_side_effects, joined, page_dict, reopen_text_object, rewritten_contents, show_side_effects, splice, + text_lines, +}; use super::{ Dict, Document, EditError, HashMap, Matrix, Metrics, Object, Op, PdfString, TextLine, TextSession, alignment_frame, group_blocks, is_centred, parse, rewrite_changed_words, rewrite_styled_block, @@ -68,16 +71,8 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi let reused = font.as_ref().filter(|m| source_encodes(m, &shown)); let mut substituted = None; let mut replacement: Vec = Vec::new(); - // ' and " also move to the next line; keep that. - match ops[first].op.as_slice() { - b"'" => replacement.push(Op::new("T*", vec![])), - b"\"" => { - replacement.push(Op::new("Tw", vec![ops[first].operands.first().cloned().unwrap_or(Object::Int(0))])); - replacement.push(Op::new("Tc", vec![ops[first].operands.get(1).cloned().unwrap_or(Object::Int(0))])); - replacement.push(Op::new("T*", vec![])); - } - _ => {} - } + // ' and " also move to the next line (and " sets the spacing); keep that. + replacement.extend(ops.get(first).map(show_side_effects).unwrap_or_default()); match reused { Some(metrics) => replacement.extend(source_show( metrics, @@ -134,8 +129,23 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi } } } - // Rebuild: the line's first operator becomes the replacement, its others go. - let new_data = splice(&pieces, &ops, |i| if i == first { (std::mem::take(&mut replacement), false) } else { (Vec::new(), !drops.contains(&i)) }); + // Rebuild: the line's first operator becomes the replacement, its others go. Each other + // dropped `'` or `"` (a later run, or a copy on the same baseline) leaves its line move and + // spacing behind, as the first one does inside the replacement, and a dropped copy its advance + // where text after it is placed by that (the line's own runs give way to the replacement's). + let mut advances = dropped_advances(doc, &ops, &res, &drops); + for i in &target.ops { + advances.remove(i); + } + let new_data = splice(&pieces, &ops, |i| { + if i == first { + return (std::mem::take(&mut replacement), false); + } + let keep = !drops.contains(&i); + let mut insert = dropped_side_effects(&ops, i, keep, None); + insert.extend(advances.remove(&i)); + (insert, keep) + }); let contents = rewritten_contents(doc, &streams, new_data)?; let page_ref = p.obj; if substituted.is_some() { @@ -995,16 +1005,18 @@ pub(crate) fn rewrite_block_from( }; let mut block_ops = block_ops(&plan, &writer, &wrapped, font.show_font.clone())?; let on_top = crate::lift_on_top(over, &mut block_ops); - let n = pdfcraft_content::num; 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", emit.tlm.iter().map(|v| n(*v)).collect())); + block_ops.extend(reopen_text_object(emit.tlm, emit.tm, after.font.as_ref().map(|(_, size)| *size), after.scale)); } + let mut advances = dropped_advances(doc, &ops, &res, &dropped); let new_data = splice(&pieces, &ops, |i| { - let insert = if i == at { std::mem::take(&mut block_ops) } else { Vec::new() }; - (insert, !dropped.contains(&i)) + let mut insert = if i == at { std::mem::take(&mut block_ops) } else { Vec::new() }; + let keep = !dropped.contains(&i); + insert.extend(dropped_side_effects(&ops, i, keep, split.then_some(start))); + insert.extend(advances.remove(&i)); + (insert, keep) }); let contents = rewritten_contents(doc, &streams, new_data)?; let new_font = !reuse; diff --git a/crates/edit/src/text_regions/ocr_words.rs b/crates/edit/src/text_regions/ocr_words.rs index d511ccdf47..518fa4a3c1 100644 --- a/crates/edit/src/text_regions/ocr_words.rs +++ b/crates/edit/src/text_regions/ocr_words.rs @@ -150,8 +150,8 @@ fn key(line: &TextLine) -> (&str, [i64; 4]) { } /// How far right a rewrite of the runs `indices` drew (`None` when it drew nothing), or an error -/// when it moved or lost any other text on the page: text positioned after the end of the text -/// before it, say, depends on the words that went. +/// when it moved or lost any other text on the page: text positioned after the end of the words +/// that went, say, when their advance couldn't be left behind (see `dropped_advances`). fn drawn_right(before: &[TextLine], indices: &[usize], after: &[TextLine]) -> Result, EditError> { let mut unmatched: HashMap<_, usize> = HashMap::new(); for line in before { diff --git a/crates/edit/src/text_regions/styled.rs b/crates/edit/src/text_regions/styled.rs index 9c2732c35f..6bb09b8891 100644 --- a/crates/edit/src/text_regions/styled.rs +++ b/crates/edit/src/text_regions/styled.rs @@ -1546,7 +1546,8 @@ fn push_row_break_marker(block_ops: &mut Vec, row_soft: &[bool], row_gap: &[ /// [`splice`] with the rewritten rows spliced in at `at`, and (when `marker` names one) a `/P` /// marker opened at its start and closed at its `next` line or, when nothing follows, after the /// paragraph's own last `ET` (appended past the stream's last operator when that close is at or -/// beyond `ops.len()`, since `splice`'s callback never visits that index). +/// beyond `ops.len()`, since `splice`'s callback never visits that index). A dropped operator +/// `i` leaves `left_behind(i, keep)` in its place: its line move, spacing and advance. fn splice_with_marker( pieces: &Pieces, ops: &[Op], @@ -1554,6 +1555,7 @@ fn splice_with_marker( at: usize, mut block_ops: Vec, drop: &std::collections::HashSet, + mut left_behind: impl FnMut(usize, bool) -> Vec, ) -> Vec> { let data = splice(pieces, ops, |i| { let mut insert = Vec::new(); @@ -1571,7 +1573,9 @@ fn splice_with_marker( if i == at { insert.append(&mut block_ops); } - (insert, !drop.contains(&i)) + let keep = !drop.contains(&i); + insert.extend(left_behind(i, keep)); + (insert, keep) }); if !marker.is_some_and(|(_, next, close)| next.is_none() && close >= ops.len()) { return data; @@ -1896,14 +1900,21 @@ pub(crate) fn rewrite_styled_block( block_ops.extend(after.ops()); 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())); + block_ops.extend(crate::text::reopen_text_object(o.tlm, o.tm, after.font.as_ref().map(|(_, size)| *size), after.scale)); } // A kept source row sits outside `block_ops`. Bound the whole paragraph when a marker is // safe. The close is not part of `block_ops`: that vector can end inside a text object // when the rewrite splits one, and a kept row's operators come later in the stream. let marker = if marked { None } else { paragraph_marker(block, lines, &ops) }; - let data = splice_with_marker(&pieces, &ops, marker, at, block_ops, &drop); + // A dropped `'` or `"` leaves its line move and spacing, and a dropped show its advance. + let mut advances = crate::text::dropped_advances(doc, &ops, &res, &drop); + let split_at = split.then_some(start); + let left_behind = |i: usize, keep: bool| { + let mut out = crate::text::dropped_side_effects(&ops, i, keep, split_at); + out.extend(advances.remove(&i)); + out + }; + let data = splice_with_marker(&pieces, &ops, marker, at, block_ops, &drop, left_behind); let contents = rewritten_contents(doc, &streams, data)?; res.set(b"Font".to_vec(), Object::Dict(fonts)); doc.update_dict(p.obj, |d| { diff --git a/crates/engine/tests/it/ocr_edit.rs b/crates/engine/tests/it/ocr_edit.rs index 078815e172..ee191add98 100644 --- a/crates/engine/tests/it/ocr_edit.rs +++ b/crates/engine/tests/it/ocr_edit.rs @@ -419,18 +419,21 @@ fn a_formatting_change_redraws_the_whole_ocr_phrase() { assert_eq!(drawn.join(" "), "John Smyth Example"); } -/// A layer whose words follow one another with no position of their own: a word that went would -/// move those after it, so keeping them isn't possible and the whole phrase is redrawn instead. +/// A layer whose words follow one another with no position of their own: a word that goes leaves +/// its advance behind (ISO 32000-2 §9.4.4), so the words after it stay where they were and are +/// kept as the scan, with nothing drawn. #[test] -fn words_that_depend_on_the_one_before_are_redrawn_rather_than_moved() { +fn words_that_depend_on_the_one_before_are_kept_in_place() { let bytes = under_scan("BT /F1 10 Tf 3 Tr 20 44 Td (John) Tj ( ) Tj (Smith) Tj ( ) Tj (Example) Tj ET"); let mut s = Session::new(); let id = s.open("relative.pdf", None, bytes, None).unwrap(); assert_eq!(texts(&s, id), ["John Smith Example"]); + let right = |s: &Session| s.get(id).unwrap().text_lines(0).iter().find(|l| l.text.contains("Example")).map(|l| l.rect[2]); + let before = right(&s).unwrap(); edit_block(&mut s, id, 0, "John Example"); let (scan, drawn) = scan_and_drawn(&s, id); - assert!(scan.is_empty(), "no word is left to shift under the scan: {scan:?}"); - assert_eq!(drawn, ["John Example"]); + assert_eq!((scan, drawn), (vec!["John".to_owned(), "Example".to_owned()], Vec::::new())); + assert_eq!(right(&s), Some(before), "Example stays where it was"); } /// The edit survives a save and reopen, and each piece can be edited again. @@ -460,19 +463,21 @@ fn a_draft_with_the_same_words_changes_nothing() { } } -/// Visible text positioned after the end of an OCR word depends on that word. An edit may not -/// leave the word gone and the visible text somewhere else while calling the rest kept. +/// Visible text positioned after the end of an OCR word depends on that word. The word that goes +/// leaves its advance behind, so the visible text stays where it was and the word kept stays the +/// scan's. #[test] -fn visible_text_that_follows_an_ocr_word_stops_it_being_kept_alone() { +fn visible_text_that_follows_a_removed_ocr_word_stays_in_place() { let bytes = under_scan("BT /F1 10 Tf 3 Tr 20 44 Td (John) Tj ( ) Tj (Smith) Tj 0 Tr /F1 14 Tf ( Label) Tj ET"); let mut s = Session::new(); let id = s.open("label.pdf", None, bytes, None).unwrap(); let label = |s: &Session| s.get(id).unwrap().text_lines(0).iter().find(|l| l.text.contains("Label")).map(|l| l.rect[0]); - assert_eq!(scan_and_drawn(&s, id).0, ["John Smith"]); + assert_eq!(scan_and_drawn(&s, id), (vec!["John Smith".to_owned()], vec!["Label".to_owned()])); let before = label(&s).unwrap(); edit_block(&mut s, id, 0, "John"); - let (scan, _) = scan_and_drawn(&s, id); - assert!(scan.is_empty(), "John can't stay alone when Label would move with it: {scan:?}, Label at {:?} from {before}", label(&s)); + // Nothing new is drawn: the visible text is the page's own. + assert_eq!(scan_and_drawn(&s, id), (vec!["John".to_owned()], vec!["Label".to_owned()])); + assert_eq!(label(&s), Some(before), "Label stays where it was"); } /// A large word right before a small one, as OCR's sizes can make them: the large word's cover,