fix(fonts): CID-as-Unicode passthrough and subscript merging (#8)

* fix(fonts): CID-as-Unicode passthrough and subscript/superscript merging

Add smart CID-as-Unicode passthrough for Identity-H fonts without
ToUnicode maps. Uses /W array median CID heuristic to distinguish
Unicode-CID PDFs (Chromium-generated) from GID-based subsets.

Add merge_subscript_items() pass that merges small-font items (<75%
of dominant font size, ≤4 chars, tightly adjacent) into parent items.
Fixes chemical formulas (NH3, H2O, KClO3), footnote references, and
subscript notation (vf, Hfg, m3/kg) that were previously orphaned
as separate text items.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(subscripts): restrict merge to purely numeric text only

Tighten subscript merging to only merge items containing ASCII digits
(0-9). This avoids false positives with ordinal indicators (º), letter
subscripts (sol, vf), and small bullet characters (▶) that caused
table restructuring regressions.

Numeric-only keeps the primary wins: chemical formulas (NH3, H2O),
footnote references, and unit notation (m2, m3).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(subscripts): restrict merge to parent text ending with a letter

Only merge numeric subscripts when the parent item's text ends with
an alphabetic character. Prevents false merges like "33" + "1" in
fractions (33 1/3%), table credit numbers after spaces, and footnote
refs after punctuation (land.1 → land. 1). Chemical formulas (NH3,
H2O, KClO3) still merge correctly since parent ends with a letter.

Reduces pdf-eval regressions from 13 to 2 (both are correct reversions
of over-aggressive footnote merging from the prior commit).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Abimael Martell
2026-03-23 09:41:10 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 7f2995ad0c
commit 6c005ef4c2
4 changed files with 339 additions and 24 deletions
+203
View File
@@ -363,6 +363,92 @@ pub(crate) fn merge_text_items(items: Vec<TextItem>) -> Vec<TextItem> {
merged
}
/// Merge subscript/superscript items into their adjacent parent items.
///
/// Subscripts (e.g. "2" in H₂O) are rendered as separate text items with a
/// much smaller font size and a slight Y offset. This pass finds such items
/// and absorbs them into the preceding normal-sized item so that downstream
/// table detection and line grouping see complete text (e.g. "H2O" not "H"+"2"+"O").
pub(crate) fn merge_subscript_items(items: Vec<TextItem>) -> Vec<TextItem> {
if items.len() < 2 {
return items;
}
// Group items by (page, approximate Y) with generous tolerance to capture
// both the parent line and the subscript/superscript offset.
let y_tolerance = 5.0;
let mut line_groups: Vec<(u32, f32, Vec<TextItem>)> = Vec::new();
for item in items {
let found = line_groups
.iter_mut()
.find(|(pg, y, _)| *pg == item.page && (item.y - *y).abs() < y_tolerance);
if let Some((_, _, group)) = found {
group.push(item);
} else {
let page = item.page;
let y = item.y;
line_groups.push((page, y, vec![item]));
}
}
let mut result = Vec::new();
for (_, _, mut group) in line_groups {
// Sort by X position
group.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap_or(std::cmp::Ordering::Equal));
// Find the dominant (most common) font size in this group
let max_fs = group.iter().map(|i| i.font_size).fold(0.0_f32, f32::max);
if max_fs < 1.0 {
result.extend(group);
continue;
}
let sub_threshold = max_fs * 0.75;
// Walk through items and merge subscripts into their preceding parent
let mut merged: Vec<TextItem> = Vec::new();
for item in group {
if item.font_size < sub_threshold
&& item.font_size > 0.0
&& item.text.len() <= 4
&& item.text.chars().all(|c| c.is_ascii_digit())
{
// This is a candidate numeric subscript/superscript (e.g. "2" in H₂O).
// Only merge purely numeric text to avoid false positives with small
// bullets, ordinal indicators, or letter-based labels.
if let Some(parent) = merged.last_mut() {
// Only merge into a parent that is normal-sized, not another subscript,
// and whose text ends with a letter. This prevents merging into numbers
// (e.g. "33" + "1" in "33 1/3%") or punctuation, while preserving
// chemical formulas (NH + "3") and footnote refs (word + "2").
let ends_with_letter = parent
.text
.chars()
.last()
.is_some_and(|c| c.is_alphabetic());
if parent.font_size >= sub_threshold && ends_with_letter {
let parent_right = parent.x + parent.width;
let gap = item.x - parent_right;
// Subscripts must be tightly adjacent (within ~1pt)
if gap < parent.font_size * 0.2 && gap > -parent.font_size * 0.3 {
parent.text.push_str(&item.text);
parent.width = (item.x + item.width) - parent.x;
continue;
}
}
}
}
merged.push(item);
}
result.extend(merged);
}
result
}
/// Helper to get f32 from Object
pub(crate) fn get_number(obj: &Object) -> Option<f32> {
match obj {
@@ -1018,4 +1104,121 @@ mod tests {
];
assert!(!is_newspaper_layout(&[col1, col2], &cols));
}
fn make_item_fs(text: &str, x: f32, y: f32, width: f32, font_size: f32) -> TextItem {
TextItem {
text: text.into(),
x,
y,
width,
height: font_size,
font: "F1".into(),
font_size,
page: 1,
is_bold: false,
is_italic: false,
item_type: ItemType::Text,
mcid: None,
}
}
#[test]
fn test_merge_subscript_items_chemical_formula() {
// NH₃: "NH" at fs=8 followed by subscript "3" at fs=4.7
let items = vec![
make_item_fs("NH", 78.0, 499.0, 12.0, 8.0),
make_item_fs("3", 90.0, 496.0, 2.3, 4.7),
make_item_fs("Cl", 100.0, 499.0, 7.0, 8.0),
];
let merged = merge_subscript_items(items);
assert_eq!(merged.len(), 2);
assert_eq!(merged[0].text, "NH3");
assert_eq!(merged[1].text, "Cl");
}
#[test]
fn test_merge_subscript_items_h2o() {
// H₂O: "H" then subscript "2" then "O"
let items = vec![
make_item_fs("H", 250.0, 499.0, 5.0, 8.0),
make_item_fs("2", 255.0, 496.0, 2.3, 4.7),
make_item_fs("O", 257.5, 499.0, 6.0, 8.0),
];
let merged = merge_subscript_items(items);
assert_eq!(merged.len(), 2);
assert_eq!(merged[0].text, "H2");
assert_eq!(merged[1].text, "O");
}
#[test]
fn test_merge_subscript_items_no_merge_far_gap() {
// Subscript-sized item that's far from the parent should NOT merge
let items = vec![
make_item_fs("Text", 78.0, 499.0, 20.0, 8.0),
make_item_fs("", 120.0, 498.0, 3.0, 3.7),
];
let merged = merge_subscript_items(items);
assert_eq!(merged.len(), 2);
assert_eq!(merged[0].text, "Text");
assert_eq!(merged[1].text, "");
}
#[test]
fn test_merge_subscript_items_no_merge_long_text() {
// Long subscript-sized text should NOT merge (not a true subscript)
let items = vec![
make_item_fs("Title", 78.0, 499.0, 30.0, 8.0),
make_item_fs("footnote", 108.0, 496.0, 20.0, 4.7),
];
let merged = merge_subscript_items(items);
assert_eq!(merged.len(), 2);
}
#[test]
fn test_merge_subscript_items_no_merge_same_font_size() {
// Same font size items should NOT be treated as subscripts
let items = vec![
make_item_fs("NH", 78.0, 499.0, 12.0, 8.0),
make_item_fs("3", 90.0, 496.0, 2.3, 8.0),
];
let merged = merge_subscript_items(items);
assert_eq!(merged.len(), 2);
}
#[test]
fn test_merge_subscript_items_no_merge_non_numeric() {
// Non-numeric subscript text (e.g. "sol", "º", "vf") should NOT merge
let items = vec![
make_item_fs("", 200.0, 639.0, 5.5, 8.0),
make_item_fs("sol", 205.8, 636.9, 5.7, 4.7),
];
let merged = merge_subscript_items(items);
assert_eq!(merged.len(), 2);
assert_eq!(merged[0].text, "");
assert_eq!(merged[1].text, "sol");
}
#[test]
fn test_merge_subscript_items_no_merge_parent_ends_with_digit() {
// "33" + "1" in "33 1/3%" — parent ends with digit, should NOT merge
let items = vec![
make_item_fs("33", 78.0, 499.0, 10.0, 8.0),
make_item_fs("1", 88.0, 496.0, 2.3, 4.7),
];
let merged = merge_subscript_items(items);
assert_eq!(merged.len(), 2);
assert_eq!(merged[0].text, "33");
assert_eq!(merged[1].text, "1");
}
#[test]
fn test_merge_subscript_items_no_merge_parent_ends_with_space() {
// "Health " + "1" — parent ends with space (table credit), should NOT merge
let items = vec![
make_item_fs("Health ", 78.0, 499.0, 30.0, 8.0),
make_item_fs("1", 108.0, 496.0, 2.3, 4.7),
];
let merged = merge_subscript_items(items);
assert_eq!(merged.len(), 2);
}
}