Skip to content

Commit ec96311

Browse files
implement ToUnicode CMap support for proper text extraction from PDFs with custom font encodings
1 parent 99db218 commit ec96311

5 files changed

Lines changed: 631 additions & 15 deletions

File tree

Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ repository = "https://github.com/firecrawl/pdf-inspector"
1111
# PDF parsing
1212
lopdf = { git = "https://github.com/J-F-Liu/lopdf", features = ["rayon"] }
1313

14+
# Compression
15+
flate2 = "1.0"
16+
1417
# Error handling
1518
thiserror = "2.0"
1619

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ Fast Rust library for PDF inspection, classification, and text extraction. Intel
1515
| | Position-Aware | Text with X/Y coordinates, font info, page numbers |
1616
| | Multi-Column Support | Automatic detection and proper reading order |
1717
| | Text Encoding | UTF-16BE, UTF-8, and Latin-1 |
18+
| | ToUnicode CMap | Proper decoding of CID-keyed fonts (Type0/Identity-H) |
19+
| | Linearized PDFs | Raw stream extraction for optimized PDFs |
1820
| **Headers** | Auto Detection | H1-H4 based on font size ratios |
1921
| **Lists** | Bullet Points | ``, `-`, `*`, ``, ``, `` |
2022
| | Numbered Lists | `1.`, `1)`, `(1)` |

src/extractor.rs

Lines changed: 77 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
//!
33
//! This module extracts text with position information for structure detection.
44
5+
use crate::tounicode::FontCMaps;
56
use crate::PdfError;
67
use lopdf::{Document, Object, ObjectId};
78
use std::path::Path;
@@ -143,23 +144,33 @@ fn extract_text_from_doc(doc: &Document) -> Result<String, PdfError> {
143144

144145
/// Extract text with position information from PDF file
145146
pub fn extract_text_with_positions<P: AsRef<Path>>(path: P) -> Result<Vec<TextItem>, PdfError> {
146-
let doc = Document::load(path)?;
147-
extract_positioned_text_from_doc(&doc)
147+
// Read the raw PDF bytes for ToUnicode extraction
148+
let pdf_bytes = std::fs::read(path.as_ref())?;
149+
let font_cmaps = FontCMaps::from_pdf_bytes(&pdf_bytes);
150+
151+
let doc = Document::load_mem(&pdf_bytes)?;
152+
extract_positioned_text_from_doc(&doc, &font_cmaps)
148153
}
149154

150155
/// Extract text with positions from memory buffer
151156
pub fn extract_text_with_positions_mem(buffer: &[u8]) -> Result<Vec<TextItem>, PdfError> {
157+
// Extract ToUnicode CMaps from raw PDF bytes
158+
let font_cmaps = FontCMaps::from_pdf_bytes(buffer);
159+
152160
let doc = Document::load_mem(buffer)?;
153-
extract_positioned_text_from_doc(&doc)
161+
extract_positioned_text_from_doc(&doc, &font_cmaps)
154162
}
155163

156164
/// Extract positioned text from loaded document
157-
fn extract_positioned_text_from_doc(doc: &Document) -> Result<Vec<TextItem>, PdfError> {
165+
fn extract_positioned_text_from_doc(
166+
doc: &Document,
167+
font_cmaps: &FontCMaps,
168+
) -> Result<Vec<TextItem>, PdfError> {
158169
let pages = doc.get_pages();
159170
let mut all_items = Vec::new();
160171

161172
for (page_num, &page_id) in pages.iter() {
162-
let items = extract_page_text_items(doc, page_id, *page_num)?;
173+
let items = extract_page_text_items(doc, page_id, *page_num, font_cmaps)?;
163174
all_items.extend(items);
164175
}
165176

@@ -187,6 +198,7 @@ fn extract_page_text_items(
187198
doc: &Document,
188199
page_id: ObjectId,
189200
page_num: u32,
201+
font_cmaps: &FontCMaps,
190202
) -> Result<Vec<TextItem>, PdfError> {
191203
use lopdf::content::Content;
192204

@@ -195,6 +207,19 @@ fn extract_page_text_items(
195207
// Get fonts for encoding
196208
let fonts = doc.get_page_fonts(page_id).unwrap_or_default();
197209

210+
// Build a map of font resource names to their base font names (for CMap lookup)
211+
let mut font_base_names: std::collections::HashMap<String, String> =
212+
std::collections::HashMap::new();
213+
for (font_name, font_dict) in &fonts {
214+
let resource_name = String::from_utf8_lossy(font_name).to_string();
215+
if let Ok(base_font) = font_dict.get(b"BaseFont") {
216+
if let Ok(name) = base_font.as_name() {
217+
let base_name = String::from_utf8_lossy(name).to_string();
218+
font_base_names.insert(resource_name, base_name);
219+
}
220+
}
221+
}
222+
198223
// Get content
199224
let content_data = doc
200225
.get_page_content(page_id)
@@ -290,9 +315,14 @@ fn extract_page_text_items(
290315
"Tj" => {
291316
// Show text string
292317
if in_text_block && !op.operands.is_empty() {
293-
if let Some(text) =
294-
extract_text_from_operand(&op.operands[0], doc, &fonts, &current_font)
295-
{
318+
if let Some(text) = extract_text_from_operand(
319+
&op.operands[0],
320+
doc,
321+
&fonts,
322+
&current_font,
323+
font_cmaps,
324+
&font_base_names,
325+
) {
296326
if !text.trim().is_empty() {
297327
let rendered_size =
298328
effective_font_size(current_font_size, &text_matrix);
@@ -319,9 +349,14 @@ fn extract_page_text_items(
319349
if let Ok(array) = op.operands[0].as_array() {
320350
let mut combined_text = String::new();
321351
for item in array {
322-
if let Some(text) =
323-
extract_text_from_operand(item, doc, &fonts, &current_font)
324-
{
352+
if let Some(text) = extract_text_from_operand(
353+
item,
354+
doc,
355+
&fonts,
356+
&current_font,
357+
font_cmaps,
358+
&font_base_names,
359+
) {
325360
combined_text.push_str(&text);
326361
}
327362
}
@@ -350,9 +385,14 @@ fn extract_page_text_items(
350385
line_matrix[5] -= current_font_size * 1.2;
351386
text_matrix = line_matrix;
352387
if !op.operands.is_empty() {
353-
if let Some(text) =
354-
extract_text_from_operand(&op.operands[0], doc, &fonts, &current_font)
355-
{
388+
if let Some(text) = extract_text_from_operand(
389+
&op.operands[0],
390+
doc,
391+
&fonts,
392+
&current_font,
393+
font_cmaps,
394+
&font_base_names,
395+
) {
356396
if !text.trim().is_empty() {
357397
let rendered_size =
358398
effective_font_size(current_font_size, &text_matrix);
@@ -408,9 +448,31 @@ fn extract_text_from_operand(
408448
doc: &Document,
409449
fonts: &std::collections::BTreeMap<Vec<u8>, &lopdf::Dictionary>,
410450
current_font: &str,
451+
font_cmaps: &FontCMaps,
452+
font_base_names: &std::collections::HashMap<String, String>,
411453
) -> Option<String> {
412454
if let Object::String(bytes, _) = obj {
413-
// Try to decode using font encoding
455+
// First, check if this font has a ToUnicode CMap we can use
456+
// This is especially important for Identity-H encoded fonts (Type0/CIDFont)
457+
if let Some(base_name) = font_base_names.get(current_font) {
458+
if let Some(cmap) = font_cmaps.get(base_name) {
459+
// Use the ToUnicode CMap to decode CID bytes
460+
let decoded = cmap.decode_cids(bytes);
461+
if !decoded.is_empty() {
462+
return Some(decoded);
463+
}
464+
}
465+
}
466+
467+
// Also try looking up by resource name directly
468+
if let Some(cmap) = font_cmaps.get(current_font) {
469+
let decoded = cmap.decode_cids(bytes);
470+
if !decoded.is_empty() {
471+
return Some(decoded);
472+
}
473+
}
474+
475+
// Try to decode using font encoding from lopdf
414476
if let Some(font_dict) = fonts.get(current_font.as_bytes()) {
415477
if let Ok(encoding) = font_dict.get_font_encoding(doc) {
416478
if let Ok(text) = Document::decode_text(&encoding, bytes) {

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ pub mod detector;
99
pub mod extractor;
1010
pub mod markdown;
1111
pub mod tables;
12+
pub mod tounicode;
1213

1314
pub use detector::{detect_pdf_type, PdfType, PdfTypeResult};
1415
pub use extractor::{extract_text, extract_text_with_positions, TextItem};

0 commit comments

Comments
 (0)