diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 5b787f4239..9e30796034 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -4315,3 +4315,103 @@ fn japanese_paragraphs_wrap_between_characters_without_closing_marks_at_a_row_st } } } + +#[test] +fn rewritten_paragraph_is_drawn_after_backgrounds_between_its_rows() { + // Each row has its own background box drawn immediately before its BT..ET, as cards and + // highlighted headings exported from LibreOffice or browsers do (#2377). + let card = "0 0.45 0.25 rg 60 696 240 16 re f BT /F1 10 Tf 1 1 1 rg 64 700 Td (SEMINARIO ONLINE) Tj ET \ + 0 0.45 0.25 rg 60 668 240 24 re f BT /F1 16 Tf 1 1 1 rg 64 674 Td (Dalla teoria alla pratica:) Tj ET \ + 0 0.45 0.25 rg 60 632 240 32 re f BT /F1 24 Tf 1 1 1 rg 64 640 Td (gestire con metodo) Tj ET \ + 0 0.45 0.25 rg 60 596 240 32 re f BT /F1 24 Tf 1 1 1 rg 64 604 Td (un progetto complesso) Tj ET"; + let shown_at = |ops: &[pdfcraft_content::Op], needle: &str| { + ops.iter() + .position(|op| { + matches!(op.op.as_slice(), b"Tj" | b"TJ") + && op.operands.iter().any(|o| matches!(o, Object::String(s) if String::from_utf8_lossy(&s.bytes).contains(needle))) + }) + .unwrap() + }; + + // 1. Editing the title rows emits all rewritten rows after all four background fills. + let mut doc = text_page(card); + assert_eq!(text::text_blocks(&doc, 0).unwrap().len(), 1); + let edited = "SEMINARIO ONLINE\nDalla teoria alla pratica:\ngestire con criterio\nun progetto nuovo"; + text::rewrite_block(&mut doc, 0, 0, Some(edited), &text::BlockStyle::default()).unwrap(); + let doc = reopen(&doc); + assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].editor_text, edited); + let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops; + let fills: Vec = ops.iter().enumerate().filter_map(|(i, op)| op.is("f").then_some(i)).collect(); + assert_eq!(fills.len(), 4); + assert!( + shown_at(&ops, "criterio") > fills[3] && shown_at(&ops, "nuovo") > fills[3], + "rewritten title rows must be drawn after their row background fills (fills at {fills:?}): {ops:?}" + ); + + // 2. Partial edit with a kept suffix row: rewritten rows 0..=2 are drawn after fills[0..=2] + // and still before kept row 3, preserving reading order. + let mut doc = text_page(card); + let partial = "WORKSHOP ONLINE\nDalla teoria alla pratica:\ngestire con criterio\nun progetto complesso"; + text::rewrite_block(&mut doc, 0, 0, Some(partial), &text::BlockStyle::default()).unwrap(); + let doc = reopen(&doc); + assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].editor_text, partial); + let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops; + let fills: Vec = ops.iter().enumerate().filter_map(|(i, op)| op.is("f").then_some(i)).collect(); + assert!( + shown_at(&ops, "WORKSHOP") > fills[2] + && shown_at(&ops, "pratica:") > fills[2] + && shown_at(&ops, "criterio") > fills[2] + && shown_at(&ops, "criterio") < shown_at(&ops, "complesso") + && shown_at(&ops, "complesso") > fills[3], + "rewritten prefix rows must follow their fills and precede the kept final row" + ); + + // 3. Editing row 2 so that it wraps pushes row 3 down; all rewritten rows must follow fills[3]. + let mut doc = text_page(card); + let wrapped_edit = "SEMINARIO ONLINE\nDalla teoria alla pratica:\ngestire con criterio e metodo approfondito\nun progetto complesso"; + text::rewrite_block(&mut doc, 0, 0, Some(wrapped_edit), &text::BlockStyle::default()).unwrap(); + let doc = reopen(&doc); + let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops; + let last_fill = ops.iter().rposition(|op| op.is("f")).unwrap(); + assert!( + shown_at(&ops, "approfondito") > last_fill && shown_at(&ops, "complesso") > last_fill, + "wrapped rows and pushed-down suffix row must follow all row background fills" + ); + + // 4. Single-style paragraph path across a CTM change between rows, with movement, underline, + // and a trailing unrelated line sharing the second row's BT..ET. + let cross_ctm = "0.9 0.9 0.2 rg 60 696 240 18 re f BT /F1 12 Tf 0 0 0 rg 64 700 Td (First highlighted row) Tj ET \ + q 1 0 0 1 10 12 cm 0.9 0.9 0.2 rg 50 668 240 18 re f \ + BT /F1 12 Tf 0 0 0 rg 54 672 Td (Second highlighted row) Tj 246 -40 Td (Side footer) Tj ET Q"; + let mut doc = text_page(cross_ctm); + let para = text::text_blocks(&doc, 0).unwrap().into_iter().position(|b| b.text.starts_with("First highlighted")).unwrap(); + assert_eq!(text::text_blocks(&doc, 0).unwrap()[para].rows.len(), 2); + text::rewrite_block( + &mut doc, + 0, + para, + Some("Updated first highlighted row and updated second highlighted row"), + &text::BlockStyle { offset: Some([6.0, -4.0]), underline: Some(true), ..Default::default() }, + ) + .unwrap(); + let doc = reopen(&doc); + let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops; + let last_fill = ops.iter().rposition(|op| op.is("f")).unwrap(); + assert!( + shown_at(&ops, "Updated first") > last_fill && shown_at(&ops, "Side footer") > shown_at(&ops, "Updated first"), + "cross-CTM single-style rewrite must follow both background fills and precede the shared BT's trailing line" + ); + let lines = text::text_lines(&doc, 0).unwrap(); + let updated_first = lines.iter().find(|l| l.text.starts_with("Updated first")).unwrap(); + let side_footer = lines.iter().find(|l| l.text == "Side footer").unwrap(); + assert!( + (updated_first.baseline()[0] - 70.0).abs() < 0.01 && (updated_first.baseline()[1] - 696.0).abs() < 0.01, + "offset baseline in user space across CTM: {:?}", + updated_first.baseline() + ); + assert!( + (side_footer.baseline()[0] - 310.0).abs() < 0.01 && (side_footer.baseline()[1] - 644.0).abs() < 0.01, + "trailing line in shared BT keeps its tlm across CTM: {:?}", + side_footer.baseline() + ); +} diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index f7b8909bc2..5f17b8c2fd 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -2067,6 +2067,8 @@ fn rewrite_block_from( // The characters to show, for choosing fonts: the breaks are moves, not glyphs. let flat = text.replace('\n', " "); let o = &first.origin; + let last = members.last().copied().unwrap_or(first); + let emit = &last.origin; let (font_name, old_size) = o.state.font.clone().ok_or_else(|| EditError::Invalid("the paragraph has no font".into()))?; let k = o.k.max(1e-6); // The size in text space: points รท the text-to-user scale. @@ -2127,7 +2129,7 @@ fn rewrite_block_from( // paragraph leaves the document as it was. The move is in page space, so it is taken back through // the CTM's linear part into the space the paragraph is drawn in. let movement = if dx != 0.0 || dy != 0.0 { - let c = o.ctm; + let c = emit.ctm; let back = Matrix([c[0], c[1], c[2], c[3], 0.0, 0.0]) .invert() .ok_or_else(|| EditError::Invalid("the paragraph is drawn in a space it can't be moved in".into()))?; @@ -2135,6 +2137,12 @@ fn rewrite_block_from( } else { None }; + let tm = if o.ctm == emit.ctm { + o.tm + } else { + let emit_inv = Matrix(emit.ctm).invert().ok_or_else(|| EditError::Invalid("the paragraph has a singular transform".into()))?; + Matrix(o.tm).then(&Matrix(o.ctm)).then(&emit_inv).0 + }; let type3 = if !reuse && needs_type3(&flat) { Some(type3_font(doc, &mut fonts_res, &flat, family, bold, max_glyphs)?) } else { None }; let advance = |s: &str| -> f64 { let t = match (&metrics, reuse) { @@ -2212,7 +2220,7 @@ fn rewrite_block_from( let n = pdfcraft_content::num; // In its own graphics state, so a new colour (or anything else) stops at the paragraph. let mut block_ops = vec![Op::new("q", vec![])]; - block_ops.extend(reveal_ops(&b, &lines, style, o.ctm)?); + block_ops.extend(reveal_ops(&b, &lines, style, emit.ctm)?); // Moved: a translation inside that state. if let Some((ax, ay)) = movement { block_ops.push(Op::new("cm", vec![n(1.0), n(0.0), n(0.0), n(1.0), n(ax), n(ay)])); @@ -2227,7 +2235,7 @@ fn rewrite_block_from( state.fill = vec![Op::new("rg", vec![n(r), n(g), n(bl)])]; } block_ops.extend(state.ops()); - block_ops.push(Op::new("Tm", o.tm.iter().map(|v| n(*v)).collect())); + block_ops.push(Op::new("Tm", tm.iter().map(|v| n(*v)).collect())); // Alignment: each line's offset from the left edge, in text space. let offset = |line: &str| -> f64 { let free = (width - advance(line)).max(0.0) / k; @@ -2310,7 +2318,7 @@ fn rewrite_block_from( }) .collect(); block_ops.push(Op::new("q", vec![])); - block_ops.push(Op::new("cm", o.tm.iter().map(|v| n(*v)).collect())); + block_ops.push(Op::new("cm", tm.iter().map(|v| n(*v)).collect())); block_ops.extend(colour); block_ops.push(Op::new("w", vec![n(size * 0.06)])); for (x0, x1, y) in underlines { @@ -2325,24 +2333,24 @@ fn rewrite_block_from( // second copy, an invisible text layer): the new text is all that may show. 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. - let start = first.ops.first().copied().unwrap_or(o.bt_op); + // Text shown earlier in the same text object stays first: the paragraph then goes where its + // last row was, after any backgrounds between the rows, or between two halves of the text object. + let start = last.ops.first().copied().unwrap_or(emit.bt_op); let split = ops - .get(o.bt_op..start) + .get(emit.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))); + .any(|(i, op)| matches!(op.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"") && !drop.contains(&(emit.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 }; + let after = if split { &emit.state } else { &emit.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())); + block_ops.push(Op::new("Tm", emit.tlm.iter().map(|v| n(*v)).collect())); } - let at = if split { start } else { o.bt_op }; + let at = if split { start } else { emit.bt_op }; let new_data = splice(&pieces, &ops, |i| { let insert = if i == at { std::mem::take(&mut block_ops) } else { Vec::new() }; (insert, !drop.contains(&i)) diff --git a/crates/edit/src/text_regions.rs b/crates/edit/src/text_regions.rs index 2d7baf519f..9e0ba2802e 100644 --- a/crates/edit/src/text_regions.rs +++ b/crates/edit/src/text_regions.rs @@ -620,19 +620,20 @@ pub(super) fn rewrite_styled_block( let kept_lines: std::collections::HashSet = kept.iter().enumerate().filter(|(_, k)| **k).filter_map(|(r, _)| block.rows.get(r)).flatten().copied().collect(); let rewritten: Vec<&TextLine> = block.lines.iter().filter(|i| !kept_lines.contains(i)).filter_map(|i| lines.get(*i)).collect(); - // The rewrite goes where the first rewritten row was drawn, so it must share the paragraph's - // space. A dropped `'` or `"` also moved to the next line, which a kept row relies on. - let lead = rewritten.first().copied().filter(|lead| { - lead.origin.ctm.iter().zip(first.origin.ctm).all(|(a, b)| (a - b).abs() < 1e-9) - && !rewritten.iter().flat_map(|l| &l.ops).any(|i| ops.get(*i).is_some_and(|op| op.is("'") || op.is("\""))) - }); + // The rewrite goes where the last rewritten row was drawn, after any backgrounds painted + // between the rows. A dropped `'` or `"` also moved to the next line, which a kept row relies on. + let lead = + rewritten.last().copied().filter(|_| !rewritten.iter().flat_map(|l| &l.ops).any(|i| ops.get(*i).is_some_and(|op| op.is("'") || op.is("\"")))); let (members, lead) = match lead { Some(lead) if !kept_lines.is_empty() => (rewritten, lead), _ => { kept.iter_mut().for_each(|k| *k = false); - (block.lines.iter().filter_map(|i| lines.get(*i)).collect(), first) + let all: Vec<&TextLine> = block.lines.iter().filter_map(|i| lines.get(*i)).collect(); + let last = all.last().copied().unwrap_or(first); + (all, last) } }; + let o = &lead.origin; for (row, _) in rows.iter().zip(&kept).filter(|(_, k)| !**k) { let text: String = row.iter().map(|v| v.0).collect(); if pdfcraft_fonts::source_shaping::required(&text) && row.first().is_some_and(|first| row.iter().any(|v| v.1 != first.1 || v.2 != first.2)) { @@ -668,8 +669,8 @@ pub(super) fn rewrite_styled_block( } let n = pdfcraft_content::num; let mut block_ops = vec![Op::new("q", vec![])]; - block_ops.extend(reveal_ops(block, lines, style, first.origin.ctm)?); - let back = Matrix([first.origin.ctm[0], first.origin.ctm[1], first.origin.ctm[2], first.origin.ctm[3], 0.0, 0.0]) + block_ops.extend(reveal_ops(block, lines, style, o.ctm)?); + let back = Matrix([o.ctm[0], o.ctm[1], o.ctm[2], o.ctm[3], 0.0, 0.0]) .invert() .ok_or_else(|| EditError::Invalid("the paragraph is drawn in a space it cannot be edited in".into()))?; let (tx, ty) = back.apply(dx, dy); @@ -741,7 +742,7 @@ pub(super) fn rewrite_styled_block( if let Some([r, g, b]) = overrides.get(&item.1).and_then(|r| r.color).or(style.color) { state.fill = vec![Op::new("rg", vec![n(r), n(g), n(b)])]; } - let inverse = Matrix(first.origin.ctm).invert().ok_or_else(|| EditError::Invalid("the paragraph has a singular transform".into()))?; + let inverse = Matrix(o.ctm).invert().ok_or_else(|| EditError::Invalid("the paragraph has a singular transform".into()))?; let mut tm = Matrix(source.origin.tm).then(&Matrix(source.origin.ctm)).then(&inverse).0; let (ax, ay) = back.apply(x - source.origin.x, baseline - source.origin.baseline); tm[4] += ax; @@ -790,10 +791,10 @@ pub(super) fn rewrite_styled_block( if underlines.get(&item.1).copied().or(style.underline) == Some(true) { let [r, g, b] = overrides.get(&item.1).and_then(|r| r.color).or(style.color).unwrap_or(source.color); let underline_size = size_of(source, item.1) * source.origin.k; - let (x0, y0) = back.apply(x - first.origin.ctm[4], baseline - first.origin.ctm[5] - underline_size * 0.12); - let (x1, y1) = back.apply(x + segment_width - first.origin.ctm[4], baseline - first.origin.ctm[5] - underline_size * 0.12); + let (x0, y0) = back.apply(x - o.ctm[4], baseline - o.ctm[5] - underline_size * 0.12); + let (x1, y1) = back.apply(x + segment_width - o.ctm[4], baseline - o.ctm[5] - underline_size * 0.12); block_ops.push(Op::new("RG", vec![n(r), n(g), n(b)])); - block_ops.push(Op::new("w", vec![n(underline_size * 0.06 / first.origin.ctm[2].hypot(first.origin.ctm[3]).max(1e-6))])); + block_ops.push(Op::new("w", vec![n(underline_size * 0.06 / o.ctm[2].hypot(o.ctm[3]).max(1e-6))])); block_ops.push(Op::new("m", vec![n(x0), n(y0)])); block_ops.push(Op::new("l", vec![n(x1), n(y1)])); block_ops.push(Op::new("S", vec![])); @@ -810,7 +811,6 @@ pub(super) fn rewrite_styled_block( block_ops.push(Op::new("Q", vec![])); let mut drop = coincident_ops(lines, &members); drop.extend(members.iter().flat_map(|l| l.ops.iter().copied())); - let o = &lead.origin; let start = lead.ops.first().copied().unwrap_or(o.bt_op); let split = ops .get(o.bt_op..start)