@@ -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