* fix(extractor): reduce garbled OCR false positives * fix(extractor): tighten garbled text OCR routing * fix(extractor): decode UTF-16 ToUnicode destinations * fix(extractor): narrow ToUnicode destination cleanup * fix(extractor): decode Aptos private ff ligature
3150 lines
108 KiB
Rust
3150 lines
108 KiB
Rust
//! 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,
|
||
/// When true, unmapped CIDs are interpreted as Unicode codepoints directly.
|
||
/// Used as a last resort for Identity-H fonts without ToUnicode/cmap/glyph names.
|
||
pub cid_passthrough: bool,
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|
||
|
||
// When a sequential remap was applied and a TrueType fallback has more
|
||
// entries than the primary ToUnicode CMap, prefer the TrueType cmap.
|
||
// Subset fonts number GIDs by document encounter order, so the sorted
|
||
// sequential remap scrambles characters. The TrueType cmap table maps
|
||
// the real GID→Unicode and is authoritative.
|
||
if remapped.is_some() {
|
||
if let Some(ref fb) = fallback {
|
||
let fb_entries = fb.char_map.len() + fb.ranges.len();
|
||
if fb_entries > primary_entries {
|
||
debug!(
|
||
"ToUnicode CMap obj={}: TrueType fallback ({} entries) > primary ({}); promoting over sequential remap",
|
||
obj_num, fb_entries, primary_entries
|
||
);
|
||
let old_remap = remapped.take().unwrap();
|
||
remapped = fallback.take();
|
||
fallback = Some(old_remap);
|
||
}
|
||
}
|
||
}
|
||
|
||
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),
|
||
hex_to_unicode_scalar(&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),
|
||
_ => {
|
||
if self.cid_passthrough {
|
||
// Last-resort: treat CID as Unicode codepoint.
|
||
// Valid for Identity-H fonts where the PDF generator
|
||
// used Unicode values as CIDs but stripped the cmap.
|
||
if let Some(ch) = char::from_u32(cid as u32) {
|
||
if !ch.is_control() || ch == '\t' || ch == '\n' {
|
||
result.push(ch);
|
||
} else {
|
||
unmapped_count += 1;
|
||
}
|
||
} else {
|
||
unmapped_count += 1;
|
||
}
|
||
} else {
|
||
// 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,
|
||
}
|
||
}
|
||
|
||
/// Get the maximum source CID across all mappings (char_map + ranges).
|
||
fn max_source_cid(&self) -> Option<u16> {
|
||
let char_max = self.char_map.keys().copied().max();
|
||
let range_max = self.ranges.iter().map(|&(_, end, _)| end).max();
|
||
match (char_max, range_max) {
|
||
(Some(a), Some(b)) => Some(a.max(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()
|
||
}
|
||
|
||
/// Convert a ToUnicode destination hex string to Unicode.
|
||
///
|
||
/// PDF ToUnicode destinations are UTF-16BE strings. Supplementary-plane
|
||
/// characters are encoded as surrogate pairs, so treating each 4-hex chunk as
|
||
/// a scalar drops emoji like D83CDF1F.
|
||
fn hex_to_unicode_string(hex: &str) -> Option<String> {
|
||
let hex: String = hex.chars().filter(|ch| !ch.is_ascii_whitespace()).collect();
|
||
if hex.is_empty() || !hex.len().is_multiple_of(2) {
|
||
return None;
|
||
}
|
||
|
||
let bytes: Option<Vec<u8>> = (0..hex.len())
|
||
.step_by(2)
|
||
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16).ok())
|
||
.collect();
|
||
let bytes = bytes?;
|
||
|
||
if bytes.len().is_multiple_of(2) {
|
||
let units: Vec<u16> = bytes
|
||
.chunks_exact(2)
|
||
.map(|chunk| u16::from_be_bytes([chunk[0], chunk[1]]))
|
||
.collect();
|
||
if let Ok(result) = String::from_utf16(&units) {
|
||
if !result.is_empty() {
|
||
return Some(normalize_tounicode_destination(result));
|
||
}
|
||
}
|
||
}
|
||
|
||
// Be permissive for non-standard one-byte destinations.
|
||
if bytes.len() == 1 {
|
||
let ch = bytes[0] as char;
|
||
if !ch.is_control() || ch == '\t' || ch == '\n' {
|
||
return Some(ch.to_string());
|
||
}
|
||
}
|
||
|
||
None
|
||
}
|
||
|
||
fn normalize_tounicode_destination(text: String) -> String {
|
||
let is_multi_char = text.chars().nth(1).is_some();
|
||
|
||
// Some malformed producer CMaps put a list of alternative whitespace or
|
||
// hyphen codepoints into one destination. Keep ordinary multi-character
|
||
// mappings intact unless that malformed signature is present.
|
||
if is_multi_char
|
||
&& text.chars().all(char::is_whitespace)
|
||
&& text.chars().any(|ch| matches!(ch, '\t' | '\n' | '\r'))
|
||
{
|
||
return if text.contains('\t') {
|
||
"\t".to_string()
|
||
} else {
|
||
" ".to_string()
|
||
};
|
||
}
|
||
|
||
if is_multi_char
|
||
&& text.contains('\u{00ad}')
|
||
&& text.chars().all(|ch| {
|
||
matches!(
|
||
ch,
|
||
'-' | '\u{00ad}' | '\u{2010}' | '\u{2011}' | '\u{2012}' | '\u{2013}' | '\u{2212}'
|
||
)
|
||
})
|
||
{
|
||
return "-".to_string();
|
||
}
|
||
|
||
text
|
||
}
|
||
|
||
fn hex_to_unicode_scalar(hex: &str) -> Option<u32> {
|
||
let text = hex_to_unicode_string(hex)?;
|
||
let mut chars = text.chars();
|
||
let ch = chars.next()?;
|
||
if chars.next().is_none() {
|
||
Some(ch as u32)
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
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,
|
||
}
|
||
}
|
||
|
||
/// Return true if the CIDFont's W (widths) array explicitly covers the given CID.
|
||
///
|
||
/// The W array uses two formats (PDF 32000-1:2008, §9.7.4.3):
|
||
/// 1. `c [w1 w2 ... wn]` — widths for CIDs c, c+1, ..., c+n-1
|
||
/// 2. `c_first c_last w` — CIDs c_first..c_last all have width w
|
||
fn w_array_covers_cid(cid_font_dict: &lopdf::Dictionary, doc: &Document, target: u16) -> bool {
|
||
let Ok(w_obj) = cid_font_dict.get(b"W") else {
|
||
return false;
|
||
};
|
||
let arr = match w_obj {
|
||
Object::Array(arr) => arr,
|
||
Object::Reference(r) => match doc.get_object(*r) {
|
||
Ok(Object::Array(arr)) => arr,
|
||
_ => return false,
|
||
},
|
||
_ => return false,
|
||
};
|
||
|
||
let resolve_int = |o: &Object| -> Option<i64> {
|
||
match o {
|
||
Object::Integer(n) => Some(*n),
|
||
Object::Reference(r) => match doc.get_object(*r) {
|
||
Ok(Object::Integer(n)) => Some(*n),
|
||
_ => None,
|
||
},
|
||
_ => None,
|
||
}
|
||
};
|
||
|
||
let resolve_arr = |o: &Object| -> Option<Vec<Object>> {
|
||
match o {
|
||
Object::Array(a) => Some(a.clone()),
|
||
Object::Reference(r) => match doc.get_object(*r) {
|
||
Ok(Object::Array(a)) => Some(a.clone()),
|
||
_ => None,
|
||
},
|
||
_ => None,
|
||
}
|
||
};
|
||
|
||
let target = target as i64;
|
||
let mut i = 0usize;
|
||
while i < arr.len() {
|
||
let Some(first) = resolve_int(&arr[i]) else {
|
||
break;
|
||
};
|
||
i += 1;
|
||
if i >= arr.len() {
|
||
break;
|
||
}
|
||
// Peek at arr[i] to decide format.
|
||
if let Some(widths) = resolve_arr(&arr[i]) {
|
||
// Format 1: c [w1 ... wn]
|
||
let last = first + widths.len() as i64 - 1;
|
||
if target >= first && target <= last {
|
||
return true;
|
||
}
|
||
i += 1;
|
||
} else if let Some(last) = resolve_int(&arr[i]) {
|
||
// Format 2: c_first c_last w
|
||
i += 1;
|
||
if i < arr.len() {
|
||
i += 1; // skip the width value
|
||
}
|
||
if target >= first && target <= last {
|
||
return true;
|
||
}
|
||
} else {
|
||
// Unknown token — abort parsing safely
|
||
break;
|
||
}
|
||
}
|
||
false
|
||
}
|
||
|
||
/// 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),
|
||
};
|
||
|
||
// If the W array actually covers the CMap's max source CID, the CMap is
|
||
// aligned with the font — no sequential renumbering happened. A sparse W
|
||
// array starting at CID 0 (for .notdef) with additional high-CID entries
|
||
// matching the CMap is the normal subset layout, not a mismatch.
|
||
if let Some(max_cid) = cmap.max_source_cid() {
|
||
if w_array_covers_cid(cid_font_dict, doc, max_cid) {
|
||
debug!(
|
||
"Subset remap skipped for obj={}: W array covers CMap max CID {}",
|
||
obj_num, max_cid
|
||
);
|
||
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 0–255 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;
|
||
}
|
||
}
|
||
}
|
||
// Fallback: Windows Unicode BMP (3,1) — maps Unicode codepoints to GIDs.
|
||
// For single-byte fonts, try each byte value as a Unicode codepoint.
|
||
// Common in OCR-generated PDFs where byte values correspond to Unicode
|
||
// codepoints but the declared encoding (WinAnsiEncoding) is wrong.
|
||
if !used_encoding_cmap {
|
||
for subtable in cmap_table.subtables {
|
||
if subtable.platform_id == ttf_parser::PlatformId::Windows
|
||
&& subtable.encoding_id == 1
|
||
{
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
/// Check if a CIDFont's /W (widths) array contains CID values that look like
|
||
/// Unicode codepoints rather than low-value GIDs.
|
||
///
|
||
/// Returns true if the median CID is >= 0x41 (letter 'A'), indicating
|
||
/// the PDF generator likely used Unicode codepoints as CIDs.
|
||
pub(crate) fn cid_values_look_like_unicode(cid_font_dict: &lopdf::Dictionary) -> bool {
|
||
let w_arr = match cid_font_dict.get(b"W").ok() {
|
||
Some(Object::Array(arr)) => arr,
|
||
_ => return false,
|
||
};
|
||
|
||
// The /W array format: [cid [w1 w2 ...]] or [cid_start cid_end w]
|
||
// We extract all CID values (the first element of each group).
|
||
let mut cids: Vec<u16> = Vec::new();
|
||
let mut i = 0;
|
||
while i < w_arr.len() {
|
||
if let Ok(cid) = w_arr[i].as_i64() {
|
||
cids.push(cid as u16);
|
||
// Skip the width data
|
||
if i + 1 < w_arr.len() {
|
||
match &w_arr[i + 1] {
|
||
Object::Array(widths) => {
|
||
// [cid [w1 w2 ...]] — CIDs are cid, cid+1, ..., cid+len-1
|
||
for j in 1..widths.len() {
|
||
cids.push((cid as u16).wrapping_add(j as u16));
|
||
}
|
||
i += 2;
|
||
}
|
||
_ => {
|
||
// [cid_start cid_end w] — range of CIDs
|
||
if i + 2 < w_arr.len() {
|
||
if let Ok(cid_end) = w_arr[i + 1].as_i64() {
|
||
for c in (cid as u16)..=(cid_end as u16) {
|
||
cids.push(c);
|
||
}
|
||
}
|
||
i += 3;
|
||
} else {
|
||
i += 1;
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
i += 1;
|
||
}
|
||
} else {
|
||
i += 1;
|
||
}
|
||
}
|
||
|
||
if cids.is_empty() {
|
||
return false;
|
||
}
|
||
|
||
cids.sort_unstable();
|
||
let median = cids[cids.len() / 2];
|
||
// Unicode text CIDs are typically >= 0x20 (space) with letters at 0x41+.
|
||
// GID-based subsets typically start at low values (0-based).
|
||
// Use median >= 0x41 as a heuristic for Unicode CIDs.
|
||
median >= 0x41
|
||
}
|
||
|
||
/// 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 {
|
||
Self::from_doc_pages(doc, None)
|
||
}
|
||
|
||
/// Build FontCMaps for specific pages only. Pass `None` for all pages.
|
||
pub fn from_doc_pages(doc: &Document, page_filter: Option<&HashSet<u32>>) -> Self {
|
||
Self::from_doc_pages_inner(doc, page_filter, false)
|
||
}
|
||
|
||
/// Build FontCMaps in fast mode: skip expensive TrueType font fallback
|
||
/// parsing. Fonts that can't be decoded from their ToUnicode CMap alone
|
||
/// will be missing, causing text extraction to produce empty/garbage text
|
||
/// which triggers `needs_ocr` fallback. This is ideal for hybrid OCR
|
||
/// pipelines where GPU OCR is always available as a fallback.
|
||
pub fn from_doc_pages_fast(doc: &Document, page_filter: Option<&HashSet<u32>>) -> Self {
|
||
Self::from_doc_pages_inner(doc, page_filter, true)
|
||
}
|
||
|
||
fn from_doc_pages_inner(
|
||
doc: &Document,
|
||
page_filter: Option<&HashSet<u32>>,
|
||
skip_truetype_fallback: bool,
|
||
) -> Self {
|
||
let mut by_obj_num: HashMap<u32, CMapEntry> = HashMap::new();
|
||
|
||
for (page_num, &page_id) in doc.get_pages().iter() {
|
||
if let Some(filter) = page_filter {
|
||
if !filter.contains(page_num) {
|
||
continue;
|
||
}
|
||
}
|
||
// Page-level fonts (includes inherited parent resources)
|
||
let fonts = doc.get_page_fonts(page_id).unwrap_or_default();
|
||
Self::collect_cmaps_from_fonts_inner(
|
||
&fonts,
|
||
doc,
|
||
&mut by_obj_num,
|
||
skip_truetype_fallback,
|
||
);
|
||
|
||
if !skip_truetype_fallback {
|
||
// 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>,
|
||
) {
|
||
Self::collect_cmaps_from_fonts_inner(fonts, doc, by_obj_num, false);
|
||
}
|
||
|
||
fn collect_cmaps_from_fonts_inner(
|
||
fonts: &std::collections::BTreeMap<Vec<u8>, &lopdf::Dictionary>,
|
||
doc: &Document,
|
||
by_obj_num: &mut HashMap<u32, CMapEntry>,
|
||
skip_truetype_fallback: bool,
|
||
) {
|
||
// 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(_) => stream.content.clone(),
|
||
};
|
||
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);
|
||
|
||
// Only build expensive fallbacks when the primary CMap is sparse.
|
||
// build_fallback_cmap_for_type0 can take seconds on large embedded
|
||
// TrueType fonts (decompressing + parsing 100K+ byte font files).
|
||
// Skip entirely when the primary CMap is sufficient.
|
||
let primary_entries = primary.char_map.len() + primary.ranges.len();
|
||
let mut fallback = if primary_entries < 10 && !skip_truetype_fallback {
|
||
// Try cheap fallback first; only attempt expensive TrueType
|
||
// parsing if cheap fallbacks don't yield results.
|
||
let cheap = build_fallback_tounicode_from_encoding(font_dict, doc)
|
||
.or_else(|| build_fallback_cmap_for_simple(font_dict, doc));
|
||
if cheap.is_some() {
|
||
cheap
|
||
} else {
|
||
build_fallback_cmap_for_type0(font_dict, doc)
|
||
}
|
||
} else if primary_entries < 10 {
|
||
// Fast mode: only try cheap fallbacks, skip TrueType parsing.
|
||
// Regions using this font will get needs_ocr=true.
|
||
build_fallback_tounicode_from_encoding(font_dict, doc)
|
||
.or_else(|| build_fallback_cmap_for_simple(font_dict, doc))
|
||
} else {
|
||
// Primary is rich enough; only try the cheap encoding fallback
|
||
build_fallback_tounicode_from_encoding(font_dict, doc)
|
||
};
|
||
|
||
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 = if skip_truetype_fallback {
|
||
build_fallback_cmap_for_simple(font_dict, doc)
|
||
} else {
|
||
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
|
||
// Skip entirely in fast mode — these fonts require expensive TrueType parsing.
|
||
if skip_truetype_fallback {
|
||
return;
|
||
}
|
||
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) {
|
||
let data = match stream.decompressed_content() {
|
||
Ok(d) => d,
|
||
Err(_) => stream.content.clone(),
|
||
};
|
||
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,
|
||
},
|
||
);
|
||
resolved = true;
|
||
}
|
||
}
|
||
|
||
// Last resort: CID-as-Unicode passthrough.
|
||
// Many PDF generators (Chromium, wkhtmltopdf) use Identity-H encoding where
|
||
// CID values ARE Unicode codepoints, but strip the cmap table and omit
|
||
// ToUnicode. We detect this by checking the /W (widths) array: if CID values
|
||
// fall in typical Unicode letter/digit ranges (0x41+), CIDs are likely Unicode.
|
||
// If CIDs are low values (< 0x41), they're GIDs in a subset font.
|
||
if !resolved {
|
||
if cid_values_look_like_unicode(cid_font_dict) {
|
||
debug!(
|
||
"Identity-H font obj={}: W array CIDs look like Unicode — using passthrough",
|
||
lookup_key
|
||
);
|
||
let mut cmap = ToUnicodeCMap::new();
|
||
cmap.code_byte_length = 2;
|
||
cmap.cid_passthrough = true;
|
||
by_obj_num.insert(
|
||
lookup_key,
|
||
CMapEntry {
|
||
primary: cmap,
|
||
remapped: None,
|
||
fallback: None,
|
||
},
|
||
);
|
||
} else {
|
||
debug!(
|
||
"Identity-H font obj={}: no decoding possible (stripped cmap, GID-based CIDs)",
|
||
lookup_key
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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_parse_bfchar_surrogate_pair_emoji() {
|
||
let cmap_content = r#"
|
||
1 begincodespacerange
|
||
<00> <FF>
|
||
endcodespacerange
|
||
2 beginbfchar
|
||
<16> <D83CDF1F>
|
||
<9D> <D83CDFAD>
|
||
endbfchar
|
||
"#;
|
||
let cmap = ToUnicodeCMap::parse(cmap_content.as_bytes()).unwrap();
|
||
|
||
assert_eq!(cmap.code_byte_length, 1);
|
||
assert_eq!(cmap.lookup(0x16), Some("🌟".to_string()));
|
||
assert_eq!(cmap.lookup(0x9D), Some("🎭".to_string()));
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_bfrange_surrogate_pair_base() {
|
||
let cmap_content = r#"
|
||
1 begincodespacerange
|
||
<00> <FF>
|
||
endcodespacerange
|
||
1 beginbfrange
|
||
<C8> <C9> <D83CDFD8>
|
||
endbfrange
|
||
"#;
|
||
let cmap = ToUnicodeCMap::parse(cmap_content.as_bytes()).unwrap();
|
||
|
||
assert_eq!(cmap.code_byte_length, 1);
|
||
assert_eq!(cmap.lookup(0xC8), Some("🏘".to_string()));
|
||
assert_eq!(cmap.lookup(0xC9), Some("🏙".to_string()));
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_bfrange_preserves_single_hyphen_like_base() {
|
||
let cmap_content = r#"
|
||
1 begincodespacerange
|
||
<00> <FF>
|
||
endcodespacerange
|
||
1 beginbfrange
|
||
<21> <22> <2013>
|
||
endbfrange
|
||
"#;
|
||
let cmap = ToUnicodeCMap::parse(cmap_content.as_bytes()).unwrap();
|
||
|
||
assert_eq!(cmap.lookup(0x21), Some("–".to_string()));
|
||
assert_eq!(cmap.lookup(0x22), Some("—".to_string()));
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_spaced_destination_hex_without_control_noise() {
|
||
let cmap_content = r#"
|
||
1 begincodespacerange
|
||
<00> <FF>
|
||
endcodespacerange
|
||
3 beginbfchar
|
||
<21> < 0009 000d 0020 00a0 >
|
||
<22> < 002d 00ad 2010 >
|
||
<23> <00a0>
|
||
endbfchar
|
||
"#;
|
||
let cmap = ToUnicodeCMap::parse(cmap_content.as_bytes()).unwrap();
|
||
|
||
assert_eq!(cmap.lookup(0x21), Some("\t".to_string()));
|
||
assert_eq!(cmap.lookup(0x22), Some("-".to_string()));
|
||
assert_eq!(cmap.lookup(0x23), Some("\u{00a0}".to_string()));
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_preserves_valid_multi_character_destinations() {
|
||
let cmap_content = r#"
|
||
1 begincodespacerange
|
||
<00> <FF>
|
||
endcodespacerange
|
||
4 beginbfchar
|
||
<21> <002d002d>
|
||
<22> <20132013>
|
||
<23> <002000a0>
|
||
<24> <00660069>
|
||
endbfchar
|
||
"#;
|
||
let cmap = ToUnicodeCMap::parse(cmap_content.as_bytes()).unwrap();
|
||
|
||
assert_eq!(cmap.lookup(0x21), Some("--".to_string()));
|
||
assert_eq!(cmap.lookup(0x22), Some("––".to_string()));
|
||
assert_eq!(cmap.lookup(0x23), Some(" \u{00a0}".to_string()));
|
||
assert_eq!(cmap.lookup(0x24), Some("fi".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"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn fallback_promotion_when_larger_than_primary() {
|
||
// Simulate: primary has 5 char_map entries, remapped exists (sequential),
|
||
// fallback has 20 entries (TrueType cmap). The fallback should be
|
||
// promoted to `remapped` and the old remap demoted to `fallback`.
|
||
let mut primary = ToUnicodeCMap::new();
|
||
for i in 0..5u16 {
|
||
primary
|
||
.char_map
|
||
.insert(100 + i, char::from(b'A' + i as u8).to_string());
|
||
}
|
||
primary.code_byte_length = 2;
|
||
|
||
let mut sequential_remap = ToUnicodeCMap::new();
|
||
for i in 0..5u16 {
|
||
sequential_remap
|
||
.char_map
|
||
.insert(i, char::from(b'A' + i as u8).to_string());
|
||
}
|
||
sequential_remap.code_byte_length = 2;
|
||
|
||
let mut truetype_fb = ToUnicodeCMap::new();
|
||
for i in 0..20u16 {
|
||
truetype_fb
|
||
.char_map
|
||
.insert(i, format!("U+{:04X}", 0x4E00 + i));
|
||
}
|
||
truetype_fb.code_byte_length = 2;
|
||
|
||
let primary_entries = primary.char_map.len() + primary.ranges.len();
|
||
let mut remapped: Option<ToUnicodeCMap> = Some(sequential_remap);
|
||
let mut fallback: Option<ToUnicodeCMap> = Some(truetype_fb);
|
||
|
||
// Apply the same promotion logic as build_cmap_entry_from_stream
|
||
if remapped.is_some() {
|
||
if let Some(ref fb) = fallback {
|
||
let fb_entries = fb.char_map.len() + fb.ranges.len();
|
||
if fb_entries > primary_entries {
|
||
let old_remap = remapped.take().unwrap();
|
||
remapped = fallback.take();
|
||
fallback = Some(old_remap);
|
||
}
|
||
}
|
||
}
|
||
|
||
// The TrueType fallback (20 entries) should now be in `remapped`
|
||
let r = remapped.unwrap();
|
||
assert_eq!(
|
||
r.char_map.len(),
|
||
20,
|
||
"TrueType cmap should be promoted to remapped"
|
||
);
|
||
|
||
// The old sequential remap (5 entries) should now be in `fallback`
|
||
let f = fallback.unwrap();
|
||
assert_eq!(
|
||
f.char_map.len(),
|
||
5,
|
||
"Sequential remap should be demoted to fallback"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn no_fallback_promotion_when_smaller() {
|
||
// When fallback has fewer entries than primary, no swap should occur.
|
||
let mut primary = ToUnicodeCMap::new();
|
||
for i in 0..50u16 {
|
||
primary
|
||
.char_map
|
||
.insert(100 + i, format!("U+{:04X}", 0x0041 + i));
|
||
}
|
||
primary.code_byte_length = 2;
|
||
|
||
let mut sequential_remap = ToUnicodeCMap::new();
|
||
for i in 0..50u16 {
|
||
sequential_remap
|
||
.char_map
|
||
.insert(i, format!("U+{:04X}", 0x0041 + i));
|
||
}
|
||
sequential_remap.code_byte_length = 2;
|
||
|
||
let mut small_fb = ToUnicodeCMap::new();
|
||
for i in 0..10u16 {
|
||
small_fb.char_map.insert(i, format!("U+{:04X}", 0x4E00 + i));
|
||
}
|
||
small_fb.code_byte_length = 2;
|
||
|
||
let primary_entries = primary.char_map.len() + primary.ranges.len();
|
||
let mut remapped: Option<ToUnicodeCMap> = Some(sequential_remap);
|
||
let mut fallback: Option<ToUnicodeCMap> = Some(small_fb);
|
||
|
||
if remapped.is_some() {
|
||
if let Some(ref fb) = fallback {
|
||
let fb_entries = fb.char_map.len() + fb.ranges.len();
|
||
if fb_entries > primary_entries {
|
||
let old_remap = remapped.take().unwrap();
|
||
remapped = fallback.take();
|
||
fallback = Some(old_remap);
|
||
}
|
||
}
|
||
}
|
||
|
||
// No swap: remapped should still have 50 entries
|
||
assert_eq!(remapped.unwrap().char_map.len(), 50);
|
||
assert_eq!(fallback.unwrap().char_map.len(), 10);
|
||
}
|
||
|
||
#[test]
|
||
fn test_max_source_cid() {
|
||
let cmap_content = r#"
|
||
1 begincodespacerange
|
||
<0000><FFFF>
|
||
endcodespacerange
|
||
2 beginbfchar
|
||
<0003> <0020>
|
||
<0031> <004E>
|
||
endbfchar
|
||
1 beginbfrange
|
||
<0208> <0227> <0430>
|
||
endbfrange
|
||
"#;
|
||
let cmap = ToUnicodeCMap::parse(cmap_content.as_bytes()).unwrap();
|
||
assert_eq!(cmap.min_source_cid(), Some(0x0003));
|
||
assert_eq!(cmap.max_source_cid(), Some(0x0227));
|
||
}
|
||
|
||
/// Helper: build a minimal CIDFont dict with a W array and check coverage.
|
||
fn cid_font_dict_with_w(w_items: Vec<lopdf::Object>) -> lopdf::Dictionary {
|
||
let mut d = lopdf::Dictionary::new();
|
||
d.set("W", lopdf::Object::Array(w_items));
|
||
d
|
||
}
|
||
|
||
#[test]
|
||
fn test_w_array_covers_cid_format1() {
|
||
// Format 1: `c [w1 w2 ... wn]` — widths for CIDs c..c+n-1.
|
||
// Mimics the 16.pdf Tahoma W array: 0[1000] 3[313] 5[401] 11[383 383] 16[363 303 382]
|
||
let doc = Document::new();
|
||
let d = cid_font_dict_with_w(vec![
|
||
lopdf::Object::Integer(0),
|
||
lopdf::Object::Array(vec![lopdf::Object::Integer(1000)]),
|
||
lopdf::Object::Integer(3),
|
||
lopdf::Object::Array(vec![lopdf::Object::Integer(313)]),
|
||
lopdf::Object::Integer(5),
|
||
lopdf::Object::Array(vec![lopdf::Object::Integer(401)]),
|
||
lopdf::Object::Integer(11),
|
||
lopdf::Object::Array(vec![
|
||
lopdf::Object::Integer(383),
|
||
lopdf::Object::Integer(383),
|
||
]),
|
||
lopdf::Object::Integer(16),
|
||
lopdf::Object::Array(vec![
|
||
lopdf::Object::Integer(363),
|
||
lopdf::Object::Integer(303),
|
||
lopdf::Object::Integer(382),
|
||
]),
|
||
lopdf::Object::Integer(570),
|
||
lopdf::Object::Array(vec![lopdf::Object::Integer(667); 26]),
|
||
]);
|
||
|
||
assert!(w_array_covers_cid(&d, &doc, 0));
|
||
assert!(w_array_covers_cid(&d, &doc, 3));
|
||
assert!(w_array_covers_cid(&d, &doc, 5));
|
||
assert!(w_array_covers_cid(&d, &doc, 11));
|
||
assert!(w_array_covers_cid(&d, &doc, 12));
|
||
assert!(w_array_covers_cid(&d, &doc, 16));
|
||
assert!(w_array_covers_cid(&d, &doc, 18));
|
||
assert!(w_array_covers_cid(&d, &doc, 570));
|
||
assert!(w_array_covers_cid(&d, &doc, 595));
|
||
// Gaps are NOT covered
|
||
assert!(!w_array_covers_cid(&d, &doc, 1));
|
||
assert!(!w_array_covers_cid(&d, &doc, 4));
|
||
assert!(!w_array_covers_cid(&d, &doc, 19));
|
||
assert!(!w_array_covers_cid(&d, &doc, 596));
|
||
}
|
||
|
||
#[test]
|
||
fn test_w_array_covers_cid_format2() {
|
||
// Format 2: `c_first c_last w` — CIDs c_first..c_last all have width w.
|
||
let doc = Document::new();
|
||
let d = cid_font_dict_with_w(vec![
|
||
lopdf::Object::Integer(100),
|
||
lopdf::Object::Integer(120),
|
||
lopdf::Object::Integer(500),
|
||
]);
|
||
|
||
assert!(w_array_covers_cid(&d, &doc, 100));
|
||
assert!(w_array_covers_cid(&d, &doc, 110));
|
||
assert!(w_array_covers_cid(&d, &doc, 120));
|
||
assert!(!w_array_covers_cid(&d, &doc, 99));
|
||
assert!(!w_array_covers_cid(&d, &doc, 121));
|
||
}
|
||
|
||
#[test]
|
||
fn test_w_array_covers_cid_missing_w() {
|
||
let doc = Document::new();
|
||
let d = lopdf::Dictionary::new();
|
||
assert!(!w_array_covers_cid(&d, &doc, 3));
|
||
}
|
||
|
||
#[test]
|
||
fn test_try_remap_skipped_when_w_covers_cmap() {
|
||
// Simulates 16.pdf: CMap's max source CID (0x0279 = 633) is explicitly
|
||
// in the W array, so no subset-renumbering happened — remap must NOT fire.
|
||
let cmap_content = r#"
|
||
1 begincodespacerange
|
||
<0000><FFFF>
|
||
endcodespacerange
|
||
2 beginbfchar
|
||
<0003> <0020>
|
||
<0031> <004E>
|
||
endbfchar
|
||
2 beginbfrange
|
||
<023A> <0253> <0410>
|
||
<0255> <0279> <042B>
|
||
endbfrange
|
||
"#;
|
||
let cmap = ToUnicodeCMap::parse(cmap_content.as_bytes()).unwrap();
|
||
|
||
let mut doc = Document::new();
|
||
// Build a CIDFont dict with Identity CIDToGIDMap and a W array that
|
||
// covers CID 633 via `597 [widths...]`.
|
||
let mut cid_font = lopdf::Dictionary::new();
|
||
cid_font.set("CIDToGIDMap", lopdf::Object::Name(b"Identity".to_vec()));
|
||
cid_font.set(
|
||
"W",
|
||
lopdf::Object::Array(vec![
|
||
lopdf::Object::Integer(0),
|
||
lopdf::Object::Array(vec![lopdf::Object::Integer(750)]),
|
||
lopdf::Object::Integer(597),
|
||
lopdf::Object::Array(vec![lopdf::Object::Integer(500); 37]), // 597..633
|
||
]),
|
||
);
|
||
let cid_font_id = doc.add_object(cid_font);
|
||
|
||
// Build the Type0 font dict with Identity-H + DescendantFonts ref.
|
||
let mut font_dict = lopdf::Dictionary::new();
|
||
font_dict.set("Encoding", lopdf::Object::Name(b"Identity-H".to_vec()));
|
||
font_dict.set(
|
||
"DescendantFonts",
|
||
lopdf::Object::Array(vec![lopdf::Object::Reference(cid_font_id)]),
|
||
);
|
||
|
||
let (primary, remapped) = try_remap_subset_cmap(cmap, &font_dict, &doc, 123);
|
||
assert!(
|
||
remapped.is_none(),
|
||
"Remap must be skipped when W covers CMap max CID (this is 16.pdf)"
|
||
);
|
||
assert_eq!(primary.lookup(0x0003), Some(" ".to_string()));
|
||
}
|
||
|
||
#[test]
|
||
fn test_try_remap_fires_for_true_subset_mismatch() {
|
||
// True mismatch: CMap has high CIDs (512-544) but W only lists low sequential CIDs.
|
||
let cmap_content = r#"
|
||
1 begincodespacerange
|
||
<0000><FFFF>
|
||
endcodespacerange
|
||
1 beginbfrange
|
||
<0200> <0220> <0410>
|
||
endbfrange
|
||
"#;
|
||
let cmap = ToUnicodeCMap::parse(cmap_content.as_bytes()).unwrap();
|
||
|
||
let mut doc = Document::new();
|
||
let mut cid_font = lopdf::Dictionary::new();
|
||
cid_font.set("CIDToGIDMap", lopdf::Object::Name(b"Identity".to_vec()));
|
||
cid_font.set(
|
||
"W",
|
||
lopdf::Object::Array(vec![
|
||
lopdf::Object::Integer(0),
|
||
lopdf::Object::Array(vec![lopdf::Object::Integer(500); 34]), // 0..33
|
||
]),
|
||
);
|
||
let cid_font_id = doc.add_object(cid_font);
|
||
|
||
let mut font_dict = lopdf::Dictionary::new();
|
||
font_dict.set("Encoding", lopdf::Object::Name(b"Identity-H".to_vec()));
|
||
font_dict.set(
|
||
"DescendantFonts",
|
||
lopdf::Object::Array(vec![lopdf::Object::Reference(cid_font_id)]),
|
||
);
|
||
|
||
let (_primary, remapped) = try_remap_subset_cmap(cmap, &font_dict, &doc, 456);
|
||
assert!(
|
||
remapped.is_some(),
|
||
"Remap must fire when CMap's CIDs are outside W array coverage"
|
||
);
|
||
}
|
||
}
|