mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-11 16:36:13 -06:00
This commit is contained in:
@@ -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
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user