From b929bbd92e27f731e71016f784c06ae9ff8a3519 Mon Sep 17 00:00:00 2001 From: Abimael Martell Date: Mon, 16 Feb 2026 14:07:47 -0800 Subject: [PATCH] perf(extractor): Cache font encodings to avoid re-parsing CMap per text op lopdf's get_font_encoding() re-parses ToUnicode CMap streams via nom for every Tj/TJ operator. Cache the encoding once per font during page setup. Also add binary search for CMap range lookup and de-dup CMap extraction. Reduces Arabic PDF processing from 45s to <1s (53x speedup). Co-Authored-By: Claude Opus 4.6 --- src/extractor.rs | 49 +++++++++++++++++++++++++++++------------------- src/tounicode.rs | 37 ++++++++++++++++++++++++++++-------- 2 files changed, 59 insertions(+), 27 deletions(-) diff --git a/src/extractor.rs b/src/extractor.rs index afa7b2c..76e0392 100644 --- a/src/extractor.rs +++ b/src/extractor.rs @@ -5,7 +5,7 @@ use crate::glyph_names::glyph_to_char; use crate::tounicode::FontCMaps; use crate::PdfError; -use lopdf::{Document, Object, ObjectId}; +use lopdf::{Document, Encoding, Object, ObjectId}; use std::collections::HashMap; use std::path::Path; @@ -961,6 +961,16 @@ fn extract_page_text_items( } } + // Cache font encodings from lopdf (once per font, not per text operand). + // This avoids re-parsing ToUnicode CMap streams for every Tj/TJ operator. + let mut encoding_cache: HashMap> = HashMap::new(); + for (font_name, font_dict) in &fonts { + let name = String::from_utf8_lossy(font_name).to_string(); + if let Ok(enc) = font_dict.get_font_encoding(doc) { + encoding_cache.insert(name, enc); + } + } + // Get XObjects (images) from page resources let xobjects = get_page_xobjects(doc, page_id); @@ -1120,13 +1130,12 @@ fn extract_page_text_items( } if let Some(text) = extract_text_from_operand( &op.operands[0], - doc, - &fonts, ¤t_font, font_cmaps, &font_base_names, &font_tounicode_refs, &font_encodings, + &encoding_cache, ) { if !text.trim().is_empty() { let rendered_size = @@ -1224,13 +1233,12 @@ fn extract_page_text_items( if !fill_is_white { if let Some(text) = extract_text_from_operand( element, - doc, - &fonts, ¤t_font, font_cmaps, &font_base_names, &font_tounicode_refs, &font_encodings, + &encoding_cache, ) { combined_text.push_str(&text); } @@ -1287,13 +1295,12 @@ fn extract_page_text_items( if !fill_is_white && !op.operands.is_empty() { if let Some(text) = extract_text_from_operand( &op.operands[0], - doc, - &fonts, ¤t_font, font_cmaps, &font_base_names, &font_tounicode_refs, &font_encodings, + &encoding_cache, ) { if !text.trim().is_empty() { let rendered_size = @@ -1498,6 +1505,15 @@ fn extract_form_xobject_text( } } + // Cache font encodings for form fonts + let mut encoding_cache: HashMap> = HashMap::new(); + for (font_name, font_dict) in &form_fonts { + let name = String::from_utf8_lossy(font_name).to_string(); + if let Ok(enc) = font_dict.get_font_encoding(doc) { + encoding_cache.insert(name, enc); + } + } + // Process the content stream let mut current_font = String::new(); let mut current_font_size: f32 = 12.0; @@ -1578,13 +1594,12 @@ fn extract_form_xobject_text( } if let Some(text) = extract_text_from_operand( &op.operands[0], - doc, - &form_fonts, ¤t_font, font_cmaps, &font_base_names, &font_tounicode_refs, &font_encodings, + &encoding_cache, ) { if !text.trim().is_empty() { let rendered_size = @@ -1682,13 +1697,12 @@ fn extract_form_xobject_text( if !fill_is_white { if let Some(text) = extract_text_from_operand( element, - doc, - &form_fonts, ¤t_font, font_cmaps, &font_base_names, &font_tounicode_refs, &font_encodings, + &encoding_cache, ) { combined_text.push_str(&text); } @@ -1965,13 +1979,12 @@ pub fn is_italic_font(font_name: &str) -> bool { #[allow(clippy::too_many_arguments)] fn extract_text_from_operand( obj: &Object, - doc: &Document, - fonts: &std::collections::BTreeMap, &lopdf::Dictionary>, current_font: &str, font_cmaps: &FontCMaps, font_base_names: &std::collections::HashMap, font_tounicode_refs: &std::collections::HashMap, font_encodings: &PageFontEncodings, + encoding_cache: &HashMap>, ) -> Option { if let Object::String(bytes, _) = obj { // First, try to look up CMap by ToUnicode object reference (most reliable) @@ -2043,12 +2056,10 @@ fn extract_text_from_operand( } } - // Try to decode using font encoding from lopdf - if let Some(font_dict) = fonts.get(current_font.as_bytes()) { - if let Ok(encoding) = font_dict.get_font_encoding(doc) { - if let Ok(text) = Document::decode_text(&encoding, bytes) { - return Some(text); - } + // Try to decode using cached font encoding from lopdf + if let Some(encoding) = encoding_cache.get(current_font) { + if let Ok(text) = Document::decode_text(encoding, bytes) { + return Some(text); } } diff --git a/src/tounicode.rs b/src/tounicode.rs index 49213e9..da470e1 100644 --- a/src/tounicode.rs +++ b/src/tounicode.rs @@ -108,6 +108,9 @@ impl ToUnicodeCMap { 2 // Default to 2-byte }; + // Sort ranges by start CID for binary search in lookup() + cmap.ranges.sort_unstable_by_key(|&(start, _, _)| start); + Some(cmap) } @@ -308,11 +311,28 @@ impl ToUnicodeCMap { return Some(s.clone()); } - // Then check ranges - for &(start, end, base) in &self.ranges { + // Binary search through sorted ranges + let idx = self + .ranges + .binary_search_by(|&(start, _, _)| start.cmp(&cid)) + .unwrap_or_else(|i| i); + + // Check the range at idx (where start == cid) + if idx < self.ranges.len() { + let (start, end, base) = self.ranges[idx]; if cid >= start && cid <= end { - let offset = (cid - start) as u32; - let unicode = base + offset; + let unicode = base + (cid - start) as u32; + if let Some(c) = char::from_u32(unicode) { + return Some(c.to_string()); + } + } + } + + // Check the range before idx (cid may fall within a range that starts before it) + if idx > 0 { + let (start, end, base) = self.ranges[idx - 1]; + if cid >= start && cid <= end { + let unicode = base + (cid - start) as u32; if let Some(c) = char::from_u32(unicode) { return Some(c.to_string()); } @@ -499,10 +519,11 @@ pub fn extract_tounicode_cmaps(pdf_bytes: &[u8]) -> HashMap } if let Ok(obj_num) = num_str.parse::() { - // Try to extract the stream for this object - if let Some(stream_data) = extract_stream_from_raw_pdf(pdf_bytes, obj_num) { - if let Some(cmap) = ToUnicodeCMap::parse(&stream_data) { - cmaps.insert(obj_num, cmap); + if let std::collections::hash_map::Entry::Vacant(e) = cmaps.entry(obj_num) { + if let Some(stream_data) = extract_stream_from_raw_pdf(pdf_bytes, obj_num) { + if let Some(cmap) = ToUnicodeCMap::parse(&stream_data) { + e.insert(cmap); + } } } }