Text extraction: word gaps follow position on the line, not drawing order (#1062) (#1074)

A line's glyphs are sorted left to right, but the space for a word gap was put before whichever of the two glyphs came later in the content stream. When the right-hand run was drawn first, the space landed inside the left run: 20.0 19.4 came out as "20. 019.4". The same rule put the space one letter early between right-to-left words drawn left to right.

Gaps are now marked by position on the line and reversed together with right-to-left runs.
This commit is contained in:
pauliusiv123-rgb authored and GitHub committed 2026-10-11 11:53:16 -04:00
1 parent 2316a8c419
commit 4666e74691
1 file changed
+33 -20
+33 -20
View File
@@ -819,7 +819,23 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec<usize>, Vec<u32>, Vec<bool>) {
for si in &blocks[b].0 {
let s = &segs[*si];
let mut idx = s.idx.clone();
// Reverse right-to-left runs (visual → logical).
// Word gaps relative to this line's typical letter gap (handles tracking).
let mut gaps: Vec<f32> = s.idx.windows(2).map(|w| glyphs[w[1]].rect[0] - glyphs[w[0]].rect[2]).collect();
gaps.sort_by(f32::total_cmp);
let typical = gaps.get(gaps.len() / 3).copied().unwrap_or(0.0).max(0.0);
let threshold = (typical + s.h * WORD_GAP).max(s.h * WORD_GAP);
// `spaces[w]`: a word gap left of the glyph at `idx[w]`, by position on the line, not
// by drawing order (#1062).
let mut spaces = vec![false; idx.len()];
for (space, pair) in spaces.iter_mut().skip(1).zip(s.idx.windows(2)) {
let &[p, c] = pair else { continue };
let gap = glyphs[c].rect[0] - glyphs[p].rect[2];
let (pt, ct) = (&glyphs[p].text, &glyphs[c].text);
let cjk = pt.chars().any(is_cjk) && ct.chars().any(is_cjk) && gap < s.h * 0.5;
let tight_cluster = (pt.chars().any(is_complex) || ct.chars().any(is_complex)) && gap < s.h * 0.6;
*space = gap > threshold && !cjk && !tight_cluster && !pt.trim().is_empty() && !ct.trim().is_empty();
}
// Reverse right-to-left runs (visual → logical), and the gaps between their glyphs with them.
let rtl = |i: usize| glyphs[i].text.chars().any(is_rtl);
let mut k = 0;
while k < idx.len() {
@@ -831,30 +847,12 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec<usize>, Vec<u32>, Vec<bool>) {
e += 1;
}
idx[k..=e].reverse();
spaces[k + 1..=e].reverse();
k = e + 1;
} else {
k += 1;
}
}
// Word gaps relative to this line's typical letter gap (handles tracking).
let mut gaps: Vec<f32> = s.idx.windows(2).map(|w| glyphs[w[1]].rect[0] - glyphs[w[0]].rect[2]).collect();
gaps.sort_by(f32::total_cmp);
let typical = gaps.get(gaps.len() / 3).copied().unwrap_or(0.0).max(0.0);
let threshold = (typical + s.h * WORD_GAP).max(s.h * WORD_GAP);
let mut spaces = vec![false; idx.len()];
for w in 1..s.idx.len() {
let (p, c) = (s.idx[w - 1], s.idx[w]);
let gap = glyphs[c].rect[0] - glyphs[p].rect[2];
let (pt, ct) = (&glyphs[p].text, &glyphs[c].text);
let cjk = pt.chars().any(is_cjk) && ct.chars().any(is_cjk) && gap < s.h * 0.5;
let tight_cluster = (pt.chars().any(is_complex) || ct.chars().any(is_complex)) && gap < s.h * 0.6;
if gap > threshold && !cjk && !tight_cluster && !pt.trim().is_empty() && !ct.trim().is_empty() {
// Mark the space before glyph `c` wherever it ended up after RTL reversal.
if let Some(pos) = idx.iter().position(|x| *x == c.max(p)) {
spaces[pos] = true;
}
}
}
for (k, g) in idx.iter().enumerate() {
order.push(*g);
line_of.push(line);
@@ -1158,6 +1156,21 @@ mod tests {
assert_eq!(layout(v).plain_text(), "每个字");
}
/// #1062: the word gap stays between the runs when the right-hand run is drawn first, and
/// between the words of a right-to-left line.
#[test]
fn word_gaps_follow_position_not_drawing_order() {
let mut v = Vec::new();
word(&mut v, "19.4", 40.0, 10.0, 6.0);
word(&mut v, "20.0", 10.0, 10.0, 6.0);
assert_eq!(layout(v).plain_text(), "20.0 19.4");
let mut v = Vec::new();
// "שלום עולם" drawn visually: עולם reversed on the left, שלום reversed on the right.
word(&mut v, "םלוע", 10.0, 10.0, 6.0);
word(&mut v, "םולש", 40.0, 10.0, 6.0);
assert_eq!(layout(v).plain_text(), "שלום עולם");
}
/// Two lines, "ab cd" and "e fg": words stop at blank glyphs, word gaps and line ends.
#[test]
fn words_and_lines_around_a_glyph() {