Skip to content

Commit 30eddad

Browse files
fix(extractor): preserve tagged overlapping text order (#114)
1 parent 1b2e2c7 commit 30eddad

1 file changed

Lines changed: 235 additions & 9 deletions

File tree

src/extractor/mod.rs

Lines changed: 235 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,133 @@ fn effective_merge_width(item: &TextItem) -> f32 {
352352
}
353353
}
354354

355+
fn is_standalone_bullet_text(text: &str) -> bool {
356+
matches!(text.trim(), "•" | "○" | "●" | "◦")
357+
}
358+
359+
fn first_text_char(text: &str) -> Option<char> {
360+
text.trim_start().chars().next()
361+
}
362+
363+
fn is_short_alpha_fragment(text: &str) -> bool {
364+
let trimmed = text.trim();
365+
let char_count = trimmed.chars().count();
366+
(1..=4).contains(&char_count) && trimmed.chars().all(char::is_alphabetic)
367+
}
368+
369+
fn has_phrase_continuation_shape(text: &str) -> bool {
370+
let trimmed = text.trim_start();
371+
trimmed
372+
.chars()
373+
.take(24)
374+
.any(|ch| ch.is_whitespace() || matches!(ch, '-'))
375+
}
376+
377+
fn should_preserve_overlapping_stream_order(group: &[&TextItem]) -> bool {
378+
if group.len() < 3 {
379+
return false;
380+
}
381+
382+
let Some(first) = group.iter().find(|item| !item.text.trim().is_empty()) else {
383+
return false;
384+
};
385+
if group.iter().all(|item| item.mcid.is_none()) {
386+
return false;
387+
}
388+
389+
let mut nonempty_count = 0;
390+
let mut saw_backtrack = false;
391+
let mut nonspace_chars = 0;
392+
let mut math_symbol_chars = 0;
393+
let mut max_font_size = first.font_size;
394+
395+
for item in group {
396+
if !item.text.trim().is_empty() {
397+
nonempty_count += 1;
398+
}
399+
if (item.font_size - first.font_size).abs() > first.font_size * 0.25 {
400+
return false;
401+
}
402+
max_font_size = max_font_size.max(item.font_size);
403+
for ch in item.text.chars().filter(|ch| !ch.is_whitespace()) {
404+
nonspace_chars += 1;
405+
if matches!(
406+
ch,
407+
'*' | 'ˆ' | '^' | '=' | '+' | '_' | '[' | ']' | '{' | '}' | '|' | '<' | '>'
408+
) {
409+
math_symbol_chars += 1;
410+
}
411+
}
412+
}
413+
414+
if nonempty_count < 2 {
415+
return false;
416+
}
417+
if nonspace_chars > 0 && math_symbol_chars * 4 > nonspace_chars {
418+
return false;
419+
}
420+
421+
let mut sorted_by_x = group.to_vec();
422+
sorted_by_x.sort_by(|a, b| a.x.total_cmp(&b.x));
423+
let cluster_start = sorted_by_x[0].x;
424+
let mut cluster_end = cluster_start + effective_merge_width(sorted_by_x[0]);
425+
for item in sorted_by_x.iter().skip(1) {
426+
let gap = item.x - cluster_end;
427+
if gap > max_font_size * 2.5 {
428+
return false;
429+
}
430+
cluster_end = cluster_end.max(item.x + effective_merge_width(item));
431+
}
432+
if cluster_end - cluster_start > max_font_size * 36.0 {
433+
return false;
434+
}
435+
436+
for index in 0..group.len() - 1 {
437+
let previous = group[index];
438+
let next = group[index + 1];
439+
let font_size = previous.font_size.max(next.font_size);
440+
let backtrack_threshold = font_size * 0.25;
441+
let previous_start = previous.x;
442+
let next_start = next.x;
443+
let next_end = next.x + effective_merge_width(next);
444+
if next_start < previous_start - backtrack_threshold
445+
&& next_end > previous_start + backtrack_threshold
446+
{
447+
let has_near_prefix = group[..=index].iter().rev().take(4).any(|item| {
448+
is_short_alpha_fragment(&item.text)
449+
&& item.x >= next_start - font_size * 0.5
450+
&& item.x <= next_start + font_size * 4.0
451+
});
452+
let starts_lowercase = first_text_char(&next.text).is_some_and(char::is_lowercase);
453+
let phrase_continuation = has_phrase_continuation_shape(&next.text);
454+
let has_near_bullet = group[..=index]
455+
.iter()
456+
.position(|item| {
457+
is_standalone_bullet_text(&item.text) && next_start <= item.x + font_size * 3.0
458+
})
459+
.is_some_and(|bullet_index| {
460+
if bullet_index >= index {
461+
return false;
462+
}
463+
group[bullet_index + 1..=index]
464+
.iter()
465+
.rev()
466+
.find(|item| !item.text.trim().is_empty())
467+
.is_some_and(|item| {
468+
item.text.trim().chars().count() <= 8
469+
&& has_phrase_continuation_shape(&next.text)
470+
})
471+
});
472+
if (has_near_prefix && starts_lowercase && phrase_continuation) || has_near_bullet {
473+
saw_backtrack = true;
474+
break;
475+
}
476+
}
477+
}
478+
479+
saw_backtrack
480+
}
481+
355482
pub(crate) fn merge_text_items(items: Vec<TextItem>) -> Vec<TextItem> {
356483
if items.is_empty() {
357484
return items;
@@ -372,28 +499,32 @@ pub(crate) fn merge_text_items(items: Vec<TextItem>) -> Vec<TextItem> {
372499
}
373500
}
374501

375-
// Sort each group by X position (direction-aware)
376-
for (_, _, group) in &mut line_groups {
502+
let mut ordered_line_groups: Vec<(u32, f32, Vec<&TextItem>, bool)> = Vec::new();
503+
504+
// Sort each group by X position (direction-aware), except for lines whose
505+
// content stream intentionally backtracks to overlay ActualText fragments.
506+
for (page, y, mut group) in line_groups {
377507
let rtl = is_rtl_text(group.iter().map(|i| &i.text));
508+
let preserve_stream_order = !rtl && should_preserve_overlapping_stream_order(&group);
378509
if rtl {
379510
group.sort_by(|a, b| b.x.total_cmp(&a.x));
380-
} else {
511+
} else if !preserve_stream_order {
381512
group.sort_by(|a, b| a.x.total_cmp(&b.x));
382513
}
514+
ordered_line_groups.push((page, y, group, preserve_stream_order));
383515
}
384516

385517
// Sort groups by page then Y descending (top of page first)
386-
line_groups.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| b.1.total_cmp(&a.1)));
518+
ordered_line_groups.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| b.1.total_cmp(&a.1)));
387519

388520
let mut merged = Vec::new();
389521

390-
for (_, _, group) in &line_groups {
522+
for (_, _, group, preserve_stream_order) in &ordered_line_groups {
391523
let mut i = 0;
392524
while i < group.len() {
393525
let first = group[i];
394526
let mut text = first.text.clone();
395527
let mut end_x = first.x + effective_merge_width(first);
396-
let x_gap_max = first.font_size * 0.5;
397528

398529
let mut j = i + 1;
399530
while j < group.len() {
@@ -403,10 +534,15 @@ pub(crate) fn merge_text_items(items: Vec<TextItem>) -> Vec<TextItem> {
403534
break;
404535
}
405536
let gap = next.x - end_x;
537+
let x_gap_max = if *preserve_stream_order && is_standalone_bullet_text(&text) {
538+
first.font_size * 1.2
539+
} else {
540+
first.font_size * 0.5
541+
};
406542
if gap > x_gap_max {
407543
break;
408544
}
409-
if gap < -first.font_size * 0.5 {
545+
if gap < -first.font_size * 0.5 && !preserve_stream_order {
410546
break;
411547
}
412548
// Insert space at word boundaries.
@@ -428,11 +564,19 @@ pub(crate) fn merge_text_items(items: Vec<TextItem>) -> Vec<TextItem> {
428564
first.font_size * 0.08
429565
}
430566
};
431-
if gap > threshold {
567+
let needs_bullet_space = *preserve_stream_order
568+
&& is_standalone_bullet_text(&text)
569+
&& !next.text.trim().is_empty();
570+
if needs_bullet_space || gap > threshold {
432571
text.push(' ');
433572
}
434573
text.push_str(&next.text);
435-
end_x = next.x + effective_merge_width(next);
574+
let next_end = next.x + effective_merge_width(next);
575+
end_x = if *preserve_stream_order {
576+
end_x.max(next_end)
577+
} else {
578+
next_end
579+
};
436580
j += 1;
437581
}
438582

@@ -577,6 +721,11 @@ mod tests {
577721
}
578722
}
579723

724+
fn with_mcid(mut item: TextItem) -> TextItem {
725+
item.mcid = Some(1);
726+
item
727+
}
728+
580729
#[test]
581730
fn trace_text_preview_truncates_on_char_boundary() {
582731
let text = format!("{}{}tail", "a".repeat(79), '\u{FFFD}');
@@ -625,6 +774,83 @@ mod tests {
625774
assert_eq!(merged[0].text, "hello world");
626775
}
627776

777+
#[test]
778+
fn merge_items_preserves_stream_order_for_backtracking_heading() {
779+
// Some tagged PDFs emit first-letter ActualText fragments, then reset
780+
// the text matrix and draw the rest of the word from the line start.
781+
let items = vec![
782+
with_mcid(make_merge_item("F", 79.4, 4.5)),
783+
with_mcid(make_merge_item("r", 83.9, 3.3)),
784+
with_mcid(make_merge_item("om tables to data-", 79.4, 89.7)),
785+
with_mcid(make_merge_item("", 168.9, 33.9)),
786+
with_mcid(make_merge_item("analytics-", 168.9, 75.5)),
787+
with_mcid(make_merge_item("ready content", 210.5, 60.8)),
788+
];
789+
790+
let merged = merge_text_items(items);
791+
792+
assert_eq!(merged.len(), 1);
793+
assert_eq!(
794+
merged[0].text,
795+
"From tables to data-analytics-ready content"
796+
);
797+
}
798+
799+
#[test]
800+
fn merge_items_preserves_stream_order_for_reset_word_prefix() {
801+
let items = vec![
802+
with_mcid(make_merge_item("N", 68.0, 7.0)),
803+
with_mcid(make_merge_item("e", 75.1, 4.0)),
804+
with_mcid(make_merge_item("w fields created", 68.0, 82.0)),
805+
];
806+
807+
let merged = merge_text_items(items);
808+
809+
assert_eq!(merged.len(), 1);
810+
assert_eq!(merged[0].text, "New fields created");
811+
}
812+
813+
#[test]
814+
fn merge_items_uses_x_order_for_untagged_backtracking_text() {
815+
let items = vec![
816+
make_merge_item("N", 68.0, 7.0),
817+
make_merge_item("e", 75.1, 4.0),
818+
make_merge_item("w fields created", 68.2, 82.0),
819+
];
820+
821+
let merged = merge_text_items(items);
822+
823+
let texts: Vec<_> = merged.iter().map(|item| item.text.as_str()).collect();
824+
assert_eq!(texts, vec!["N", "w fields created", "e"]);
825+
}
826+
827+
#[test]
828+
fn merge_items_preserves_bullet_stream_order_with_backtracking() {
829+
let items = vec![
830+
with_mcid(make_merge_item("•", 79.4, 5.0)),
831+
with_mcid(make_merge_item("The MS", 91.0, 32.6)),
832+
with_mcid(make_merge_item("A LoS project", 84.4, 70.0)),
833+
];
834+
835+
let merged = merge_text_items(items);
836+
837+
assert_eq!(merged.len(), 1);
838+
assert_eq!(merged[0].text, "• The MSA LoS project");
839+
}
840+
841+
#[test]
842+
fn merge_items_keeps_normal_bullet_gap_limit_without_stream_order() {
843+
let items = vec![
844+
make_merge_item("•", 79.4, 5.0),
845+
make_merge_item("Distant item", 91.0, 60.0),
846+
];
847+
848+
let merged = merge_text_items(items);
849+
850+
let texts: Vec<_> = merged.iter().map(|item| item.text.as_str()).collect();
851+
assert_eq!(texts, vec!["•", "Distant item"]);
852+
}
853+
628854
#[test]
629855
fn test_group_into_lines() {
630856
let items = vec![

0 commit comments

Comments
 (0)