fix(edit): keep the overlap guard for the wider footnote-marker tolerance

CodeRabbit review on #3471: the widened baseline tolerance (half the
larger run's size, for a same-row footnote marker) could also let two
genuinely stacked rows of ordinary body text join into one row, if
horizontal compression (Tz) narrows the first row enough to put the
second row's start within it — a negative `gap` inside the existing
overlap allowance, with a baseline shift inside the new 50% band but
outside the original 20% one.

Only take the wider tolerance when the two runs don't overlap
horizontally (`gap >= 0.0`), which is true for a footnote marker drawn
right after the text it follows. Overlapping runs keep the original,
tighter 20% tolerance.

Added stacked_rows_that_merely_overlap_do_not_join_into_one_row,
confirmed it fails without this change (merges into one row/block) and
passes with it. cargo test -p pdfcraft-edit (197 passed), fmt, clippy
-D warnings, xtask layers and xtask assets all clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Michael
2026-10-20 01:48:31 +08:00
parent 2f70006d60
commit fe2ece4675
2 changed files with 24 additions and 2 deletions
+18
View File
@@ -5235,3 +5235,21 @@ fn a_clipping_copy_on_the_same_baseline_is_refused() {
assert!(matches!(&block, Err(EditError::Invalid(m)) if m.contains("clipping path")), "{block:?}");
assert_eq!(text::text_lines(&doc, 0).unwrap().len(), 2, "refused means untouched");
}
/// Two genuinely stacked rows of ordinary body text must not join into one row just because the
/// first is narrow enough to put the second's start within the #3252 tolerance band: here the
/// second row starts 1.4pt to the left of where the first ends (so the two overlap a little,
/// as compressed text or a tight column can) and 5pt below its baseline — inside the widened 50%
/// tolerance but outside the original 20% one. They stay two rows (and two paragraphs).
#[test]
fn stacked_rows_that_merely_overlap_do_not_join_into_one_row() {
let doc = text_page("BT /F1 12 Tf 72 700 Td (A compressed line of body text here) Tj 190 -5 Td (Second stacked line of body text) Tj ET");
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(lines.len(), 2);
let gap = lines[1].rect[0] - lines[0].rect[2];
assert!((-2.4..0.0).contains(&gap), "the rows overlap a little, within the old tolerance band: {gap}");
let baseline_shift = lines[0].rect[3] - lines[1].rect[3];
assert!((2.4..6.0).contains(&baseline_shift), "between the old and new vertical tolerance: {baseline_shift}");
let blocks = text::text_blocks(&doc, 0).unwrap();
assert_eq!(blocks.len(), 2, "two separate rows stay two paragraphs, not one: {blocks:?}");
}
+6 -2
View File
@@ -74,8 +74,12 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec<TextBlock> {
// A superscript or subscript run (a footnote marker, say) sits on the same visual
// row as the text around it, raised or lowered by `Ts` from the body baseline
// (#3252). That shift is typically up to half the body size, not the marker's own
// smaller size, so the tolerance is scaled by the larger of the two.
&& (prev.frame_origin().1 - line.frame_origin().1).abs() < prev.size.max(line.size) * 0.5
// smaller size — but only between runs with no horizontal overlap (`gap >= 0.0`):
// two stacked rows of ordinary body text can land within that wider tolerance too
// when horizontal compression (`Tz`) narrows the first row enough to overlap the
// second's start, and the tighter 20% tolerance is what keeps those apart.
&& ((prev.frame_origin().1 - line.frame_origin().1).abs() < prev.size.min(line.size) * 0.2
|| (gap >= 0.0 && (prev.frame_origin().1 - line.frame_origin().1).abs() < prev.size.max(line.size) * 0.5))
&& gap >= -prev.size.min(line.size) * 0.2
&& gap < prev.size.max(line.size) * 1.5
// Separate same-style text objects that merely touch or overlap, with no space