Files
pdf-inspector/src/tounicode.rs
T
Abimael MartellandClaude Opus 4.6 a4a2e1f267 Fix PUA glyph decoding, Symbol font cmap mapping, and UTF-8 detection
- Strip PUA F000 offset for "uniF0XX" glyph names (e.g. uniF072 → 'r')
- Use Mac Roman/Symbol cmap subtables for proper code→GID→Unicode mapping
  instead of assuming GID equals character code in subsetted fonts
- Skip fallback CMap building for fonts with explicit encoding
- Move UTF-8 detection before lopdf single-byte encoding decoder

Fixes systematic letter substitutions (CITY→CITQ), missing French letters,
and UTF-8 mojibake (José→José). Eval: missing_text -32%, encoding_issue 7→4.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 13:54:12 -08:00

2389 lines
80 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! ToUnicode CMap parsing for PDF text extraction
//!
//! This module parses ToUnicode CMaps to convert CID-encoded text to Unicode.
use log::{debug, warn};
use lopdf::{Document, Object, ObjectId};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use crate::glyph_names::glyph_to_char;
/// A parsed ToUnicode CMap mapping CIDs to Unicode strings
#[derive(Debug, Default, Clone)]
pub struct ToUnicodeCMap {
/// Direct character mappings (CID -> Unicode codepoint(s))
pub char_map: HashMap<u16, String>,
/// Range mappings (start_cid, end_cid) -> base_unicode
pub ranges: Vec<(u16, u16, u32)>,
/// Byte width of source codes (1 or 2), determined from codespace and CMap entries
pub code_byte_length: u8,
}
pub(crate) fn build_cmap_entry_from_stream(
data: &[u8],
font_dict: &lopdf::Dictionary,
doc: &Document,
obj_num: u32,
) -> Option<CMapEntry> {
if let Some(cmap) = ToUnicodeCMap::parse(data) {
let (mut primary, mut remapped) = try_remap_subset_cmap(cmap, font_dict, doc, obj_num);
let mut fallback = build_fallback_tounicode_from_encoding(font_dict, doc)
.or_else(|| build_fallback_cmap_for_type0(font_dict, doc))
.or_else(|| build_fallback_cmap_for_simple(font_dict, doc));
let primary_entries = primary.char_map.len() + primary.ranges.len();
if primary_entries < 10 {
if let Some(fb) = fallback.take() {
debug!(
"ToUnicode CMap obj={} too sparse ({} entries); using fallback",
obj_num, primary_entries
);
remapped = Some(primary);
primary = fb;
}
}
return Some(CMapEntry {
primary,
remapped,
fallback,
});
}
let fallback = build_fallback_cmap_for_type0(font_dict, doc)
.or_else(|| build_fallback_cmap_for_simple(font_dict, doc))?;
debug!(
"ToUnicode CMap obj={} parse failed; using fallback (entries={})",
obj_num,
fallback.char_map.len()
);
Some(CMapEntry {
primary: fallback,
remapped: None,
fallback: None,
})
}
impl ToUnicodeCMap {
/// Create a new empty CMap
pub fn new() -> Self {
Self::default()
}
/// Parse a ToUnicode CMap from its decompressed content
pub fn parse(content: &[u8]) -> Option<Self> {
let text = String::from_utf8_lossy(content);
let mut cmap = ToUnicodeCMap::new();
let mut src_hex_lengths: Vec<usize> = Vec::new();
let mut use_cmap_name: Option<String> = None;
// Parse begincodespacerange ... endcodespacerange to determine byte width
let mut codespace_byte_len: Option<u8> = None;
if let Some(cs_start) = text.find("begincodespacerange") {
let section_start = cs_start + "begincodespacerange".len();
if let Some(cs_end) = text[section_start..].find("endcodespacerange") {
let section = &text[section_start..section_start + cs_end];
// Parse hex values to determine byte length
let mut in_hex = false;
let mut hex_len = 0;
for c in section.chars() {
if c == '<' {
in_hex = true;
hex_len = 0;
} else if c == '>' {
if in_hex && hex_len > 0 {
let byte_len = (hex_len + 1) / 2; // 2 hex digits = 1 byte
codespace_byte_len = Some(byte_len as u8);
}
in_hex = false;
} else if in_hex && c.is_ascii_hexdigit() {
hex_len += 1;
}
}
}
}
// Parse "usecmap" if present
if let Some(name) = find_usecmap_name(&text) {
use_cmap_name = Some(name);
}
// Parse beginbfchar ... endbfchar sections
let mut pos = 0;
while let Some(start) = text[pos..].find("beginbfchar") {
let section_start = pos + start + "beginbfchar".len();
if let Some(end) = text[section_start..].find("endbfchar") {
let section = &text[section_start..section_start + end];
cmap.parse_bfchar_section(section, &mut src_hex_lengths);
pos = section_start + end;
} else {
break;
}
}
// Parse beginbfrange ... endbfrange sections
pos = 0;
while let Some(start) = text[pos..].find("beginbfrange") {
let section_start = pos + start + "beginbfrange".len();
if let Some(end) = text[section_start..].find("endbfrange") {
let section = &text[section_start..section_start + end];
cmap.parse_bfrange_section(section, &mut src_hex_lengths);
pos = section_start + end;
} else {
break;
}
}
if cmap.char_map.is_empty() && cmap.ranges.is_empty() {
return None;
}
// Determine byte width: use codespace if available, otherwise infer from entries
cmap.code_byte_length = if let Some(cs_len) = codespace_byte_len {
// If codespace says 2-byte but ALL entries use 1-byte source codes
// (hex length <= 2), treat as 1-byte. This handles the common case where
// codespace is <0000><FFFF> but entries are <20>, <41>, etc.
if cs_len == 2 && !src_hex_lengths.is_empty() && src_hex_lengths.iter().all(|&l| l <= 2)
{
1
} else {
cs_len
}
} else if !src_hex_lengths.is_empty() {
// No codespace declaration: infer from entry hex lengths
let max_hex_len = src_hex_lengths.iter().max().copied().unwrap_or(4);
if max_hex_len <= 2 {
1
} else {
2
}
} else {
2 // Default to 2-byte
};
// Sort ranges by start CID for binary search in lookup()
cmap.ranges.sort_unstable_by_key(|&(start, _, _)| start);
if let Some(name) = use_cmap_name {
if let Some(base) = load_builtin_cmap_by_name(&name) {
cmap = merge_cmaps(base, cmap);
} else {
warn!("usecmap={} could not be loaded", name);
}
}
Some(cmap)
}
/// Parse a bfchar section: <src> <dst> pairs
fn parse_bfchar_section(&mut self, section: &str, src_hex_lengths: &mut Vec<usize>) {
// Match pairs of hex values: <XXXX> <YYYY>
let mut chars = section.chars().peekable();
loop {
// Skip whitespace
while chars.peek().is_some_and(|c| c.is_whitespace()) {
chars.next();
}
// Look for opening <
if chars.peek() != Some(&'<') {
break;
}
chars.next(); // consume <
// Read source hex
let mut src_hex = String::new();
while chars.peek().is_some_and(|&c| c != '>') {
if let Some(c) = chars.next() {
src_hex.push(c);
}
}
chars.next(); // consume >
// Track source hex length for byte width detection
let trimmed_src = src_hex.trim();
if !trimmed_src.is_empty() {
src_hex_lengths.push(trimmed_src.len());
}
// Skip whitespace
while chars.peek().is_some_and(|c| c.is_whitespace()) {
chars.next();
}
// Look for opening <
if chars.peek() != Some(&'<') {
continue;
}
chars.next(); // consume <
// Read destination hex
let mut dst_hex = String::new();
while chars.peek().is_some_and(|&c| c != '>') {
if let Some(c) = chars.next() {
dst_hex.push(c);
}
}
chars.next(); // consume >
// Parse and store mapping
if let (Some(src), Some(dst)) =
(parse_hex_u16(&src_hex), hex_to_unicode_string(&dst_hex))
{
self.char_map.insert(src, dst);
}
}
}
/// Parse a bfrange section: <start> <end> <base> or <start> <end> [<u1> <u2> ...] triplets
fn parse_bfrange_section(&mut self, section: &str, src_hex_lengths: &mut Vec<usize>) {
let mut chars = section.chars().peekable();
loop {
// Skip whitespace
while chars.peek().is_some_and(|c| c.is_whitespace()) {
chars.next();
}
// Look for opening <
if chars.peek() != Some(&'<') {
break;
}
chars.next(); // consume <
// Read start hex
let mut start_hex = String::new();
while chars.peek().is_some_and(|&c| c != '>') {
if let Some(c) = chars.next() {
start_hex.push(c);
}
}
chars.next(); // consume >
// Track source hex length
let trimmed_start = start_hex.trim();
if !trimmed_start.is_empty() {
src_hex_lengths.push(trimmed_start.len());
}
// Skip whitespace
while chars.peek().is_some_and(|c| c.is_whitespace()) {
chars.next();
}
// Read end hex
if chars.peek() != Some(&'<') {
continue;
}
chars.next();
let mut end_hex = String::new();
while chars.peek().is_some_and(|&c| c != '>') {
if let Some(c) = chars.next() {
end_hex.push(c);
}
}
chars.next();
// Skip whitespace
while chars.peek().is_some_and(|c| c.is_whitespace()) {
chars.next();
}
// Read base - could be <hex> or [array]
if chars.peek() == Some(&'<') {
chars.next();
let mut base_hex = String::new();
while chars.peek().is_some_and(|&c| c != '>') {
if let Some(c) = chars.next() {
base_hex.push(c);
}
}
chars.next();
// Store range mapping
if let (Some(start), Some(end), Some(base)) = (
parse_hex_u16(&start_hex),
parse_hex_u16(&end_hex),
parse_hex_u32(&base_hex),
) {
self.ranges.push((start, end, base));
}
} else if chars.peek() == Some(&'[') {
// Array format: [<unicode1> <unicode2> ...]
// Each entry maps to start_cid + index
chars.next(); // consume [
if let (Some(start), Some(end)) =
(parse_hex_u16(&start_hex), parse_hex_u16(&end_hex))
{
let mut cid = start;
loop {
// Skip whitespace
while chars.peek().is_some_and(|c| c.is_whitespace()) {
chars.next();
}
if chars.peek() == Some(&']') {
chars.next();
break;
}
if chars.peek() != Some(&'<') {
break;
}
chars.next(); // consume <
let mut hex = String::new();
while chars.peek().is_some_and(|&c| c != '>') {
if let Some(c) = chars.next() {
hex.push(c);
}
}
chars.next(); // consume >
if let Some(unicode_str) = hex_to_unicode_string(&hex) {
self.char_map.insert(cid, unicode_str);
}
if cid >= end {
// Skip remaining entries and closing bracket
while chars.peek().is_some_and(|&c| c != ']') {
chars.next();
}
if chars.peek() == Some(&']') {
chars.next();
}
break;
}
cid = cid.saturating_add(1);
}
} else {
// Couldn't parse start/end, skip the array
while chars.peek().is_some_and(|&c| c != ']') {
chars.next();
}
if chars.peek() == Some(&']') {
chars.next();
}
}
}
}
}
/// Look up a CID and return the Unicode string
pub fn lookup(&self, cid: u16) -> Option<String> {
// First check direct mappings
if let Some(s) = self.char_map.get(&cid) {
return Some(s.clone());
}
// 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 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());
}
}
}
None
}
/// Per-byte CMap lookup without Latin-1 fallback.
/// Returns `(raw_byte, Option<cmap_result>)` for each byte.
/// Only meaningful for single-byte (code_byte_length==1) CMaps.
pub fn lookup_bytes(&self, bytes: &[u8]) -> Vec<(u8, Option<String>)> {
bytes
.iter()
.map(|&b| {
let code = b as u16;
let result = self.lookup(code).filter(|s| !s.contains('\u{FFFD}'));
(b, result)
})
.collect()
}
/// Decode a byte slice to a Unicode string, respecting the CMap's code byte width
pub fn decode_cids(&self, bytes: &[u8]) -> String {
let mut result = String::new();
let mut unmapped_count = 0usize;
if self.code_byte_length == 1 {
// Single-byte codes: each byte is a code
for &b in bytes {
let code = b as u16;
match self.lookup(code) {
Some(s) if !s.contains('\u{FFFD}') => result.push_str(&s),
_ => {
// For single-byte unmapped codes, try as Latin-1
// (the byte IS the character code in most legacy encodings)
if b >= 0x20 {
result.push(b as char);
}
unmapped_count += 1;
}
}
}
} else {
// Two-byte codes: CIDs are 2 bytes each (big-endian)
for chunk in bytes.chunks(2) {
if chunk.len() == 2 {
let cid = u16::from_be_bytes([chunk[0], chunk[1]]);
match self.lookup(cid) {
Some(s) if !s.contains('\u{FFFD}') => result.push_str(&s),
_ => {
// Do NOT blindly interpret CIDs as Unicode codepoints.
// CIDs are font-internal indices, not Unicode values.
// Unmapped 2-byte CIDs are skipped to avoid CJK garbage.
unmapped_count += 1;
}
}
}
}
}
// If too many codes were unmapped, signal failure by returning empty
// so the caller can fall through to other decoding methods
let total = if self.code_byte_length == 1 {
bytes.len()
} else {
bytes.len() / 2
};
if total > 0 && unmapped_count > total / 2 {
return String::new();
}
result
}
/// Get the minimum source CID across all mappings (char_map + ranges).
fn min_source_cid(&self) -> Option<u16> {
let char_min = self.char_map.keys().copied().min();
let range_min = self.ranges.iter().map(|&(start, _, _)| start).min();
match (char_min, range_min) {
(Some(a), Some(b)) => Some(a.min(b)),
(a @ Some(_), None) => a,
(None, b @ Some(_)) => b,
(None, None) => None,
}
}
/// Remap a CMap that references pre-subsetting GIDs to sequential post-subsetting GIDs.
/// Collects all source CIDs, sorts them, and reassigns to 1, 2, 3, ...
pub fn remap_to_sequential(&self) -> ToUnicodeCMap {
let mut cid_to_unicode: HashMap<u16, String> = HashMap::new();
// Expand ranges first
for &(start, end, base) in &self.ranges {
for cid in start..=end {
let unicode_cp = base + (cid - start) as u32;
if let Some(ch) = char::from_u32(unicode_cp) {
cid_to_unicode.insert(cid, ch.to_string());
}
}
}
// char_map entries override range entries
for (&cid, unicode) in &self.char_map {
cid_to_unicode.insert(cid, unicode.clone());
}
// Sort old CIDs ascending
let mut old_cids: Vec<u16> = cid_to_unicode.keys().copied().collect();
old_cids.sort_unstable();
// Build new CMap with sequential CIDs starting at 1
let mut new_cmap = ToUnicodeCMap::new();
for (i, &old_cid) in old_cids.iter().enumerate() {
let new_cid = (i + 1) as u16; // GID 0 is .notdef, content CIDs start at 1
if let Some(unicode) = cid_to_unicode.get(&old_cid) {
new_cmap.char_map.insert(new_cid, unicode.clone());
}
}
new_cmap.code_byte_length = self.code_byte_length;
new_cmap
}
}
/// Parse a hex string to u16
fn parse_hex_u16(hex: &str) -> Option<u16> {
u16::from_str_radix(hex.trim(), 16).ok()
}
/// Parse a hex string to u32
fn parse_hex_u32(hex: &str) -> Option<u32> {
u32::from_str_radix(hex.trim(), 16).ok()
}
/// Convert a hex string to a Unicode string
/// Handles both 2-byte (BMP) and 4-byte (supplementary) codepoints
fn hex_to_unicode_string(hex: &str) -> Option<String> {
let hex = hex.trim();
let mut result = String::new();
// Process 4 hex digits at a time
let mut i = 0;
while i + 4 <= hex.len() {
if let Ok(cp) = u32::from_str_radix(&hex[i..i + 4], 16) {
if let Some(c) = char::from_u32(cp) {
result.push(c);
}
}
i += 4;
}
if result.is_empty() {
None
} else {
Some(result)
}
}
fn find_usecmap_name(text: &str) -> Option<String> {
for line in text.lines() {
if line.contains("usecmap") {
let parts: Vec<&str> = line.split_whitespace().collect();
for i in 0..parts.len() {
if parts[i] == "usecmap" && i > 0 {
let name = parts[i - 1].trim();
if let Some(stripped) = name.strip_prefix('/') {
return Some(stripped.to_string());
}
}
}
}
}
None
}
/// Navigate to the first DescendantFont dictionary of a Type0 font.
fn get_descendant_cid_font<'a>(
font_dict: &'a lopdf::Dictionary,
doc: &'a Document,
) -> Option<&'a lopdf::Dictionary> {
let desc_fonts_obj = font_dict.get(b"DescendantFonts").ok()?;
let arr = match desc_fonts_obj {
Object::Array(arr) => arr,
Object::Reference(r) => match doc.get_object(*r) {
Ok(Object::Array(arr)) => arr,
_ => return None,
},
_ => return None,
};
if arr.is_empty() {
return None;
}
match &arr[0] {
Object::Reference(r) => doc.get_dictionary(*r).ok(),
Object::Dictionary(d) => Some(d),
_ => None,
}
}
/// Get the starting CID from a CIDFont's W (widths) array.
fn get_w_array_start_cid(cid_font_dict: &lopdf::Dictionary, doc: &Document) -> Option<u16> {
let w_obj = cid_font_dict.get(b"W").ok()?;
let arr = match w_obj {
Object::Array(arr) => arr,
Object::Reference(r) => match doc.get_object(*r) {
Ok(Object::Array(arr)) => arr,
_ => return None,
},
_ => return None,
};
if arr.is_empty() {
return None;
}
match &arr[0] {
Object::Integer(n) => Some(*n as u16),
Object::Reference(r) => match doc.get_object(*r) {
Ok(Object::Integer(n)) => Some(*n as u16),
_ => None,
},
_ => None,
}
}
/// Extract CIDToGIDMap as a vector of GIDs (u16) indexed by CID.
fn get_cid_to_gid_map(cid_font_dict: &lopdf::Dictionary, doc: &Document) -> Option<Vec<u16>> {
let obj = cid_font_dict.get(b"CIDToGIDMap").ok()?;
match obj {
Object::Name(n) if n.as_slice() == b"Identity" => None,
Object::Reference(r) => match doc.get_object(*r) {
Ok(Object::Stream(s)) => parse_cid_to_gid_stream(&s.decompressed_content().ok()?),
_ => None,
},
Object::Stream(s) => parse_cid_to_gid_stream(&s.decompressed_content().ok()?),
_ => None,
}
}
fn parse_cid_to_gid_stream(data: &[u8]) -> Option<Vec<u16>> {
if data.len() < 2 {
return None;
}
let mut map = Vec::with_capacity(data.len() / 2);
for chunk in data.chunks_exact(2) {
map.push(u16::from_be_bytes([chunk[0], chunk[1]]));
}
Some(map)
}
/// Build a CID→Unicode CMap by applying a CIDToGIDMap to an existing CMap that maps GID→Unicode.
fn build_cmap_with_cid_to_gid_map(
cmap: &ToUnicodeCMap,
cid_to_gid: &[u16],
) -> Option<ToUnicodeCMap> {
let mut new_cmap = ToUnicodeCMap::new();
for (cid, &gid) in cid_to_gid.iter().enumerate() {
if let Some(s) = cmap.lookup(gid) {
new_cmap.char_map.insert(cid as u16, s);
}
}
if new_cmap.char_map.is_empty() {
None
} else {
new_cmap.code_byte_length = 2;
Some(new_cmap)
}
}
/// Detect and fix broken ToUnicode CMaps from subset fonts with GID mismatch.
///
/// Some PDF generators subset-embed fonts by renumbering GIDs sequentially (1, 2, 3...)
/// but fail to update the ToUnicode CMap, which still references original GID values.
/// This detects the mismatch and remaps the CMap to sequential positions.
fn try_remap_subset_cmap(
cmap: ToUnicodeCMap,
font_dict: &lopdf::Dictionary,
doc: &Document,
obj_num: u32,
) -> (ToUnicodeCMap, Option<ToUnicodeCMap>) {
// Only applies to Identity-H/V CID fonts
let encoding = font_dict
.get(b"Encoding")
.ok()
.and_then(|o| o.as_name().ok());
if encoding != Some(b"Identity-H") && encoding != Some(b"Identity-V") {
return (cmap, None);
}
// CMap's minimum source CID must be > 2 (indicating old, non-sequential GIDs)
let min_cid = match cmap.min_source_cid() {
Some(c) if c > 2 => c,
_ => return (cmap, None),
};
// Navigate to DescendantFonts[0]
let cid_font_dict = match get_descendant_cid_font(font_dict, doc) {
Some(d) => d,
None => return (cmap, None),
};
// If there's an explicit CIDToGIDMap, build a repaired CMap using it.
if let Some(cid_to_gid) = get_cid_to_gid_map(cid_font_dict, doc) {
if let Some(repaired) = build_cmap_with_cid_to_gid_map(&cmap, &cid_to_gid) {
debug!(
"CIDToGIDMap repair applied for obj={}: {} entries",
obj_num,
repaired.char_map.len()
);
return (cmap, Some(repaired));
}
// Fall through to sequential remap if repair failed.
}
// W array must start at a low CID (≤ 2), indicating sequential post-subset GIDs
let w_start = match get_w_array_start_cid(cid_font_dict, doc) {
Some(c) if c <= 2 => c,
_ => return (cmap, None),
};
debug!(
"Subset GID mismatch detected for obj={}: W starts at CID {}, CMap min CID {}. Remapping to sequential.",
obj_num, w_start, min_cid
);
let remapped = cmap.remap_to_sequential();
(cmap, Some(remapped))
}
/// Build a ToUnicodeCMap from an embedded TrueType font's cmap table.
///
/// For Identity-H CID fonts, CID == GID. The TrueType cmap maps Unicode→GID,
/// so we reverse it to get GID→Unicode (i.e. CID→Unicode).
pub fn build_cmap_from_truetype(font_data: &[u8]) -> Option<ToUnicodeCMap> {
let face = ttf_parser::Face::parse(font_data, 0).ok()?;
let gid_to_unicode = build_gid_to_unicode(&face)?;
debug!(
"TrueType cmap: {} GID→Unicode entries",
gid_to_unicode.len()
);
let mut cmap = ToUnicodeCMap::new();
for (gid, ch) in &gid_to_unicode {
cmap.char_map.insert(*gid, ch.to_string());
}
cmap.code_byte_length = 2; // Identity-H uses 2-byte CIDs
Some(cmap)
}
/// Build a single-byte CMap for simple fonts by treating the character code
/// as a glyph id (best-effort fallback when no usable ToUnicode exists).
fn build_simple_cmap_from_truetype(font_data: &[u8]) -> Option<ToUnicodeCMap> {
let face = ttf_parser::Face::parse(font_data, 0).ok()?;
let gid_to_unicode = build_gid_to_unicode(&face)?;
let mut cmap = ToUnicodeCMap::new();
// Use the font's encoding cmap subtable for proper code→GID→Unicode mapping.
// In subsetted TrueType fonts, GID ≠ character code, so we need the cmap table
// to translate byte codes (as used in the PDF content stream) to GIDs.
let mut used_encoding_cmap = false;
if let Some(cmap_table) = face.tables().cmap {
// Prefer Mac Roman (1,0): maps byte codes 0255 directly to GIDs.
for subtable in cmap_table.subtables {
if subtable.platform_id == ttf_parser::PlatformId::Macintosh
&& subtable.encoding_id == 0
{
for code in 0x20..=0xFF_u32 {
if let Some(gid) = subtable.glyph_index(code) {
if let Some(&ch) = gid_to_unicode.get(&gid.0) {
let ch = strip_pua_char(ch);
cmap.char_map.entry(code as u16).or_insert(ch.to_string());
}
}
}
used_encoding_cmap = true;
break;
}
}
// Fallback: Windows Symbol (3,0) — maps F000+byte to GIDs.
if !used_encoding_cmap {
for subtable in cmap_table.subtables {
if subtable.platform_id == ttf_parser::PlatformId::Windows
&& subtable.encoding_id == 0
{
for code in 0x20..=0xFF_u32 {
if let Some(gid) = subtable.glyph_index(code + 0xF000) {
if let Some(&ch) = gid_to_unicode.get(&gid.0) {
let ch = strip_pua_char(ch);
cmap.char_map.entry(code as u16).or_insert(ch.to_string());
}
}
}
used_encoding_cmap = true;
break;
}
}
}
}
if !used_encoding_cmap {
// No encoding cmap found — fall back to treating GID as code.
for (&gid, &ch) in &gid_to_unicode {
if gid <= 0xFF {
cmap.char_map.insert(gid, ch.to_string());
}
}
// Fill missing single-byte codes from glyph names (helps with ligatures like "t_i").
for gid_idx in 0..face.number_of_glyphs() {
let gid = ttf_parser::GlyphId(gid_idx);
let gid_val = gid.0;
if gid_val > 0xFF || cmap.char_map.contains_key(&gid_val) {
continue;
}
if let Some(name) = face.glyph_name(gid) {
if let Some(s) = glyph_name_to_string(name) {
cmap.char_map.insert(gid_val, s);
}
}
}
}
if cmap.char_map.is_empty() {
return None;
}
debug!(
"TrueType simple cmap: {} code→Unicode entries",
cmap.char_map.len()
);
cmap.code_byte_length = 1;
Some(cmap)
}
/// Strip Private Use Area F000 offset (Windows Symbol encoding convention).
fn strip_pua_char(ch: char) -> char {
let cp = ch as u32;
if (0xF000..=0xF0FF).contains(&cp) {
char::from_u32(cp - 0xF000).unwrap_or(ch)
} else {
ch
}
}
fn glyph_name_to_string(name: &str) -> Option<String> {
let base = name.split('.').next().unwrap_or(name);
if let Some(ch) = glyph_to_char(base) {
return Some(ch.to_string());
}
if base.contains('_') {
let mut out = String::new();
for part in base.split('_') {
if part.is_empty() {
return None;
}
if let Some(ch) = glyph_to_char(part) {
out.push(ch);
} else if part.len() == 1 {
out.push(part.chars().next().unwrap());
} else {
return None;
}
}
if !out.is_empty() {
return Some(out);
}
}
if matches!(base, "ti" | "tt" | "tz") {
return Some(base.to_string());
}
None
}
/// Build a ToUnicodeCMap from a font's glyph names (post table).
/// Uses Adobe Glyph List to map glyph names to Unicode.
fn build_cmap_from_glyph_names(face: &ttf_parser::Face<'_>) -> Option<ToUnicodeCMap> {
let mut cmap = ToUnicodeCMap::new();
for gid in 0..face.number_of_glyphs() {
let gid = ttf_parser::GlyphId(gid);
if let Some(name) = face.glyph_name(gid) {
if let Some(ch) = glyph_to_char(name) {
cmap.char_map.insert(gid.0, ch.to_string());
}
}
}
if cmap.char_map.is_empty() {
return None;
}
debug!(
"TrueType post glyph names: {} GID→Unicode entries",
cmap.char_map.len()
);
cmap.code_byte_length = 2;
Some(cmap)
}
fn build_gid_to_unicode(face: &ttf_parser::Face<'_>) -> Option<HashMap<u16, char>> {
let mut gid_to_unicode: HashMap<u16, char> = HashMap::new();
// Iterate all Unicode codepoints that have a glyph mapping.
// For each codepoint, the face gives us a GlyphId; reverse that to GID→Unicode.
// We prefer the first (lowest) codepoint for each GID to handle duplicates.
for subtable in face.tables().cmap.iter().flat_map(|cmap| cmap.subtables) {
let is_symbol =
subtable.platform_id == ttf_parser::PlatformId::Windows && subtable.encoding_id == 0;
if !subtable.is_unicode() && !is_symbol {
continue;
}
subtable.codepoints(|cp| {
if let Some(ch) = char::from_u32(cp) {
if let Some(gid) = subtable.glyph_index(cp) {
let gid_val = gid.0;
gid_to_unicode.entry(gid_val).or_insert(ch);
}
}
});
}
if gid_to_unicode.is_empty() {
return build_cmap_from_glyph_names(face).map(|cmap| {
let mut map = HashMap::new();
for (gid, s) in cmap.char_map {
if let Some(ch) = s.chars().next() {
map.insert(gid, ch);
}
}
map
});
}
Some(gid_to_unicode)
}
/// Build a ToUnicodeCMap from pdf.js built-in binary CMaps (bcmaps).
fn build_cmap_from_builtin_cmap(ordering: &str) -> Option<ToUnicodeCMap> {
let name = format!("Adobe-{}-UCS2.bcmap", ordering);
let dir = find_bcmaps_dir()?;
let path = dir.join(name);
let data = std::fs::read(&path).ok()?;
let mut cmap = parse_binary_cmap(&data).ok()?;
if cmap.char_map.is_empty() && cmap.ranges.is_empty() {
return None;
}
cmap.code_byte_length = 2;
debug!(
"Built-in CMap {}: char_map={} ranges={}",
path.display(),
cmap.char_map.len(),
cmap.ranges.len()
);
Some(cmap)
}
fn find_bcmaps_dir() -> Option<PathBuf> {
if let Ok(dir) = std::env::var("PDF_INSPECTOR_BCMAPS_DIR") {
let p = PathBuf::from(dir);
if p.is_dir() {
return Some(p);
}
}
let default = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("external")
.join("bcmaps");
if default.is_dir() {
return Some(default);
}
None
}
fn parse_binary_cmap(data: &[u8]) -> Result<ToUnicodeCMap, String> {
let mut stream = BinaryCMapStream::new(data);
let _header = stream.read_byte().ok_or("unexpected EOF in bcmap header")?;
let mut cmap = ToUnicodeCMap::new();
let mut use_cmap: Option<String> = None;
while let Some(b) = stream.read_byte() {
let typ = b >> 5;
if typ == 7 {
match b & 0x1f {
0 => {
stream.read_string()?;
}
1 => {
let name = stream.read_string()?;
use_cmap = Some(name);
}
_ => {}
}
continue;
}
let sequence = (b & 0x10) != 0;
let data_size = (b & 0x0f) as usize;
if data_size + 1 > 16 {
return Err("invalid dataSize in bcmap".to_string());
}
let subitems = stream.read_number()? as usize;
match typ {
4 => {
// bfchar
for i in 0..subitems {
let src = stream.read_hex_number(1)?;
let dst = stream.read_hex_bytes(data_size + 1)?;
let src_code = hex_to_u32(&src) as u16;
if let Some(s) = bytes_to_unicode_string(&dst) {
cmap.char_map.insert(src_code, s);
}
if i + 1 < subitems && sequence {
// sequence handled by encoded data, nothing to do
}
}
}
5 => {
// bfrange
for _ in 0..subitems {
let start = stream.read_hex_number(1)?;
let end_delta = stream.read_hex_number(1)?;
let mut end = start.clone();
add_hex(&mut end, &end_delta);
let dst = stream.read_hex_bytes(data_size + 1)?;
let start_code = hex_to_u32(&start) as u16;
let end_code = hex_to_u32(&end) as u16;
if let Some(s) = bytes_to_unicode_string(&dst) {
if s.chars().count() == 1 {
let base = s.chars().next().unwrap() as u32;
cmap.ranges.push((start_code, end_code, base));
} else {
// Expand multi-char sequences
let mut cid = start_code;
for ch in s.chars() {
cmap.char_map.insert(cid, ch.to_string());
if cid == end_code {
break;
}
cid = cid.saturating_add(1);
}
}
}
}
}
_ => {
// Skip unsupported types by consuming their payload.
// We only implement bfchar/bfrange for UCS2 maps.
for _ in 0..subitems {
// Best-effort skip: read a few fields based on type.
if typ <= 3 {
let _ = stream.read_hex_number(data_size)?;
let _ = stream.read_hex_number(data_size)?;
if typ >= 1 {
let _ = stream.read_number()?;
}
}
}
}
}
}
cmap.ranges.sort_unstable_by_key(|&(start, _, _)| start);
if let Some(name) = use_cmap {
if let Some(base) = load_builtin_cmap_by_name(&name) {
cmap = merge_cmaps(base, cmap);
} else {
warn!("bcmap usecmap={} could not be loaded", name);
}
}
Ok(cmap)
}
struct BinaryCMapStream<'a> {
data: &'a [u8],
pos: usize,
}
impl<'a> BinaryCMapStream<'a> {
fn new(data: &'a [u8]) -> Self {
Self { data, pos: 0 }
}
fn read_byte(&mut self) -> Option<u8> {
if self.pos >= self.data.len() {
None
} else {
let b = self.data[self.pos];
self.pos += 1;
Some(b)
}
}
fn read_number(&mut self) -> Result<u32, String> {
let mut n = 0u32;
loop {
let b = self.read_byte().ok_or("unexpected EOF in bcmap")?;
let last = (b & 0x80) == 0;
n = (n << 7) | (b & 0x7f) as u32;
if last {
break;
}
}
Ok(n)
}
fn read_hex_number(&mut self, size: usize) -> Result<Vec<u8>, String> {
// encoded 7-bit number into size+1 bytes
let mut stack = Vec::new();
loop {
let b = self.read_byte().ok_or("unexpected EOF in bcmap")?;
let last = (b & 0x80) == 0;
stack.push(b & 0x7f);
if last {
break;
}
}
let mut out = vec![0u8; size + 1];
let mut buffer = 0u32;
let mut buffer_size = 0u32;
let mut i: i32 = size as i32;
while i >= 0 {
while buffer_size < 8 && !stack.is_empty() {
buffer |= (stack.pop().unwrap() as u32) << buffer_size;
buffer_size += 7;
}
out[i as usize] = (buffer & 0xff) as u8;
buffer >>= 8;
buffer_size = buffer_size.saturating_sub(8);
i -= 1;
}
Ok(out)
}
fn read_hex_bytes(&mut self, len: usize) -> Result<Vec<u8>, String> {
if self.pos + len > self.data.len() {
return Err("unexpected EOF in bcmap".to_string());
}
let out = self.data[self.pos..self.pos + len].to_vec();
self.pos += len;
Ok(out)
}
fn read_string(&mut self) -> Result<String, String> {
let len = self.read_number()? as usize;
let mut buf = Vec::with_capacity(len);
for _ in 0..len {
let v = self.read_number()? as u8;
buf.push(v);
}
String::from_utf8(buf).map_err(|e| e.to_string())
}
}
fn hex_to_u32(bytes: &[u8]) -> u32 {
let mut n = 0u32;
for &b in bytes {
n = (n << 8) | b as u32;
}
n
}
fn add_hex(a: &mut [u8], b: &[u8]) {
let mut c = 0u16;
for i in (0..a.len()).rev() {
c += a[i] as u16 + b[i] as u16;
a[i] = (c & 0xff) as u8;
c >>= 8;
}
}
fn bytes_to_unicode_string(bytes: &[u8]) -> Option<String> {
if bytes.is_empty() {
return None;
}
if !bytes.len().is_multiple_of(2) {
// Treat as latin-1 bytes
return Some(bytes.iter().map(|&b| b as char).collect());
}
let mut out = String::new();
for chunk in bytes.chunks_exact(2) {
let cp = u16::from_be_bytes([chunk[0], chunk[1]]) as u32;
if let Some(ch) = char::from_u32(cp) {
out.push(ch);
}
}
if out.is_empty() {
None
} else {
Some(out)
}
}
#[derive(Debug, Clone)]
struct EncodingCMap {
map: HashMap<u16, u16>,
code_byte_length: u8,
is_identity: bool,
}
fn build_fallback_tounicode_from_encoding(
font_dict: &lopdf::Dictionary,
doc: &Document,
) -> Option<ToUnicodeCMap> {
let encoding = build_encoding_cmap_from_font(font_dict, doc)?;
let ordering = get_cid_system_info_ordering(font_dict, doc)?;
let ucs2 = build_cmap_from_builtin_cmap(&ordering)?;
if encoding.is_identity {
// Identity mapping: charcode == CID
return Some(ucs2);
}
let mut cmap = ToUnicodeCMap::new();
for (charcode, cid) in encoding.map {
if let Some(s) = ucs2.lookup(cid) {
cmap.char_map.insert(charcode, s);
}
}
if cmap.char_map.is_empty() {
return None;
}
cmap.code_byte_length = encoding.code_byte_length;
Some(cmap)
}
fn get_cid_system_info_ordering(font_dict: &lopdf::Dictionary, doc: &Document) -> Option<String> {
let cid_font_dict = get_descendant_cid_font(font_dict, doc)?;
let csi_obj = cid_font_dict.get(b"CIDSystemInfo").ok()?;
let csi_dict = match csi_obj {
Object::Reference(r) => doc.get_dictionary(*r).ok()?,
Object::Dictionary(d) => d,
_ => return None,
};
let ordering = csi_dict.get(b"Ordering").ok().and_then(|o| {
if let Object::String(bytes, _) = o {
Some(String::from_utf8_lossy(bytes).to_string())
} else {
None
}
})?;
Some(ordering)
}
fn build_encoding_cmap_from_font(
font_dict: &lopdf::Dictionary,
doc: &Document,
) -> Option<EncodingCMap> {
let encoding_obj = font_dict.get(b"Encoding").ok()?;
match encoding_obj {
Object::Name(name) => {
let enc = name.as_slice();
if enc == b"Identity-H" || enc == b"Identity-V" {
return Some(EncodingCMap {
map: HashMap::new(),
code_byte_length: 2,
is_identity: true,
});
}
let enc_name = String::from_utf8_lossy(enc).to_string();
load_builtin_encoding_cmap(&enc_name)
}
Object::Reference(r) => {
let obj = doc.get_object(*r).ok()?;
parse_encoding_cmap_object(obj, doc)
}
Object::Stream(s) => parse_encoding_cmap_stream(&s.decompressed_content().ok()?),
Object::Dictionary(_) => None,
_ => None,
}
}
fn parse_encoding_cmap_object(obj: &Object, doc: &Document) -> Option<EncodingCMap> {
match obj {
Object::Stream(s) => parse_encoding_cmap_stream(&s.decompressed_content().ok()?),
Object::Reference(r) => {
let obj = doc.get_object(*r).ok()?;
parse_encoding_cmap_object(obj, doc)
}
_ => None,
}
}
fn load_builtin_encoding_cmap(name: &str) -> Option<EncodingCMap> {
let dir = find_bcmaps_dir()?;
let path = dir.join(format!("{}.bcmap", name));
let data = std::fs::read(&path).ok()?;
parse_binary_cmap_encoding(&data).ok()
}
fn parse_encoding_cmap_stream(data: &[u8]) -> Option<EncodingCMap> {
let text = String::from_utf8_lossy(data);
let mut src_hex_lengths: Vec<usize> = Vec::new();
let mut codespace_byte_len: Option<u8> = None;
if let Some(cs_start) = text.find("begincodespacerange") {
let section_start = cs_start + "begincodespacerange".len();
if let Some(cs_end) = text[section_start..].find("endcodespacerange") {
let section = &text[section_start..section_start + cs_end];
let mut in_hex = false;
let mut hex_len = 0;
for c in section.chars() {
if c == '<' {
in_hex = true;
hex_len = 0;
} else if c == '>' {
if in_hex && hex_len > 0 {
let byte_len = (hex_len + 1) / 2;
codespace_byte_len = Some(byte_len as u8);
}
in_hex = false;
} else if in_hex && c.is_ascii_hexdigit() {
hex_len += 1;
}
}
}
}
let mut map = HashMap::new();
let mut pos = 0;
while let Some(start) = text[pos..].find("begincidchar") {
let section_start = pos + start + "begincidchar".len();
if let Some(end) = text[section_start..].find("endcidchar") {
let section = &text[section_start..section_start + end];
parse_cidchar_section(section, &mut map, &mut src_hex_lengths);
pos = section_start + end;
} else {
break;
}
}
pos = 0;
while let Some(start) = text[pos..].find("begincidrange") {
let section_start = pos + start + "begincidrange".len();
if let Some(end) = text[section_start..].find("endcidrange") {
let section = &text[section_start..section_start + end];
parse_cidrange_section(section, &mut map, &mut src_hex_lengths);
pos = section_start + end;
} else {
break;
}
}
if map.is_empty() {
return None;
}
let code_byte_length = if let Some(cs_len) = codespace_byte_len {
cs_len
} else if !src_hex_lengths.is_empty() {
let max_hex_len = src_hex_lengths.iter().max().copied().unwrap_or(4);
if max_hex_len <= 2 {
1
} else {
2
}
} else {
2
};
Some(EncodingCMap {
map,
code_byte_length,
is_identity: false,
})
}
fn parse_cidchar_section(
section: &str,
map: &mut HashMap<u16, u16>,
src_hex_lengths: &mut Vec<usize>,
) {
let mut chars = section.chars().peekable();
loop {
while chars.peek().is_some_and(|c| c.is_whitespace()) {
chars.next();
}
if chars.peek() != Some(&'<') {
break;
}
chars.next();
let mut src_hex = String::new();
while chars.peek().is_some_and(|&c| c != '>') {
if let Some(c) = chars.next() {
src_hex.push(c);
}
}
chars.next();
let trimmed_src = src_hex.trim();
if !trimmed_src.is_empty() {
src_hex_lengths.push(trimmed_src.len());
}
while chars.peek().is_some_and(|c| c.is_whitespace()) {
chars.next();
}
let mut cid_str = String::new();
while chars.peek().is_some_and(|c| !c.is_whitespace()) {
if let Some(c) = chars.next() {
cid_str.push(c);
}
}
if let (Some(code), Ok(cid)) = (parse_hex_u16(&src_hex), cid_str.parse::<u16>()) {
map.insert(code, cid);
}
}
}
fn parse_cidrange_section(
section: &str,
map: &mut HashMap<u16, u16>,
src_hex_lengths: &mut Vec<usize>,
) {
let mut chars = section.chars().peekable();
loop {
while chars.peek().is_some_and(|c| c.is_whitespace()) {
chars.next();
}
if chars.peek() != Some(&'<') {
break;
}
chars.next();
let mut start_hex = String::new();
while chars.peek().is_some_and(|&c| c != '>') {
if let Some(c) = chars.next() {
start_hex.push(c);
}
}
chars.next();
let trimmed_start = start_hex.trim();
if !trimmed_start.is_empty() {
src_hex_lengths.push(trimmed_start.len());
}
while chars.peek().is_some_and(|c| c.is_whitespace()) {
chars.next();
}
if chars.peek() != Some(&'<') {
continue;
}
chars.next();
let mut end_hex = String::new();
while chars.peek().is_some_and(|&c| c != '>') {
if let Some(c) = chars.next() {
end_hex.push(c);
}
}
chars.next();
while chars.peek().is_some_and(|c| c.is_whitespace()) {
chars.next();
}
let mut cid_str = String::new();
while chars.peek().is_some_and(|c| !c.is_whitespace()) {
if let Some(c) = chars.next() {
cid_str.push(c);
}
}
let (Some(start), Some(end), Ok(start_cid)) = (
parse_hex_u16(&start_hex),
parse_hex_u16(&end_hex),
cid_str.parse::<u16>(),
) else {
continue;
};
let mut cid = start_cid;
for code in start..=end {
map.insert(code, cid);
cid = cid.saturating_add(1);
}
}
}
fn parse_binary_cmap_encoding(data: &[u8]) -> Result<EncodingCMap, String> {
let mut stream = BinaryCMapStream::new(data);
let _header = stream.read_byte().ok_or("unexpected EOF in bcmap header")?;
let mut map: HashMap<u16, u16> = HashMap::new();
let mut max_code_size: u8 = 1;
let mut use_cmap: Option<String> = None;
while let Some(b) = stream.read_byte() {
let typ = b >> 5;
if typ == 7 {
match b & 0x1f {
0 => {
stream.read_string()?;
}
1 => {
let name = stream.read_string()?;
use_cmap = Some(name);
}
_ => {}
}
continue;
}
let _sequence = (b & 0x10) != 0;
let data_size = (b & 0x0f) as usize;
if data_size + 1 > 16 {
return Err("invalid dataSize in bcmap".to_string());
}
max_code_size = max_code_size.max((data_size + 1) as u8);
let subitems = stream.read_number()? as usize;
match typ {
2 => {
// cidchar
let mut prev_code: u32 = 0;
for i in 0..subitems {
let code_bytes = stream.read_hex_number(data_size)?;
let code = hex_to_u32(&code_bytes);
let cid = stream.read_number()? as u16;
if i == 0 {
prev_code = code;
map.insert(code as u16, cid);
continue;
}
if _sequence {
prev_code = prev_code.saturating_add(1);
map.insert(prev_code as u16, cid);
} else {
map.insert(code as u16, cid);
prev_code = code;
}
}
}
3 => {
// cidrange
for _ in 0..subitems {
let start = stream.read_hex_number(data_size)?;
let end_delta = stream.read_hex_number(data_size)?;
let mut end = start.clone();
add_hex(&mut end, &end_delta);
let cid_start = stream.read_number()? as u16;
let start_code = hex_to_u32(&start) as u16;
let end_code = hex_to_u32(&end) as u16;
let mut cid = cid_start;
for code in start_code..=end_code {
map.insert(code, cid);
cid = cid.saturating_add(1);
}
}
}
_ => {
// Skip other types
for _ in 0..subitems {
let _ = stream.read_hex_number(data_size)?;
let _ = stream.read_hex_number(data_size)?;
let _ = stream.read_number()?;
}
}
}
}
if let Some(name) = use_cmap {
if let Some(base) = load_builtin_encoding_cmap(&name) {
let mut merged = base.map;
merged.extend(map);
return Ok(EncodingCMap {
map: merged,
code_byte_length: base.code_byte_length.max(max_code_size),
is_identity: false,
});
}
}
Ok(EncodingCMap {
map,
code_byte_length: max_code_size,
is_identity: false,
})
}
fn load_builtin_cmap_by_name(name: &str) -> Option<ToUnicodeCMap> {
if !name.ends_with("UCS2") {
return None;
}
let dir = find_bcmaps_dir()?;
let path = dir.join(format!("{}.bcmap", name));
let data = std::fs::read(&path).ok()?;
let mut cmap = parse_binary_cmap(&data).ok()?;
if cmap.char_map.is_empty() && cmap.ranges.is_empty() {
return None;
}
cmap.code_byte_length = 2;
Some(cmap)
}
fn merge_cmaps(mut base: ToUnicodeCMap, overlay: ToUnicodeCMap) -> ToUnicodeCMap {
for (cid, s) in overlay.char_map {
base.char_map.insert(cid, s);
}
base.ranges.extend(overlay.ranges);
base.ranges.sort_unstable_by_key(|&(start, _, _)| start);
base.code_byte_length = base.code_byte_length.max(overlay.code_byte_length);
base
}
/// Build a ToUnicodeCMap from predefined CID→Unicode mapping based on CIDSystemInfo.
///
/// Supports Adobe-Korea1 (Korean) character collection. Can be extended for
/// Adobe-Japan1, Adobe-GB1, Adobe-CNS1 in the future.
fn build_cmap_from_cid_system_info(
cid_font_dict: &lopdf::Dictionary,
doc: &Document,
) -> Option<ToUnicodeCMap> {
let csi_obj = cid_font_dict.get(b"CIDSystemInfo").ok()?;
let csi_dict = match csi_obj {
Object::Reference(r) => doc.get_dictionary(*r).ok()?,
Object::Dictionary(d) => d,
_ => return None,
};
let ordering = csi_dict.get(b"Ordering").ok().and_then(|o| {
if let Object::String(bytes, _) = o {
Some(String::from_utf8_lossy(bytes).to_string())
} else {
None
}
})?;
match ordering.as_str() {
"Korea1" => {
use crate::adobe_korea1::ADOBE_KOREA1_CID_TO_UNICODE;
let mut cmap = ToUnicodeCMap::new();
for &(cid, unicode) in ADOBE_KOREA1_CID_TO_UNICODE.iter() {
if let Some(ch) = char::from_u32(unicode as u32) {
cmap.char_map.insert(cid, ch.to_string());
}
}
cmap.code_byte_length = 2;
debug!(
"Adobe-Korea1 predefined CMap: {} entries",
cmap.char_map.len()
);
Some(cmap)
}
"Japan1" | "GB1" | "CNS1" => build_cmap_from_builtin_cmap(&ordering),
_ => None,
}
}
/// Collection of ToUnicode CMaps indexed by ToUnicode stream object number
#[derive(Debug, Default, Clone)]
pub struct FontCMaps {
/// Map of ToUnicode object number to CMap
by_obj_num: HashMap<u32, CMapEntry>,
}
/// Primary CMap plus optional alternative variants.
#[derive(Debug, Clone)]
pub struct CMapEntry {
pub primary: ToUnicodeCMap,
pub remapped: Option<ToUnicodeCMap>,
pub fallback: Option<ToUnicodeCMap>,
}
impl FontCMaps {
/// Build FontCMaps from a lopdf Document model.
///
/// Iterates every page, collects fonts (including Form XObject fonts),
/// and parses any `/ToUnicode` streams via lopdf's decompression.
pub fn from_doc(doc: &Document) -> Self {
let mut by_obj_num: HashMap<u32, CMapEntry> = HashMap::new();
for (_page_num, &page_id) in doc.get_pages().iter() {
// Page-level fonts (includes inherited parent resources)
let fonts = doc.get_page_fonts(page_id).unwrap_or_default();
Self::collect_cmaps_from_fonts(&fonts, doc, &mut by_obj_num);
// Fonts inside Form XObjects referenced by this page
Self::collect_cmaps_from_xobjects(doc, page_id, &mut by_obj_num);
}
FontCMaps { by_obj_num }
}
/// Parse ToUnicode CMaps from a set of font dictionaries.
/// Also handles Identity-H/V CID fonts without ToUnicode by parsing
/// the embedded TrueType cmap from FontFile2.
fn collect_cmaps_from_fonts(
fonts: &std::collections::BTreeMap<Vec<u8>, &lopdf::Dictionary>,
doc: &Document,
by_obj_num: &mut HashMap<u32, CMapEntry>,
) {
// First pass: collect ToUnicode CMaps
for font_dict in fonts.values() {
let obj_ref = match font_dict
.get(b"ToUnicode")
.ok()
.and_then(|o| o.as_reference().ok())
{
Some(r) => r,
None => continue,
};
let obj_num = obj_ref.0;
if by_obj_num.contains_key(&obj_num) {
continue;
}
let stream = match doc.get_object(obj_ref).and_then(Object::as_stream) {
Ok(s) => s,
Err(_) => continue,
};
let data = match stream.decompressed_content() {
Ok(d) => d,
Err(_) => continue,
};
if let Some(cmap) = ToUnicodeCMap::parse(&data) {
debug!(
"CMap obj={:<6} code_byte_length={} char_map={} ranges={}",
obj_num,
cmap.code_byte_length,
cmap.char_map.len(),
cmap.ranges.len()
);
let (mut primary, mut remapped) =
try_remap_subset_cmap(cmap, font_dict, doc, obj_num);
let mut fallback = build_fallback_tounicode_from_encoding(font_dict, doc)
.or_else(|| build_fallback_cmap_for_type0(font_dict, doc))
.or_else(|| build_fallback_cmap_for_simple(font_dict, doc));
// If the ToUnicode map is extremely sparse, prefer the fallback
// (often a better mapping for Symbol/Wingdings/Arabic CID fonts).
let primary_entries = primary.char_map.len() + primary.ranges.len();
if primary_entries < 10 {
if let Some(fb) = fallback.take() {
debug!(
"ToUnicode CMap obj={} too sparse ({} entries); using fallback",
obj_num, primary_entries
);
remapped = Some(primary);
primary = fb;
}
}
by_obj_num.insert(
obj_num,
CMapEntry {
primary,
remapped,
fallback,
},
);
} else {
// ToUnicode present but parse failed; try fallbacks to avoid empty decoding.
let fallback = build_fallback_cmap_for_type0(font_dict, doc)
.or_else(|| build_fallback_cmap_for_simple(font_dict, doc));
if let Some(fb) = fallback {
debug!(
"ToUnicode CMap obj={} parse failed; using fallback (entries={})",
obj_num,
fb.char_map.len()
);
by_obj_num.insert(
obj_num,
CMapEntry {
primary: fb,
remapped: None,
fallback: None,
},
);
}
}
}
// Second pass: Identity-H/V fonts without ToUnicode
// Try: (1) embedded TrueType/OpenType cmap, (2) predefined CID→Unicode mapping
for font_dict in fonts.values() {
if font_dict.get(b"ToUnicode").is_ok() {
continue;
}
let encoding = match font_dict
.get(b"Encoding")
.ok()
.and_then(|o| o.as_name().ok())
{
Some(name) => name,
None => continue,
};
if encoding != b"Identity-H" && encoding != b"Identity-V" {
continue;
}
// Navigate: DescendantFonts[0]
let desc_fonts_obj = match font_dict.get(b"DescendantFonts").ok() {
Some(obj) => obj,
None => continue,
};
let desc_fonts = match desc_fonts_obj {
Object::Array(arr) => arr.clone(),
Object::Reference(r) => match doc.get_object(*r) {
Ok(Object::Array(arr)) => arr.clone(),
_ => continue,
},
_ => continue,
};
if desc_fonts.is_empty() {
continue;
}
let cid_font_dict = match &desc_fonts[0] {
Object::Reference(r) => match doc.get_dictionary(*r) {
Ok(d) => d,
_ => continue,
},
Object::Dictionary(d) => d,
_ => continue,
};
// Try to build CMap from embedded font (FontFile2 or FontFile3)
let font_descriptor = cid_font_dict
.get(b"FontDescriptor")
.ok()
.and_then(|o| match o {
Object::Reference(r) => doc.get_dictionary(*r).ok(),
Object::Dictionary(d) => Some(d),
_ => None,
});
let mut resolved = false;
// Determine the font file reference (FontFile2 or FontFile3)
let font_file_ref = font_descriptor.and_then(|fd| {
fd.get(b"FontFile2")
.ok()
.and_then(|o| o.as_reference().ok())
.or_else(|| {
fd.get(b"FontFile3")
.ok()
.and_then(|o| o.as_reference().ok())
})
});
// The lookup key must match what get_font_file2_obj_num() returns:
// font file obj_num if present, else CIDFont dict obj_num
let lookup_key = font_file_ref
.map(|r| r.0)
.unwrap_or_else(|| match &desc_fonts[0] {
Object::Reference(r) => r.0,
_ => 0,
});
if lookup_key == 0 || by_obj_num.contains_key(&lookup_key) {
continue;
}
// Try parsing embedded TrueType/OpenType cmap
if let Some(ff_ref) = font_file_ref {
if let Ok(stream) = doc.get_object(ff_ref).and_then(Object::as_stream) {
if let Ok(data) = stream.decompressed_content() {
if let Some(cmap) = build_cmap_from_truetype(&data) {
debug!(
"TrueType CMap obj={:<6} (embedded font) char_map={}",
lookup_key,
cmap.char_map.len()
);
by_obj_num.insert(
lookup_key,
CMapEntry {
primary: cmap,
remapped: None,
fallback: None,
},
);
resolved = true;
}
}
}
}
// Fallback: predefined CID→Unicode mapping from CIDSystemInfo
if !resolved {
if let Some(cmap) = build_cmap_from_cid_system_info(cid_font_dict, doc) {
debug!(
"Predefined CMap obj={:<6} (CIDSystemInfo) char_map={}",
lookup_key,
cmap.char_map.len()
);
by_obj_num.insert(
lookup_key,
CMapEntry {
primary: cmap,
remapped: None,
fallback: None,
},
);
}
}
}
// Third pass: simple fonts without ToUnicode (use embedded font cmap as fallback)
for font_dict in fonts.values() {
if font_dict.get(b"ToUnicode").is_ok() {
continue;
}
// Skip fonts with explicit encoding — they can be decoded by the
// standard encoding path (lopdf) and don't need a fallback CMap.
if let Ok(enc) = font_dict.get(b"Encoding") {
if enc.as_name().is_ok() || enc.as_dict().is_ok() || enc.as_reference().is_ok() {
continue;
}
}
let subtype = match font_dict
.get(b"Subtype")
.ok()
.and_then(|o| o.as_name().ok())
{
Some(name) => name,
None => continue,
};
if subtype == b"Type0" {
continue;
}
let font_descriptor = font_dict.get(b"FontDescriptor").ok().and_then(|o| match o {
Object::Reference(r) => doc.get_dictionary(*r).ok(),
Object::Dictionary(d) => Some(d),
_ => None,
});
let font_file_ref = font_descriptor.and_then(|fd| {
fd.get(b"FontFile2")
.ok()
.and_then(|o| o.as_reference().ok())
.or_else(|| {
fd.get(b"FontFile3")
.ok()
.and_then(|o| o.as_reference().ok())
})
});
let ff_ref = match font_file_ref {
Some(r) => r,
None => continue,
};
let lookup_key = ff_ref.0;
if by_obj_num.contains_key(&lookup_key) {
continue;
}
if let Ok(stream) = doc.get_object(ff_ref).and_then(Object::as_stream) {
if let Ok(data) = stream.decompressed_content() {
if let Some(cmap) = build_simple_cmap_from_truetype(&data) {
debug!(
"Simple font cmap obj={:<6} (embedded font) char_map={}",
lookup_key,
cmap.char_map.len()
);
by_obj_num.insert(
lookup_key,
CMapEntry {
primary: cmap,
remapped: None,
fallback: None,
},
);
}
}
}
}
}
/// Walk Form XObjects in a page's resources and collect their font CMaps.
fn collect_cmaps_from_xobjects(
doc: &Document,
page_id: ObjectId,
by_obj_num: &mut HashMap<u32, CMapEntry>,
) {
let (resource_dict, resource_ids) = match doc.get_page_resources(page_id) {
Ok(r) => r,
Err(_) => return,
};
let mut visited = HashSet::new();
if let Some(resources) = resource_dict {
Self::walk_xobject_fonts(resources, doc, by_obj_num, &mut visited);
}
for resource_id in resource_ids {
if let Ok(resources) = doc.get_dictionary(resource_id) {
Self::walk_xobject_fonts(resources, doc, by_obj_num, &mut visited);
}
}
}
/// Recursively collect font CMaps from XObjects in a resource dictionary.
fn walk_xobject_fonts(
resources: &lopdf::Dictionary,
doc: &Document,
by_obj_num: &mut HashMap<u32, CMapEntry>,
visited: &mut HashSet<ObjectId>,
) {
let xobject_dict = match resources.get(b"XObject") {
Ok(Object::Reference(id)) => doc.get_object(*id).and_then(Object::as_dict).ok(),
Ok(Object::Dictionary(dict)) => Some(dict),
_ => None,
};
let xobject_dict = match xobject_dict {
Some(d) => d,
None => return,
};
for (_name, value) in xobject_dict.iter() {
let id = match value {
Object::Reference(id) => *id,
_ => continue,
};
if !visited.insert(id) {
continue;
}
let stream = match doc.get_object(id).and_then(Object::as_stream) {
Ok(s) => s,
Err(_) => continue,
};
let is_form = stream
.dict
.get(b"Subtype")
.and_then(|o| o.as_name())
.is_ok_and(|n| n == b"Form");
if !is_form {
continue;
}
// Collect fonts from this Form XObject's Resources
if let Ok(form_resources) = stream.dict.get(b"Resources").and_then(Object::as_dict) {
// Extract font dict from the Form's resources
let font_dict_obj = match form_resources.get(b"Font") {
Ok(Object::Reference(id)) => doc.get_object(*id).and_then(Object::as_dict).ok(),
Ok(Object::Dictionary(dict)) => Some(dict),
_ => None,
};
if let Some(font_dict) = font_dict_obj {
let mut fonts = std::collections::BTreeMap::new();
for (name, value) in font_dict.iter() {
let font = match value {
Object::Reference(id) => doc.get_dictionary(*id).ok(),
Object::Dictionary(dict) => Some(dict),
_ => None,
};
if let Some(font) = font {
fonts.insert(name.clone(), font);
}
}
Self::collect_cmaps_from_fonts(&fonts, doc, by_obj_num);
}
// Recurse into nested XObjects
Self::walk_xobject_fonts(form_resources, doc, by_obj_num, visited);
}
}
}
/// Get a CMap by ToUnicode object number
pub fn get_by_obj(&self, obj_num: u32) -> Option<&CMapEntry> {
self.by_obj_num.get(&obj_num)
}
}
/// For Type0 CID fonts, try to build a fallback CMap from embedded font data
/// or CIDSystemInfo when a ToUnicode CMap is present but incomplete.
fn build_fallback_cmap_for_type0(
font_dict: &lopdf::Dictionary,
doc: &Document,
) -> Option<ToUnicodeCMap> {
let subtype = font_dict.get(b"Subtype").ok()?.as_name().ok()?;
if subtype != b"Type0" {
return None;
}
let encoding = font_dict
.get(b"Encoding")
.ok()
.and_then(|o| o.as_name().ok())?;
if encoding != b"Identity-H" && encoding != b"Identity-V" {
return None;
}
let desc_fonts_obj = font_dict.get(b"DescendantFonts").ok()?;
let desc_fonts = match desc_fonts_obj {
Object::Array(arr) => arr,
Object::Reference(r) => match doc.get_object(*r) {
Ok(Object::Array(arr)) => arr,
_ => return None,
},
_ => return None,
};
if desc_fonts.is_empty() {
return None;
}
let cid_font_dict = match &desc_fonts[0] {
Object::Reference(r) => doc.get_dictionary(*r).ok()?,
Object::Dictionary(d) => d,
_ => return None,
};
let font_descriptor = cid_font_dict
.get(b"FontDescriptor")
.ok()
.and_then(|o| match o {
Object::Reference(r) => doc.get_dictionary(*r).ok(),
Object::Dictionary(d) => Some(d),
_ => None,
});
let font_file_ref = font_descriptor.and_then(|fd| {
fd.get(b"FontFile2")
.ok()
.and_then(|o| o.as_reference().ok())
.or_else(|| {
fd.get(b"FontFile3")
.ok()
.and_then(|o| o.as_reference().ok())
})
});
if let Some(ff_ref) = font_file_ref {
if let Ok(stream) = doc.get_object(ff_ref).and_then(Object::as_stream) {
if let Ok(data) = stream.decompressed_content() {
if let Some(cmap) = build_cmap_from_truetype(&data) {
if let Some(cid_to_gid) = get_cid_to_gid_map(cid_font_dict, doc) {
if let Some(repaired) = build_cmap_with_cid_to_gid_map(&cmap, &cid_to_gid) {
debug!(
"Fallback TrueType CMap repaired with CIDToGIDMap: {} entries",
repaired.char_map.len()
);
return Some(repaired);
}
}
debug!(
"Fallback TrueType CMap (Type0+ToUnicode) char_map={}",
cmap.char_map.len()
);
return Some(cmap);
}
}
}
}
if let Some(cmap) = build_cmap_from_cid_system_info(cid_font_dict, doc) {
debug!(
"Fallback CIDSystemInfo CMap (Type0+ToUnicode) char_map={}",
cmap.char_map.len()
);
return Some(cmap);
}
None
}
fn build_fallback_cmap_for_simple(
font_dict: &lopdf::Dictionary,
doc: &Document,
) -> Option<ToUnicodeCMap> {
let subtype = font_dict.get(b"Subtype").ok()?.as_name().ok()?;
if subtype == b"Type0" {
return None;
}
let font_descriptor = font_dict
.get(b"FontDescriptor")
.ok()
.and_then(|o| match o {
Object::Reference(r) => doc.get_dictionary(*r).ok(),
Object::Dictionary(d) => Some(d),
_ => None,
})?;
let font_file_ref = font_descriptor
.get(b"FontFile2")
.ok()
.and_then(|o| o.as_reference().ok())
.or_else(|| {
font_descriptor
.get(b"FontFile3")
.ok()
.and_then(|o| o.as_reference().ok())
})?;
if let Ok(stream) = doc.get_object(font_file_ref).and_then(Object::as_stream) {
if let Ok(data) = stream.decompressed_content() {
if let Some(cmap) = build_simple_cmap_from_truetype(&data) {
debug!(
"Fallback simple font cmap (ToUnicode present) char_map={}",
cmap.char_map.len()
);
return Some(cmap);
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_bfchar_2byte() {
let cmap_content = r#"
/CIDInit /ProcSet findresource begin
12 dict begin
begincmap
1 begincodespacerange
<0000><FFFF>
endcodespacerange
3 beginbfchar
<0003> <0020>
<0024> <0041>
<0025> <0042>
endbfchar
endcmap
"#;
let cmap = ToUnicodeCMap::parse(cmap_content.as_bytes()).unwrap();
assert_eq!(cmap.code_byte_length, 2);
assert_eq!(cmap.lookup(0x0003), Some(" ".to_string()));
assert_eq!(cmap.lookup(0x0024), Some("A".to_string()));
assert_eq!(cmap.lookup(0x0025), Some("B".to_string()));
}
#[test]
fn test_parse_bfchar_1byte() {
// This is the pattern that caused the CJK bug: codespace is <0000><FFFF>
// but all source codes are 1-byte hex (e.g., <20>, <41>)
let cmap_content = r#"
1 begincodespacerange
<0000> <FFFF>
endcodespacerange
3 beginbfchar
<20> <0020>
<41> <0041>
<42> <0042>
endbfchar
"#;
let cmap = ToUnicodeCMap::parse(cmap_content.as_bytes()).unwrap();
// Should detect as 1-byte because all source codes are 1-byte hex
assert_eq!(cmap.code_byte_length, 1);
assert_eq!(cmap.lookup(0x0020), Some(" ".to_string()));
assert_eq!(cmap.lookup(0x0041), Some("A".to_string()));
}
#[test]
fn test_decode_cids_2byte() {
let cmap_content = r#"
1 begincodespacerange
<0000><FFFF>
endcodespacerange
3 beginbfchar
<0003> <0020>
<0024> <0041>
<0025> <0042>
endbfchar
"#;
let cmap = ToUnicodeCMap::parse(cmap_content.as_bytes()).unwrap();
// "AB " in 2-byte CID encoding
let cids = [0x00, 0x24, 0x00, 0x25, 0x00, 0x03];
assert_eq!(cmap.decode_cids(&cids), "AB ");
}
#[test]
fn test_decode_cids_1byte_no_cjk_garbage() {
// Simulates the bug: CMap with 1-byte source codes
let cmap_content = r#"
1 begincodespacerange
<0000> <FFFF>
endcodespacerange
5 beginbfchar
<20> <0020>
<42> <0042>
<79> <0079>
<50> <0050>
<52> <0052>
endbfchar
"#;
let cmap = ToUnicodeCMap::parse(cmap_content.as_bytes()).unwrap();
assert_eq!(cmap.code_byte_length, 1);
// "By" should decode to "By", NOT to CJK character 䉹
let bytes = [0x42, 0x79];
let result = cmap.decode_cids(&bytes);
assert_eq!(result, "By");
assert!(!result.contains('䉹'), "Should not produce CJK garbage");
// "PR" should decode to "PR"
let bytes2 = [0x50, 0x52];
assert_eq!(cmap.decode_cids(&bytes2), "PR");
}
#[test]
fn test_bfrange_array_format() {
let cmap_content = r#"
1 begincodespacerange
<0000> <FFFF>
endcodespacerange
1 beginbfrange
<0003> <0005> [<0041> <0042> <0043>]
endbfrange
"#;
let cmap = ToUnicodeCMap::parse(cmap_content.as_bytes()).unwrap();
assert_eq!(cmap.lookup(0x0003), Some("A".to_string()));
assert_eq!(cmap.lookup(0x0004), Some("B".to_string()));
assert_eq!(cmap.lookup(0x0005), Some("C".to_string()));
}
#[test]
fn test_remap_to_sequential() {
// Simulate a broken CMap where GIDs are from pre-subsetting:
// Old GID 3 → space, old GID 36 → 'A', old GID 37 → 'B'
// The subset font has sequential GIDs: 1=space, 2='A', 3='B'
let cmap_content = r#"
1 begincodespacerange
<0000><FFFF>
endcodespacerange
3 beginbfchar
<0003> <0020>
<0024> <0041>
<0025> <0042>
endbfchar
"#;
let cmap = ToUnicodeCMap::parse(cmap_content.as_bytes()).unwrap();
// Original CMap: CID 3 → space, CID 36 → 'A', CID 37 → 'B'
assert_eq!(cmap.lookup(0x0003), Some(" ".to_string()));
assert_eq!(cmap.lookup(0x0024), Some("A".to_string()));
assert_eq!(cmap.lookup(0x0025), Some("B".to_string()));
assert_eq!(cmap.lookup(0x0001), None);
assert_eq!(cmap.lookup(0x0002), None);
// After remapping: CID 1 → space, CID 2 → 'A', CID 3 → 'B'
let remapped = cmap.remap_to_sequential();
assert_eq!(remapped.lookup(0x0001), Some(" ".to_string()));
assert_eq!(remapped.lookup(0x0002), Some("A".to_string()));
assert_eq!(remapped.lookup(0x0003), Some("B".to_string()));
assert_eq!(remapped.lookup(0x0024), None);
assert_eq!(remapped.lookup(0x0025), None);
}
#[test]
fn test_remap_to_sequential_with_ranges() {
// CMap with a bfrange: old GIDs 100-102 → 'X', 'Y', 'Z'
// Plus a bfchar: old GID 50 → space
let cmap_content = r#"
1 begincodespacerange
<0000><FFFF>
endcodespacerange
1 beginbfchar
<0032> <0020>
endbfchar
1 beginbfrange
<0064> <0066> <0058>
endbfrange
"#;
let cmap = ToUnicodeCMap::parse(cmap_content.as_bytes()).unwrap();
assert_eq!(cmap.lookup(0x0032), Some(" ".to_string())); // CID 50
assert_eq!(cmap.lookup(0x0064), Some("X".to_string())); // CID 100
assert_eq!(cmap.lookup(0x0065), Some("Y".to_string())); // CID 101
assert_eq!(cmap.lookup(0x0066), Some("Z".to_string())); // CID 102
let remapped = cmap.remap_to_sequential();
// Sorted old CIDs: 50, 100, 101, 102 → new CIDs: 1, 2, 3, 4
assert_eq!(remapped.lookup(0x0001), Some(" ".to_string()));
assert_eq!(remapped.lookup(0x0002), Some("X".to_string()));
assert_eq!(remapped.lookup(0x0003), Some("Y".to_string()));
assert_eq!(remapped.lookup(0x0004), Some("Z".to_string()));
// Ranges should be cleared (all in char_map now)
assert!(remapped.ranges.is_empty());
}
#[test]
fn test_min_source_cid() {
let cmap_content = r#"
1 begincodespacerange
<0000><FFFF>
endcodespacerange
2 beginbfchar
<0003> <0020>
<0024> <0041>
endbfchar
1 beginbfrange
<0030> <0032> <0058>
endbfrange
"#;
let cmap = ToUnicodeCMap::parse(cmap_content.as_bytes()).unwrap();
assert_eq!(cmap.min_source_cid(), Some(3));
}
#[test]
fn test_unmapped_2byte_cids_skipped() {
let cmap_content = r#"
1 begincodespacerange
<0000><FFFF>
endcodespacerange
1 beginbfchar
<0041> <0041>
endbfchar
"#;
let cmap = ToUnicodeCMap::parse(cmap_content.as_bytes()).unwrap();
assert_eq!(cmap.code_byte_length, 2);
// CID 0x4279 is unmapped - should NOT produce CJK character
let bytes = [0x42, 0x79];
let result = cmap.decode_cids(&bytes);
assert!(
!result.contains('䉹'),
"Unmapped 2-byte CIDs should not produce CJK"
);
}
}