Issue #1368: keep text a dragged paragraph passed over (#1381)

Rewriting a paragraph also removed every line overlapping its box by more
than half, to clear fake-bold and repeated copies. Each drag is its own
edit, so after dropping one paragraph on another and dragging it on, the
covered paragraph was taken for a copy and deleted from the content
stream. A copy now also has to show the same text.

The same check is used when replacing a line, so a distinct line drawn
close enough to overlap (#1316) is no longer removed either.
This commit is contained in:
Ridwan authored and GitHub committed 2026-10-12 07:21:23 -04:00
1 parent a0575f2478
commit 5bf6d17538
3 files changed
+32 -17

No files matched your search

+15
View File
@@ -1824,6 +1824,21 @@ fn a_line_drawn_twice_is_replaced_everywhere() {
assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::<Vec<_>>(), ["Hello there", "world"]);
}
#[test]
fn a_paragraph_dragged_across_another_leaves_it_alone() {
// #1368: each drag is its own edit. Dropping Alpha on Bravo, then dragging it on, treated
// Bravo (now under Alpha) as a copy of Alpha and deleted it.
let mut doc = text_page("BT /F1 12 Tf 100 700 Td (Alpha line) Tj ET BT /F1 12 Tf 100 500 Td (Bravo line) Tj ET");
let down = text::BlockStyle { offset: Some([0.0, -200.0]), ..Default::default() };
text::rewrite_block(&mut doc, 0, 0, None, &down).unwrap();
let alpha = text::text_blocks(&doc, 0).unwrap().iter().position(|b| b.text == "Alpha line").unwrap();
text::rewrite_block(&mut doc, 0, alpha, None, &down).unwrap();
let doc = reopen(&doc);
let mut texts: Vec<_> = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect();
texts.sort();
assert_eq!(texts, ["Alpha line", "Bravo line"]);
}
#[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";
+16 -15
View File
@@ -1434,7 +1434,7 @@ 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 in the same area go too, so the replacement is all that shows.
let drops = coincident_ops(&lines, std::slice::from_ref(&target.rect));
let drops = coincident_ops(&lines, &[&target]);
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;
@@ -1455,19 +1455,21 @@ fn font_size_before(ops: &[Op], at: usize) -> Option<f64> {
ops[..at].iter().rev().find(|o| o.is("Tf")).and_then(|o| o.num(1))
}
/// The operators of every line drawn in the same area as one of `rects`. Documents sometimes
/// The operators of every copy of one of `members`. Documents sometimes
/// draw a line more than once (fake bold, an invisible text layer), and a surviving copy would
/// show the old text under the replaced line. Copies overlap a member's rect by more than half of
/// the smaller rect; neighbouring lines share no area.
fn coincident_ops(lines: &[TextLine], rects: &[[f64; 4]]) -> std::collections::HashSet<usize> {
/// show the old text under the replaced line. Copies show the same text and overlap a member's
/// rect by more than half of the smaller rect; neighbouring lines share no area, and other text
/// that merely overlaps (a paragraph dragged across another, #1368) is not a copy.
fn coincident_ops(lines: &[TextLine], members: &[&TextLine]) -> std::collections::HashSet<usize> {
let area = |r: [f64; 4]| ((r[2] - r[0]) * (r[3] - r[1])).max(0.0);
let mut drop = std::collections::HashSet::new();
for l in lines {
let covered = rects.iter().any(|m| {
let ix = (m[2].min(l.rect[2]) - m[0].max(l.rect[0])).max(0.0);
let iy = (m[3].min(l.rect[3]) - m[1].max(l.rect[1])).max(0.0);
let small = area(*m).min(area(l.rect));
small > 0.0 && ix * iy / small > 0.5
let covered = members.iter().any(|m| {
let r = m.rect;
let ix = (r[2].min(l.rect[2]) - r[0].max(l.rect[0])).max(0.0);
let iy = (r[3].min(l.rect[3]) - r[1].max(l.rect[1])).max(0.0);
let small = area(r).min(area(l.rect));
small > 0.0 && ix * iy / small > 0.5 && l.text.trim() == m.text.trim()
});
if covered {
drop.extend(l.ops.iter().copied());
@@ -1489,9 +1491,9 @@ pub fn clear_block(doc: &mut Document, page: usize, block: usize) -> Result<(),
let lines = text_lines(doc, page)?;
let blocks = group_blocks(&lines);
let target = blocks.get(block).ok_or_else(|| EditError::Invalid("the paragraph no longer exists".into()))?;
let rects: Vec<_> = target.lines.iter().filter_map(|i| lines.get(*i)).map(|l| l.rect).collect();
let mut drops = coincident_ops(&lines, &rects);
drops.extend(target.lines.iter().filter_map(|i| lines.get(*i)).flat_map(|l| l.ops.iter().copied()));
let members: Vec<_> = target.lines.iter().filter_map(|i| lines.get(*i)).collect();
let mut drops = coincident_ops(&lines, &members);
drops.extend(members.iter().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();
@@ -1873,8 +1875,7 @@ pub(crate) fn rewrite_block_with_limit(
block_ops.push(Op::new("Q", vec![]));
// The paragraph's operators go, and so does anything drawn in the same area (a fake-bold
// second copy, an invisible text layer): the new text is all that may show.
let member_rects: Vec<[f64; 4]> = members.iter().map(|l| l.rect).collect();
let mut drop = coincident_ops(&lines, &member_rects);
let mut drop = coincident_ops(&lines, &members);
drop.extend(members.iter().flat_map(|l| l.ops.iter().copied()));
// 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.
+1 -2
View File
@@ -668,8 +668,7 @@ pub(super) fn rewrite_styled_block(
let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::<Vec<_>>());
let ops = pieces.parse().ops;
let members: Vec<_> = block.lines.iter().filter_map(|i| lines.get(*i)).collect();
let rects: Vec<_> = members.iter().map(|l| l.rect).collect();
let mut drop = coincident_ops(lines, &rects);
let mut drop = coincident_ops(lines, &members);
drop.extend(members.iter().flat_map(|l| l.ops.iter().copied()));
let o = &first.origin;
let start = first.ops.first().copied().unwrap_or(o.bt_op);