From 279712769dc0b32141771d08dbc7fa4773d62a5d Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Thu, 15 Oct 2026 22:49:12 +0800 Subject: [PATCH] fix(edit): keep overlapping watermarks and distinct paragraphs on text edit (#1756) --- crates/edit/src/tests.rs | 66 ++++++++++++++++++++++++++++++++++++++++ crates/edit/src/text.rs | 13 ++++++-- 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index b542142df8..5b787f4239 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -2220,6 +2220,72 @@ fn replacing_a_line_keeps_a_distinct_nearby_line() { } } +#[test] +fn editing_text_keeps_an_overlapping_watermark_and_covered_paragraphs() { + let listed = |doc: &Document| text::text_lines(doc, 0).unwrap().into_iter().map(|l| l.text).collect::>(); + // #1756: a rotated watermark's bounding box covers the middle of the page, and a body line + // whose baseline happens to sit near the watermark's starting y is still unrelated text. + for rotation in [35.0, 45.0, 0.0] { + let mut base = text_page("BT /F1 14 Tf 60 700 Td (Heading) Tj ET"); + add_watermark( + &mut base, + &[0], + &Watermark { text: "TEST WATERMARK".into(), font_size: 48.0, rotation, opacity: 0.25, ..Watermark::default() }, + false, + ) + .unwrap(); + let wm_y = text::text_lines(&base, 0).unwrap().into_iter().find(|l| l.text == "TEST WATERMARK").unwrap().origin_baseline(); + let mut doc = text_page(&format!("BT /F1 14 Tf 1 0 0 1 60 {wm_y:.2} Tm (Body paragraph one with several words here) Tj ET")); + add_watermark( + &mut doc, + &[0], + &Watermark { text: "TEST WATERMARK".into(), font_size: 48.0, rotation, opacity: 0.25, ..Watermark::default() }, + false, + ) + .unwrap(); + let line_idx = text::text_lines(&doc, 0).unwrap().iter().position(|l| l.text.starts_with("Body")).unwrap(); + let mut line_edited = doc.clone(); + text::replace_line(&mut line_edited, 0, line_idx, "Edited body line").unwrap(); + assert_eq!(listed(&reopen(&line_edited)), ["Edited body line", "TEST WATERMARK"], "rotation {rotation}"); + + let block_idx = text::text_blocks(&doc, 0).unwrap().iter().position(|b| b.text.starts_with("Body")).unwrap(); + let mut preview = doc.clone(); + text::clear_block(&mut preview, 0, block_idx).unwrap(); + assert_eq!(listed(&preview), ["TEST WATERMARK"], "clear_block keeps the watermark at rotation {rotation}"); + + text::rewrite_block(&mut doc, 0, block_idx, Some("Edited body paragraph"), &text::BlockStyle::default()).unwrap(); + assert_eq!(listed(&reopen(&doc)), ["Edited body paragraph", "TEST WATERMARK"], "rotation {rotation}"); + } + + // #1368: dragging one paragraph onto another paragraph's baseline and moving it on again + // leaves the covered paragraph intact. + let mut doc = text_page("BT /F1 12 Tf 72 700 Td (First paragraph) Tj ET BT /F1 12 Tf 72 600 Td (Second paragraph) Tj ET"); + text::rewrite_block(&mut doc, 0, 0, None, &text::BlockStyle { offset: Some([0.0, -100.0]), ..Default::default() }).unwrap(); + let second = text::text_blocks(&doc, 0).unwrap().iter().position(|b| b.text == "Second paragraph"); + assert!(second.is_some(), "the covered paragraph stays on the page: {:?}", listed(&doc)); + let first = text::text_blocks(&doc, 0).unwrap().iter().position(|b| b.text == "First paragraph").unwrap(); + text::rewrite_block(&mut doc, 0, first, None, &text::BlockStyle { offset: Some([0.0, -100.0]), ..Default::default() }).unwrap(); + assert_eq!(listed(&reopen(&doc)), ["First paragraph", "Second paragraph"]); + + // Even when a large horizontal watermark shows the same word as a body line at the same y, + // or a rotated 12 pt line crosses a horizontal 12 pt line of the same word at the same origin, + // editing one keeps the other. + let mut same_word = text_page("BT /F1 12 Tf 1 0 0 1 200 400 Tm (DRAFT) Tj ET BT /F1 48 Tf 1 0 0 1 150 400 Tm (DRAFT) Tj ET"); + text::replace_line(&mut same_word, 0, 0, "FINAL").unwrap(); + let after = text::text_lines(&reopen(&same_word), 0).unwrap(); + assert_eq!(after.iter().map(|l| (&l.text[..], l.size as i32)).collect::>(), [("FINAL", 12), ("DRAFT", 48)]); + + let mut crossed = text_page("BT /F1 12 Tf 1 0 0 1 100 500 Tm (Crossed) Tj ET BT /F1 12 Tf 0.707 0.707 -0.707 0.707 100 500 Tm (Crossed) Tj ET"); + text::replace_line(&mut crossed, 0, 0, "Horizontal").unwrap(); + assert_eq!(listed(&reopen(&crossed)), ["Horizontal", "Crossed"]); + + // An invisible OCR text layer (`3 Tr`) on the same baseline and size as a visible line is + // still removed when the visible line is edited, even if OCR misread a letter. + let mut ocr = text_page("BT /F1 12 Tf 0 Tr 1 0 0 1 72 700 Tm (Hello world) Tj ET BT /F1 12 Tf 3 Tr 1 0 0 1 72 700 Tm (Rello world) Tj ET"); + text::replace_line(&mut ocr, 0, 0, "Goodbye world").unwrap(); + assert_eq!(listed(&reopen(&ocr)), ["Goodbye world"]); +} + #[test] fn paragraphs_move_and_rewrap_to_a_new_width() { let para = "BT /F1 10 Tf 12 TL 100 700 Td (One two three four five six seven) Tj T* (eight nine ten eleven twelve) Tj ET"; diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 9c83cf9a39..f7b8909bc2 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -1772,7 +1772,8 @@ 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. Copies of the // line drawn on the same baseline go too, so the replacement is all that shows. - let drops = coincident_ops(&lines, &[&target]); + let mut drops = coincident_ops(&lines, &[&target]); + drops.extend(target.ops.iter().copied()); let new_data = splice(&pieces, &ops, |i| if i == first { (std::mem::take(&mut replacement), false) } else { (Vec::new(), !drops.contains(&i)) }); let contents = rewritten_contents(doc, &streams, new_data); let page_ref = p.obj; @@ -1804,7 +1805,10 @@ fn coincident_ops(lines: &[TextLine], targets: &[&TextLine]) -> std::collections let area = |r: [f64; 4]| ((r[2] - r[0]) * (r[3] - r[1])).max(0.0); let mut drop = std::collections::HashSet::new(); for line in lines { - let copy = targets.iter().any(|target| overlaps_half(line.rect, target.rect, area) && on_same_baseline(line, target)); + let copy = targets.iter().any(|target| { + let same_text = line.text.trim() == target.text.trim() || (line.origin.state.render_mode == 3) != (target.origin.state.render_mode == 3); + same_text && overlaps_half(line.rect, target.rect, area) && on_same_baseline(line, target) + }); if copy { drop.extend(line.ops.iter().copied()); } @@ -1822,13 +1826,16 @@ fn overlaps_half(a: [f64; 4], b: [f64; 4], area: impl Fn([f64; 4]) -> f64) -> bo /// Perpendicular distance between baselines, in user space. Horizontal text compares height; /// turned text compares the distance across the writing direction. fn on_same_baseline(line: &TextLine, target: &TextLine) -> bool { + let (dir, target_dir) = (line.direction(), target.direction()); + let same_dir = (dir.0 - target_dir.0).abs() < 1e-3 && (dir.1 - target_dir.1).abs() < 1e-3; + let same_size = line.size.min(target.size) >= line.size.max(target.size) * 0.8; let angle = target.rotation().to_radians(); let (sin, cos) = if angle.is_finite() { angle.sin_cos() } else { (0.0, 1.0) }; let dx = line.origin.x - target.origin.x; let dy = line.origin.baseline - target.origin.baseline; let offset = dx.mul_add(-sin, dy * cos).abs(); let size = line.size.min(target.size).max(1.0); - offset.is_finite() && offset < size * 0.3 + same_dir && same_size && offset.is_finite() && offset < size * 0.3 } /// The paragraphs on a page (0-based).