feat(detect): flag pages with gid-encoded fonts for OCR

Fonts using raw glyph ID names (gidNNNNN) in their Differences
encoding cannot be decoded to Unicode without the original font's
cmap table. Detect this pattern during font parsing and add
affected pages to pages_needing_ocr so downstream consumers
know to use OCR instead.

Fixes text extraction on PDFs like Tezukuri_Food-Menu.pdf where
the main body font (AcuminVariableConcept) uses gid-encoded
glyphs — even PyMuPDF and ODL fail on these.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Abimael Martell
2026-03-19 08:36:16 -07:00
co-authored by Claude Opus 4.6
parent da8c27f3c4
commit 74d416e8ce
6 changed files with 96 additions and 30 deletions
+5 -3
View File
@@ -20,13 +20,15 @@ use super::fonts::{
use super::xobjects::{extract_form_xobject_text, get_page_xobjects, XObjectType};
use super::{get_number, multiply_matrices};
/// Returns `(page_extraction, has_gid_fonts)` where `has_gid_fonts` indicates
/// the page uses fonts with unresolvable gid-encoded glyphs.
pub(crate) fn extract_page_text_items(
doc: &Document,
page_id: ObjectId,
page_num: u32,
font_cmaps: &FontCMaps,
include_invisible: bool,
) -> Result<PageExtraction, PdfError> {
) -> Result<(PageExtraction, bool), PdfError> {
use lopdf::content::Content;
let mut items = Vec::new();
@@ -46,7 +48,7 @@ pub(crate) fn extract_page_text_items(
let fonts = doc.get_page_fonts(page_id).unwrap_or_default();
// Build font encoding maps from Differences arrays
let font_encodings = build_font_encodings(doc, &fonts);
let (font_encodings, has_gid_fonts) = build_font_encodings(doc, &fonts);
// Build font width info for accurate text positioning
let font_widths = build_font_widths(doc, &fonts);
@@ -877,7 +879,7 @@ pub(crate) fn extract_page_text_items(
}
let items = super::merge_text_items(items);
Ok((items, rects, lines))
Ok(((items, rects, lines), has_gid_fonts))
}
/// Remove near-duplicate rects (same coordinates within 0.5 pt tolerance).
+44 -11
View File
@@ -473,29 +473,37 @@ pub(crate) fn get_operand_bytes(obj: &Object) -> Option<&[u8]> {
}
}
/// Build encoding maps for all fonts on a page
/// Build encoding maps for all fonts on a page.
/// Returns `(encodings, has_gid_fonts)` where `has_gid_fonts` is true when
/// any font uses raw glyph ID names (gidNNNNN) that can't be decoded.
pub(crate) fn build_font_encodings(
doc: &Document,
fonts: &std::collections::BTreeMap<Vec<u8>, &lopdf::Dictionary>,
) -> PageFontEncodings {
) -> (PageFontEncodings, bool) {
let mut encodings = PageFontEncodings::new();
let mut has_gid_fonts = false;
for (font_name, font_dict) in fonts {
let resource_name = String::from_utf8_lossy(font_name).to_string();
if let Some(encoding_map) = parse_font_encoding(doc, font_dict) {
encodings.insert(resource_name, encoding_map);
if let Some(result) = parse_font_encoding(doc, font_dict) {
if result.gid_glyph_count > 0 {
has_gid_fonts = true;
}
if !result.map.is_empty() {
encodings.insert(resource_name, result.map);
}
}
}
encodings
(encodings, has_gid_fonts)
}
/// Parse font encoding from a font dictionary
pub(crate) fn parse_font_encoding(
doc: &Document,
font_dict: &lopdf::Dictionary,
) -> Option<FontEncodingMap> {
) -> Option<EncodingResult> {
let encoding_obj = font_dict.get(b"Encoding").ok()?;
// Encoding can be a name or a dictionary
@@ -519,11 +527,21 @@ pub(crate) fn parse_font_encoding(
}
}
/// Result of parsing an encoding dictionary's Differences array.
pub(crate) struct EncodingResult {
pub map: FontEncodingMap,
/// Number of glyph names matching the `gidNNNNN` pattern (raw glyph IDs).
/// These indicate a font with unresolvable encoding — the glyph IDs
/// reference the original font's glyph table, but without the original
/// font's cmap there is no way to map them to Unicode.
pub gid_glyph_count: u32,
}
/// Parse an encoding dictionary with Differences array
pub(crate) fn parse_encoding_dictionary(
doc: &Document,
enc_dict: &lopdf::Dictionary,
) -> Option<FontEncodingMap> {
) -> Option<EncodingResult> {
let differences = enc_dict.get(b"Differences").ok()?;
let diff_array = match differences {
@@ -541,6 +559,7 @@ pub(crate) fn parse_encoding_dictionary(
let mut encoding_map = FontEncodingMap::new();
let mut current_code: u8 = 0;
let mut ligature_count = 0u32;
let mut gid_glyph_count = 0u32;
for item in diff_array {
match item {
@@ -562,6 +581,14 @@ pub(crate) fn parse_encoding_dictionary(
);
ligature_count += 1;
}
// Detect raw glyph ID names (e.g. "gid00053") that can't be
// mapped to Unicode without the original font's cmap table.
if glyph_name.starts_with("gid")
&& glyph_name.len() >= 4
&& glyph_name[3..].chars().all(|c| c.is_ascii_digit())
{
gid_glyph_count += 1;
}
if let Some(ch) = glyph_to_char(&glyph_name) {
encoding_map.insert(current_code, ch);
} else {
@@ -584,11 +611,17 @@ pub(crate) fn parse_encoding_dictionary(
);
}
if encoding_map.is_empty() {
None
} else {
Some(encoding_map)
if gid_glyph_count > 0 {
debug!(
" Differences: {} gid-encoded glyphs (unresolvable without original font)",
gid_glyph_count
);
}
Some(EncodingResult {
map: encoding_map,
gid_glyph_count,
})
}
/// Get the CMap lookup key for an Identity-H/V CID font without ToUnicode.
+22 -9
View File
@@ -97,7 +97,7 @@ pub(crate) fn extract_text_with_positions_and_rects<P: AsRef<Path>>(
Err(e) => return Err(e.into()),
};
let font_cmaps = FontCMaps::from_doc(&doc);
let (extraction, _thresholds) =
let (extraction, _thresholds, _gid_pages) =
extract_positioned_text_from_doc(&doc, &font_cmaps, page_filter)?;
Ok(extraction)
}
@@ -130,7 +130,7 @@ pub(crate) fn extract_text_with_positions_mem_and_rects(
Err(e) => return Err(e.into()),
};
let font_cmaps = FontCMaps::from_doc(&doc);
let (extraction, _thresholds) =
let (extraction, _thresholds, _gid_pages) =
extract_positioned_text_from_doc(&doc, &font_cmaps, page_filter)?;
Ok(extraction)
}
@@ -149,7 +149,7 @@ pub(crate) fn extract_positioned_text_from_doc(
doc: &Document,
font_cmaps: &FontCMaps,
page_filter: Option<&HashSet<u32>>,
) -> Result<(PageExtraction, PageThresholds), PdfError> {
) -> Result<(PageExtraction, PageThresholds, HashSet<u32>), PdfError> {
extract_positioned_text_impl(doc, font_cmaps, page_filter, false)
}
@@ -159,7 +159,7 @@ pub(crate) fn extract_positioned_text_include_invisible(
doc: &Document,
font_cmaps: &FontCMaps,
page_filter: Option<&HashSet<u32>>,
) -> Result<(PageExtraction, PageThresholds), PdfError> {
) -> Result<(PageExtraction, PageThresholds, HashSet<u32>), PdfError> {
extract_positioned_text_impl(doc, font_cmaps, page_filter, true)
}
@@ -168,12 +168,13 @@ fn extract_positioned_text_impl(
font_cmaps: &FontCMaps,
page_filter: Option<&HashSet<u32>>,
include_invisible: bool,
) -> Result<(PageExtraction, PageThresholds), PdfError> {
) -> Result<(PageExtraction, PageThresholds, HashSet<u32>), PdfError> {
let pages = doc.get_pages();
let mut all_items = Vec::new();
let mut all_rects = Vec::new();
let mut all_lines = Vec::new();
let mut page_thresholds: PageThresholds = HashMap::new();
let mut gid_encoded_pages: HashSet<u32> = HashSet::new();
// Build page ObjectId → page number map for form field extraction
let page_id_to_num: HashMap<ObjectId, u32> =
@@ -185,18 +186,26 @@ fn extract_positioned_text_impl(
continue;
}
}
let (mut items, rects, lines) =
let ((mut items, rects, lines), has_gid_fonts) =
extract_page_text_items(doc, page_id, *page_num, font_cmaps, include_invisible)?;
if has_gid_fonts {
gid_encoded_pages.insert(*page_num);
}
let threshold = crate::text_utils::fix_letterspaced_items(&mut items);
if threshold > 0.10 {
page_thresholds.insert(*page_num, threshold);
}
debug!(
"page {}: {} text items, {} rects, {} lines",
"page {}: {} text items, {} rects, {} lines{}",
page_num,
items.len(),
rects.len(),
lines.len()
lines.len(),
if has_gid_fonts {
" [gid-encoded fonts]"
} else {
""
}
);
if log::log_enabled!(log::Level::Trace) {
for item in &items {
@@ -229,7 +238,11 @@ fn extract_positioned_text_impl(
let form_items = extract_form_fields(doc, &page_id_to_num);
all_items.extend(form_items);
Ok(((all_items, all_rects, all_lines), page_thresholds))
Ok((
(all_items, all_rects, all_lines),
page_thresholds,
gid_encoded_pages,
))
}
// ---------------------------------------------------------------------------
+1 -1
View File
@@ -156,7 +156,7 @@ fn extract_form_xobject_text_inner(
// Get fonts from the Form's Resources
let form_fonts = get_form_fonts(doc, &stream.dict);
let font_encodings = build_font_encodings(doc, &form_fonts);
let (font_encodings, _has_gid_fonts) = build_font_encodings(doc, &form_fonts);
// Build font width info for the form
let font_widths = build_font_widths(doc, &form_fonts);