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:
co-authored by
Claude Opus 4.6
parent
da8c27f3c4
commit
74d416e8ce
@@ -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
@@ -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
@@ -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,
|
||||
))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user