mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-02 16:54:19 +00:00
Keep lines with footnote references in paragraph reading order (#2929)
This commit is contained in:
1 parent
924c4da4ac
commit
22ec11cd28
1 file changed
+168
-9
+168
-9
@@ -945,6 +945,32 @@ struct SegExtra {
|
||||
more: Vec<Run>,
|
||||
}
|
||||
|
||||
/// Whether `(script_bbox, script_h)` is a superscript or subscript on `(host_bbox, host_h)`.
|
||||
fn is_script_on(host_bbox: &[f32; 4], host_h: f32, script_bbox: &[f32; 4], script_h: f32) -> bool {
|
||||
let host_cy = (host_bbox[1] + host_bbox[3]) / 2.0;
|
||||
let script_cy = (script_bbox[1] + script_bbox[3]) / 2.0;
|
||||
let script_w = script_bbox[2] - script_bbox[0];
|
||||
let host_w = host_bbox[2] - host_bbox[0];
|
||||
let smaller = script_h >= host_h * 0.45 && script_w < host_h * 3.0 && (script_h < host_h * 0.9 || (script_h <= host_h && host_w > script_w));
|
||||
if !smaller {
|
||||
return false;
|
||||
}
|
||||
if script_cy < host_cy {
|
||||
// Superscript: raised above the host baseline, with its bottom inside the host line.
|
||||
host_cy - script_cy >= host_h * 0.12
|
||||
&& host_cy - script_cy < host_h * 0.7
|
||||
&& host_bbox[3] - script_bbox[3] >= host_h * 0.15
|
||||
&& script_bbox[3] > host_bbox[1] + host_h * 0.25
|
||||
} else {
|
||||
// Subscript: lowered below the host baseline, with its centre still inside the host line.
|
||||
script_cy - host_cy >= host_h * 0.12
|
||||
&& script_cy - host_cy < host_h * 0.5
|
||||
&& script_bbox[3] > host_bbox[3]
|
||||
&& host_bbox[3] > script_bbox[1] + script_h * 0.5
|
||||
&& script_bbox[1] >= host_bbox[1] - script_h * 0.2
|
||||
}
|
||||
}
|
||||
|
||||
impl Seg {
|
||||
fn new(first: Run, bbox: [f32; 4], h: f32) -> Self {
|
||||
Seg { first, extra: None, bbox, h }
|
||||
@@ -958,9 +984,25 @@ impl Seg {
|
||||
std::iter::once(self.first).chain(self.extra.iter().flat_map(|e| e.more.iter().copied()))
|
||||
}
|
||||
|
||||
/// Extend `self`'s box and height with `(bbox, h)`. A smaller script (superscript or
|
||||
/// subscript) widens the line horizontally without pulling its vertical baseline band up or
|
||||
/// down; when a line begins with a script and continues with taller body text, the body text's
|
||||
/// vertical band becomes the line's.
|
||||
fn extend_box(&mut self, bbox: &[f32; 4], h: f32) {
|
||||
if h > self.h * 1.15 || is_script_on(bbox, h, &self.bbox, self.h) {
|
||||
self.bbox[1] = bbox[1];
|
||||
self.bbox[3] = bbox[3];
|
||||
} else if !(self.h > h * 1.15 || is_script_on(&self.bbox, self.h, bbox, h)) {
|
||||
self.bbox[1] = self.bbox[1].min(bbox[1]);
|
||||
self.bbox[3] = self.bbox[3].max(bbox[3]);
|
||||
}
|
||||
self.bbox[0] = self.bbox[0].min(bbox[0]);
|
||||
self.bbox[2] = self.bbox[2].max(bbox[2]);
|
||||
self.h = self.h.max(h);
|
||||
}
|
||||
|
||||
fn merge(&mut self, other: Seg) {
|
||||
union(&mut self.bbox, &other.bbox);
|
||||
self.h = self.h.max(other.h);
|
||||
self.extend_box(&other.bbox, other.h);
|
||||
let extra = self.extra.get_or_insert_with(Default::default);
|
||||
extra.more.push(other.first);
|
||||
if let Some(o) = other.extra {
|
||||
@@ -1177,8 +1219,10 @@ fn collide(glyphs: &[&TextGlyph], height: &dyn Fn(usize) -> f32, h: f32, sc: &mu
|
||||
}
|
||||
budget -= 1;
|
||||
let (ra, rb) = (union_find(&mut sc.parent, ri), union_find(&mut sc.parent, rj));
|
||||
let hj = height(j);
|
||||
let script = is_script_on(&glyphs[i].rect, hi, &glyphs[j].rect, hj) || is_script_on(&glyphs[j].rect, hj, &glyphs[i].rect, hi);
|
||||
let overlap = x1.min(glyphs[j].rect[2]) - x0(j);
|
||||
if ra != rb && overlap > RUN_COLLISION * hi.min(height(j)) {
|
||||
if ra != rb && !script && overlap > RUN_COLLISION * hi.min(hj) {
|
||||
sc.parent[ra] = rb;
|
||||
collided = true;
|
||||
}
|
||||
@@ -1341,11 +1385,16 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec<usize>, Vec<u32>, Vec<bool>) {
|
||||
let mut furthest_progress = 0.0;
|
||||
for i in 0..n {
|
||||
let g = glyphs[i].rect;
|
||||
let hi = height(i);
|
||||
let cont = segs.last().is_some_and(|s| {
|
||||
let Some(p) = s.first.end.checked_sub(1) else { return false };
|
||||
let ph = height(p).min(height(i));
|
||||
let ph = height(p).min(hi);
|
||||
let sh = s.h.min(hi);
|
||||
let gap = g[0] - glyphs[p].rect[2];
|
||||
(cy(i) - cy(p)).abs() < ph * 0.5 && gap < ph * 3.0 && g[0] > glyphs[p].rect[0] - ph * 2.0
|
||||
let same_line = ((cy(i) - cy(p)).abs() < ph * 0.5 && gap < ph * 3.0)
|
||||
|| ((cy(i) - s.cy()).abs() < sh * 0.3 && (s.h / hi - 1.0).abs() < 0.35 && gap < sh * 3.0);
|
||||
let on_seg = gap < s.h * 1.2 && (is_script_on(&s.bbox, s.h, &g, hi) || is_script_on(&g, hi, &s.bbox, s.h));
|
||||
(same_line || on_seg) && g[0] > glyphs[p].rect[0] - sh * 2.0
|
||||
});
|
||||
let reset = cont && segs.last().is_some_and(|s| restarts_run(&glyphs, s.first.start, i, furthest_progress));
|
||||
if cont
|
||||
@@ -1353,11 +1402,10 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec<usize>, Vec<u32>, Vec<bool>) {
|
||||
&& let Some(s) = segs.last_mut()
|
||||
{
|
||||
s.first.end = i + 1;
|
||||
union(&mut s.bbox, &g);
|
||||
s.h = s.h.max(height(i));
|
||||
s.extend_box(&g, hi);
|
||||
furthest_progress = furthest_progress.max((g[0] - run_start_x).abs()).max((g[2] - run_start_x).abs());
|
||||
} else {
|
||||
segs.push(Seg::new(Run { start: i, end: i + 1 }, g, height(i)));
|
||||
segs.push(Seg::new(Run { start: i, end: i + 1 }, g, hi));
|
||||
run_start_x = g[0];
|
||||
furthest_progress = 0.0;
|
||||
}
|
||||
@@ -1406,7 +1454,8 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec<usize>, Vec<u32>, Vec<bool>) {
|
||||
kept_runs
|
||||
};
|
||||
segs.truncate(retained);
|
||||
// Merge same-baseline segments that nearly touch.
|
||||
// Merge same-baseline segments that nearly touch, then attach any remaining superscript or
|
||||
// subscript segments to the closest host line segment they sit on.
|
||||
let mut merged = true;
|
||||
while merged {
|
||||
merged = false;
|
||||
@@ -1427,6 +1476,28 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec<usize>, Vec<u32>, Vec<bool>) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if !merged {
|
||||
for b in 0..segs.len() {
|
||||
let sb = &segs[b];
|
||||
let best_host = (0..segs.len())
|
||||
.filter(|&a| {
|
||||
if a == b {
|
||||
return false;
|
||||
}
|
||||
let sa = &segs[a];
|
||||
let gap = (sb.bbox[0] - sa.bbox[2]).max(sa.bbox[0] - sb.bbox[2]);
|
||||
sa.bbox[2] - sa.bbox[0] > sb.bbox[2] - sb.bbox[0] && gap < sa.h * 1.2 && is_script_on(&sa.bbox, sa.h, &sb.bbox, sb.h)
|
||||
})
|
||||
.min_by(|&a1, &a2| (segs[a1].cy() - sb.cy()).abs().total_cmp(&(segs[a2].cy() - sb.cy()).abs()));
|
||||
if let Some(a) = best_host {
|
||||
let sb = segs.remove(b);
|
||||
let a = if b < a { a - 1 } else { a };
|
||||
segs[a].merge(sb);
|
||||
merged = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Blocks.
|
||||
@@ -2646,4 +2717,92 @@ mod tests {
|
||||
assert_eq!(ocr_layer_text(4, false), expected, "symbolic, no /ToUnicode");
|
||||
assert_eq!(ocr_layer_text(32, true), expected, "nonsymbolic, /ToUnicode for ASCII only");
|
||||
}
|
||||
|
||||
/// Issue #2929: a footnote reference (superscript) on a line stays in that line instead of
|
||||
/// splitting out as a raised segment that steals the block's tail and pushes the host line
|
||||
/// after the paragraph.
|
||||
#[test]
|
||||
fn paragraph_selection_includes_lines_with_footnote_references() {
|
||||
for inline in [true, false] {
|
||||
let ops = if inline {
|
||||
"BT /F1 12 Tf 1 0 0 1 40 300 Tm (First line of the paragraph) Tj \
|
||||
1 0 0 1 40 285 Tm (Second line with a note) Tj \
|
||||
/F1 8 Tf 5.5 Ts (1) Tj 0 Ts \
|
||||
/F1 12 Tf ( and more text) Tj \
|
||||
1 0 0 1 40 270 Tm (Third line of the paragraph) Tj \
|
||||
/F1 7 Tf 1 0 0 1 40 104 Tm (1) Tj \
|
||||
/F1 10 Tf 1 0 0 1 47 100 Tm (Footnote at the bottom) Tj ET"
|
||||
} else {
|
||||
// Superscripts drawn in a separate pass after the body text.
|
||||
"BT /F1 12 Tf 1 0 0 1 40 300 Tm (First line of the paragraph) Tj \
|
||||
1 0 0 1 40 285 Tm (Second line with a note) Tj \
|
||||
1 0 0 1 173 285 Tm (and more text) Tj \
|
||||
1 0 0 1 40 270 Tm (Third line of the paragraph) Tj \
|
||||
/F1 10 Tf 1 0 0 1 47 100 Tm (Footnote at the bottom) Tj \
|
||||
/F1 8 Tf 1 0 0 1 165 290.5 Tm (1) Tj \
|
||||
/F1 7 Tf 1 0 0 1 40 104 Tm (1) Tj ET"
|
||||
};
|
||||
let text = page_glyphs(ops);
|
||||
let start = text.nearest(40.0, 95.0).unwrap();
|
||||
let end = text.nearest(185.0, 128.0).unwrap();
|
||||
assert_eq!(
|
||||
text.text_of(start..end + 1),
|
||||
"First line of the paragraph\nSecond line with a note1 and more text\nThird line of the paragraph",
|
||||
"inline={inline}"
|
||||
);
|
||||
assert_eq!(text.line_rects(start..end + 1).len(), 3, "three selected lines, inline={inline}");
|
||||
assert_eq!(
|
||||
text.plain_text(),
|
||||
"First line of the paragraph\nSecond line with a note1 and more text\nThird line of the paragraph\n1 Footnote at the bottom",
|
||||
"inline={inline}"
|
||||
);
|
||||
}
|
||||
|
||||
// Superscript on the first line, multi-character bracketed reference `[12]` on the second
|
||||
// line, and subscript on the third line (both inline and out-of-order).
|
||||
for inline in [true, false] {
|
||||
let ops = if inline {
|
||||
"BT /F1 12 Tf 1 0 0 1 40 300 Tm (Opening claim) Tj \
|
||||
/F1 8 Tf 5.5 Ts (1) Tj 0 Ts \
|
||||
/F1 12 Tf ( in the first line) Tj \
|
||||
1 0 0 1 40 285 Tm (Middle citation) Tj \
|
||||
/F1 8 Tf 5.5 Ts ([12]) Tj 0 Ts \
|
||||
/F1 12 Tf ( continues here) Tj \
|
||||
1 0 0 1 40 270 Tm (Formula H) Tj \
|
||||
/F1 8 Tf -3 Ts (2) Tj 0 Ts \
|
||||
/F1 12 Tf (O in the third line) Tj ET"
|
||||
} else {
|
||||
"BT /F1 12 Tf 1 0 0 1 40 300 Tm (Opening claim) Tj \
|
||||
1 0 0 1 125 300 Tm (in the first line) Tj \
|
||||
1 0 0 1 40 285 Tm (Middle citation) Tj \
|
||||
1 0 0 1 135 285 Tm (continues here) Tj \
|
||||
1 0 0 1 40 270 Tm (Formula H) Tj \
|
||||
1 0 0 1 101 270 Tm (O in the third line) Tj \
|
||||
/F1 8 Tf 1 0 0 1 117 305.5 Tm (1) Tj \
|
||||
1 0 0 1 117 290.5 Tm ([12]) Tj \
|
||||
1 0 0 1 96.5 267 Tm (2) Tj ET"
|
||||
};
|
||||
let text = page_glyphs(ops);
|
||||
assert_eq!(
|
||||
text.plain_text(),
|
||||
"Opening claim1 in the first line\nMiddle citation[12] continues here\nFormula H2O in the third line",
|
||||
"inline={inline}"
|
||||
);
|
||||
assert_eq!(text.line_rects(0..text.glyphs.len()).len(), 3, "inline={inline}");
|
||||
}
|
||||
|
||||
// Justified wide gap (18pt = 1.5 * h) after an inline superscript stays on the same line,
|
||||
// and an out-of-order superscript kerned slightly over punctuation does not break the host
|
||||
// word via overprint collision.
|
||||
let justified = page_glyphs(
|
||||
"BT /F1 12 Tf 1 0 0 1 40 300 Tm (Justified clause.) Tj \
|
||||
/F1 8 Tf 5.5 Ts (1) Tj 0 Ts \
|
||||
/F1 12 Tf 1 0 0 1 151 300 Tm (Next sentence on the same line) Tj \
|
||||
1 0 0 1 40 285 Tm (Kerned clause.) Tj \
|
||||
1 0 0 1 130 285 Tm (continues cleanly) Tj \
|
||||
/F1 8 Tf 1 0 0 1 118 290.5 Tm (2) Tj ET",
|
||||
);
|
||||
assert_eq!(justified.plain_text(), "Justified clause.1 Next sentence on the same line\nKerned clause.2 continues cleanly");
|
||||
assert_eq!(justified.line_rects(0..justified.glyphs.len()).len(), 2);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user