use super::get_number;
use crate::glyph_names::glyph_to_char;
use crate::tounicode::FontCMaps;
use crate::types::{
FontEncoding, FontEncodingMap, FontWidthInfo, PageFontEncodings, PageFontWidths,
};
use log::debug;
use lopdf::{Document, Encoding, Object, ObjectId};
use std::collections::HashMap;
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub(crate) enum CMapChoice {
Primary,
Remapped,
}
#[derive(Debug, Default, Clone)]
pub(crate) struct CMapDecisionCache {
decisions: HashMap<u32, CMapDecision>,
}
#[derive(Debug, Default, Clone)]
struct CMapDecision {
primary_sample: String,
remapped_sample: String,
sample_bytes: usize,
choice: Option<CMapChoice>,
}
impl CMapDecisionCache {
pub(crate) fn new() -> Self {
Self::default()
}
pub(crate) fn get_choice(&self, obj_num: u32) -> Option<CMapChoice> {
self.decisions.get(&obj_num).and_then(|d| d.choice)
}
pub(crate) fn consider(
&mut self,
obj_num: u32,
primary: &str,
remapped: &str,
bytes_len: usize,
) -> Option<CMapChoice> {
const SAMPLE_TARGET_BYTES: usize = 240;
let entry = self.decisions.entry(obj_num).or_default();
entry.sample_bytes = entry.sample_bytes.saturating_add(bytes_len);
entry.primary_sample.push_str(primary);
entry.remapped_sample.push_str(remapped);
if entry.choice.is_none() && entry.sample_bytes >= SAMPLE_TARGET_BYTES {
let score_primary = score_text(&entry.primary_sample);
let score_remap = score_text(&entry.remapped_sample);
entry.choice = if score_remap > score_primary + 5 {
Some(CMapChoice::Remapped)
} else {
Some(CMapChoice::Primary)
};
}
entry.choice
}
}
pub(crate) fn resolve_array<'a>(doc: &'a Document, obj: &'a Object) -> Option<&'a Vec<Object>> {
match obj {
Object::Array(arr) => Some(arr),
Object::Reference(r) => {
if let Ok(Object::Array(arr)) = doc.get_object(*r) {
Some(arr)
} else {
None
}
}
_ => None,
}
}
pub(crate) fn resolve_dict<'a>(
doc: &'a Document,
obj: &'a Object,
) -> Option<&'a lopdf::Dictionary> {
match obj {
Object::Dictionary(d) => Some(d),
Object::Reference(r) => doc.get_dictionary(*r).ok(),
_ => None,
}
}
pub(crate) fn build_font_widths(
doc: &Document,
fonts: &std::collections::BTreeMap<Vec<u8>, &lopdf::Dictionary>,
) -> PageFontWidths {
let mut widths = PageFontWidths::new();
for (font_name, font_dict) in fonts {
let resource_name = String::from_utf8_lossy(font_name).to_string();
let subtype = font_dict
.get(b"Subtype")
.ok()
.and_then(|o| o.as_name().ok())
.map(|n| String::from_utf8_lossy(n).to_string())
.unwrap_or_default();
let base_font = font_dict
.get(b"BaseFont")
.ok()
.and_then(|o| o.as_name().ok())
.map(|n| String::from_utf8_lossy(n).to_string())
.unwrap_or_default();
let has_tounicode = font_dict.get(b"ToUnicode").is_ok();
let has_descendants = font_dict.get(b"DescendantFonts").is_ok();
let encoding_str = font_dict
.get(b"Encoding")
.ok()
.map(|o| match o {
Object::Name(n) => String::from_utf8_lossy(n).to_string(),
Object::Reference(_) => "ref(dict)".to_string(),
Object::Dictionary(_) => "dict".to_string(),
_ => format!("{:?}", o),
})
.unwrap_or_else(|| "none".to_string());
debug!(
"font {:<10} sub={:<12} base={:<45} toUni={:<6} enc={:<20} cid={}",
resource_name, subtype, base_font, has_tounicode, encoding_str, has_descendants
);
if let Some(info) = parse_font_widths(doc, font_dict) {
widths.insert(resource_name, info);
}
}
widths
}
pub(crate) fn build_type3_scales(
doc: &Document,
fonts: &std::collections::BTreeMap<Vec<u8>, &lopdf::Dictionary>,
) -> HashMap<String, f32> {
let mut scales = HashMap::new();
for (font_name, font_dict) in fonts {
let is_type3 = font_dict
.get(b"Subtype")
.ok()
.and_then(|o| o.as_name().ok())
.is_some_and(|n| n == b"Type3");
if !is_type3 {
continue;
}
let num = |o: &Object| {
let resolved = match o {
Object::Reference(r) => match doc.get_object(*r) {
Ok(inner) => inner,
Err(_) => return 0.0,
},
other => other,
};
match resolved {
Object::Integer(i) => *i as f32,
Object::Real(r) => *r,
_ => 0.0,
}
};
let Some(matrix) = font_dict
.get(b"FontMatrix")
.ok()
.and_then(|o| resolve_array(doc, o))
else {
continue;
};
let Some(bbox) = font_dict
.get(b"FontBBox")
.ok()
.and_then(|o| resolve_array(doc, o))
else {
continue;
};
if matrix.len() < 4 || bbox.len() < 4 {
continue;
}
let scale_y = (num(&matrix[2]).powi(2) + num(&matrix[3]).powi(2)).sqrt();
let bbox_h = (num(&bbox[3]) - num(&bbox[1])).abs();
let scale = bbox_h * scale_y;
const CONSISTENT_LO: f32 = 0.25;
const CONSISTENT_HI: f32 = 4.0;
if scale.is_finite() && scale > 0.0 && !(CONSISTENT_LO..=CONSISTENT_HI).contains(&scale) {
scales.insert(String::from_utf8_lossy(font_name).to_string(), scale);
}
}
scales
}
pub(crate) fn build_type3_y_flips(
doc: &Document,
fonts: &std::collections::BTreeMap<Vec<u8>, &lopdf::Dictionary>,
) -> std::collections::HashSet<String> {
let mut flipped = std::collections::HashSet::new();
for (font_name, font_dict) in fonts {
let is_type3 = font_dict
.get(b"Subtype")
.ok()
.and_then(|o| o.as_name().ok())
.is_some_and(|n| n == b"Type3");
if !is_type3 {
continue;
}
let Some(matrix) = font_dict
.get(b"FontMatrix")
.ok()
.and_then(|o| resolve_array(doc, o))
else {
continue;
};
let Some(d) = matrix.get(3) else {
continue;
};
let d = match d {
Object::Reference(r) => doc.get_object(*r).ok().and_then(|o| o.as_float().ok()),
other => other.as_float().ok(),
};
if d.is_some_and(|d| d < 0.0) {
flipped.insert(String::from_utf8_lossy(font_name).to_string());
}
}
flipped
}
pub(crate) fn item_font_name<'a>(resource_name: &'a str, base_font: &'a str) -> &'a str {
if crate::text_utils::is_cid_font(resource_name) {
resource_name
} else {
base_font
}
}
pub(crate) fn parse_font_widths(
doc: &Document,
font_dict: &lopdf::Dictionary,
) -> Option<FontWidthInfo> {
let subtype = font_dict.get(b"Subtype").ok()?;
let subtype_name = subtype.as_name().ok()?;
match subtype_name {
b"Type0" => parse_type0_widths(doc, font_dict),
b"Type1" | b"TrueType" | b"MMType1" => parse_simple_font_widths(doc, font_dict)
.or_else(|| base14_fallback_widths(doc, font_dict)),
b"Type3" => parse_simple_font_widths(doc, font_dict),
_ => None,
}
}
fn base14_fallback_widths(doc: &Document, font_dict: &lopdf::Dictionary) -> Option<FontWidthInfo> {
let base_font = font_dict
.get(b"BaseFont")
.ok()
.and_then(|o| o.as_name().ok())
.map(|n| String::from_utf8_lossy(n).to_string())?;
if !crate::extractor::base14::is_base14_font(&base_font) {
return None;
}
let enc_map = parse_font_encoding(doc, font_dict)
.map(|r| r.map)
.unwrap_or_default();
let mut widths = HashMap::new();
for code in 0u16..=255 {
let ch = enc_map
.get(&(code as u8))
.copied()
.or_else(|| crate::extractor::base14::builtin_encoding_char(&base_font, code as u8))
.unwrap_or_else(|| decode_single_byte_fallback_char(code as u8, true));
if let Some(w) = crate::extractor::base14::base14_char_width(&base_font, ch) {
widths.insert(code, w);
}
}
let space_width = widths.get(&32).copied().unwrap_or(250);
debug!(
" base14 fallback widths for {} ({} codes mapped)",
base_font,
widths.len()
);
Some(FontWidthInfo {
widths,
default_width: 500,
space_width,
is_cid: false,
units_scale: 0.001,
wmode: 0,
})
}
pub(crate) fn parse_simple_font_widths(
doc: &Document,
font_dict: &lopdf::Dictionary,
) -> Option<FontWidthInfo> {
let first_char = font_dict.get(b"FirstChar").ok().and_then(|o| match o {
Object::Integer(n) => Some(*n as u16),
Object::Reference(r) => doc.get_object(*r).ok().and_then(|o| {
if let Object::Integer(n) = o {
Some(*n as u16)
} else {
None
}
}),
_ => None,
})?;
let last_char = font_dict.get(b"LastChar").ok().and_then(|o| match o {
Object::Integer(n) => Some(*n as u16),
Object::Reference(r) => doc.get_object(*r).ok().and_then(|o| {
if let Object::Integer(n) = o {
Some(*n as u16)
} else {
None
}
}),
_ => None,
})?;
let widths_obj = font_dict.get(b"Widths").ok()?;
let widths_array = resolve_array(doc, widths_obj)?;
let mut widths = HashMap::new();
let mut space_width: u16 = 0;
for (i, w_obj) in widths_array.iter().enumerate() {
let code = first_char + i as u16;
if code > last_char {
break;
}
let w = match w_obj {
Object::Integer(n) => *n as u16,
Object::Real(n) => *n as u16,
Object::Reference(r) => {
if let Ok(obj) = doc.get_object(*r) {
match obj {
Object::Integer(n) => *n as u16,
Object::Real(n) => *n as u16,
_ => continue,
}
} else {
continue;
}
}
_ => continue,
};
if code == 32 {
space_width = w;
}
widths.insert(code, w);
}
let units_scale = if let Ok(fm) = font_dict.get(b"FontMatrix") {
if let Some(arr) = resolve_array(doc, fm) {
if !arr.is_empty() {
match &arr[0] {
Object::Real(r) => r.abs(),
Object::Integer(i) => (*i as f32).abs(),
_ => 0.001,
}
} else {
0.001
}
} else {
0.001
}
} else {
0.001 };
if space_width == 0 {
if !widths.is_empty() && (units_scale - 0.001).abs() > 0.0005 {
let sum: u32 = widths.values().map(|&w| w as u32).sum();
let avg = sum as f32 / widths.len() as f32;
space_width = (avg * 0.45).max(1.0) as u16;
} else {
space_width = 250;
}
}
Some(FontWidthInfo {
widths,
default_width: 0,
space_width,
is_cid: false,
units_scale,
wmode: 0,
})
}
pub(crate) fn parse_type0_widths(
doc: &Document,
font_dict: &lopdf::Dictionary,
) -> Option<FontWidthInfo> {
let desc_fonts_obj = font_dict.get(b"DescendantFonts").ok()?;
let desc_fonts = resolve_array(doc, desc_fonts_obj)?;
if desc_fonts.is_empty() {
return None;
}
let cid_font_dict = resolve_dict(doc, &desc_fonts[0])?;
let default_width = cid_font_dict
.get(b"DW")
.ok()
.and_then(|o| match o {
Object::Integer(n) => Some(*n as u16),
Object::Real(n) => Some(*n as u16),
_ => None,
})
.unwrap_or(1000);
let mut widths = HashMap::new();
if let Ok(w_obj) = cid_font_dict.get(b"W") {
if let Some(w_array) = resolve_array(doc, w_obj) {
parse_cid_w_array(doc, w_array, &mut widths);
}
}
let space_width = widths
.get(&32)
.or_else(|| widths.get(&3))
.copied()
.unwrap_or(if default_width > 0 {
default_width / 4
} else {
250
});
let wmode = font_dict
.get(b"WMode")
.ok()
.and_then(|o| match o {
Object::Integer(n) => Some(*n as u8),
_ => None,
})
.unwrap_or(0);
Some(FontWidthInfo {
widths,
default_width,
space_width,
is_cid: true,
units_scale: 0.001, wmode,
})
}
pub(crate) fn parse_cid_w_array(
doc: &Document,
w_array: &[Object],
widths: &mut HashMap<u16, u16>,
) {
let mut i = 0;
let mut assigned = 0usize;
while i < w_array.len() {
if assigned >= crate::tounicode::MAX_CID_W_EXPANSION {
return;
}
let start_cid = match &w_array[i] {
Object::Integer(n) => *n as u16,
Object::Real(n) => *n as u16,
_ => {
i += 1;
continue;
}
};
i += 1;
if i >= w_array.len() {
break;
}
match &w_array[i] {
Object::Array(arr) => {
for (j, w_obj) in arr.iter().enumerate() {
if !assign_cid_width(
widths,
start_cid.wrapping_add(j as u16),
w_obj,
&mut assigned,
) {
return;
}
}
i += 1;
}
Object::Reference(r) => {
if let Ok(Object::Array(arr)) = doc.get_object(*r) {
for (j, w_obj) in arr.iter().enumerate() {
if !assign_cid_width(
widths,
start_cid.wrapping_add(j as u16),
w_obj,
&mut assigned,
) {
return;
}
}
i += 1;
} else {
i += 1; }
}
Object::Integer(end_cid) => {
let end = *end_cid as u16;
i += 1;
if i >= w_array.len() {
break;
}
let w = match &w_array[i] {
Object::Integer(n) => *n as u16,
Object::Real(n) => *n as u16,
_ => {
i += 1;
continue;
}
};
if !assign_cid_width_range(widths, start_cid, end, w, &mut assigned) {
return;
}
i += 1;
}
Object::Real(end_cid) => {
let end = *end_cid as u16;
i += 1;
if i >= w_array.len() {
break;
}
let w = match &w_array[i] {
Object::Integer(n) => *n as u16,
Object::Real(n) => *n as u16,
_ => {
i += 1;
continue;
}
};
if !assign_cid_width_range(widths, start_cid, end, w, &mut assigned) {
return;
}
i += 1;
}
_ => {
i += 1;
}
}
}
}
fn assign_cid_width(
widths: &mut HashMap<u16, u16>,
cid: u16,
w_obj: &Object,
assigned: &mut usize,
) -> bool {
let w = match w_obj {
Object::Integer(n) => *n as u16,
Object::Real(n) => *n as u16,
_ => return true,
};
if *assigned >= crate::tounicode::MAX_CID_W_EXPANSION {
return false;
}
widths.insert(cid, w);
*assigned += 1;
true
}
fn assign_cid_width_range(
widths: &mut HashMap<u16, u16>,
start: u16,
end: u16,
w: u16,
assigned: &mut usize,
) -> bool {
if start > end {
return true;
}
for cid in start..=end {
if *assigned >= crate::tounicode::MAX_CID_W_EXPANSION {
return false;
}
widths.insert(cid, w);
*assigned += 1;
}
true
}
pub(crate) fn compute_string_width_ts(
bytes: &[u8],
font_info: &FontWidthInfo,
font_size: f32,
char_spacing: f32,
word_spacing: f32,
) -> f32 {
let mut total: f32 = 0.0;
let mut num_spaces: usize = 0;
let num_chars = if font_info.is_cid {
let mut j = 0;
let mut count = 0usize;
while j + 1 < bytes.len() {
let cid = u16::from_be_bytes([bytes[j], bytes[j + 1]]);
let w = font_info
.widths
.get(&cid)
.copied()
.unwrap_or(font_info.default_width);
total += w as f32;
if cid == 32 {
num_spaces += 1;
}
count += 1;
j += 2;
}
count
} else {
for &b in bytes {
let code = b as u16;
let w = font_info
.widths
.get(&code)
.copied()
.unwrap_or(font_info.default_width);
total += w as f32;
if b == 0x20 {
num_spaces += 1;
}
}
bytes.len()
};
total * font_info.units_scale * font_size
+ num_chars as f32 * char_spacing
+ num_spaces as f32 * word_spacing
}
pub(crate) fn get_operand_bytes(obj: &Object) -> Option<&[u8]> {
if let Object::String(bytes, _) = obj {
Some(bytes)
} else {
None
}
}
pub(crate) fn build_font_encodings(
doc: &Document,
fonts: &std::collections::BTreeMap<Vec<u8>, &lopdf::Dictionary>,
cmaps: &FontCMaps,
font_cache: &mut FontStyleCache,
) -> (PageFontEncodings, bool) {
let mut encodings = PageFontEncodings::new();
let mut has_gid_fonts = false;
for (font_name, font_dict) in fonts {
let resource_name = String::from_utf8_lossy(font_name).to_string();
let mut differences = FontEncodingMap::new();
let mut identity_overrides = FontEncodingMap::new();
if let Some(result) = parse_font_encoding(doc, font_dict) {
if !result.gid_codes.is_empty()
&& !tounicode_maps_codes(font_dict, cmaps, &result.gid_codes)
{
has_gid_fonts = true;
}
if !result.map.is_empty() {
identity_overrides = stale_identity_cmap_overrides(doc, font_dict, cmaps, &result);
differences = result.map;
}
}
let blank_codes = blank_glyph_codes(doc, font_dict, font_cache);
if !differences.is_empty() || !blank_codes.is_empty() {
encodings.insert(
resource_name,
FontEncoding {
differences,
identity_overrides,
blank_codes,
},
);
}
}
(encodings, has_gid_fonts)
}
fn blank_glyph_reads_as_space(encoding: Option<&FontEncoding>, code: u8, label: &str) -> bool {
encoding.is_some_and(|map| map.blank_codes.contains(&code))
&& label.chars().any(|c| !is_invisible_format(c))
}
fn is_invisible_format(c: char) -> bool {
matches!(
c,
'\u{00AD}'
| '\u{200B}'..='\u{200F}'
| '\u{202A}'..='\u{202E}'
| '\u{2060}'..='\u{2064}'
| '\u{2066}'..='\u{2069}'
| '\u{FEFF}'
)
}
fn blank_glyph_codes(
doc: &Document,
font_dict: &lopdf::Dictionary,
font_cache: &mut FontStyleCache,
) -> std::collections::HashSet<u8> {
use ttf_parser::PlatformId;
let font_file = || -> Option<ObjectId> {
if font_dict.get(b"Subtype").ok()?.as_name().ok()? != b"TrueType"
|| font_dict.get(b"Encoding").is_ok()
{
return None;
}
let descriptor = resolve_dict(doc, font_dict.get(b"FontDescriptor").ok()?)?;
if descriptor.get(b"Flags").ok()?.as_i64().ok()? & 4 == 0 {
return None;
}
descriptor.get(b"FontFile2").ok()?.as_reference().ok()
};
let Some(ff_ref) = font_file() else {
return Default::default();
};
if let Some(cached) = font_cache.blank_codes_by_font_file.get(&ff_ref) {
return cached.clone();
}
let compute = || -> Option<std::collections::HashSet<u8>> {
let data = font_file_data(doc, ff_ref)?;
let face = ttf_parser::Face::parse(&data, 0).ok()?;
let cmap = face.tables().cmap?;
let glyph_for = |code: u8| -> Option<ttf_parser::GlyphId> {
let code = u32::from(code);
for subtable in cmap.subtables {
let candidates: &[u32] = match (subtable.platform_id, subtable.encoding_id) {
(PlatformId::Macintosh, 0) => &[code],
(PlatformId::Windows, 0) => {
&[0xF000 + code, 0xF100 + code, 0xF200 + code, code]
}
_ => continue,
};
for &candidate in candidates {
if let Some(gid) = subtable.glyph_index(candidate) {
if gid.0 != 0 {
return Some(gid);
}
}
}
}
None
};
let mut blank = std::collections::HashSet::new();
let mut outlined = 0usize;
let mut mapped = 0usize;
for code in 0x20u8..=255 {
let Some(gid) = glyph_for(code) else {
continue;
};
mapped += 1;
if face.glyph_bounding_box(gid).is_some() {
outlined += 1;
continue;
}
if face.glyph_hor_advance(gid).is_some_and(|w| w > 0) {
blank.insert(code);
}
}
if blank.is_empty() || (outlined == 0 && mapped > 2) {
return None;
}
debug!(
"blank glyph codes for {}: {:?}",
font_dict
.get(b"BaseFont")
.ok()
.and_then(|o| o.as_name().ok())
.map(|n| String::from_utf8_lossy(n).into_owned())
.unwrap_or_default(),
{
let mut codes: Vec<u8> = blank.iter().copied().collect();
codes.sort_unstable();
codes
}
);
Some(blank)
};
let blank = compute().unwrap_or_default();
font_cache
.blank_codes_by_font_file
.insert(ff_ref, blank.clone());
blank
}
fn stale_identity_cmap_overrides(
doc: &Document,
font_dict: &lopdf::Dictionary,
cmaps: &FontCMaps,
encoding: &EncodingResult,
) -> FontEncodingMap {
let verified = || -> Option<FontEncodingMap> {
if font_dict.get(b"Subtype").ok()?.as_name().ok()? != b"Type1" {
return None;
}
let cmap_ref = font_dict.get(b"ToUnicode").ok()?.as_reference().ok()?;
let entry = cmaps.get_by_obj(cmap_ref.0)?;
if entry.primary.code_byte_length != 1 || entry.remapped.is_some() {
return None;
}
let elsewhere: std::collections::HashSet<String> = (0..=255)
.filter_map(|code| entry.primary.lookup(code))
.collect();
let single_case_match =
|case: String| case.chars().count() == 1 && elsewhere.contains(&case);
let candidates: FontEncodingMap = encoding
.map
.iter()
.filter_map(|(&code, &ch)| {
(code.is_ascii_graphic()
&& !ch.is_ascii()
&& (ch.is_alphabetic() || ch == '\u{00a0}')
&& entry.primary.lookup(code as u16).as_deref()
== Some(&(code as char).to_string())
&& (elsewhere.contains(&ch.to_string())
|| (ch.is_alphabetic()
&& (single_case_match(ch.to_lowercase().to_string())
|| single_case_match(ch.to_uppercase().to_string())))))
.then_some((code, ch))
})
.collect();
let exact_anchor_count = |map: &FontEncodingMap| {
map.values()
.copied()
.filter(|ch| ch.is_alphabetic() && elsewhere.contains(&ch.to_string()))
.collect::<std::collections::HashSet<char>>()
.len()
};
if exact_anchor_count(&candidates) < 3 {
return None;
}
let descriptor = resolve_dict(doc, font_dict.get(b"FontDescriptor").ok()?)?;
let font_ref = descriptor.get(b"FontFile3").ok()?.as_reference().ok()?;
let stream = doc.get_object(font_ref).ok()?.as_stream().ok()?;
if stream.dict.get(b"Subtype").ok()?.as_name().ok()? != b"Type1C" {
return None;
}
let data = font_file_data(doc, font_ref)?;
let cff = ttf_parser::cff::Table::parse(&data)?;
let overrides: FontEncodingMap = candidates
.into_iter()
.filter(|(code, _)| {
encoding
.glyph_names
.get(code)
.is_some_and(|name| cff.glyph_index_by_name(name).is_some())
})
.collect();
(exact_anchor_count(&overrides) >= 3).then_some(overrides)
};
verified().unwrap_or_default()
}
fn tounicode_maps_codes(font_dict: &lopdf::Dictionary, cmaps: &FontCMaps, codes: &[u8]) -> bool {
let Some(obj_ref) = font_dict
.get(b"ToUnicode")
.ok()
.and_then(|o| o.as_reference().ok())
else {
return false;
};
let Some(entry) = cmaps.get_by_obj(obj_ref.0) else {
return false;
};
codes.iter().any(|&code| {
entry
.primary
.lookup(code as u16)
.is_some_and(|s| !s.is_empty() && !s.contains('\u{FFFD}'))
})
}
pub(crate) fn parse_font_encoding(
doc: &Document,
font_dict: &lopdf::Dictionary,
) -> Option<EncodingResult> {
let encoding_obj = font_dict.get(b"Encoding").ok()?;
let base_font_name = font_dict
.get(b"BaseFont")
.ok()
.and_then(|o| o.as_name().ok())
.map(|n| String::from_utf8_lossy(n).to_string());
match encoding_obj {
Object::Name(_name) => {
None }
Object::Reference(obj_ref) => {
if let Ok(enc_dict) = doc.get_dictionary(*obj_ref) {
parse_encoding_dictionary(doc, enc_dict, base_font_name.as_deref())
} else {
None
}
}
Object::Dictionary(enc_dict) => {
parse_encoding_dictionary(doc, enc_dict, base_font_name.as_deref())
}
_ => None,
}
}
pub(crate) struct EncodingResult {
pub map: FontEncodingMap,
glyph_names: HashMap<u8, String>,
pub gid_codes: Vec<u8>,
}
pub(crate) fn parse_encoding_dictionary(
doc: &Document,
enc_dict: &lopdf::Dictionary,
base_font_name: Option<&str>,
) -> Option<EncodingResult> {
let differences = enc_dict.get(b"Differences").ok()?;
let diff_array = match differences {
Object::Array(arr) => arr.clone(),
Object::Reference(obj_ref) => {
if let Ok(Object::Array(arr)) = doc.get_object(*obj_ref) {
arr.clone()
} else {
return None;
}
}
_ => return None,
};
let mut encoding_map = FontEncodingMap::new();
let mut glyph_names = HashMap::new();
let mut current_code: u8 = 0;
let mut ligature_count = 0u32;
let mut gid_codes: Vec<u8> = Vec::new();
for item in diff_array {
match item {
Object::Integer(n) => {
current_code = n as u8;
}
Object::Name(name) => {
let glyph_name = String::from_utf8_lossy(&name).to_string();
let mapped_char = glyph_to_char(&glyph_name)
.or_else(|| private_glyph_to_char(&glyph_name, base_font_name));
if mapped_char.is_some_and(is_ligature_char) {
debug!(
" Differences: code=0x{:02X} glyph={:?} (ligature)",
current_code, glyph_name
);
ligature_count += 1;
}
if glyph_name.starts_with("gid")
&& glyph_name.len() >= 4
&& glyph_name[3..].chars().all(|c| c.is_ascii_digit())
{
gid_codes.push(current_code);
}
if let Some(ch) = mapped_char {
encoding_map.insert(current_code, ch);
glyph_names.insert(current_code, glyph_name);
} else {
debug!(
" Differences: code=0x{:02X} glyph={:?} (unmapped)",
current_code, glyph_name
);
}
current_code = current_code.wrapping_add(1);
}
_ => {}
}
}
if ligature_count > 0 {
debug!(
" Differences: {} total entries, {} ligatures",
encoding_map.len(),
ligature_count
);
}
if !gid_codes.is_empty() {
debug!(
" Differences: {} gid-encoded glyphs (decodable only via ToUnicode)",
gid_codes.len()
);
}
Some(EncodingResult {
map: encoding_map,
glyph_names,
gid_codes,
})
}
fn private_glyph_to_char(glyph_name: &str, base_font_name: Option<&str>) -> Option<char> {
let base_font_name = strip_subset_prefix(base_font_name?);
if base_font_name.eq_ignore_ascii_case("Aptos") && glyph_name == "g431" {
Some('\u{FB00}')
} else {
None
}
}
fn strip_subset_prefix(font_name: &str) -> &str {
font_name
.split_once('+')
.map_or(font_name, |(_, stripped)| stripped)
}
fn is_ligature_char(ch: char) -> bool {
matches!(
ch,
'\u{FB00}' | '\u{FB01}' | '\u{FB02}' | '\u{FB03}' | '\u{FB04}'
)
}
pub(crate) fn get_font_file2_obj_num(doc: &Document, font_dict: &lopdf::Dictionary) -> Option<u32> {
let subtype = font_dict
.get(b"Subtype")
.ok()
.and_then(|o| o.as_name().ok());
if subtype == Some(b"Type0") {
let encoding = font_dict.get(b"Encoding").ok()?.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 = resolve_array(doc, desc_fonts_obj)?;
if desc_fonts.is_empty() {
return None;
}
let cid_font_dict = resolve_dict(doc, &desc_fonts[0])?;
let font_descriptor_obj = cid_font_dict.get(b"FontDescriptor").ok()?;
let font_descriptor = resolve_dict(doc, font_descriptor_obj)?;
if let Some(ff_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())
})
{
return Some(ff_ref.0);
}
if let Object::Reference(r) = &desc_fonts[0] {
return Some(r.0);
}
return None;
}
let font_descriptor_obj = font_dict.get(b"FontDescriptor").ok()?;
let font_descriptor = resolve_dict(doc, font_descriptor_obj)?;
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())
})
.map(|r| r.0)
}
#[derive(Debug, Default)]
pub(crate) struct FontStyleCache {
by_font_file: HashMap<ObjectId, FontStyle>,
blank_codes_by_font_file: HashMap<ObjectId, std::collections::HashSet<u8>>,
}
impl FontStyleCache {
pub(crate) fn new() -> Self {
Self::default()
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) struct FontStyle {
pub(crate) italic: bool,
pub(crate) bold: bool,
pub(crate) weight: Option<u16>,
}
impl FontStyle {
pub(crate) fn flags(self) -> (bool, bool) {
(self.italic, self.bold)
}
}
pub(crate) fn font_style(
doc: &Document,
font_dict: &lopdf::Dictionary,
style_cache: &mut FontStyleCache,
) -> FontStyle {
let has_weight_class = is_ordinary_text_font(font_dict);
let name_weight = font_dict
.get(b"BaseFont")
.ok()
.filter(|_| has_weight_class)
.and_then(|obj| obj.as_name().ok())
.and_then(|name| crate::text_utils::font_weight_from_name(&String::from_utf8_lossy(name)));
let descriptor = font_dict
.get(b"FontDescriptor")
.ok()
.and_then(|obj| resolve_dict(doc, obj))
.or_else(|| {
let desc_fonts = font_dict.get(b"DescendantFonts").ok()?;
let desc_fonts = resolve_array(doc, desc_fonts)?;
let cid_font_dict = resolve_dict(doc, desc_fonts.first()?)?;
resolve_dict(doc, cid_font_dict.get(b"FontDescriptor").ok()?)
});
let Some(descriptor) = descriptor else {
return FontStyle {
weight: name_weight,
..FontStyle::default()
};
};
let italic_angle = descriptor
.get(b"ItalicAngle")
.ok()
.and_then(get_number)
.unwrap_or(0.0);
let flags = descriptor
.get(b"Flags")
.ok()
.and_then(|obj| obj.as_i64().ok())
.unwrap_or(0);
let mut style = FontStyle {
italic: italic_angle.abs() >= 4.0 || flags & (1 << 6) != 0,
bold: flags & (1 << 18) != 0,
weight: None,
};
if let Some(ff_ref) = font_file_ref(descriptor) {
let embedded = *style_cache
.by_font_file
.entry(ff_ref)
.or_insert_with(|| embedded_style(doc, ff_ref));
style.italic |= embedded.italic;
style.bold |= embedded.bold;
style.weight = embedded.weight;
}
style.weight = if has_weight_class {
style
.weight
.or_else(|| {
descriptor
.get(b"FontWeight")
.ok()
.and_then(|obj| resolve_number(doc, obj))
.and_then(weight_class)
})
.or(name_weight)
} else {
None
};
style
}
fn is_ordinary_text_font(font_dict: &lopdf::Dictionary) -> bool {
font_dict
.get(b"Subtype")
.ok()
.and_then(|obj| obj.as_name().ok())
.is_some_and(|subtype| matches!(subtype, b"Type0" | b"Type1" | b"MMType1" | b"TrueType"))
}
fn resolve_number(doc: &Document, obj: &Object) -> Option<f32> {
match obj {
Object::Reference(id) => doc.get_object(*id).ok().and_then(get_number),
direct => get_number(direct),
}
}
fn weight_class(value: f32) -> Option<u16> {
if !value.is_finite() || value < 1.0 {
return None;
}
Some(value.round().clamp(100.0, 900.0) as u16)
}
fn embedded_style(doc: &Document, ff_ref: ObjectId) -> FontStyle {
let Some(data) = font_file_data(doc, ff_ref) else {
return FontStyle::default();
};
if let Ok(face) = ttf_parser::Face::parse(&data, 0) {
let os2 = face.tables().os2;
let mac_bold = os2.is_none()
&& face
.raw_face()
.table(ttf_parser::Tag::from_bytes(b"head"))
.and_then(|head| head.get(44..46))
.is_some_and(|bytes| u16::from_be_bytes([bytes[0], bytes[1]]) & 1 != 0);
FontStyle {
italic: face.is_italic() || face.italic_angle().abs() >= 4.0,
bold: face.is_bold() || mac_bold,
weight: os2.and_then(|os2| weight_class(f32::from(os2.weight().to_number()))),
}
} else if let Some(name) = cff_font_name(&data) {
FontStyle {
italic: crate::text_utils::is_italic_font(&name),
bold: crate::text_utils::is_bold_font(&name),
weight: crate::text_utils::font_weight_from_name(&name),
}
} else {
FontStyle::default()
}
}
fn cff_font_name(data: &[u8]) -> Option<String> {
if data.len() < 4 || data[0] != 1 {
return None;
}
let hdr_size = data[2] as usize;
let count = u16::from_be_bytes([*data.get(hdr_size)?, *data.get(hdr_size + 1)?]) as usize;
if count == 0 {
return None;
}
let off_size = *data.get(hdr_size + 2)? as usize;
if !(1..=4).contains(&off_size) {
return None;
}
let read_offset = |idx: usize| -> Option<usize> {
let at = hdr_size + 3 + idx * off_size;
let bytes = data.get(at..at + off_size)?;
let mut v = 0usize;
for b in bytes {
v = (v << 8) | *b as usize;
}
Some(v)
};
let start = read_offset(0)?;
let end = read_offset(1)?;
if start == 0 || end < start {
return None;
}
let objects_base = hdr_size + 3 + (count + 1) * off_size - 1;
let name = data.get(objects_base + start..objects_base + end)?;
Some(String::from_utf8_lossy(name).to_string())
}
fn font_file_ref(descriptor: &lopdf::Dictionary) -> Option<ObjectId> {
descriptor
.get(b"FontFile2")
.ok()
.and_then(|o| o.as_reference().ok())
.or_else(|| {
descriptor
.get(b"FontFile3")
.ok()
.and_then(|o| o.as_reference().ok())
})
}
fn font_file_data(doc: &Document, ff_ref: ObjectId) -> Option<Vec<u8>> {
let stream = doc
.get_object(ff_ref)
.and_then(lopdf::Object::as_stream)
.ok()?;
Some(
stream
.decompressed_content()
.unwrap_or_else(|_| stream.content.clone()),
)
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn extract_text_from_operand(
obj: &Object,
current_font: &str,
base_font_name: Option<&str>,
font_cmaps: &FontCMaps,
font_tounicode_refs: &std::collections::HashMap<String, u32>,
inline_cmaps: &std::collections::HashMap<String, crate::tounicode::CMapEntry>,
font_encodings: &PageFontEncodings,
encoding_cache: &HashMap<String, Encoding<'_>>,
cmap_decisions: &mut CMapDecisionCache,
font_widths: &PageFontWidths,
) -> Option<(String, bool)> {
let is_type0_cid_font = font_widths
.get(current_font)
.is_some_and(|info| info.is_cid);
let use_cp1252_fallback =
should_use_cp1252_single_byte_fallback(base_font_name, is_type0_cid_font);
let result = (|| -> Option<String> {
if let Object::String(bytes, _) = obj {
let mut decode_with_entry = |entry: &crate::tounicode::CMapEntry| -> Option<String> {
if entry.primary.code_byte_length == 1 {
let encoding_map = font_encodings.get(current_font);
let decode_byte = |b: u8| -> Option<String> {
let code = b as u16;
if let Some(s) = entry.primary.lookup(code) {
if !s.contains('\u{FFFD}') {
if s == (b as char).to_string() {
if let Some(&ch) =
encoding_map.and_then(|map| map.identity_overrides.get(&b))
{
return Some(ch.to_string());
}
}
return Some(s);
}
}
if let Some(fb) = entry.fallback.as_ref().and_then(|c| c.lookup(code)) {
if !fb.contains('\u{FFFD}') {
return Some(fb);
}
}
if let Some(map) = encoding_map {
if let Some(&ch) = map.differences.get(&b) {
return Some(ch.to_string());
}
}
if b >= 0x20 {
return Some(
decode_single_byte_fallback_char(b, use_cp1252_fallback)
.to_string(),
);
}
None
};
let decoded: String = bytes
.iter()
.filter_map(|&b| {
let label = decode_byte(b)?;
if blank_glyph_reads_as_space(encoding_map, b, &label) {
return Some(" ".to_string());
}
Some(label)
})
.collect();
if !decoded.is_empty() {
return Some(decoded);
}
return None;
}
if bytes.len() % 2 == 1 {
let lookups = entry.primary.lookup_bytes(bytes);
let decoded: String = lookups
.iter()
.filter_map(|&(_b, ref cmap_result)| cmap_result.clone())
.collect();
if !decoded.is_empty() {
return Some(decoded);
}
}
let decoded_primary = entry.primary.decode_cids(bytes);
if let Some(remapped) = entry.remapped.as_ref() {
let decoded_remap = remapped.decode_cids(bytes);
let decoded_fallback = entry.fallback.as_ref().map(|c| c.decode_cids(bytes));
if let Some(choice) = cmap_decisions
.get_choice(font_tounicode_refs.get(current_font).copied().unwrap_or(0))
{
let decoded = match choice {
CMapChoice::Primary => decoded_primary.clone(),
CMapChoice::Remapped => decoded_remap.clone(),
};
if !decoded.is_empty() {
return Some(decoded);
}
}
let choice = cmap_decisions.consider(
font_tounicode_refs.get(current_font).copied().unwrap_or(0),
&decoded_primary,
&decoded_remap,
bytes.len(),
);
let mut decoded = match choice {
Some(CMapChoice::Primary) => decoded_primary,
Some(CMapChoice::Remapped) => decoded_remap,
None => choose_best_cmap_decode(decoded_primary, decoded_remap),
};
if let Some(fb) = decoded_fallback {
let expected = bytes.len() / 2;
let decoded_len = decoded.chars().count();
let prefer_fallback = (!fb.is_empty() && decoded.is_empty())
|| (!fb.is_empty() && expected > 0 && decoded_len * 2 < expected);
if prefer_fallback || score_text(&fb) > score_text(&decoded) + 3 {
decoded = fb;
}
}
if !decoded.is_empty() {
return Some(decoded);
}
} else if !decoded_primary.is_empty() {
if let Some(fb) = entry.fallback.as_ref().map(|c| c.decode_cids(bytes)) {
let expected = bytes.len() / 2;
let decoded_len = decoded_primary.chars().count();
let prefer_fallback = (!fb.is_empty() && decoded_primary.is_empty())
|| (!fb.is_empty() && expected > 0 && decoded_len * 2 < expected);
if prefer_fallback || score_text(&fb) > score_text(&decoded_primary) + 3 {
return Some(fb);
}
}
return Some(decoded_primary);
}
None
};
let mut has_cmap = false;
if let Some(entry) = inline_cmaps.get(current_font) {
has_cmap = true;
if let Some(decoded) = decode_with_entry(entry) {
return Some(decoded);
}
}
if let Some(&obj_num) = font_tounicode_refs.get(current_font) {
if let Some(entry) = font_cmaps.get_by_obj(obj_num) {
has_cmap = true;
if let Some(decoded) = decode_with_entry(entry) {
return Some(decoded);
}
}
}
if is_type0_cid_font && bytes.iter().any(|&b| b > 0x7F) {
let cid_count = (bytes.len() / 2).max(1);
return Some("\u{FFFD}".repeat(cid_count));
}
if let Some(encoding) = font_encodings.get(current_font) {
let encoding_map = &encoding.differences;
let has_diff_match = bytes
.iter()
.any(|b| encoding_map.contains_key(b) || encoding.blank_codes.contains(b));
if has_diff_match {
let decoded: String = bytes
.iter()
.filter_map(|&b| {
let label = if let Some(&ch) = encoding_map.get(&b) {
Some(ch)
} else if b >= 0x20 {
Some(decode_single_byte_fallback_char(b, use_cp1252_fallback))
} else {
None }?;
if blank_glyph_reads_as_space(Some(encoding), b, &label.to_string()) {
return Some(' ');
}
Some(label)
})
.collect();
if !decoded.is_empty() {
return Some(decoded);
}
}
}
if bytes.len() >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF {
let utf16: Vec<u16> = bytes[2..]
.as_chunks::<2>()
.0
.iter()
.map(|chunk| u16::from_be_bytes(*chunk))
.collect();
let text = String::from_utf16_lossy(&utf16);
if text.contains('\u{FFFD}') {
debug!(
"utf16 loss produced replacement for font={} bytes_len={}",
current_font,
bytes.len()
);
}
return Some(text);
}
if bytes.len() >= 4 && bytes.len() % 2 == 0 {
let nulls = bytes.iter().filter(|&&b| b == 0).count();
if nulls * 4 > bytes.len() {
let utf16: Vec<u16> = bytes
.as_chunks::<2>()
.0
.iter()
.map(|chunk| u16::from_be_bytes(*chunk))
.collect();
let text = String::from_utf16_lossy(&utf16);
if score_text(&text) > 0 {
return Some(text);
}
}
}
if bytes.iter().any(|&b| b > 0x7F) {
if let Ok(text) = std::str::from_utf8(bytes) {
return Some(text.to_string());
}
}
if let Some(encoding) = encoding_cache.get(current_font) {
if let Ok(text) = Document::decode_text(encoding, bytes) {
let text = normalize_cp1252_controls(text, use_cp1252_fallback);
if text.contains('\u{FFFD}') {
debug!(
"decode_text produced replacement for font={} bytes_len={}",
current_font,
bytes.len()
);
if bytes.len() <= 8 {
let hex: String = bytes.iter().map(|b| format!("{:02X}", b)).collect();
debug!(
"decode_text replacement bytes font={} base={:?} hex={}",
current_font, base_font_name, hex
);
}
if bytes.iter().all(|&b| (0x20..=0x7E).contains(&b)) {
return Some(bytes.iter().map(|&b| b as char).collect());
}
if let Some(symbol_text) = decode_symbol_fallback(bytes, base_font_name) {
return Some(symbol_text);
}
if has_cmap || font_tounicode_refs.contains_key(current_font) {
return None;
}
} else {
return Some(text);
}
}
}
if let Some(symbol_text) = decode_symbol_fallback(bytes, base_font_name) {
return Some(symbol_text);
}
Some(decode_single_byte_fallback(bytes, use_cp1252_fallback))
} else {
None
}
})();
result.map(|text| {
let (text, legacy_symbol_rewrite) = clean_symbol_pua(text);
let text = remap_texcm_math_symbols(text, base_font_name);
(
normalize_cp1252_controls(text, use_cp1252_fallback),
legacy_symbol_rewrite,
)
})
}
fn remap_texcm_math_symbols(text: String, base_font_name: Option<&str>) -> String {
let is_texcm = base_font_name.is_some_and(|n| {
let n = n.rsplit_once('+').map_or(n, |(_, s)| s);
n.eq_ignore_ascii_case("TeXCMMathsSymbols")
});
if !is_texcm {
return text;
}
text.chars()
.map(|c| match c {
'¼' => '=',
'½' => '-',
'þ' => '+',
'ð' => '(',
'Þ' => ')',
_ => c,
})
.collect()
}
fn decode_single_byte_fallback(bytes: &[u8], use_cp1252_fallback: bool) -> String {
bytes
.iter()
.map(|&b| decode_single_byte_fallback_char(b, use_cp1252_fallback))
.collect()
}
fn decode_single_byte_fallback_char(byte: u8, use_cp1252_fallback: bool) -> char {
if !use_cp1252_fallback {
return byte as char;
}
match byte {
0x80 => '\u{20AC}',
0x82 => '\u{201A}',
0x83 => '\u{0192}',
0x84 => '\u{201E}',
0x85 => '\u{2026}',
0x86 => '\u{2020}',
0x87 => '\u{2021}',
0x88 => '\u{02C6}',
0x89 => '\u{2030}',
0x8A => '\u{0160}',
0x8B => '\u{2039}',
0x8C => '\u{0152}',
0x8E => '\u{017D}',
0x91 => '\u{2018}',
0x92 => '\u{2019}',
0x93 => '\u{201C}',
0x94 => '\u{201D}',
0x95 => '\u{2022}',
0x96 => '\u{2013}',
0x97 => '\u{2014}',
0x98 => '\u{02DC}',
0x99 => '\u{2122}',
0x9A => '\u{0161}',
0x9B => '\u{203A}',
0x9C => '\u{0153}',
0x9E => '\u{017E}',
0x9F => '\u{0178}',
_ => byte as char,
}
}
fn normalize_cp1252_controls(text: String, use_cp1252_fallback: bool) -> String {
if !use_cp1252_fallback {
return text;
}
if !text
.chars()
.any(|ch| ('\u{0080}'..='\u{009F}').contains(&ch))
{
return text;
}
text.chars()
.map(|ch| {
if ('\u{0080}'..='\u{009F}').contains(&ch) {
decode_single_byte_fallback_char(ch as u8, true)
} else {
ch
}
})
.collect()
}
fn should_use_cp1252_single_byte_fallback(
base_font_name: Option<&str>,
is_type0_cid_font: bool,
) -> bool {
if is_type0_cid_font {
return false;
}
let Some(base_font_name) = base_font_name else {
return true;
};
let font_name = base_font_name
.rsplit_once('+')
.map_or(base_font_name, |(_, stripped)| stripped)
.to_ascii_lowercase();
let non_cp1252_prefixes = [
"cmr", "cmb", "cmmi", "cmsy", "cmex", "cmtt", "cmss", "cmti", "ecrm", "ecbx", "ecti",
"tcrm", "tctt", "msam", "msbm", "ttdc",
];
if non_cp1252_prefixes
.iter()
.any(|prefix| font_name.starts_with(prefix))
{
return false;
}
let non_cp1252_names = ["math", "symbol", "dingbat", "emoji"];
!non_cp1252_names.iter().any(|name| font_name.contains(name))
}
fn clean_symbol_pua(text: String) -> (String, bool) {
if !text.chars().any(|c| ('\u{F000}'..='\u{F0FF}').contains(&c)) {
return (text, false);
}
let mut rewritten = false;
let text = text
.chars()
.map(|c| {
let code = c as u32;
if !(0xF000..=0xF0FF).contains(&code) {
return c;
}
let low = code - 0xF000;
let replacement = match low {
0xA1 | 0xA7 | 0xB7 => '\u{2022}',
0xFC => '\u{2713}',
0x20..=0xFF => char::from_u32(low).unwrap_or(c),
_ => c,
};
rewritten |= replacement != c;
replacement
})
.collect();
(text, rewritten)
}
fn decode_symbol_fallback(bytes: &[u8], base_font_name: Option<&str>) -> Option<String> {
let name = base_font_name?.to_ascii_lowercase();
if !name.contains("symbol") && !name.contains("wingdings") && !name.contains("zapfdingbats") {
return None;
}
let mut out = String::new();
for &b in bytes {
if b < 0x20 {
continue;
}
if let Some(ch) = char::from_u32(0xF000 + b as u32) {
out.push(ch);
}
}
if out.is_empty() {
None
} else {
Some(out)
}
}
fn choose_best_cmap_decode(primary: String, remapped: String) -> String {
if primary.is_empty() {
return remapped;
}
if remapped.is_empty() {
return primary;
}
let score_primary = score_text(&primary);
let score_remap = score_text(&remapped);
if score_remap > score_primary + 3 {
remapped
} else {
primary
}
}
fn score_text(text: &str) -> i32 {
const COMMON_WORDS: [&str; 22] = [
"the", "and", "of", "to", "in", "a", "is", "that", "for", "with", "on", "as", "by", "from",
"this", "be", "are", "at", "or", "not", "it", "our",
];
let mut letters = 0i32;
let mut spaces = 0i32;
let mut digits = 0i32;
let mut other = 0i32;
let mut word_hits = 0i32;
let mut current = String::new();
for ch in text.chars() {
if ch.is_ascii_alphabetic() {
letters += 1;
current.push(ch.to_ascii_lowercase());
} else {
if !current.is_empty() {
if COMMON_WORDS.iter().any(|w| *w == current) {
word_hits += 1;
}
current.clear();
}
if ch == ' ' {
spaces += 1;
} else if ch.is_ascii_digit() {
digits += 1;
} else if ch.is_control() || ch == '\u{FFFD}' {
other += 3;
} else if ('\u{4E00}'..='\u{9FFF}').contains(&ch)
|| ('\u{3040}'..='\u{309F}').contains(&ch)
|| ('\u{30A0}'..='\u{30FF}').contains(&ch)
|| ('\u{3400}'..='\u{4DBF}').contains(&ch)
|| ('\u{F900}'..='\u{FAFF}').contains(&ch)
{
letters += 1; } else {
other += 1;
}
}
}
if !current.is_empty() && COMMON_WORDS.iter().any(|w| *w == current) {
word_hits += 1;
}
let mut score = word_hits * 10 + letters + spaces * 2 + digits - other * 2;
if letters > 15 && word_hits == 0 {
score -= 15;
}
score
}
#[cfg(test)]
mod tests {
#[test]
fn item_font_name_prefers_family_over_resource_tag() {
use super::item_font_name;
assert_eq!(item_font_name("F2", "ABCDEF+CMMI10"), "ABCDEF+CMMI10");
assert_eq!(item_font_name("T22", "Times-Roman"), "Times-Roman");
assert_eq!(item_font_name("C2_0", "ABCDEE+SimSun"), "C2_0");
assert_eq!(item_font_name("C0_1", "ABCDEE+MSMincho"), "C0_1");
}
#[test]
fn type3_scale_resolves_indirect_matrix_and_bbox_numbers() {
use lopdf::{dictionary, Document, Object};
let mut doc = Document::with_version("1.4");
let matrix_d = doc.add_object(Object::Real(-1.0));
let bbox_top = doc.add_object(Object::Integer(3));
let font_dict = dictionary! {
"Type" => "Font",
"Subtype" => "Type3",
"FontMatrix" => vec![
Object::Integer(1),
Object::Integer(0),
Object::Integer(0),
Object::Reference(matrix_d),
Object::Integer(0),
Object::Integer(0),
],
"FontBBox" => vec![
Object::Integer(1),
Object::Integer(-156),
Object::Integer(37),
Object::Reference(bbox_top),
],
};
let mut fonts = std::collections::BTreeMap::new();
fonts.insert(b"T2".to_vec(), &font_dict);
let scales = super::build_type3_scales(&doc, &fonts);
let scale = scales.get("T2").copied().unwrap_or(1.0);
assert!(
(scale - 159.0).abs() < 0.5,
"scale should use resolved indirect values, got {scale}"
);
}
#[cfg(test)]
fn type3_scale_for(matrix_y: f32, bbox_lo: i64, bbox_hi: i64) -> Option<f32> {
use lopdf::{dictionary, Document, Object};
let doc = Document::with_version("1.4");
let font_dict = dictionary! {
"Type" => "Font",
"Subtype" => "Type3",
"FontMatrix" => vec![
Object::Real(matrix_y), Object::Integer(0), Object::Integer(0),
Object::Real(matrix_y), Object::Integer(0), Object::Integer(0),
],
"FontBBox" => vec![
Object::Integer(0), Object::Integer(bbox_lo),
Object::Integer(600), Object::Integer(bbox_hi),
],
};
let mut fonts = std::collections::BTreeMap::new();
fonts.insert(b"T9".to_vec(), &font_dict);
super::build_type3_scales(&doc, &fonts).get("T9").copied()
}
#[test]
fn type3_scale_skips_self_consistent_fonts() {
assert_eq!(type3_scale_for(0.001, -200, 500), None);
assert_eq!(type3_scale_for(0.001, -100, 1000), None);
}
#[test]
fn type3_scale_applies_to_inconsistent_fonts_at_any_matrix_scale() {
let s = type3_scale_for(0.005, 0, 1000).expect("0.005 matrix should rescale");
assert!((s - 5.0).abs() < 0.01, "got {s}");
let s = type3_scale_for(1.0, -156, 3).expect("PK pattern should rescale");
assert!((s - 159.0).abs() < 0.5, "got {s}");
}
#[test]
fn type3_scale_ignores_degenerate_bbox() {
assert_eq!(type3_scale_for(0.001, 0, 0), None);
}
#[test]
fn texcm_math_symbols_remap() {
assert_eq!(
super::remap_texcm_math_symbols("S ¼ kB þ 1".into(), Some("EEKVNO+TeXCMMathsSymbols")),
"S = kB + 1"
);
assert_eq!(
super::remap_texcm_math_symbols("¼ cup þorn".into(), Some("Times-Roman")),
"¼ cup þorn"
);
assert_eq!(super::remap_texcm_math_symbols("¼".into(), None), "¼");
}
use super::*;
use lopdf::dictionary;
fn make_font_info(widths: &[(u16, u16)], default_width: u16, is_cid: bool) -> FontWidthInfo {
FontWidthInfo {
widths: widths.iter().copied().collect(),
default_width,
space_width: widths
.iter()
.find(|(k, _)| *k == 32)
.map(|(_, v)| *v)
.unwrap_or(default_width),
is_cid,
units_scale: 0.001,
wmode: 0,
}
}
fn doc_with_descriptor(descriptor: lopdf::Dictionary) -> (Document, lopdf::Dictionary) {
let mut doc = Document::with_version("1.4");
let desc_id = doc.add_object(descriptor);
let font_dict = dictionary! {
"Type" => "Font",
"Subtype" => "TrueType",
"BaseFont" => "Tc1",
"FontDescriptor" => desc_id,
};
(doc, font_dict)
}
#[test]
fn descriptor_italic_angle_sets_italic() {
let (doc, font_dict) = doc_with_descriptor(dictionary! {
"Type" => "FontDescriptor",
"FontName" => "Tc1",
"ItalicAngle" => -12,
"Flags" => 32,
});
assert_eq!(
font_style(&doc, &font_dict, &mut FontStyleCache::new()).flags(),
(true, false)
);
}
#[test]
fn descriptor_italic_flag_bit_sets_italic() {
let (doc, font_dict) = doc_with_descriptor(dictionary! {
"Type" => "FontDescriptor",
"FontName" => "Tc1",
"ItalicAngle" => 0,
"Flags" => 64, });
assert_eq!(
font_style(&doc, &font_dict, &mut FontStyleCache::new()).flags(),
(true, false)
);
}
#[test]
fn descriptor_force_bold_flag_sets_bold() {
let (doc, font_dict) = doc_with_descriptor(dictionary! {
"Type" => "FontDescriptor",
"FontName" => "Tc1",
"ItalicAngle" => 0,
"Flags" => 1 << 18, });
assert_eq!(
font_style(&doc, &font_dict, &mut FontStyleCache::new()).flags(),
(false, true)
);
}
#[test]
fn tiny_italic_angle_is_not_italic() {
let (doc, font_dict) = doc_with_descriptor(dictionary! {
"Type" => "FontDescriptor",
"FontName" => "Tc1",
"ItalicAngle" => lopdf::Object::Real(-1.0),
"Flags" => 32,
});
assert_eq!(
font_style(&doc, &font_dict, &mut FontStyleCache::new()).flags(),
(false, false)
);
}
#[test]
fn missing_descriptor_yields_no_flags() {
let doc = Document::with_version("1.4");
let font_dict = dictionary! { "Type" => "Font", "BaseFont" => "Tc1" };
assert_eq!(
font_style(&doc, &font_dict, &mut FontStyleCache::new()).flags(),
(false, false)
);
}
fn sfnt_with_style(mac_style: u16, os2_selection: Option<u16>) -> Vec<u8> {
sfnt_with_style_and_weight(mac_style, os2_selection.map(|selection| (selection, 0)))
}
fn sfnt_with_style_and_weight(mac_style: u16, os2: Option<(u16, u16)>) -> Vec<u8> {
let mut head = vec![0u8; 54];
head[18..20].copy_from_slice(&1000u16.to_be_bytes()); head[44..46].copy_from_slice(&mac_style.to_be_bytes());
let hhea = vec![0u8; 36];
let mut maxp = vec![0u8; 6];
maxp[..4].copy_from_slice(&0x00005000u32.to_be_bytes());
maxp[4..6].copy_from_slice(&1u16.to_be_bytes()); let mut tables = vec![(*b"head", head), (*b"hhea", hhea), (*b"maxp", maxp)];
if let Some((selection, weight_class)) = os2 {
let mut os2 = vec![0u8; 78]; os2[4..6].copy_from_slice(&weight_class.to_be_bytes()); os2[62..64].copy_from_slice(&selection.to_be_bytes()); tables.push((*b"OS/2", os2));
}
tables.sort_by_key(|(tag, _)| *tag);
let count = tables.len();
let mut data = vec![0u8; 12 + count * 16];
data[..4].copy_from_slice(&0x00010000u32.to_be_bytes());
data[4..6].copy_from_slice(&(count as u16).to_be_bytes());
for (i, (tag, table)) in tables.into_iter().enumerate() {
let offset = data.len() as u32;
let record = 12 + i * 16;
data[record..record + 4].copy_from_slice(&tag);
data[record + 8..record + 12].copy_from_slice(&offset.to_be_bytes());
data[record + 12..record + 16].copy_from_slice(&(table.len() as u32).to_be_bytes());
data.extend_from_slice(&table);
while !data.len().is_multiple_of(4) {
data.push(0);
}
}
data
}
fn descriptor_flags_from_sfnt(mac_style: u16, os2_selection: Option<u16>) -> (bool, bool) {
let mut doc = Document::with_version("1.4");
let font_file = doc.add_object(lopdf::Stream::new(
dictionary! {},
sfnt_with_style(mac_style, os2_selection),
));
let descriptor = doc.add_object(dictionary! {
"Type" => "FontDescriptor",
"FontName" => "ABCDEF+OpaqueFace",
"Flags" => 4,
"ItalicAngle" => 0,
"FontFile2" => font_file,
});
let font_dict = dictionary! {
"Type" => "Font",
"Subtype" => "TrueType",
"BaseFont" => "ABCDEF+OpaqueFace",
"FontDescriptor" => descriptor,
};
font_style(&doc, &font_dict, &mut FontStyleCache::new()).flags()
}
#[test]
fn embedded_mac_bold_without_os2_survives_opaque_subset_names() {
assert_eq!(descriptor_flags_from_sfnt(1, None), (false, true));
assert_eq!(descriptor_flags_from_sfnt(0, None), (false, false));
assert_eq!(descriptor_flags_from_sfnt(2, None), (false, false));
}
#[test]
fn embedded_os2_bold_remains_authoritative() {
assert_eq!(descriptor_flags_from_sfnt(0, Some(1 << 5)), (false, true));
assert_eq!(descriptor_flags_from_sfnt(1, Some(1 << 6)), (false, false));
}
fn embedded_font(
base_font: &str,
font_file: Vec<u8>,
descriptor_extra: lopdf::Dictionary,
) -> (Document, lopdf::Dictionary) {
let mut doc = Document::with_version("1.4");
let font_file = doc.add_object(lopdf::Stream::new(dictionary! {}, font_file));
let mut descriptor = dictionary! {
"Type" => "FontDescriptor",
"FontName" => base_font,
"Flags" => 4,
"ItalicAngle" => 0,
"FontFile2" => font_file,
};
for (key, value) in descriptor_extra.into_iter() {
descriptor.set(key.clone(), value.clone());
}
let descriptor = doc.add_object(descriptor);
let font_dict = dictionary! {
"Type" => "Font",
"Subtype" => "TrueType",
"BaseFont" => base_font,
"FontDescriptor" => descriptor,
};
(doc, font_dict)
}
#[test]
fn weight_class_comes_from_the_embedded_os2_table_first() {
let (doc, font_dict) = embedded_font(
"ABCDEF+OpaqueFace",
sfnt_with_style_and_weight(0, Some((0, 700))),
dictionary! { "FontWeight" => 400 },
);
assert_eq!(
font_style(&doc, &font_dict, &mut FontStyleCache::new()),
FontStyle {
italic: false,
bold: false,
weight: Some(700),
}
);
}
#[test]
fn weight_class_falls_back_to_the_descriptor_then_the_name() {
let (doc, font_dict) = embedded_font(
"ABCDEF+Face-Bold",
sfnt_with_style_and_weight(0, None),
dictionary! { "FontWeight" => 300 },
);
assert_eq!(
font_style(&doc, &font_dict, &mut FontStyleCache::new()).weight,
Some(300)
);
let (doc, font_dict) = embedded_font(
"ABCDEF+Face-Bold",
sfnt_with_style_and_weight(0, None),
dictionary! {},
);
assert_eq!(
font_style(&doc, &font_dict, &mut FontStyleCache::new()).weight,
Some(700)
);
let (doc, font_dict) = embedded_font(
"ABCDEF+Face-Md",
sfnt_with_style_and_weight(0, Some((0, 0))),
dictionary! {},
);
assert_eq!(
font_style(&doc, &font_dict, &mut FontStyleCache::new()).weight,
Some(500)
);
}
#[test]
fn descriptor_font_weight_is_clamped_to_the_scale() {
for (value, expected) in [
(lopdf::Object::Integer(700), Some(700)),
(lopdf::Object::Real(500.0), Some(500)),
(lopdf::Object::Integer(1000), Some(900)),
(lopdf::Object::Integer(0), None),
(lopdf::Object::Integer(-1), None),
(lopdf::Object::Name(b"Bold".to_vec()), None),
] {
let (doc, font_dict) = doc_with_descriptor(dictionary! {
"Type" => "FontDescriptor",
"FontName" => "Tc1",
"Flags" => 32,
"FontWeight" => value.clone(),
});
assert_eq!(
font_style(&doc, &font_dict, &mut FontStyleCache::new()).weight,
expected,
"{value:?}"
);
}
}
#[test]
fn indirect_font_weight_is_resolved() {
let mut doc = Document::with_version("1.4");
let weight_id = doc.add_object(lopdf::Object::Integer(600));
let desc_id = doc.add_object(dictionary! {
"Type" => "FontDescriptor",
"FontName" => "Tc1",
"Flags" => 32,
"ItalicAngle" => 0,
"FontWeight" => weight_id,
});
let font_dict = dictionary! {
"Type" => "Font",
"Subtype" => "TrueType",
"BaseFont" => "Tc1",
"FontDescriptor" => desc_id,
};
assert_eq!(
font_style(&doc, &font_dict, &mut FontStyleCache::new()).weight,
Some(600)
);
}
#[test]
fn type3_fonts_carry_no_weight_class() {
let mut doc = Document::with_version("1.4");
let desc_id = doc.add_object(dictionary! {
"Type" => "FontDescriptor",
"FontName" => "Glyphs-Bold",
"Flags" => 4 | (1 << 18),
"ItalicAngle" => 0,
"FontWeight" => 700,
});
let font_dict = dictionary! {
"Type" => "Font",
"Subtype" => "Type3",
"BaseFont" => "Glyphs-Bold",
"FontDescriptor" => desc_id,
};
assert_eq!(
font_style(&doc, &font_dict, &mut FontStyleCache::new()),
FontStyle {
italic: false,
bold: true,
weight: None,
}
);
let font_dict = dictionary! { "Type" => "Font", "BaseFont" => "Anything-Bold" };
assert_eq!(
font_style(&doc, &font_dict, &mut FontStyleCache::new()).weight,
None
);
}
#[test]
fn name_weight_needs_no_descriptor() {
let doc = Document::with_version("1.4");
let font_dict = dictionary! {
"Type" => "Font",
"Subtype" => "Type1",
"BaseFont" => "Helvetica-Light",
};
assert_eq!(
font_style(&doc, &font_dict, &mut FontStyleCache::new()),
FontStyle {
italic: false,
bold: false,
weight: Some(300),
}
);
}
#[test]
fn type0_descendant_descriptor_is_resolved() {
let mut doc = Document::with_version("1.4");
let desc_id = doc.add_object(dictionary! {
"Type" => "FontDescriptor",
"FontName" => "ABCDEF+F1",
"ItalicAngle" => -15,
});
let cid_id = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => "CIDFontType2",
"FontDescriptor" => desc_id,
});
let font_dict = dictionary! {
"Type" => "Font",
"Subtype" => "Type0",
"BaseFont" => "ABCDEF+F1",
"DescendantFonts" => vec![lopdf::Object::Reference(cid_id)],
};
assert_eq!(
font_style(&doc, &font_dict, &mut FontStyleCache::new()).flags(),
(true, false)
);
}
fn bare_cff_with_name(name: &str) -> Vec<u8> {
let mut data = vec![1, 0, 4, 1]; data.extend_from_slice(&1u16.to_be_bytes()); data.push(1); data.push(1); data.push(1 + name.len() as u8); data.extend_from_slice(name.as_bytes());
data
}
#[test]
fn bare_cff_name_weight_outranks_the_descriptor() {
let mut doc = Document::with_version("1.4");
let ff_id = doc.add_object(lopdf::Object::Stream(lopdf::Stream::new(
dictionary! {},
bare_cff_with_name("ABCDEF+Face-Md"),
)));
let desc_id = doc.add_object(dictionary! {
"Type" => "FontDescriptor",
"FontName" => "ABCDEF+Face-Md",
"ItalicAngle" => 0,
"Flags" => 32,
"FontWeight" => 400,
"FontFile3" => ff_id,
});
let font_dict = dictionary! {
"Type" => "Font",
"Subtype" => "Type1",
"BaseFont" => "Tc1",
"FontDescriptor" => desc_id,
};
assert_eq!(
font_style(&doc, &font_dict, &mut FontStyleCache::new()),
FontStyle {
italic: false,
bold: false,
weight: Some(500),
}
);
}
#[test]
fn embedded_font_style_is_cached_by_font_file_object() {
use lopdf::{Object, Stream};
let mut doc = Document::with_version("1.4");
let ff_id = doc.add_object(Object::Stream(Stream::new(
dictionary! {},
bare_cff_with_name("ABCDEF+Test-BoldItalic"),
)));
let desc_id = doc.add_object(dictionary! {
"Type" => "FontDescriptor",
"FontName" => "ABCDEF+Test-BoldItalic",
"ItalicAngle" => 0,
"Flags" => 32,
"FontFile3" => ff_id,
});
let font_dict = dictionary! {
"Type" => "Font",
"Subtype" => "Type1",
"BaseFont" => "Tc1",
"FontDescriptor" => desc_id,
};
let mut cache = FontStyleCache::new();
assert_eq!(
font_style(&doc, &font_dict, &mut cache).flags(),
(true, true)
);
assert_eq!(cache.by_font_file.len(), 1);
doc.objects.insert(
ff_id,
Object::Stream(Stream::new(dictionary! {}, vec![0u8; 4])),
);
assert_eq!(
font_style(&doc, &font_dict, &mut cache).flags(),
(true, true)
);
assert_eq!(
font_style(&doc, &font_dict, &mut FontStyleCache::new()).flags(),
(false, false)
);
}
#[test]
fn compute_string_width_ts_no_tc_tw() {
let fi = make_font_info(&[(72, 500), (101, 400), (108, 300)], 600, false);
let bytes = b"Hello"; let w = compute_string_width_ts(bytes, &fi, 10.0, 0.0, 0.0);
assert!((w - 21.0).abs() < 0.01);
}
#[test]
fn compute_string_width_ts_with_positive_tc() {
let fi = make_font_info(&[], 500, false);
let bytes = b"ab"; let w = compute_string_width_ts(bytes, &fi, 10.0, 0.5, 0.0);
assert!((w - 11.0).abs() < 0.01);
}
#[test]
fn compute_string_width_ts_with_negative_tc() {
let fi = make_font_info(&[], 500, false);
let bytes = b"ab";
let w = compute_string_width_ts(bytes, &fi, 10.0, -0.3, 0.0);
assert!((w - 9.4).abs() < 0.01);
}
#[test]
fn compute_string_width_ts_with_tw() {
let fi = make_font_info(&[(32, 250)], 500, false);
let bytes = b"a b"; let w = compute_string_width_ts(bytes, &fi, 10.0, 0.0, 0.8);
assert!((w - 13.3).abs() < 0.01);
}
#[test]
fn compute_string_width_ts_with_tc_and_tw() {
let fi = make_font_info(&[(32, 250)], 500, false);
let bytes = b"a b"; let w = compute_string_width_ts(bytes, &fi, 10.0, 0.1, 0.5);
assert!((w - 13.3).abs() < 0.01);
}
#[test]
fn compute_string_width_ts_cid_font() {
let fi = make_font_info(&[(65, 500), (32, 250)], 600, true);
let bytes = &[0u8, 65, 0, 32];
let w = compute_string_width_ts(bytes, &fi, 12.0, 0.2, 0.3);
assert!((w - 9.7).abs() < 0.01);
}
#[test]
fn compute_string_width_ts_large_tc() {
let fi = make_font_info(&[], 500, false);
let bytes = b"abc"; let w = compute_string_width_ts(bytes, &fi, 10.0, 5.0, 0.0);
assert!((w - 30.0).abs() < 0.01);
}
#[test]
fn score_text_cjk() {
let japanese = "2026年9月期 1Q 業績報告";
let garbled = "\u{FFFD}\u{FFFD}\u{FFFD}";
let s_jp = score_text(japanese);
let s_garbled = score_text(garbled);
assert!(
s_jp > s_garbled,
"Japanese text ({s_jp}) should score higher than garbled ({s_garbled})"
);
}
#[test]
fn score_text_cjk_vs_ascii_garbage() {
let cjk = "株式会社の業績についてご報告いたします";
let garbage = "}{|~`^@#$%&*()!<>[];:',./";
let s_cjk = score_text(cjk);
let s_garbage = score_text(garbage);
assert!(
s_cjk > s_garbage,
"CJK text ({s_cjk}) should score higher than garbage ({s_garbage})"
);
}
#[test]
fn score_text_english_still_works() {
let good = "the quick brown fox and the lazy dog";
let bad = "###!!!@@@$$$";
assert!(score_text(good) > score_text(bad));
}
fn doc_with_private_differences() -> (Document, lopdf::ObjectId) {
let mut doc = Document::with_version("1.7");
let encoding_id = doc.add_object(dictionary! {
"Differences" => Object::Array(vec![
Object::Integer(0x88),
Object::Name(b"g431".to_vec()),
Object::Name(b"fi".to_vec()),
Object::Integer(0xAD),
Object::Name(b"fl".to_vec()),
]),
});
(doc, encoding_id)
}
#[test]
fn aptos_private_g431_maps_to_ff_ligature() {
let (doc, encoding_id) = doc_with_private_differences();
let font_dict = dictionary! {
"BaseFont" => Object::Name(b"NJEQOD+Aptos".to_vec()),
"Encoding" => Object::Reference(encoding_id),
};
let result = parse_font_encoding(&doc, &font_dict).expect("encoding should parse");
assert_eq!(result.map.get(&0x88u8), Some(&'\u{FB00}'));
assert_eq!(result.map.get(&0x89u8), Some(&'\u{FB01}'));
assert_eq!(result.map.get(&0xADu8), Some(&'\u{FB02}'));
}
#[test]
fn private_g431_does_not_map_for_unrelated_fonts() {
let (doc, encoding_id) = doc_with_private_differences();
let font_dict = dictionary! {
"BaseFont" => Object::Name(b"ABCDEF+OtherFont".to_vec()),
"Encoding" => Object::Reference(encoding_id),
};
let result = parse_font_encoding(&doc, &font_dict).expect("encoding should parse");
assert!(!result.map.contains_key(&0x88u8));
assert_eq!(result.map.get(&0x89u8), Some(&'\u{FB01}'));
assert_eq!(result.map.get(&0xADu8), Some(&'\u{FB02}'));
}
#[test]
fn cid_font_with_unparseable_cmap_does_not_emit_latin1_mojibake() {
let bytes = vec![0xCD_u8, 0xD9, 0xCD, 0xD9];
let obj = Object::String(bytes, lopdf::StringFormat::Hexadecimal);
let font_cmaps = FontCMaps::default();
let mut font_tounicode_refs: HashMap<String, u32> = HashMap::new();
font_tounicode_refs.insert("F0".to_string(), 999);
let inline_cmaps = HashMap::new();
let font_encodings: PageFontEncodings = HashMap::new();
let encoding_cache: HashMap<String, Encoding<'_>> = HashMap::new();
let mut decisions = CMapDecisionCache::new();
let mut font_widths: PageFontWidths = HashMap::new();
font_widths.insert("F0".to_string(), make_font_info(&[], 1000, true));
let result = extract_text_from_operand(
&obj,
"F0",
None,
&font_cmaps,
&font_tounicode_refs,
&inline_cmaps,
&font_encodings,
&encoding_cache,
&mut decisions,
&font_widths,
);
let (text, _) = result.expect("CID font fallback should still emit a marker");
assert!(
!text.contains('\u{00CD}') && !text.contains('\u{00D9}'),
"CID font with unparseable CMap leaked Latin-1 mojibake: {text:?}"
);
assert!(
text.contains('\u{FFFD}'),
"CID font with unparseable CMap should emit U+FFFD so detect_encoding_issues fires: {text:?}"
);
}
#[test]
fn simple_font_single_byte_fallback_passes_high_bytes_through() {
let bytes = vec![0x24_u8, 0x47, 0xB6, 0x56]; let obj = Object::String(bytes, lopdf::StringFormat::Hexadecimal);
let font_cmaps = FontCMaps::default();
let mut font_tounicode_refs: HashMap<String, u32> = HashMap::new();
font_tounicode_refs.insert("F1".to_string(), 999);
let inline_cmaps = HashMap::new();
let font_encodings: PageFontEncodings = HashMap::new();
let encoding_cache: HashMap<String, Encoding<'_>> = HashMap::new();
let mut decisions = CMapDecisionCache::new();
let mut font_widths: PageFontWidths = HashMap::new();
font_widths.insert("F1".to_string(), make_font_info(&[], 1000, false));
let (text, _) = extract_text_from_operand(
&obj,
"F1",
None,
&font_cmaps,
&font_tounicode_refs,
&inline_cmaps,
&font_encodings,
&encoding_cache,
&mut decisions,
&font_widths,
)
.expect("simple font should round-trip Latin-1 bytes");
assert_eq!(text, "$G\u{00B6}V");
assert!(
!text.contains('\u{FFFD}'),
"simple font fallback must not stamp FFFD over legitimate bytes: {text:?}"
);
}
#[test]
fn legacy_cleanup_evidence_tracks_changes_without_changing_aliases() {
for (source, expected, rewritten) in [
("AΩμ$•✓", "AΩμ$•✓", false),
("\u{f057}\u{f0b7}\u{f0fc}", "W•✓", true),
("\u{f010}\u{e123}", "\u{f010}\u{e123}", false),
("Price \u{f024}", "Price $", true),
] {
assert_eq!(
clean_symbol_pua(source.to_string()),
(expected.to_string(), rewritten)
);
}
}
#[test]
fn simple_font_single_byte_fallback_maps_cp1252_punctuation() {
let bytes = vec![b'l', 0x92_u8, b'a', b'c', b'a', b'd'];
let obj = Object::String(bytes, lopdf::StringFormat::Hexadecimal);
let font_cmaps = FontCMaps::default();
let font_tounicode_refs: HashMap<String, u32> = HashMap::new();
let inline_cmaps = HashMap::new();
let font_encodings: PageFontEncodings = HashMap::new();
let encoding_cache: HashMap<String, Encoding<'_>> = HashMap::new();
let mut decisions = CMapDecisionCache::new();
let font_widths: PageFontWidths = HashMap::new();
let (text, _) = extract_text_from_operand(
&obj,
"F1",
None,
&font_cmaps,
&font_tounicode_refs,
&inline_cmaps,
&font_encodings,
&encoding_cache,
&mut decisions,
&font_widths,
)
.expect("simple font should decode CP1252 punctuation");
assert_eq!(text, "l’acad");
}
#[test]
fn cached_encoding_decode_normalizes_cp1252_controls() {
let text = normalize_cp1252_controls("d\u{92}un \u{96} test".to_string(), true);
assert_eq!(text, "d’un – test");
}
#[test]
fn tex_font_decode_keeps_c1_ligature_bytes_unmodified() {
let text = normalize_cp1252_controls("de\u{85}ciente \u{87}uid".to_string(), false);
assert_eq!(text, "de\u{85}ciente \u{87}uid");
assert!(!should_use_cp1252_single_byte_fallback(
Some("TTdcr10"),
false
));
assert!(!should_use_cp1252_single_byte_fallback(
Some("cmr10"),
false
));
}
#[test]
fn winansi_text_font_uses_cp1252_fallback() {
assert!(should_use_cp1252_single_byte_fallback(
Some("BJPQNQ+Times-Roman"),
false
));
}
fn gid_font_doc(bfchar: Option<&str>) -> (Document, lopdf::ObjectId) {
use lopdf::Stream;
let mut doc = Document::with_version("1.4");
let cmap = format!(
"/CIDInit /ProcSet findresource begin
12 dict begin
begincmap
1 begincodespacerange
<00> <FF>
endcodespacerange
1 beginbfchar
{}
endbfchar
endcmap
CMapName currentdict /CMap defineresource pop
end
end",
bfchar.unwrap_or_default()
);
let tounicode_id = doc.add_object(Object::Stream(Stream::new(
dictionary! {},
cmap.into_bytes(),
)));
let enc_id = doc.add_object(dictionary! {
"Type" => "Encoding",
"Differences" => vec![
1.into(),
Object::Name(b"gid1283".to_vec()),
Object::Name(b"gid1464".to_vec()),
],
});
let mut font = dictionary! {
"Type" => "Font",
"Subtype" => "TrueType",
"BaseFont" => "ABCDEF+OpenSymbol",
"Encoding" => Object::Reference(enc_id),
};
if bfchar.is_some() {
font.set("ToUnicode", Object::Reference(tounicode_id));
}
let font_id = doc.add_object(font);
let page_id = doc.add_object(dictionary! {
"Type" => "Page",
"Resources" => dictionary! {
"Font" => dictionary! { "F1" => Object::Reference(font_id) },
},
"MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
});
let pages_id = doc.add_object(dictionary! {
"Type" => "Pages",
"Count" => Object::Integer(1),
"Kids" => vec![Object::Reference(page_id)],
});
let catalog_id = doc.add_object(dictionary! {
"Type" => "Catalog",
"Pages" => Object::Reference(pages_id),
});
doc.trailer.set("Root", Object::Reference(catalog_id));
(doc, page_id)
}
fn gid_flagged(bfchar: Option<&str>) -> bool {
let (doc, page_id) = gid_font_doc(bfchar);
let cmaps = FontCMaps::from_doc(&doc);
let fonts = doc.get_page_fonts(page_id).unwrap();
let (_, has_gid_fonts) =
build_font_encodings(&doc, &fonts, &cmaps, &mut FontStyleCache::new());
has_gid_fonts
}
#[test]
fn gid_differences_with_covering_tounicode_are_not_flagged() {
assert!(!gid_flagged(Some("<01> <2022>\n<02> <25E6>")));
}
#[test]
fn gid_differences_with_partial_tounicode_are_not_flagged() {
assert!(!gid_flagged(Some(
"<01> <D83DDC68200DD83DDC69200DD83DDC67>"
)));
}
#[test]
fn gid_differences_without_tounicode_are_flagged() {
assert!(
gid_flagged(None),
"gid glyphs without ToUnicode are unresolvable"
);
}
#[test]
fn gid_differences_with_disjoint_tounicode_are_flagged() {
assert!(gid_flagged(Some("<10> <0041>")));
}
#[test]
fn gid_differences_with_replacement_char_tounicode_are_flagged() {
assert!(gid_flagged(Some("<01> <FFFD>\n<02> <FFFD>")));
}
#[test]
fn parse_cid_w_array_range_and_consecutive() {
use super::parse_cid_w_array;
use lopdf::{Document, Object};
use std::collections::HashMap;
let doc = Document::new();
let mut widths = HashMap::new();
let w = vec![
Object::Integer(10),
Object::Integer(12),
Object::Integer(500),
Object::Integer(20),
Object::Array(vec![Object::Integer(100), Object::Integer(200)]),
];
parse_cid_w_array(&doc, &w, &mut widths);
assert_eq!(widths.get(&10), Some(&500));
assert_eq!(widths.get(&11), Some(&500));
assert_eq!(widths.get(&12), Some(&500));
assert_eq!(widths.get(&20), Some(&100));
assert_eq!(widths.get(&21), Some(&200));
}
#[test]
fn parse_cid_w_array_repeated_full_ranges_stay_bounded() {
use super::parse_cid_w_array;
use crate::tounicode::MAX_CID_W_EXPANSION;
use lopdf::{Document, Object};
use std::collections::HashMap;
let doc = Document::new();
let mut widths = HashMap::new();
let mut w = Vec::new();
for _ in 0..5_000 {
w.push(Object::Integer(0));
w.push(Object::Integer(65535));
w.push(Object::Integer(500));
}
parse_cid_w_array(&doc, &w, &mut widths);
assert!(widths.len() <= MAX_CID_W_EXPANSION);
assert_eq!(widths.get(&0), Some(&500));
assert_eq!(widths.get(&65535), Some(&500));
}
}
#[cfg(test)]
#[path = "stale_cmap_tests.rs"]
mod stale_cmap_tests;
#[cfg(test)]
#[path = "blank_glyph_tests.rs"]
mod blank_glyph_tests;