Merge PR #2558: fix(edit): emit rewritten paragraph after backgrounds between its rows (#2377)

This commit is contained in:
aiongg
2026-10-16 14:02:18 +00:00
3 changed files with 134 additions and 26 deletions
+100
View File
@@ -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<usize> = 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<usize> = 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()
);
}
+20 -12
View File
@@ -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))
+14 -14
View File
@@ -620,19 +620,20 @@ pub(super) fn rewrite_styled_block(
let kept_lines: std::collections::HashSet<usize> =
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)