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 <noreply@anthropic.com>
This commit is contained in:
Abimael Martell
2026-02-16 14:07:47 -08:00
co-authored by Claude Opus 4.6
parent fb6d98fde4
commit b929bbd92e
2 changed files with 59 additions and 27 deletions
+30 -19
View File
@@ -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<String, Encoding<'_>> = 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,
&current_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,
&current_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,
&current_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<String, Encoding<'_>> = 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,
&current_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,
&current_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<Vec<u8>, &lopdf::Dictionary>,
current_font: &str,
font_cmaps: &FontCMaps,
font_base_names: &std::collections::HashMap<String, String>,
font_tounicode_refs: &std::collections::HashMap<String, u32>,
font_encodings: &PageFontEncodings,
encoding_cache: &HashMap<String, Encoding<'_>>,
) -> Option<String> {
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);
}
}
+29 -8
View File
@@ -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<u32, ToUnicodeCMap>
}
if let Ok(obj_num) = num_str.parse::<u32>() {
// 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);
}
}
}
}