Skip to content

Commit 89a05db

Browse files
tobocop2yfedoseev
andauthored
fix: stop merging widely-spaced subscript digits into decimals (#816) (#817)
Subscript index pairs like `P_{1,0}` in scientific and math PDFs were extracting as `P1.0`. The decimal-merge rule joins two adjacent pure-digit runs with a `.` — a heuristic meant for split-box dollar amounts where the whole part and cents print in separate fixed-width boxes (`123456` + `72` -> `123456.72`). The rule's upper gap bound was too permissive. Real split-box amounts sit ~0.8-1.0x the font size apart, but subscript index digits are a smaller font spaced ~1.5-1.7x apart, so the old 2.0x ceiling let the rule fire and invent a decimal the document never contained. Tighten the ceiling to 1.3x the font size, which separates genuine integer/cents boxes from widely spaced subscripts. Adds a unit test covering the subscript spacing; the existing split-box dollar-amount tests still pass. Signed-off-by: Tobias Perelstein <5562156+tobocop2@users.noreply.github.com> Co-authored-by: Yury F. <yfedoseev@gmail.com>
1 parent 6e8eda4 commit 89a05db

1 file changed

Lines changed: 166 additions & 1 deletion

File tree

src/extractors/text.rs

Lines changed: 166 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4646,11 +4646,20 @@ impl<'doc> TextExtractor<'doc> {
46464646
// gaps was being mangled into "201.3", losing the year token from
46474647
// word-F1 scoring. Real "$123 _ 45" split-box layouts always have
46484648
// a gap > ~half the font size; tight letter spacing is < 0.1 em.
4649+
//
4650+
// The gap also needs an upper ceiling. In scientific and math
4651+
// PDFs, subscript index pairs like `P_{1,0}` draw the two subscript
4652+
// digits in a smaller font (~7pt) spaced ~1.5-1.7x the font size
4653+
// apart; too loose a ceiling lets the rule fire and invent a
4654+
// decimal ("1" + "0" -> "1.0"). Genuine split-box amounts cluster
4655+
// near ~0.8-1.0x the font size, so a 1.3x ceiling separates real
4656+
// integer/cents boxes from widely-spaced subscripts.
46494657
let min_decimal_gap = current.font_size * 0.4;
4658+
let max_decimal_gap = current.font_size * 1.3;
46504659
let decimal_merge = same_line
46514660
&& same_mcid
46524661
&& gap > min_decimal_gap
4653-
&& gap < current.font_size * 2.0
4662+
&& gap < max_decimal_gap
46544663
&& !current.text.is_empty()
46554664
&& !span.text.is_empty()
46564665
&& current.text.chars().all(|c| c.is_ascii_digit())
@@ -16387,6 +16396,162 @@ mod profile_based_space_tests {
1638716396
);
1638816397
}
1638916398

16399+
#[test]
16400+
fn test_no_decimal_merge_for_wide_subscript_digits() {
16401+
// Subscript index pairs (e.g. `P_{1,0}` in scientific PDFs) draw the
16402+
// two subscript digits in a smaller font (~7pt) spaced far apart
16403+
// (~1.5-1.7x the font size). The decimal-merge rule was joining them
16404+
// into an invented decimal ("1" + "0" -> "1.0"). A real split-box
16405+
// dollar amount clusters near ~0.8-1.0x the font size, so a wide gap is
16406+
// not an integer/cents amount and must stay separate.
16407+
let mut extractor = TextExtractor::new();
16408+
extractor.merging_config = SpanMergingConfig::legacy();
16409+
16410+
// 7pt subscript digits: "1" at x=100.0 (w=3.5), "0" at x=114.5 (w=3.5).
16411+
// gap = 114.5 - (100.0 + 3.5) = 11.0pt -> 11.0 / 7.0 = 1.57x font size.
16412+
extractor.spans = vec![
16413+
TextSpan {
16414+
text_rise: 0.0,
16415+
artifact_type: None,
16416+
text: "1".to_string(),
16417+
bbox: Rect::new(100.0, 700.0, 3.5, 7.0),
16418+
font_name: "F1".to_string(),
16419+
font_size: 7.0,
16420+
font_weight: FontWeight::Normal,
16421+
color: Color::black(),
16422+
mcid: None,
16423+
mcid_scope: None,
16424+
sequence: 0,
16425+
split_boundary_before: false,
16426+
offset_semantic: false,
16427+
is_italic: false,
16428+
is_monospace: false,
16429+
char_spacing: 0.0,
16430+
word_spacing: 0.0,
16431+
horizontal_scaling: 100.0,
16432+
primary_detected: false,
16433+
char_widths: vec![],
16434+
char_x_offsets: Vec::new(),
16435+
heading_level: None,
16436+
rotation_degrees: 0.0,
16437+
wmode: 0,
16438+
rtl_draw_logical: false,
16439+
},
16440+
TextSpan {
16441+
text_rise: 0.0,
16442+
artifact_type: None,
16443+
text: "0".to_string(),
16444+
bbox: Rect::new(114.5, 700.0, 3.5, 7.0), // 11.0pt gap = 1.57x font
16445+
font_name: "F1".to_string(),
16446+
font_size: 7.0,
16447+
font_weight: FontWeight::Normal,
16448+
color: Color::black(),
16449+
mcid: None,
16450+
mcid_scope: None,
16451+
sequence: 1,
16452+
split_boundary_before: false,
16453+
offset_semantic: false,
16454+
is_italic: false,
16455+
is_monospace: false,
16456+
char_spacing: 0.0,
16457+
word_spacing: 0.0,
16458+
horizontal_scaling: 100.0,
16459+
primary_detected: false,
16460+
char_widths: vec![],
16461+
char_x_offsets: Vec::new(),
16462+
heading_level: None,
16463+
rotation_degrees: 0.0,
16464+
wmode: 0,
16465+
rtl_draw_logical: false,
16466+
},
16467+
];
16468+
16469+
extractor.merge_adjacent_spans();
16470+
// Widely-spaced subscript digits must NOT be joined into a decimal.
16471+
assert_eq!(
16472+
extractor.spans.len(),
16473+
2,
16474+
"Widely-spaced subscript digits should not merge into a decimal value"
16475+
);
16476+
assert!(
16477+
!extractor.spans.iter().any(|s| s.text.contains('.')),
16478+
"No invented decimal point should appear between subscript digits"
16479+
);
16480+
}
16481+
16482+
#[test]
16483+
fn test_decimal_merge_just_under_ceiling_still_joins() {
16484+
// The ceiling that stops subscripts must not be so tight that it drops
16485+
// genuine split-box amounts. Real amounts cluster near ~0.8-1.0x the
16486+
// font size; this locks the ceiling by proving an amount at ~1.2x the
16487+
// font size (just under the 1.3x cap) still merges.
16488+
let mut extractor = TextExtractor::new();
16489+
extractor.merging_config = SpanMergingConfig::legacy();
16490+
16491+
// 12pt digits: "1234" at x=200.0 (w=24.0), "56" at x=238.4 (w=12.0).
16492+
// gap = 238.4 - (200.0 + 24.0) = 14.4pt -> 14.4 / 12.0 = 1.2x font size.
16493+
extractor.spans = vec![
16494+
TextSpan {
16495+
text_rise: 0.0,
16496+
artifact_type: None,
16497+
text: "1234".to_string(),
16498+
bbox: Rect::new(200.0, 700.0, 24.0, 12.0),
16499+
font_name: "F1".to_string(),
16500+
font_size: 12.0,
16501+
font_weight: FontWeight::Normal,
16502+
color: Color::black(),
16503+
mcid: None,
16504+
mcid_scope: None,
16505+
sequence: 0,
16506+
split_boundary_before: false,
16507+
offset_semantic: false,
16508+
is_italic: false,
16509+
is_monospace: false,
16510+
char_spacing: 0.0,
16511+
word_spacing: 0.0,
16512+
horizontal_scaling: 100.0,
16513+
primary_detected: false,
16514+
char_widths: vec![],
16515+
char_x_offsets: Vec::new(),
16516+
heading_level: None,
16517+
rotation_degrees: 0.0,
16518+
wmode: 0,
16519+
rtl_draw_logical: false,
16520+
},
16521+
TextSpan {
16522+
text_rise: 0.0,
16523+
artifact_type: None,
16524+
text: "56".to_string(),
16525+
bbox: Rect::new(238.4, 700.0, 12.0, 12.0), // 14.4pt gap = 1.2x font
16526+
font_name: "F1".to_string(),
16527+
font_size: 12.0,
16528+
font_weight: FontWeight::Normal,
16529+
color: Color::black(),
16530+
mcid: None,
16531+
mcid_scope: None,
16532+
sequence: 1,
16533+
split_boundary_before: false,
16534+
offset_semantic: false,
16535+
is_italic: false,
16536+
is_monospace: false,
16537+
char_spacing: 0.0,
16538+
word_spacing: 0.0,
16539+
horizontal_scaling: 100.0,
16540+
primary_detected: false,
16541+
char_widths: vec![],
16542+
char_x_offsets: Vec::new(),
16543+
heading_level: None,
16544+
rotation_degrees: 0.0,
16545+
wmode: 0,
16546+
rtl_draw_logical: false,
16547+
},
16548+
];
16549+
16550+
extractor.merge_adjacent_spans();
16551+
assert_eq!(extractor.spans.len(), 1, "Amount just under the ceiling should still merge");
16552+
assert_eq!(extractor.spans[0].text, "1234.56");
16553+
}
16554+
1639016555
#[test]
1639116556
fn test_cross_font_word_glue_single_letter_prefix() {
1639216557
// A single-letter span in one font, tight-kerned against a

0 commit comments

Comments
 (0)