use super::get_number;
use super::type1::BuiltinEncoding;
use crate::glyph_names::glyph_name_to_string;
use crate::tounicode::{CidDecodeStats, CodeMapping, FontCMaps};
use crate::types::{
BaseEncoding, BoldSource, FontEncoding, FontEncodingMap, FontLabel, FontWidthInfo,
PageFontEncodings, PageFontKinds, PageFontWidths, PendingCoverage,
};
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>,
pending_coverage: PendingCoverage,
last_label: Option<FontLabel>,
coverage_off: u32,
}
#[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 record_coverage(&mut self, font: &str, stats: CidDecodeStats) {
if stats.codes == 0 || self.coverage_off > 0 {
return;
}
match self.pending_coverage.last_mut() {
Some((label, pending)) if &**label == font => pending.add(stats),
_ => {
let label = match &self.last_label {
Some(label) if &**label == font => label.clone(),
_ => {
let label = FontLabel::from(font);
self.last_label = Some(label.clone());
label
}
};
self.pending_coverage.push((label, stats));
}
}
}
pub(crate) fn take_run_coverage(&mut self) -> PendingCoverage {
std::mem::take(&mut self.pending_coverage)
}
pub(crate) fn without_coverage<R>(&mut self, read: impl FnOnce(&mut Self) -> R) -> R {
self.coverage_off += 1;
let result = read(self);
self.coverage_off -= 1;
result
}
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>,
font_cache: &mut FontStyleCache,
) -> 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, font_cache) {
widths.insert(resource_name, info);
}
}
widths
}
pub(crate) fn build_font_kinds(
fonts: &std::collections::BTreeMap<Vec<u8>, &lopdf::Dictionary>,
) -> PageFontKinds {
fonts
.iter()
.filter_map(|(font_name, font_dict)| {
let subtype = font_dict.get(b"Subtype").ok()?.as_name().ok()?;
Some((
String::from_utf8_lossy(font_name).to_string(),
subtype == b"Type0",
))
})
.collect()
}
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,
font_cache: &mut FontStyleCache,
) -> 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, font_cache)),
b"Type3" => parse_simple_font_widths(doc, font_dict),
_ => None,
}
}
fn base14_fallback_widths(
doc: &Document,
font_dict: &lopdf::Dictionary,
font_cache: &mut FontStyleCache,
) -> 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 encoding = parse_font_encoding(doc, font_dict);
let mut base = encoding
.as_ref()
.and_then(|r| r.base)
.or_else(|| builtin_base_encoding(doc, font_dict));
let named_codes = encoding
.as_ref()
.map(|r| r.named_codes.clone())
.unwrap_or_default();
let mut sequences = encoding
.as_ref()
.map(|r| r.sequences.clone())
.unwrap_or_default();
let mut enc_map = encoding.as_ref().map(|r| r.map.clone()).unwrap_or_default();
if let Some(result) = &encoding {
let by_index = glyph_index_chars(doc, font_dict, &result.gid_names, font_cache);
merge_program_readings(by_index, &mut enc_map, &mut sequences);
}
let mut named_codes = named_codes;
apply_program_encoding(
doc,
font_dict,
font_cache,
&mut base,
&mut enc_map,
&mut sequences,
&mut named_codes,
);
let blank_codes = blank_glyph_codes(doc, font_dict, font_cache);
let named = named_encoding(doc, font_dict).and_then(|name| BaseEncoding::from_name(&name));
if !keeps_encoding(&enc_map, &sequences, &blank_codes, base, named) {
named_codes.clear();
}
let mut widths = HashMap::new();
for code in 0u16..=255 {
if let Some(text) = sequences
.get(&(code as u8))
.filter(|_| !enc_map.contains_key(&(code as u8)))
{
let total = text
.chars()
.map(|ch| {
crate::extractor::base14::base14_char_width(&base_font, ch).unwrap_or(500)
})
.fold(0u16, u16::saturating_add);
widths.insert(code, total);
continue;
}
let Some(ch) = enc_map.get(&(code as u8)).copied().or_else(|| {
(code >= 0x20 && !named_codes.contains(&(code as u8))).then(|| {
base.and_then(|base| base.char_for(code as u8))
.unwrap_or_else(|| decode_single_byte_fallback_char(code as u8, true))
})
}) else {
continue;
};
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 = HashMap::new();
let mut base: Option<BaseEncoding> = None;
let mut named_codes = std::collections::HashSet::new();
let mut sequences: HashMap<u8, String> = HashMap::new();
let type3 = font_dict
.get(b"Subtype")
.ok()
.and_then(|o| o.as_name().ok())
.is_some_and(|n| n == b"Type3");
if let Some(result) = parse_font_encoding(doc, font_dict) {
base = result.base;
named_codes = result.named_codes.clone();
sequences = result.sequences.clone();
let by_index = if type3 {
HashMap::new()
} else {
glyph_index_chars(doc, font_dict, &result.gid_names, font_cache)
};
let unresolved: Vec<u8> = if type3 {
Vec::new()
} else {
result
.gid_codes
.iter()
.copied()
.filter(|code| !by_index.contains_key(code))
.collect()
};
if !unresolved.is_empty() && !tounicode_maps_codes(font_dict, cmaps, &unresolved) {
has_gid_fonts = true;
}
if !result.map.is_empty()
|| !result.sequences.is_empty()
|| !result.unread_names.is_empty()
{
identity_overrides = stale_identity_cmap_overrides(doc, font_dict, cmaps, &result);
}
differences = result.map;
merge_program_readings(by_index, &mut differences, &mut sequences);
}
if base.is_none() {
base = builtin_base_encoding(doc, font_dict);
}
apply_program_encoding(
doc,
font_dict,
font_cache,
&mut base,
&mut differences,
&mut sequences,
&mut named_codes,
);
let named = named_encoding(doc, font_dict).and_then(|name| BaseEncoding::from_name(&name));
let blank_codes = blank_glyph_codes(doc, font_dict, font_cache);
if keeps_encoding(&differences, &sequences, &blank_codes, base, named) {
encodings.insert(
resource_name,
FontEncoding {
differences,
identity_overrides,
blank_codes,
base,
named,
named_codes,
sequences,
},
);
}
}
(encodings, has_gid_fonts)
}
fn named_encoding(doc: &Document, font_dict: &lopdf::Dictionary) -> Option<Vec<u8>> {
let encoding = match font_dict.get(b"Encoding").ok()? {
Object::Reference(id) => doc.get_object(*id).ok()?,
other => other,
};
encoding.as_name().ok().map(<[u8]>::to_vec)
}
fn builtin_base_encoding(doc: &Document, font_dict: &lopdf::Dictionary) -> Option<BaseEncoding> {
let base_font = font_dict.get(b"BaseFont").ok()?.as_name().ok()?;
let builtin =
crate::extractor::base14::builtin_symbol_encoding(&String::from_utf8_lossy(base_font))?;
let own_name: &[u8] = match builtin {
BaseEncoding::Symbol => b"SymbolEncoding",
_ => b"ZapfDingbatsEncoding",
};
match named_encoding(doc, font_dict) {
None => Some(builtin),
Some(name) => (name == own_name).then_some(builtin),
}
}
fn apply_program_encoding(
doc: &Document,
font_dict: &lopdf::Dictionary,
font_cache: &mut FontStyleCache,
base: &mut Option<BaseEncoding>,
differences: &mut FontEncodingMap,
sequences: &mut HashMap<u8, String>,
named_codes: &mut std::collections::HashSet<u8>,
) {
if base.is_some() || reads_nothing_it_names(named_codes, differences, sequences) {
return;
}
let program = type1_builtin_encoding(doc, font_dict, named_codes, font_cache);
*base = program.base;
merge_program_readings(program.readings, differences, sequences);
named_codes.extend(program.absent);
}
fn keeps_encoding(
differences: &FontEncodingMap,
sequences: &HashMap<u8, String>,
blank_codes: &std::collections::HashSet<u8>,
base: Option<BaseEncoding>,
named: Option<BaseEncoding>,
) -> bool {
!differences.is_empty()
|| !sequences.is_empty()
|| !blank_codes.is_empty()
|| base.is_some()
|| named.is_some()
}
fn reads_nothing_it_names(
named_codes: &std::collections::HashSet<u8>,
differences: &FontEncodingMap,
sequences: &HashMap<u8, String>,
) -> bool {
!named_codes.is_empty() && differences.is_empty() && sequences.is_empty()
}
fn type1_builtin_encoding(
doc: &Document,
font_dict: &lopdf::Dictionary,
named_codes: &std::collections::HashSet<u8>,
font_cache: &mut FontStyleCache,
) -> ProgramEncoding {
let Some(ff_ref) = type1_program_under_no_named_base(doc, font_dict) else {
return ProgramEncoding::default();
};
let builtin = font_cache
.builtin_encodings_by_font_file
.entry(ff_ref)
.or_insert_with(|| {
font_file_data(doc, ff_ref).and_then(|data| super::type1::builtin_encoding(&data))
});
match builtin {
None => ProgramEncoding::default(),
Some(BuiltinEncoding::Standard) => ProgramEncoding {
base: Some(BaseEncoding::Standard),
..ProgramEncoding::default()
},
Some(BuiltinEncoding::Custom(names)) => {
let base_font_name = font_dict
.get(b"BaseFont")
.ok()
.and_then(|o| o.as_name().ok())
.map(String::from_utf8_lossy);
let readings = names
.iter()
.filter(|(code, _)| !named_codes.contains(code))
.filter_map(|(code, name)| {
let text = glyph_name_to_string(name).or_else(|| {
private_glyph_to_char(name, base_font_name.as_deref()).map(String::from)
})?;
let private =
|c: char| ('\u{E000}'..='\u{F8FF}').contains(&c) || c >= '\u{F0000}';
let reads = !text.chars().any(private)
&& !text.chars().all(crate::bidi::is_combining_mark);
reads.then_some((*code, text))
})
.collect();
let assigned: std::collections::HashSet<u8> =
names.iter().map(|(code, _)| *code).collect();
let absent = (0..=u8::MAX)
.filter(|code| {
*code != b' ' && !assigned.contains(code) && !named_codes.contains(code)
})
.collect();
ProgramEncoding {
base: None,
readings,
absent,
}
}
}
}
#[derive(Default)]
struct ProgramEncoding {
base: Option<BaseEncoding>,
readings: HashMap<u8, String>,
absent: Vec<u8>,
}
fn type1_program_under_no_named_base(
doc: &Document,
font_dict: &lopdf::Dictionary,
) -> Option<ObjectId> {
let subtype = font_dict.get(b"Subtype").ok()?.as_name().ok()?;
if subtype != b"Type1" && subtype != b"MMType1" {
return None;
}
if let Ok(encoding) = font_dict.get(b"Encoding") {
let encoding = match encoding {
Object::Reference(id) => doc.get_object(*id).ok()?,
other => other,
};
if encoding.as_dict().ok()?.has(b"BaseEncoding") {
return None;
}
}
let descriptor = resolve_dict(doc, font_dict.get(b"FontDescriptor").ok()?)?;
descriptor.get(b"FontFile").ok()?.as_reference().ok()
}
fn merge_program_readings(
by_index: HashMap<u8, String>,
differences: &mut FontEncodingMap,
sequences: &mut HashMap<u8, String>,
) {
for (code, text) in by_index {
let mut chars = text.chars();
match (chars.next(), chars.next()) {
(Some(ch), None) => {
sequences.remove(&code);
differences.insert(code, ch);
}
(Some(_), Some(_)) => {
differences.remove(&code);
sequences.insert(code, text);
}
(None, _) => {}
}
}
}
fn glyph_index_chars(
doc: &Document,
font_dict: &lopdf::Dictionary,
names: &[(u8, String)],
font_cache: &mut FontStyleCache,
) -> HashMap<u8, String> {
if names.is_empty() {
return HashMap::new();
}
let font_file = || -> Option<ObjectId> {
let descriptor = resolve_dict(doc, font_dict.get(b"FontDescriptor").ok()?)?;
[b"FontFile2".as_slice(), b"FontFile3".as_slice()]
.into_iter()
.find_map(|key| descriptor.get(key).ok()?.as_reference().ok())
};
let Some(ff_ref) = font_file() else {
return HashMap::new();
};
let cached = font_cache
.numbered_glyphs_by_font_file
.entry(ff_ref)
.or_default();
let unresolved: Vec<&str> = names
.iter()
.map(|(_, name)| name.as_str())
.filter(|name| !cached.contains_key(*name))
.collect();
if !unresolved.is_empty() {
let resolved = font_file_data(doc, ff_ref)
.map(|data| resolve_numbered_glyph_names(&data, &unresolved))
.unwrap_or_default();
for name in unresolved {
cached.insert(name.to_string(), resolved.get(name).cloned().flatten());
}
}
names
.iter()
.filter_map(|(code, name)| Some((*code, cached.get(name)?.clone()?)))
.collect()
}
fn resolve_numbered_glyph_names(data: &[u8], names: &[&str]) -> HashMap<String, Option<String>> {
struct Program<'a> {
face: Option<ttf_parser::Face<'a>>,
bare_cff: Option<ttf_parser::cff::Table<'a>>,
cmap_chars: HashMap<u16, char>,
}
impl Program<'_> {
fn text(&self, gid: u16) -> Option<String> {
match (&self.face, &self.bare_cff) {
(Some(face), _) => crate::tounicode::glyph_text(face, &self.cmap_chars, gid),
(None, Some(cff)) => {
glyph_name_to_string(cff.glyph_name(ttf_parser::GlyphId(gid))?)
}
(None, None) => None,
}
}
fn cff(&self) -> Option<&ttf_parser::cff::Table<'_>> {
self.face
.as_ref()
.and_then(|face| face.tables().cff.as_ref())
.or(self.bare_cff.as_ref())
}
fn glyph_by_name(&self, name: &str) -> Option<u16> {
match (&self.face, self.cff()) {
(Some(face), _) => face.glyph_index_by_name(name),
(None, Some(cff)) => cff.glyph_index_by_name(name),
(None, None) => None,
}
.map(|gid| gid.0)
}
fn glyph_by_cid(&self, cid: u16) -> Option<u16> {
let cff = self.cff()?;
(0..cff.number_of_glyphs())
.find(|&gid| cff.glyph_cid(ttf_parser::GlyphId(gid)) == Some(cid))
}
}
let mut programs: Vec<Program> = (0..ttf_parser::fonts_in_collection(data).unwrap_or(1))
.filter_map(|index| ttf_parser::Face::parse(data, index).ok())
.map(|face| Program {
cmap_chars: crate::tounicode::cmap_glyph_chars(&face),
face: Some(face),
bare_cff: None,
})
.collect();
if programs.is_empty() {
if let Some(cff) = ttf_parser::cff::Table::parse(data) {
programs.push(Program {
cmap_chars: HashMap::new(),
face: None,
bare_cff: Some(cff),
});
}
}
let Some(first) = programs.first() else {
return HashMap::new();
};
let resolve = |name: &str| -> Option<String> {
if let Some((program, gid)) = programs
.iter()
.find_map(|program| program.glyph_by_name(name).map(|gid| (program, gid)))
{
return program.text(gid);
}
match numbered_glyph_name(name)? {
NumberedGlyph::Index(index) => first.text(index),
NumberedGlyph::Cid(cid) => {
if let Some((program, gid)) = programs
.iter()
.find_map(|program| program.glyph_by_cid(cid).map(|gid| (program, gid)))
{
return program.text(gid);
}
first.text(cid)
}
}
};
names
.iter()
.map(|&name| (name.to_string(), resolve(name)))
.collect()
}
type PredefinedTable = [Option<char>; 256];
fn predefined_table(name: &[u8]) -> PredefinedTable {
let doc = Document::new();
let font = lopdf::dictionary! {
"Type" => "Font",
"Subtype" => "Type1",
"BaseFont" => "Helvetica",
"Encoding" => Object::Name(name.to_vec())
};
let mut table = [None; 256];
if let Ok(encoding) = font.get_font_encoding(&doc) {
for (code, slot) in table.iter_mut().enumerate() {
*slot = Document::decode_text(&encoding, &[code as u8])
.ok()
.and_then(|text| {
let mut chars = text.chars();
match (chars.next(), chars.next()) {
(Some(ch), None) => Some(ch),
_ => None,
}
});
}
}
table
}
static STANDARD_TABLE: std::sync::LazyLock<PredefinedTable> =
std::sync::LazyLock::new(|| predefined_table(b"StandardEncoding"));
static WIN_ANSI_TABLE: std::sync::LazyLock<PredefinedTable> =
std::sync::LazyLock::new(|| predefined_table(b"WinAnsiEncoding"));
static MAC_ROMAN_TABLE: std::sync::LazyLock<PredefinedTable> =
std::sync::LazyLock::new(|| predefined_table(b"MacRomanEncoding"));
static MAC_EXPERT_TABLE: std::sync::LazyLock<PredefinedTable> =
std::sync::LazyLock::new(|| predefined_table(b"MacExpertEncoding"));
impl BaseEncoding {
pub(crate) fn from_name(name: &[u8]) -> Option<Self> {
Some(match name {
b"StandardEncoding" => Self::Standard,
b"WinAnsiEncoding" => Self::WinAnsi,
b"MacRomanEncoding" => Self::MacRoman,
b"MacExpertEncoding" => Self::MacExpert,
_ => return None,
})
}
pub(crate) fn char_for(self, code: u8) -> Option<char> {
let table: &PredefinedTable = match self {
Self::Standard => &STANDARD_TABLE,
Self::WinAnsi => &WIN_ANSI_TABLE,
Self::MacRoman => &MAC_ROMAN_TABLE,
Self::MacExpert => &MAC_EXPERT_TABLE,
Self::Symbol | Self::ZapfDingbats => {
return crate::extractor::base14::symbol_encoding_char(self, code)
}
};
table[usize::from(code)]
}
}
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 slot_identity_entry(code: u8, mapped: &str) -> bool {
let mut chars = mapped.chars();
let (Some(ch), None) = (chars.next(), chars.next()) else {
return false;
};
let slot = code as char;
ch == slot
|| crate::bidi_mirroring::mirrored(slot) == Some(ch)
|| BaseEncoding::Standard.char_for(code) == Some(ch)
}
fn stale_identity_cmap_overrides(
doc: &Document,
font_dict: &lopdf::Dictionary,
cmaps: &FontCMaps,
encoding: &EncodingResult,
) -> HashMap<u8, String> {
let verified = || -> Option<HashMap<u8, String>> {
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.remapped.is_some() {
return None;
}
let mapped = |code: u8| {
entry
.primary
.lookup(u16::from(code))
.filter(|text| !text.is_empty() && !text.contains('\u{FFFD}'))
};
let mapped_codes: Vec<u8> = (0..=255u8).filter(|&code| mapped(code).is_some()).collect();
let elsewhere: std::collections::HashSet<String> = mapped_codes
.iter()
.filter_map(|&code| mapped(code))
.collect();
let mut described = 0usize;
let mut repairs: HashMap<u8, String> = HashMap::new();
for &code in &encoding.named_codes {
if !code.is_ascii_graphic() {
continue;
}
let Some(old) = mapped(code) else {
continue;
};
if !slot_identity_entry(code, &old) {
continue;
}
described += 1;
let reading = if let Some(&ch) = encoding.map.get(&code) {
let disagrees = if ch.is_ascii() {
ch.is_ascii_alphanumeric()
&& !old.eq_ignore_ascii_case(ch.encode_utf8(&mut [0; 4]))
} else {
ch.is_alphabetic() || ch == '\u{00a0}'
};
if !disagrees {
continue;
}
ch.to_string()
} else if let Some(text) = encoding.sequences.get(&code) {
if *text == old {
continue;
}
text.clone()
} else if encoding.unread_names.contains_key(&code) {
String::new()
} else {
continue;
};
repairs.insert(code, reading);
}
if repairs.is_empty() {
return None;
}
let anchors = |repairs: &HashMap<u8, String>| {
repairs
.values()
.filter(|text| {
let mut chars = text.chars();
matches!(
(chars.next(), chars.next()),
(Some(ch), None) if !ch.is_ascii() && ch.is_alphabetic()
) && elsewhere.contains(text.as_str())
})
.collect::<std::collections::HashSet<&String>>()
.len()
};
let code_bound = |key: &[u8]| -> Option<u16> {
match font_dict.get(key).ok()? {
Object::Integer(n) => u16::try_from(*n).ok(),
Object::Reference(id) => {
u16::try_from(doc.get_object(*id).ok()?.as_i64().ok()?).ok()
}
_ => None,
}
};
let mostly_elsewhere = match (code_bound(b"FirstChar"), code_bound(b"LastChar")) {
(Some(first), Some(last)) if first <= last => {
let outside = mapped_codes
.iter()
.filter(|&&code| u16::from(code) < first || u16::from(code) > last)
.count();
outside * 2 > mapped_codes.len()
}
_ => false,
};
let foreign = |repairs: &HashMap<u8, String>| {
mostly_elsewhere
&& !repairs.is_empty()
&& (repairs.len() == described
|| (repairs.len() >= 3 && repairs.len() * 2 > described))
};
if anchors(&repairs) < 3 && !foreign(&repairs) {
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)?;
repairs.retain(|code, _| {
encoding
.glyph_names
.get(code)
.or_else(|| encoding.unread_names.get(code))
.is_some_and(|name| cff.glyph_index_by_name(name).is_some())
});
(foreign(&repairs) || anchors(&repairs) >= 3).then_some(repairs)
};
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>,
unread_names: HashMap<u8, String>,
pub gid_codes: Vec<u8>,
pub gid_names: Vec<(u8, String)>,
pub named_codes: std::collections::HashSet<u8>,
pub sequences: HashMap<u8, String>,
pub base: Option<BaseEncoding>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum NumberedGlyph {
Index(u16),
Cid(u16),
}
fn numbered_glyph_name(name: &str) -> Option<NumberedGlyph> {
let (prefix, digits) = ["glyph", "index", "gid", "cid", "g", "G"]
.iter()
.find_map(|prefix| name.strip_prefix(prefix).map(|digits| (*prefix, digits)))?;
if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
let number = digits.parse().ok()?;
Some(if prefix == "cid" {
NumberedGlyph::Cid(number)
} else {
NumberedGlyph::Index(number)
})
}
pub(crate) fn parse_encoding_dictionary(
doc: &Document,
enc_dict: &lopdf::Dictionary,
base_font_name: Option<&str>,
) -> Option<EncodingResult> {
let base = enc_dict
.get(b"BaseEncoding")
.ok()
.and_then(|o| match o {
Object::Reference(id) => doc.get_object(*id).ok()?.as_name().ok(),
other => other.as_name().ok(),
})
.and_then(BaseEncoding::from_name);
let diff_array = match enc_dict.get(b"Differences") {
Ok(Object::Array(arr)) => arr.clone(),
Ok(Object::Reference(obj_ref)) => match doc.get_object(*obj_ref) {
Ok(Object::Array(arr)) => arr.clone(),
_ => Vec::new(),
},
_ => Vec::new(),
};
if diff_array.is_empty() && base.is_none() {
return None;
}
let mut encoding_map = FontEncodingMap::new();
let mut glyph_names = HashMap::new();
let mut unread_names = HashMap::new();
let mut current_code: u8 = 0;
let mut ligature_count = 0u32;
let mut gid_codes: Vec<u8> = Vec::new();
let mut gid_names: Vec<(u8, String)> = Vec::new();
let mut named_codes = std::collections::HashSet::new();
let mut sequences: HashMap<u8, String> = HashMap::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();
named_codes.insert(current_code);
let mapped = glyph_name_to_string(&glyph_name).or_else(|| {
private_glyph_to_char(&glyph_name, base_font_name).map(String::from)
});
let mut chars = mapped.as_deref().unwrap_or_default().chars();
let mapped_char = match (chars.next(), chars.next()) {
(Some(ch), None) => Some(ch),
_ => None,
};
if mapped_char.is_some_and(is_ligature_char)
|| mapped.as_ref().is_some_and(|text| text.chars().count() > 1)
{
debug!(
" Differences: code=0x{:02X} glyph={:?} (ligature)",
current_code, glyph_name
);
ligature_count += 1;
}
encoding_map.remove(¤t_code);
sequences.remove(¤t_code);
glyph_names.remove(¤t_code);
unread_names.remove(¤t_code);
gid_codes.retain(|code| *code != current_code);
gid_names.retain(|(code, _)| *code != current_code);
let numbered = mapped.is_none() && numbered_glyph_name(&glyph_name).is_some();
if numbered {
gid_codes.push(current_code);
gid_names.push((current_code, glyph_name.clone()));
}
if let Some(ch) = mapped_char {
encoding_map.insert(current_code, ch);
glyph_names.insert(current_code, glyph_name);
} else if let Some(text) = mapped {
sequences.insert(current_code, text);
glyph_names.insert(current_code, glyph_name);
} else {
debug!(
" Differences: code=0x{:02X} glyph={:?} (unmapped)",
current_code, glyph_name
);
if !numbered {
unread_names.insert(current_code, glyph_name);
}
}
current_code = current_code.wrapping_add(1);
}
_ => {}
}
}
if ligature_count > 0 {
debug!(
" Differences: {} total entries, {} ligatures",
encoding_map.len() + sequences.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,
unread_names,
gid_codes,
gid_names,
named_codes,
sequences,
base,
})
}
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>>,
numbered_glyphs_by_font_file: HashMap<ObjectId, HashMap<String, Option<String>>>,
builtin_encodings_by_font_file: HashMap<ObjectId, Option<BuiltinEncoding>>,
}
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) bold_source: Option<BoldSource>,
pub(crate) weight: Option<u16>,
pub(crate) fixed_pitch: Option<bool>,
}
impl FontStyle {
#[cfg(test)]
pub(crate) fn flags(self) -> (bool, bool) {
(self.italic, self.bold)
}
pub(crate) fn with_name(mut self, name: &str) -> Self {
if crate::text_utils::is_bold_font(name) {
self.bold = true;
self.bold_source = BoldSource::first(self.bold_source, Some(BoldSource::FontName));
}
if crate::text_utils::is_italic_font(name) {
self.italic = true;
}
self
}
pub(crate) fn with_measured_pitch(mut self, widths: Option<&FontWidthInfo>) -> Self {
if self.fixed_pitch.is_none() {
self.fixed_pitch = widths.and_then(FontWidthInfo::fixed_pitch_by_advance);
}
self
}
}
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| match obj {
Object::Reference(id) => doc.get_object(*id).ok().and_then(|o| o.as_i64().ok()),
direct => direct.as_i64().ok(),
})
.unwrap_or(0);
let force_bold = flags & (1 << 18) != 0;
let mut style = FontStyle {
italic: italic_angle.abs() >= 4.0 || flags & (1 << 6) != 0,
bold: force_bold,
bold_source: force_bold.then_some(BoldSource::FontFlags),
weight: None,
fixed_pitch: (flags & 1 != 0).then_some(true),
};
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.bold_source = BoldSource::first(style.bold_source, embedded.bold_source);
style.weight = embedded.weight;
style.fixed_pitch = style.fixed_pitch.or(embedded.fixed_pitch);
}
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);
let bold = face.is_bold() || mac_bold;
FontStyle {
italic: face.is_italic() || face.italic_angle().abs() >= 4.0,
bold,
bold_source: bold.then_some(BoldSource::FontFlags),
weight: os2.and_then(|os2| weight_class(f32::from(os2.weight().to_number()))),
fixed_pitch: face.is_monospaced().then_some(true),
}
} else if let Some(name) = cff_font_name(&data) {
let bold = crate::text_utils::is_bold_font(&name);
FontStyle {
italic: crate::text_utils::is_italic_font(&name),
bold,
bold_source: bold.then_some(BoldSource::FontName),
weight: crate::text_utils::font_weight_from_name(&name),
fixed_pitch: None,
}
} 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()),
)
}
struct CidDecode<'a> {
text: String,
codes: usize,
stats: CidDecodeStats,
cmap: &'a crate::tounicode::ToUnicodeCMap,
}
impl<'a> CidDecode<'a> {
fn new(cmap: &'a crate::tounicode::ToUnicodeCMap, bytes: &[u8]) -> Self {
let decoded = cmap.decode_cids_with(bytes, |out, label| out.push_str(label));
Self {
text: decoded.text,
codes: decoded.contributing,
stats: decoded.stats,
cmap,
}
}
fn joined(self, bytes: &[u8]) -> String {
if self.text.chars().count() == self.codes
|| !self.text.chars().any(crate::text_utils::is_rtl_char)
{
return self.text;
}
self.cmap
.decode_cids_with(bytes, |out, label| {
crate::bidi::push_glyph_characters(out, label)
})
.text
}
}
enum NamedReading {
Text(String),
Unread,
}
fn differences_reading(encoding: &FontEncoding, code: u8) -> Option<NamedReading> {
if let Some(&ch) = encoding.differences.get(&code) {
Some(NamedReading::Text(ch.to_string()))
} else if let Some(text) = encoding.sequences.get(&code) {
Some(NamedReading::Text(text.clone()))
} else if encoding.named_codes.contains(&code) {
Some(NamedReading::Unread)
} else {
None
}
}
#[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,
font_kinds: &PageFontKinds,
) -> Option<(String, bool)> {
let is_type0_cid_font = font_widths
.get(current_font)
.is_some_and(|info| info.is_cid);
let is_simple_font = font_kinds
.get(current_font)
.is_some_and(|&composite| !composite);
let use_cp1252_fallback =
should_use_cp1252_single_byte_fallback(base_font_name, is_type0_cid_font);
let font_label = base_font_name
.filter(|name| !name.is_empty())
.unwrap_or(current_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 || is_simple_font {
let encoding_map = font_encodings.get(current_font);
let decode_byte = |b: u8| -> Option<String> {
let code = b as u16;
let mut control_destination = false;
match entry.primary.lookup_code(code) {
CodeMapping::Text(s) if !s.contains('\u{FFFD}') => {
if let Some(text) =
encoding_map.and_then(|map| map.identity_overrides.get(&b))
{
if slot_identity_entry(b, &s) {
return (!text.is_empty()).then(|| text.clone());
}
}
if s == (b as char).to_string() {
if let Some(text) =
encoding_map.and_then(|map| map.sequences.get(&b))
{
return Some(text.clone());
}
}
return Some(s);
}
CodeMapping::ControlDestination => control_destination = true,
CodeMapping::Text(_) | CodeMapping::Unmapped => {}
}
let by_name = encoding_map.and_then(|map| differences_reading(map, b));
if control_destination {
match by_name {
Some(NamedReading::Text(text)) => return Some(text),
Some(NamedReading::Unread) => return Some("\u{FFFD}".to_string()),
None => {}
}
}
if let Some(fb) = entry.fallback.as_ref().and_then(|c| c.lookup(code)) {
if !fb.contains('\u{FFFD}') {
return Some(fb);
}
}
match by_name {
Some(NamedReading::Text(text)) => return Some(text),
Some(NamedReading::Unread) => return None,
None => {}
}
if b >= 0x20 {
if let Some(ch) = encoding_map
.and_then(|map| map.base)
.and_then(|base| base.char_for(b))
{
return Some(ch.to_string());
}
if control_destination {
if let Some(ch) = encoding_map
.and_then(|map| map.named)
.and_then(|named| named.char_for(b))
{
return Some(ch.to_string());
}
}
}
if b >= 0x20 && !control_destination {
return Some(
decode_single_byte_fallback_char(b, use_cp1252_fallback)
.to_string(),
);
}
if control_destination {
return Some("\u{FFFD}".to_string());
}
None
};
let mut decoded = String::new();
for &b in bytes {
let Some(label) = decode_byte(b) else {
continue;
};
if blank_glyph_reads_as_space(encoding_map, b, &label) {
decoded.push(' ');
} else {
crate::bidi::push_glyph_characters(&mut decoded, &label);
}
}
if is_type0_cid_font && !bytes.is_empty() {
let mapped_by_cmap = |b: u8| {
let code = b as u16;
let usable = |s: String| !s.is_empty() && !s.contains('\u{FFFD}');
entry.primary.lookup(code).is_some_and(usable)
|| entry
.fallback
.as_ref()
.and_then(|c| c.lookup(code))
.is_some_and(usable)
};
let unmapped = bytes.iter().filter(|&&b| !mapped_by_cmap(b)).count();
cmap_decisions.record_coverage(
font_label,
CidDecodeStats {
codes: u32::try_from(bytes.len()).unwrap_or(u32::MAX),
interpolated: 0,
unmapped: u32::try_from(unmapped).unwrap_or(u32::MAX),
},
);
}
if !decoded.is_empty() {
return Some(decoded);
}
return None;
}
let mut unread_byte_stats: Option<CidDecodeStats> = None;
if bytes.len() % 2 == 1 {
let key = font_tounicode_refs.get(current_font).copied().unwrap_or(0);
let remapped = entry
.remapped
.as_ref()
.filter(|_| cmap_decisions.get_choice(key) == Some(CMapChoice::Remapped));
let usable = |mapping: CodeMapping| match mapping {
CodeMapping::Text(s) if !s.is_empty() && !s.contains('\u{FFFD}') => Some(s),
_ => None,
};
let labels: Vec<(Option<String>, bool)> = bytes
.iter()
.map(|&b| {
let code = u16::from(b);
let read = [Some(&entry.primary), remapped, entry.fallback.as_ref()]
.into_iter()
.flatten()
.find_map(|cmap| usable(cmap.lookup_code(code)));
match read {
Some(label) => (Some(label), false),
None => {
let control = matches!(
entry.primary.lookup_code(code),
CodeMapping::ControlDestination
);
(control.then(|| "\u{FFFD}".to_string()), true)
}
}
})
.collect();
let mut decoded = String::new();
for label in labels.iter().filter_map(|(label, _)| label.as_ref()) {
crate::bidi::push_glyph_characters(&mut decoded, label);
}
let unmapped = labels.iter().filter(|(_, unmapped)| *unmapped).count();
let byte_stats = CidDecodeStats {
codes: u32::try_from(bytes.len()).unwrap_or(u32::MAX),
interpolated: 0,
unmapped: u32::try_from(unmapped).unwrap_or(u32::MAX),
};
if !decoded.is_empty() {
if is_type0_cid_font {
cmap_decisions.record_coverage(font_label, byte_stats);
}
return Some(decoded);
}
if is_type0_cid_font {
unread_byte_stats = Some(byte_stats);
}
}
let key = font_tounicode_refs.get(current_font).copied().unwrap_or(0);
let primary = CidDecode::new(&entry.primary, bytes);
let primary_stats = primary.stats;
if let Some(remapped) = entry.remapped.as_ref() {
let remap = CidDecode::new(remapped, bytes);
let fallback = entry.fallback.as_ref().map(|c| CidDecode::new(c, bytes));
if let Some(choice) = cmap_decisions.get_choice(key) {
let chosen = match choice {
CMapChoice::Primary => &primary,
CMapChoice::Remapped => &remap,
};
if !chosen.text.is_empty() {
let chosen = match choice {
CMapChoice::Primary => primary,
CMapChoice::Remapped => remap,
};
cmap_decisions.record_coverage(font_label, chosen.stats);
return Some(chosen.joined(bytes));
}
}
let choice =
cmap_decisions.consider(key, &primary.text, &remap.text, bytes.len());
let mut decoded = match choice {
Some(CMapChoice::Primary) => primary,
Some(CMapChoice::Remapped) => remap,
None => choose_best_cmap_decode(primary, remap, bytes.len() / 2),
};
if let Some(fb) = fallback {
let expected = bytes.len() / 2;
let decoded_len = decoded.text.chars().count();
let prefer_fallback = (!fb.text.is_empty() && decoded.text.is_empty())
|| (!fb.text.is_empty() && expected > 0 && decoded_len * 2 < expected);
if prefer_fallback || score_text(&fb.text) > score_text(&decoded.text) + 3 {
decoded = fb;
}
}
if !decoded.text.is_empty() {
cmap_decisions.record_coverage(font_label, decoded.stats);
return Some(decoded.joined(bytes));
}
} else if !primary.text.is_empty() {
if let Some(fb) = entry.fallback.as_ref().map(|c| CidDecode::new(c, bytes)) {
let expected = bytes.len() / 2;
let decoded_len = primary.text.chars().count();
let prefer_fallback = (!fb.text.is_empty() && primary.text.is_empty())
|| (!fb.text.is_empty() && expected > 0 && decoded_len * 2 < expected);
if prefer_fallback || score_text(&fb.text) > score_text(&primary.text) + 3 {
cmap_decisions.record_coverage(font_label, fb.stats);
return Some(fb.joined(bytes));
}
}
cmap_decisions.record_coverage(font_label, primary.stats);
return Some(primary.joined(bytes));
}
if is_type0_cid_font {
cmap_decisions
.record_coverage(font_label, unread_byte_stats.unwrap_or(primary_stats));
}
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 = encoding.base.is_some()
|| bytes.iter().any(|b| {
encoding_map.contains_key(b)
|| encoding.sequences.contains_key(b)
|| encoding.blank_codes.contains(b)
|| encoding.named_codes.contains(b)
});
if has_diff_match {
let decoded: String = bytes
.iter()
.filter_map(|&b| {
let label: String = if let Some(&ch) = encoding_map.get(&b) {
ch.to_string()
} else if let Some(text) = encoding.sequences.get(&b) {
text.clone()
} else if encoding.named_codes.contains(&b) {
return None;
} else if let Some(ch) = encoding
.base
.filter(|_| b >= 0x20)
.and_then(|base| base.char_for(b))
{
ch.to_string()
} else if b >= 0x20 {
decode_single_byte_fallback_char(b, use_cp1252_fallback).to_string()
} else {
return None; };
if blank_glyph_reads_as_space(Some(encoding), b, &label) {
return Some(" ".to_string());
}
Some(label)
})
.fold(String::new(), |mut decoded, label| {
crate::bidi::push_glyph_characters(&mut decoded, &label);
decoded
});
if !decoded.is_empty() {
return Some(decoded);
}
if bytes
.iter()
.all(|b| encoding.named_codes.contains(b) || *b < 0x20)
{
return Some(String::new());
}
}
}
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<'a>(
primary: CidDecode<'a>,
remapped: CidDecode<'a>,
glyphs: usize,
) -> CidDecode<'a> {
if primary.text.is_empty() {
return remapped;
}
if remapped.text.is_empty() {
return primary;
}
let score = |text: &str| {
let score = text_score(text);
if glyphs > 2 {
score.total()
} else {
score.total_without_short_words()
}
};
if score(&remapped.text) > score(&primary.text) + 3 {
remapped
} else {
primary
}
}
fn score_text(text: &str) -> i32 {
text_score(text).total()
}
struct TextScore {
short_words: i32,
long_words: i32,
letters: i32,
characters: i32,
}
impl TextScore {
const WORD_POINTS: i32 = 10;
fn total(&self) -> i32 {
let words = self.short_words + self.long_words;
let mut total = words * Self::WORD_POINTS + self.characters;
if self.letters > 15 && words == 0 {
total -= 15;
}
total
}
fn total_without_short_words(&self) -> i32 {
self.total() - self.short_words * Self::WORD_POINTS
}
}
fn text_score(text: &str) -> TextScore {
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 short_words = 0i32;
let mut long_words = 0i32;
let mut count_word = |word: &str| {
if COMMON_WORDS.contains(&word) {
if word.len() < 3 {
short_words += 1;
} else {
long_words += 1;
}
}
};
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() {
count_word(¤t);
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() {
count_word(¤t);
}
TextScore {
short_words,
long_words,
letters,
characters: letters + spaces * 2 + digits - other * 2,
}
}
#[cfg(test)]
mod tests {
#[test]
fn encoding_dictionary_with_a_base_encoding_and_no_differences_is_read() {
let doc = Document::new();
let enc = lopdf::dictionary! {
"Type" => "Encoding",
"BaseEncoding" => "WinAnsiEncoding"
};
let result = parse_encoding_dictionary(&doc, &enc, None).expect("base encoding parsed");
assert_eq!(result.base, Some(BaseEncoding::WinAnsi));
assert!(result.map.is_empty());
let empty = lopdf::dictionary! { "Type" => "Encoding" };
assert!(parse_encoding_dictionary(&doc, &empty, None).is_none());
let both = lopdf::dictionary! {
"Type" => "Encoding",
"BaseEncoding" => "MacRomanEncoding",
"Differences" => Object::Array(vec![Object::Integer(0x41), Object::Name(b"Alpha".to_vec())])
};
let result = parse_encoding_dictionary(&doc, &both, None).unwrap();
assert_eq!(result.base, Some(BaseEncoding::MacRoman));
assert_eq!(result.map.get(&0x41), Some(&'\u{0391}'));
let mut doc = Document::new();
let name_id = doc.add_object(Object::Name(b"WinAnsiEncoding".to_vec()));
let indirect = lopdf::dictionary! {
"Type" => "Encoding",
"BaseEncoding" => Object::Reference(name_id)
};
let result =
parse_encoding_dictionary(&doc, &indirect, None).expect("base encoding parsed");
assert_eq!(result.base, Some(BaseEncoding::WinAnsi));
}
#[test]
fn base_encoding_leaves_control_bytes_out() {
let bytes = vec![0x41_u8, 0x0D, 0x09, 0x42];
let obj = Object::String(bytes, lopdf::StringFormat::Literal);
let font_cmaps = FontCMaps::default();
let font_tounicode_refs: HashMap<String, u32> = HashMap::new();
let inline_cmaps = HashMap::new();
let mut font_encodings: PageFontEncodings = HashMap::new();
font_encodings.insert(
"F0".to_string(),
FontEncoding {
differences: FontEncodingMap::new(),
identity_overrides: HashMap::new(),
blank_codes: Default::default(),
base: Some(BaseEncoding::WinAnsi),
named: None,
named_codes: Default::default(),
sequences: Default::default(),
},
);
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, false));
let (text, _) = extract_text_from_operand(
&obj,
"F0",
Some("Helvetica"),
&font_cmaps,
&font_tounicode_refs,
&inline_cmaps,
&font_encodings,
&encoding_cache,
&mut decisions,
&font_widths,
&font_kinds("F0", false),
)
.expect("text decoded");
assert_eq!(text, "AB");
}
#[test]
fn two_byte_codes_reading_as_several_right_to_left_characters_are_joined() {
use crate::bidi::GLYPH_JOINER;
use crate::tounicode::{CMapEntry, ToUnicodeCMap};
let cmap = |entries: &[(u16, &str)]| {
let mut cmap = ToUnicodeCMap {
code_byte_length: 2,
..Default::default()
};
for &(cid, text) in entries {
cmap.char_map.insert(cid, text.to_string());
}
cmap
};
let decode = |entry: CMapEntry, decisions: &mut CMapDecisionCache, bytes: Vec<u8>| {
let mut inline_cmaps = HashMap::new();
inline_cmaps.insert("F0".to_string(), entry);
extract_text_from_operand(
&Object::String(bytes, lopdf::StringFormat::Literal),
"F0",
None,
&FontCMaps::default(),
&HashMap::new(),
&inline_cmaps,
&HashMap::new(),
&HashMap::new(),
decisions,
&HashMap::new(),
&font_kinds("F0", true),
)
.map(|(text, _)| text)
};
let single = || CMapEntry {
primary: cmap(&[(1, "\u{0644}\u{0627}"), (2, "\u{0647}"), (3, "fi")]),
remapped: None,
fallback: None,
};
assert_eq!(
decode(single(), &mut CMapDecisionCache::new(), vec![0, 2, 0, 1]).as_deref(),
Some(&*format!("\u{0647}\u{0644}{GLYPH_JOINER}\u{0627}"))
);
assert_eq!(
decode(single(), &mut CMapDecisionCache::new(), vec![0, 3, 0, 3]).as_deref(),
Some("fifi")
);
let either = || CMapEntry {
primary: cmap(&[(1, "\u{0644}"), (2, "\u{0627}\u{0647}")]),
remapped: Some(cmap(&[(1, "\u{0644}\u{0627}"), (2, "\u{0647}")])),
fallback: None,
};
let english = "the and of to in a is that for with on as by from this be are at or not";
let mut prefers_remapped = CMapDecisionCache::new();
prefers_remapped.consider(0, "", english, 240);
assert_eq!(prefers_remapped.get_choice(0), Some(CMapChoice::Remapped));
assert_eq!(
decode(either(), &mut prefers_remapped, vec![0, 1, 0, 2]).as_deref(),
Some(&*format!("\u{0644}{GLYPH_JOINER}\u{0627}\u{0647}"))
);
let mut prefers_primary = CMapDecisionCache::new();
prefers_primary.consider(0, english, "", 240);
assert_eq!(prefers_primary.get_choice(0), Some(CMapChoice::Primary));
assert_eq!(
decode(either(), &mut prefers_primary, vec![0, 1, 0, 2]).as_deref(),
Some(&*format!("\u{0644}\u{0627}{GLYPH_JOINER}\u{0647}"))
);
}
fn cid_font_widths() -> PageFontWidths {
let mut widths = PageFontWidths::new();
widths.insert(
"F0".to_string(),
FontWidthInfo {
widths: HashMap::new(),
default_width: 600,
space_width: 600,
is_cid: true,
units_scale: 0.001,
wmode: 0,
},
);
widths
}
fn font_kinds(name: &str, composite: bool) -> PageFontKinds {
PageFontKinds::from([(name.to_string(), composite)])
}
fn decode_through(
primary: crate::tounicode::ToUnicodeCMap,
bytes: Vec<u8>,
) -> (Option<String>, PendingCoverage) {
let mut inline_cmaps = HashMap::new();
inline_cmaps.insert(
"F0".to_string(),
crate::tounicode::CMapEntry {
primary,
remapped: None,
fallback: None,
},
);
let mut decisions = CMapDecisionCache::new();
let text = extract_text_from_operand(
&Object::String(bytes, lopdf::StringFormat::Literal),
"F0",
Some("AAAAAA+Font"),
&FontCMaps::default(),
&HashMap::new(),
&inline_cmaps,
&HashMap::new(),
&HashMap::new(),
&mut decisions,
&cid_font_widths(),
&font_kinds("F0", true),
)
.map(|(text, _)| text);
(text, decisions.take_run_coverage())
}
#[test]
fn a_cmap_that_reads_less_than_half_of_a_string_records_its_reading() {
use crate::tounicode::ToUnicodeCMap;
let mut primary = ToUnicodeCMap {
code_byte_length: 2,
..Default::default()
};
primary.char_map.insert(36, "A".to_string());
primary.char_map.insert(38, "C".to_string());
primary.refresh_gap_fills();
let (text, coverage) = decode_through(primary, vec![0, 37, 0, 0x80, 0, 0x81, 0, 0x82]);
assert_eq!(text.as_deref(), Some("\u{FFFD}\u{FFFD}\u{FFFD}\u{FFFD}"));
assert_eq!(
coverage,
vec![(
FontLabel::from("AAAAAA+Font"),
CidDecodeStats {
codes: 4,
interpolated: 1,
unmapped: 3
}
)]
);
}
#[test]
fn a_single_byte_cmap_that_reads_none_of_a_string_counts_each_byte_as_a_code() {
use crate::tounicode::ToUnicodeCMap;
let mut primary = ToUnicodeCMap {
code_byte_length: 1,
..Default::default()
};
primary.char_map.insert(0x41, "A".to_string());
primary.refresh_gap_fills();
let (_, coverage) = decode_through(primary, vec![0x01, 0x02]);
assert_eq!(
coverage,
vec![(
FontLabel::from("AAAAAA+Font"),
CidDecodeStats {
codes: 2,
interpolated: 0,
unmapped: 2
}
)]
);
}
#[test]
fn a_single_byte_cmap_that_reads_part_of_a_string_counts_the_rest_as_unmapped() {
use crate::tounicode::ToUnicodeCMap;
let mut primary = ToUnicodeCMap {
code_byte_length: 1,
..Default::default()
};
primary.char_map.insert(0x41, "A".to_string());
primary.char_map.insert(0x42, "B".to_string());
primary.refresh_gap_fills();
let (text, coverage) = decode_through(primary, vec![0x41, 0x42, 0x43]);
assert_eq!(text.as_deref(), Some("ABC"));
assert_eq!(
coverage,
vec![(
FontLabel::from("AAAAAA+Font"),
CidDecodeStats {
codes: 3,
interpolated: 0,
unmapped: 1
}
)]
);
}
#[test]
fn a_simple_fonts_cmap_that_cannot_read_a_string_records_no_gap() {
use crate::tounicode::{CMapEntry, ToUnicodeCMap};
let mut primary = ToUnicodeCMap {
code_byte_length: 2,
..Default::default()
};
primary.char_map.insert(0x54, "T".to_string());
primary.char_map.insert(0x65, "e".to_string());
primary.refresh_gap_fills();
let mut inline_cmaps = HashMap::new();
inline_cmaps.insert(
"F0".to_string(),
CMapEntry {
primary,
remapped: None,
fallback: None,
},
);
for kinds in [font_kinds("F0", false), PageFontKinds::new()] {
let mut decisions = CMapDecisionCache::new();
let text = extract_text_from_operand(
&Object::String(b"Te".to_vec(), lopdf::StringFormat::Literal),
"F0",
Some("AAAAAA+Font"),
&FontCMaps::default(),
&HashMap::new(),
&inline_cmaps,
&HashMap::new(),
&HashMap::new(),
&mut decisions,
&HashMap::new(),
&kinds,
)
.map(|(text, _)| text);
assert_eq!(text.as_deref(), Some("Te"), "{kinds:?}");
assert!(decisions.take_run_coverage().is_empty(), "{kinds:?}");
}
}
#[test]
fn a_font_with_an_empty_base_font_name_reports_under_its_resource_name() {
use crate::tounicode::{CMapEntry, ToUnicodeCMap};
let mut primary = ToUnicodeCMap {
code_byte_length: 2,
..Default::default()
};
primary.char_map.insert(36, "A".to_string());
primary.refresh_gap_fills();
let mut inline_cmaps = HashMap::new();
inline_cmaps.insert(
"F0".to_string(),
CMapEntry {
primary,
remapped: None,
fallback: None,
},
);
let mut decisions = CMapDecisionCache::new();
let text = extract_text_from_operand(
&Object::String(vec![0, 36, 0, 0x80], lopdf::StringFormat::Literal),
"F0",
Some(""),
&FontCMaps::default(),
&HashMap::new(),
&inline_cmaps,
&HashMap::new(),
&HashMap::new(),
&mut decisions,
&cid_font_widths(),
&font_kinds("F0", true),
)
.map(|(text, _)| text);
assert_eq!(text.as_deref(), Some("A\u{FFFD}"));
assert_eq!(
decisions.take_run_coverage(),
vec![(
FontLabel::from("F0"),
CidDecodeStats {
codes: 2,
interpolated: 0,
unmapped: 1
}
)]
);
}
#[test]
fn coverage_is_not_recorded_inside_without_coverage() {
let stats = CidDecodeStats {
codes: 2,
interpolated: 1,
unmapped: 0,
};
let mut decisions = CMapDecisionCache::new();
decisions.without_coverage(|decisions| decisions.record_coverage("F", stats));
assert!(decisions.take_run_coverage().is_empty());
decisions.record_coverage("F", stats);
decisions.record_coverage("F", stats);
let mut twice = stats;
twice.add(stats);
assert_eq!(
decisions.take_run_coverage(),
vec![(FontLabel::from("F"), twice)]
);
assert!(decisions.take_run_coverage().is_empty());
decisions.record_coverage("F", stats);
decisions.record_coverage("G", stats);
decisions.record_coverage("G", stats);
assert_eq!(
decisions.take_run_coverage(),
vec![(FontLabel::from("F"), stats), (FontLabel::from("G"), twice)]
);
}
#[test]
fn an_odd_length_string_reads_the_bytes_its_fallback_cmap_has_as_covered() {
use crate::tounicode::{CMapEntry, ToUnicodeCMap};
let two_byte = |entries: &[(u16, &str)]| {
let mut cmap = ToUnicodeCMap {
code_byte_length: 2,
..Default::default()
};
for &(code, text) in entries {
cmap.char_map.insert(code, text.to_string());
}
cmap.refresh_gap_fills();
cmap
};
let mut inline_cmaps = HashMap::new();
inline_cmaps.insert(
"F0".to_string(),
CMapEntry {
primary: two_byte(&[(0x41, "A")]),
remapped: None,
fallback: Some(two_byte(&[(0x42, "B"), (0x43, "C")])),
},
);
let mut decisions = CMapDecisionCache::new();
let text = extract_text_from_operand(
&Object::String(vec![0x41, 0x42, 0x43], lopdf::StringFormat::Literal),
"F0",
Some("AAAAAA+Font"),
&FontCMaps::default(),
&HashMap::new(),
&inline_cmaps,
&HashMap::new(),
&HashMap::new(),
&mut decisions,
&cid_font_widths(),
&font_kinds("F0", true),
)
.map(|(text, _)| text);
assert_eq!(text.as_deref(), Some("ABC"));
assert_eq!(
decisions.take_run_coverage(),
vec![(
FontLabel::from("AAAAAA+Font"),
CidDecodeStats {
codes: 3,
interpolated: 0,
unmapped: 0
}
)]
);
}
#[test]
fn an_odd_length_string_read_byte_by_byte_through_a_two_byte_cmap_counts_its_bytes() {
use crate::tounicode::ToUnicodeCMap;
let mut primary = ToUnicodeCMap {
code_byte_length: 2,
..Default::default()
};
primary.char_map.insert(0x41, "A".to_string());
primary.refresh_gap_fills();
let (text, coverage) = decode_through(primary, vec![0x41, 0x42, 0x43]);
assert_eq!(text.as_deref(), Some("A"));
assert_eq!(
coverage,
vec![(
FontLabel::from("AAAAAA+Font"),
CidDecodeStats {
codes: 3,
interpolated: 0,
unmapped: 2
}
)]
);
}
#[test]
fn numbered_names_resolve_through_fontfile3_when_fontfile2_is_not_a_reference() {
let doc = Document::load("tests/fixtures/glyph_names_in_embedded_fonts.pdf").unwrap();
let mut doc = doc;
let page_id = *doc.get_pages().get(&1).unwrap();
let fonts = doc.get_page_fonts(page_id).unwrap();
let font = (*fonts.get(&b"F2".to_vec()).expect("F2")).clone();
let names = vec![
(0x41_u8, "g1".to_string()),
(0x42, "g2".to_string()),
(0x43, "glyph3".to_string()),
];
let expected: HashMap<u8, String> =
[(0x41, "\u{03B4}"), (0x42, "\u{03B5}"), (0x43, "\u{03B6}")]
.into_iter()
.map(|(code, text)| (code, text.to_string()))
.collect();
assert_eq!(
glyph_index_chars(&doc, &font, &names, &mut FontStyleCache::new()),
expected
);
let descriptor_ref = font.get(b"FontDescriptor").unwrap().as_reference().unwrap();
let mut descriptor = doc.get_dictionary(descriptor_ref).unwrap().clone();
let program = descriptor.get(b"FontFile2").unwrap().clone();
descriptor.set("FontFile3", program);
descriptor.set("FontFile2", Object::Integer(0));
let new_descriptor = doc.add_object(descriptor);
let mut moved = font.clone();
moved.set("FontDescriptor", Object::Reference(new_descriptor));
assert_eq!(
glyph_index_chars(&doc, &moved, &names, &mut FontStyleCache::new()),
expected
);
}
#[test]
fn a_string_of_named_but_unmapped_codes_reads_as_nothing_without_a_base() {
let bytes = vec![0x81_u8, 0x82, 0x9B];
let obj = Object::String(bytes, lopdf::StringFormat::Literal);
let font_cmaps = FontCMaps::default();
let font_tounicode_refs: HashMap<String, u32> = HashMap::new();
let inline_cmaps = HashMap::new();
let mut font_encodings: PageFontEncodings = HashMap::new();
font_encodings.insert(
"F0".to_string(),
FontEncoding {
differences: FontEncodingMap::new(),
identity_overrides: HashMap::new(),
blank_codes: Default::default(),
base: None,
named: None,
named_codes: [0x81, 0x82, 0x9B].into_iter().collect(),
sequences: Default::default(),
},
);
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, false));
let decoded = extract_text_from_operand(
&obj,
"F0",
Some("SyntheticSubset"),
&font_cmaps,
&font_tounicode_refs,
&inline_cmaps,
&font_encodings,
&encoding_cache,
&mut decisions,
&font_widths,
&font_kinds("F0", false),
);
let text = decoded.map(|(text, _)| text).unwrap_or_default();
assert!(
!text.contains('\u{201A}') && !text.contains('\u{203A}') && !text.contains('\u{0081}'),
"{text:?}"
);
assert!(text.trim().is_empty(), "{text:?}");
}
#[test]
fn component_ligature_names_read_as_their_letters() {
let doc = Document::new();
let enc = lopdf::dictionary! {
"Type" => "Encoding",
"Differences" => Object::Array(vec![
Object::Integer(0x41),
Object::Name(b"f_t".to_vec()),
Object::Name(b"f_f_i".to_vec()),
Object::Name(b"f_i.liga".to_vec()),
Object::Name(b"uni00660069".to_vec()),
Object::Name(b"a.sc".to_vec()),
Object::Name(b"f_zzz".to_vec()),
])
};
let result = parse_encoding_dictionary(&doc, &enc, None).expect("parsed");
assert_eq!(result.sequences.get(&0x41).map(String::as_str), Some("ft"));
assert_eq!(result.sequences.get(&0x42).map(String::as_str), Some("ffi"));
assert_eq!(result.sequences.get(&0x43).map(String::as_str), Some("fi"));
assert_eq!(result.sequences.get(&0x44).map(String::as_str), Some("fi"));
assert_eq!(result.map.get(&0x45), Some(&'a'));
assert!(!result.map.contains_key(&0x46) && !result.sequences.contains_key(&0x46));
assert!(result.named_codes.contains(&0x46));
let obj = Object::String(
vec![0x41, 0x20, 0x42, 0x45, 0x46],
lopdf::StringFormat::Literal,
);
let font_cmaps = FontCMaps::default();
let font_tounicode_refs: HashMap<String, u32> = HashMap::new();
let inline_cmaps = HashMap::new();
let mut font_encodings: PageFontEncodings = HashMap::new();
font_encodings.insert(
"F0".to_string(),
FontEncoding {
differences: result.map.clone(),
identity_overrides: HashMap::new(),
blank_codes: Default::default(),
base: Some(BaseEncoding::WinAnsi),
named: None,
named_codes: result.named_codes.clone(),
sequences: result.sequences.clone(),
},
);
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, false));
let (text, _) = extract_text_from_operand(
&obj,
"F0",
Some("Helvetica"),
&font_cmaps,
&font_tounicode_refs,
&inline_cmaps,
&font_encodings,
&encoding_cache,
&mut decisions,
&font_widths,
&font_kinds("F0", false),
)
.expect("text decoded");
assert_eq!(text, "ft ffia");
}
#[test]
fn codes_differences_name_but_do_not_read_are_measured_as_they_read() {
let doc = Document::new();
let font = lopdf::dictionary! {
"Type" => "Font",
"Subtype" => "Type1",
"BaseFont" => "Times-Roman",
"Encoding" => Object::Dictionary(lopdf::dictionary! {
"Type" => "Encoding",
"Differences" => Object::Array(vec![
Object::Integer(0x3D),
Object::Name(b"=".to_vec()),
Object::Integer(0x3B),
Object::Name(b";".to_vec()),
])
})
};
let widths =
base14_fallback_widths(&doc, &font, &mut FontStyleCache::new()).expect("widths");
for (code, ch) in [(0x3D_u16, '='), (0x3B, ';')] {
assert_eq!(
widths.widths.get(&code).copied(),
crate::extractor::base14::base14_char_width("Times-Roman", ch),
"{ch}"
);
}
let mut based = font.clone();
based.set(
"Encoding",
Object::Dictionary(lopdf::dictionary! {
"Type" => "Encoding",
"BaseEncoding" => "WinAnsiEncoding",
"Differences" => Object::Array(vec![
Object::Integer(0x3D),
Object::Name(b"=".to_vec()),
])
}),
);
let widths =
base14_fallback_widths(&doc, &based, &mut FontStyleCache::new()).expect("widths");
assert_eq!(widths.widths.get(&0x3D), None);
}
#[test]
fn a_program_whose_names_read_nothing_leaves_its_notdef_codes_as_they_read() {
let face = |name: &str| {
let mut doc = Document::with_version("1.4");
let mut program = format!(
"%!PS-AdobeFont-1.0: Face 1.0\n11 dict begin\n/FontName /Face def\n\
/FontType 1 def\n/Encoding 256 array\n\
0 1 255 {{1 index exch /.notdef put}} for\ndup 65 /{name} put\n\
readonly def\ncurrentdict end\ncurrentfile eexec\n"
)
.into_bytes();
program.extend_from_slice(&[0xd9, 0xd6, 0x6f, 0x63]);
let file = doc.add_object(lopdf::Stream::new(dictionary! {}, program));
let descriptor = doc.add_object(dictionary! {
"Type" => "FontDescriptor",
"FontName" => "Times-Roman",
"FontFile" => Object::Reference(file),
});
let font = dictionary! {
"Type" => "Font",
"Subtype" => "Type1",
"BaseFont" => "Times-Roman",
"FontDescriptor" => Object::Reference(descriptor),
};
(doc, font)
};
for (name, keeps_encoding) in [("uniE000", false), ("Alpha", true)] {
let (doc, font) = face(name);
let fonts = std::collections::BTreeMap::from([(b"F1".to_vec(), &font)]);
let (encodings, _) = build_font_encodings(
&doc,
&fonts,
&FontCMaps::from_doc(&doc),
&mut FontStyleCache::new(),
);
assert_eq!(encodings.contains_key("F1"), keeps_encoding, "{name}");
let widths =
base14_fallback_widths(&doc, &font, &mut FontStyleCache::new()).expect("widths");
let expected = if keeps_encoding {
None
} else {
crate::extractor::base14::base14_char_width("Times-Roman", 'B')
};
assert_eq!(widths.widths.get(&0x42).copied(), expected, "{name}");
}
}
#[test]
fn a_code_named_twice_keeps_its_last_name_in_text_and_width() {
let doc = Document::new();
let font = |names: Vec<&[u8]>| {
let mut differences = Vec::new();
for name in names {
differences.push(Object::Integer(0x40));
differences.push(Object::Name(name.to_vec()));
}
lopdf::dictionary! {
"Type" => "Font",
"Subtype" => "Type1",
"BaseFont" => "Helvetica",
"Encoding" => Object::Dictionary(lopdf::dictionary! {
"Type" => "Encoding",
"Differences" => Object::Array(differences)
})
}
};
let last_letter = font(vec![b"f_i", b"a"]);
let result = parse_font_encoding(&doc, &last_letter).expect("parsed");
assert_eq!(result.map.get(&0x40), Some(&'a'));
assert!(!result.sequences.contains_key(&0x40));
let widths =
base14_fallback_widths(&doc, &last_letter, &mut FontStyleCache::new()).expect("widths");
assert_eq!(
widths.widths.get(&0x40).copied(),
crate::extractor::base14::base14_char_width("Helvetica", 'a')
);
let last_ligature = font(vec![b"a", b"f_i"]);
let result = parse_font_encoding(&doc, &last_ligature).expect("parsed");
assert_eq!(result.sequences.get(&0x40).map(String::as_str), Some("fi"));
assert!(!result.map.contains_key(&0x40));
let widths = base14_fallback_widths(&doc, &last_ligature, &mut FontStyleCache::new())
.expect("widths");
let fi: u16 = ['f', 'i']
.into_iter()
.map(|ch| crate::extractor::base14::base14_char_width("Helvetica", ch).unwrap())
.sum();
assert_eq!(widths.widths.get(&0x40).copied(), Some(fi));
}
#[test]
fn a_sequence_of_many_letters_saturates_its_width() {
let doc = Document::new();
let name = format!("uni{}", "0041".repeat(100));
let font = lopdf::dictionary! {
"Type" => "Font",
"Subtype" => "Type1",
"BaseFont" => "Helvetica",
"Encoding" => Object::Dictionary(lopdf::dictionary! {
"Type" => "Encoding",
"Differences" => Object::Array(vec![
Object::Integer(0x40),
Object::Name(name.into_bytes()),
])
})
};
let result = parse_font_encoding(&doc, &font).expect("parsed");
assert_eq!(result.sequences.get(&0x40).map(String::len), Some(100));
let widths =
base14_fallback_widths(&doc, &font, &mut FontStyleCache::new()).expect("widths");
assert_eq!(widths.widths.get(&0x40).copied(), Some(u16::MAX));
}
#[test]
fn a_later_numbered_name_takes_a_code_from_an_earlier_character() {
let doc = Document::new();
let font = |names: Vec<&[u8]>| {
let mut differences = Vec::new();
for name in names {
differences.push(Object::Integer(0x40));
differences.push(Object::Name(name.to_vec()));
}
lopdf::dictionary! {
"Type" => "Font",
"Subtype" => "Type1",
"BaseFont" => "Helvetica",
"Encoding" => Object::Dictionary(lopdf::dictionary! {
"Type" => "Encoding",
"Differences" => Object::Array(differences)
})
}
};
let numbered_last = parse_font_encoding(&doc, &font(vec![b"a", b"g5"])).expect("parsed");
assert!(!numbered_last.map.contains_key(&0x40));
assert!(!numbered_last.glyph_names.contains_key(&0x40));
assert_eq!(numbered_last.gid_codes, vec![0x40]);
assert_eq!(numbered_last.gid_names, vec![(0x40, "g5".to_string())]);
let letter_last = parse_font_encoding(&doc, &font(vec![b"g5", b"a"])).expect("parsed");
assert_eq!(letter_last.map.get(&0x40), Some(&'a'));
assert!(letter_last.gid_codes.is_empty());
assert!(letter_last.gid_names.is_empty());
}
#[test]
fn a_numbered_name_the_program_reads_as_letters_decodes_and_measures_so() {
let doc = Document::load("tests/fixtures/ligature_glyph_names.pdf").unwrap();
let page_id = *doc.get_pages().get(&1).unwrap();
let fonts = doc.get_page_fonts(page_id).unwrap();
let fixture = (*fonts.get(&b"F1".to_vec()).expect("F1")).clone();
let descriptor = resolve_dict(&doc, fixture.get(b"FontDescriptor").unwrap()).unwrap();
let program_ref = descriptor
.get(b"FontFile2")
.unwrap()
.as_reference()
.unwrap();
let program = font_file_data(&doc, program_ref).expect("program");
let face = ttf_parser::Face::parse(&program, 0).unwrap();
let ft = format!("g{}", face.glyph_index_by_name("f_t").unwrap().0);
let font = |names: Vec<&[u8]>| {
let mut differences = Vec::new();
for name in names {
differences.push(Object::Integer(0x41));
differences.push(Object::Name(name.to_vec()));
}
let mut dict = fixture.clone();
dict.set("BaseFont", Object::Name(b"Helvetica".to_vec()));
dict.remove(b"Widths");
dict.remove(b"FirstChar");
dict.remove(b"LastChar");
dict.set(
"Encoding",
Object::Dictionary(lopdf::dictionary! {
"Type" => "Encoding",
"Differences" => Object::Array(differences)
}),
);
dict
};
let width =
|ch: char| crate::extractor::base14::base14_char_width("Helvetica", ch).unwrap();
let encoding_of = |dict: &lopdf::Dictionary| {
let mut resources = std::collections::BTreeMap::new();
resources.insert(b"F1".to_vec(), dict);
let (mut encodings, has_gid_fonts) = build_font_encodings(
&doc,
&resources,
&FontCMaps::default(),
&mut FontStyleCache::new(),
);
(encodings.remove("F1").expect("encoding"), has_gid_fonts)
};
let numbered_last = font(vec![b"a", ft.as_bytes()]);
let (encoding, has_gid_fonts) = encoding_of(&numbered_last);
assert_eq!(
encoding.sequences.get(&0x41).map(String::as_str),
Some("ft")
);
assert!(!encoding.differences.contains_key(&0x41));
assert!(!has_gid_fonts);
let widths = base14_fallback_widths(&doc, &numbered_last, &mut FontStyleCache::new())
.expect("widths");
assert_eq!(
widths.widths.get(&0x41).copied(),
Some(width('f') + width('t'))
);
let letter_last = font(vec![ft.as_bytes(), b"a"]);
let (encoding, _) = encoding_of(&letter_last);
assert_eq!(encoding.differences.get(&0x41), Some(&'a'));
assert!(!encoding.sequences.contains_key(&0x41));
let widths =
base14_fallback_widths(&doc, &letter_last, &mut FontStyleCache::new()).expect("widths");
assert_eq!(widths.widths.get(&0x41).copied(), Some(width('a')));
}
#[test]
fn a_font_whose_names_all_read_as_nothing_keeps_the_single_byte_fallback() {
let doc = Document::new();
let font = lopdf::dictionary! {
"Type" => "Font",
"Subtype" => "Type1",
"BaseFont" => "Times-Roman",
"Encoding" => Object::Dictionary(lopdf::dictionary! {
"Type" => "Encoding",
"Differences" => Object::Array(vec![
Object::Integer(0x3D),
Object::Name(b"=".to_vec()),
Object::Integer(0x3B),
Object::Name(b";".to_vec()),
])
})
};
let mut resources = std::collections::BTreeMap::new();
resources.insert(b"F0".to_vec(), &font);
let (font_encodings, _) = build_font_encodings(
&doc,
&resources,
&FontCMaps::default(),
&mut FontStyleCache::new(),
);
assert!(!font_encodings.contains_key("F0"));
let obj = Object::String(
vec![0x61_u8, 0x3D, 0x62, 0x3B],
lopdf::StringFormat::Literal,
);
let font_cmaps = FontCMaps::default();
let font_tounicode_refs: HashMap<String, u32> = HashMap::new();
let inline_cmaps = 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, false));
let (text, _) = extract_text_from_operand(
&obj,
"F0",
Some("Times-Roman"),
&font_cmaps,
&font_tounicode_refs,
&inline_cmaps,
&font_encodings,
&encoding_cache,
&mut decisions,
&font_widths,
&font_kinds("F0", false),
)
.expect("text decoded");
assert_eq!(text, "a=b;");
}
#[test]
fn a_named_but_unmapped_code_reads_as_nothing() {
let bytes = vec![0x41_u8, 0x81, 0x42];
let obj = Object::String(bytes, lopdf::StringFormat::Literal);
let font_cmaps = FontCMaps::default();
let font_tounicode_refs: HashMap<String, u32> = HashMap::new();
let inline_cmaps = HashMap::new();
let mut font_encodings: PageFontEncodings = HashMap::new();
font_encodings.insert(
"F0".to_string(),
FontEncoding {
differences: FontEncodingMap::new(),
identity_overrides: HashMap::new(),
blank_codes: Default::default(),
base: Some(BaseEncoding::WinAnsi),
named: None,
named_codes: [0x81].into_iter().collect(),
sequences: Default::default(),
},
);
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, false));
let (text, _) = extract_text_from_operand(
&obj,
"F0",
Some("Helvetica"),
&font_cmaps,
&font_tounicode_refs,
&inline_cmaps,
&font_encodings,
&encoding_cache,
&mut decisions,
&font_widths,
&font_kinds("F0", false),
)
.expect("text decoded");
assert_eq!(text, "AB");
font_encodings.get_mut("F0").unwrap().named_codes.clear();
let (text, _) = extract_text_from_operand(
&obj,
"F0",
Some("Helvetica"),
&font_cmaps,
&font_tounicode_refs,
&inline_cmaps,
&font_encodings,
&encoding_cache,
&mut decisions,
&font_widths,
&font_kinds("F0", false),
)
.expect("text decoded");
assert_eq!(text, "A\u{2022}B");
}
#[test]
fn builtin_symbol_encoding_yields_to_a_named_encoding_however_it_is_written() {
let mut doc = Document::new();
let symbol = lopdf::dictionary! {
"Type" => "Font",
"Subtype" => "Type1",
"BaseFont" => "Symbol"
};
assert_eq!(
builtin_base_encoding(&doc, &symbol),
Some(BaseEncoding::Symbol)
);
let mut direct = symbol.clone();
direct.set("Encoding", Object::Name(b"WinAnsiEncoding".to_vec()));
assert_eq!(builtin_base_encoding(&doc, &direct), None);
let name_id = doc.add_object(Object::Name(b"WinAnsiEncoding".to_vec()));
let mut indirect = symbol.clone();
indirect.set("Encoding", Object::Reference(name_id));
assert_eq!(builtin_base_encoding(&doc, &indirect), None);
let mut own = symbol.clone();
own.set("Encoding", Object::Name(b"SymbolEncoding".to_vec()));
assert_eq!(
builtin_base_encoding(&doc, &own),
Some(BaseEncoding::Symbol)
);
let mut other = symbol.clone();
other.set("Encoding", Object::Name(b"ZapfDingbatsEncoding".to_vec()));
assert_eq!(builtin_base_encoding(&doc, &other), None);
let helvetica = lopdf::dictionary! {
"Type" => "Font",
"Subtype" => "Type1",
"BaseFont" => "Helvetica"
};
assert_eq!(builtin_base_encoding(&doc, &helvetica), None);
let alpha = crate::extractor::base14::base14_char_width("Symbol", '\u{03B1}');
assert!(alpha.is_some());
let widths = base14_fallback_widths(&doc, &symbol, &mut FontStyleCache::new())
.expect("Symbol widths");
assert_eq!(widths.widths.get(&0x61).copied(), alpha);
let widths = base14_fallback_widths(&doc, &indirect, &mut FontStyleCache::new())
.expect("Symbol widths");
assert_eq!(widths.widths.get(&0x61), None);
let mut remapped = symbol.clone();
remapped.set(
"Encoding",
Object::Dictionary(lopdf::dictionary! {
"Type" => "Encoding",
"Differences" => Object::Array(vec![Object::Integer(0x01), Object::Name(b"alpha".to_vec())])
}),
);
let widths = base14_fallback_widths(&doc, &remapped, &mut FontStyleCache::new())
.expect("Symbol widths");
assert_eq!(widths.widths.get(&0x01).copied(), alpha);
assert_eq!(widths.widths.get(&0x09), None);
let widths = base14_fallback_widths(&doc, &symbol, &mut FontStyleCache::new())
.expect("Symbol widths");
assert_eq!(widths.widths.get(&0x01), None);
}
#[test]
fn predefined_base_encodings_place_accented_letters() {
assert_eq!(BaseEncoding::WinAnsi.char_for(0xF1), Some('\u{00F1}'));
assert_eq!(BaseEncoding::WinAnsi.char_for(0x41), Some('A'));
assert_eq!(BaseEncoding::MacRoman.char_for(0x8E), Some('\u{00E9}'));
assert_eq!(BaseEncoding::Standard.char_for(0xE1), Some('\u{00C6}'));
assert_eq!(BaseEncoding::Standard.char_for(0x80), None);
assert_eq!(BaseEncoding::WinAnsi.char_for(0x81), Some('\u{2022}'));
assert_eq!(BaseEncoding::WinAnsi.char_for(0x95), Some('\u{2022}'));
assert_eq!(BaseEncoding::Symbol.char_for(0x61), Some('\u{03B1}'));
assert_eq!(BaseEncoding::ZapfDingbats.char_for(0x33), Some('\u{2713}'));
assert_eq!(
BaseEncoding::from_name(b"MacExpertEncoding"),
Some(BaseEncoding::MacExpert)
);
assert_eq!(BaseEncoding::from_name(b"Identity-H"), None);
}
#[test]
fn numbered_glyph_names_are_recognized() {
use NumberedGlyph::{Cid, Index};
assert_eq!(numbered_glyph_name("g12"), Some(Index(12)));
assert_eq!(numbered_glyph_name("gid00053"), Some(Index(53)));
assert_eq!(numbered_glyph_name("glyph3"), Some(Index(3)));
assert_eq!(numbered_glyph_name("index7"), Some(Index(7)));
assert_eq!(numbered_glyph_name("G5"), Some(Index(5)));
assert_eq!(numbered_glyph_name("cid00012"), Some(Cid(12)));
assert_eq!(numbered_glyph_name("gamma"), None);
assert_eq!(numbered_glyph_name("g"), None);
assert_eq!(numbered_glyph_name("g12a"), None);
}
#[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> {
sfnt_with_tables(mac_style, os2, None)
}
fn sfnt_with_tables(
mac_style: u16,
os2: Option<(u16, u16)>,
fixed_pitch: Option<bool>,
) -> 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));
}
if let Some(fixed_pitch) = fixed_pitch {
let mut post = vec![0u8; 32]; post[..4].copy_from_slice(&0x00030000u32.to_be_bytes());
post[12..16].copy_from_slice(&u32::from(fixed_pitch).to_be_bytes()); tables.push((*b"post", post));
}
sfnt_from_tables(tables)
}
fn sfnt_from_tables(mut tables: Vec<([u8; 4], Vec<u8>)>) -> Vec<u8> {
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 sfnt_with_glyph_names(names: &[Option<&str>]) -> Vec<u8> {
sfnt_with_glyph_names_and_outlines(names, true)
}
fn sfnt_with_glyph_names_and_outlines(names: &[Option<&str>], outlines: bool) -> Vec<u8> {
let num_glyphs = names.len() as u16;
let mut head = vec![0u8; 54];
head[18..20].copy_from_slice(&1000u16.to_be_bytes()); head[50..52].copy_from_slice(&1i16.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(&num_glyphs.to_be_bytes());
let mut post = Vec::new();
post.extend(0x0002_0000u32.to_be_bytes());
post.extend([0u8; 28]);
post.extend(num_glyphs.to_be_bytes());
let mut strings = Vec::new();
let mut next_index = 258u16;
for name in names {
match name {
Some(name) => {
post.extend(next_index.to_be_bytes());
next_index += 1;
strings.push(name.len() as u8);
strings.extend(name.as_bytes());
}
None => post.extend(0u16.to_be_bytes()),
}
}
post.extend(strings);
let mut tables = vec![
(*b"head", head),
(*b"hhea", hhea),
(*b"maxp", maxp),
(*b"post", post),
];
if outlines {
let loca = vec![0u8; 4 * (usize::from(num_glyphs) + 1)];
tables.push((*b"loca", loca));
tables.push((*b"glyf", Vec::new()));
}
sfnt_from_tables(tables)
}
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,
bold_source: None,
weight: Some(700),
fixed_pitch: None,
}
);
}
#[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,
bold_source: Some(BoldSource::FontFlags),
weight: None,
fixed_pitch: 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,
bold_source: None,
weight: Some(300),
fixed_pitch: None,
}
);
}
#[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,
bold_source: None,
weight: Some(500),
fixed_pitch: None,
}
);
}
#[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,
&font_kinds("F0", true),
);
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:?}"
);
}
fn simple_font_doc(
bfchar: &str,
program: Option<Vec<u8>>,
program_filter: Option<&[u8]>,
encoding: Option<Object>,
indirect_encoding: bool,
) -> (Document, u32, lopdf::ObjectId) {
use lopdf::Stream;
let mut doc = Document::with_version("1.4");
let cmap = format!(
"/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n\
1 begincodespacerange\n<00> <FF>\nendcodespacerange\n\
12 beginbfchar\n{bfchar}\nendbfchar\nendcmap\n\
CMapName currentdict /CMap defineresource pop\nend\nend"
);
let tounicode_id = doc.add_object(Stream::new(dictionary! {}, cmap.into_bytes()));
let mut descriptor = dictionary! {
"Type" => "FontDescriptor",
"FontName" => "ABCDEF+Subset",
"Flags" => 4,
"FontBBox" => vec![0.into(), 0.into(), 600.into(), 700.into()],
"ItalicAngle" => 0,
"Ascent" => 700,
"Descent" => 0,
"CapHeight" => 700,
"StemV" => 80,
};
if let Some(program) = program {
let mut stream =
Stream::new(dictionary! { "Length1" => program.len() as i64 }, program);
if let Some(filter) = program_filter {
stream.dict.set("Filter", Object::Name(filter.to_vec()));
}
let font_file = doc.add_object(stream);
descriptor.set("FontFile2", font_file);
}
let descriptor_id = doc.add_object(descriptor);
let mut font = dictionary! {
"Type" => "Font",
"Subtype" => "TrueType",
"BaseFont" => "ABCDEF+Subset",
"FirstChar" => 0x21,
"LastChar" => 0x2C,
"Widths" => vec![Object::Integer(600); 12],
"FontDescriptor" => descriptor_id,
"ToUnicode" => tounicode_id,
};
if let Some(encoding) = encoding {
let encoding = if indirect_encoding {
Object::Reference(doc.add_object(encoding))
} else {
encoding
};
font.set("Encoding", encoding);
}
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, tounicode_id.0, page_id)
}
fn simple_fonts_sharing_a_tounicode_doc(
bfchar: &str,
encodings: [Object; 2],
) -> (Document, u32, lopdf::ObjectId) {
use lopdf::Stream;
let mut doc = Document::with_version("1.4");
let cmap = format!(
"/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n\
1 begincodespacerange\n<00> <FF>\nendcodespacerange\n\
12 beginbfchar\n{bfchar}\nendbfchar\nendcmap\n\
CMapName currentdict /CMap defineresource pop\nend\nend"
);
let tounicode_id = doc.add_object(Stream::new(dictionary! {}, cmap.into_bytes()));
let mut resources = dictionary! {};
for (name, encoding) in ["F1", "F2"].into_iter().zip(encodings) {
let descriptor_id = doc.add_object(dictionary! {
"Type" => "FontDescriptor",
"FontName" => "ABCDEF+Subset",
"Flags" => 4,
"FontBBox" => vec![0.into(), 0.into(), 600.into(), 700.into()],
"ItalicAngle" => 0,
"Ascent" => 700,
"Descent" => 0,
"CapHeight" => 700,
"StemV" => 80,
});
let font_id = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => "TrueType",
"BaseFont" => "ABCDEF+Subset",
"FirstChar" => 0x21,
"LastChar" => 0x2C,
"Widths" => vec![Object::Integer(600); 12],
"FontDescriptor" => descriptor_id,
"ToUnicode" => tounicode_id,
"Encoding" => encoding,
});
resources.set(name, Object::Reference(font_id));
}
let page_id = doc.add_object(dictionary! {
"Type" => "Page",
"Resources" => dictionary! { "Font" => resources },
"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, tounicode_id.0, page_id)
}
fn cid_font_doc(
bfchar: &str,
program: Vec<u8>,
program_filter: Option<&[u8]>,
indirect_encoding: bool,
cid_to_gid: Option<&[u16]>,
) -> (Document, u32, lopdf::ObjectId) {
use lopdf::Stream;
let mut doc = Document::with_version("1.5");
let cmap = format!(
"/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n\
/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n\
/CMapName /Adobe-Identity-UCS def\n/CMapType 2 def\n\
1 begincodespacerange\n<0000> <FFFF>\nendcodespacerange\n\
4 beginbfchar\n{bfchar}\nendbfchar\nendcmap\n\
CMapName currentdict /CMap defineresource pop\nend\nend"
);
let tounicode_id = doc.add_object(Stream::new(dictionary! {}, cmap.into_bytes()));
let mut stream = Stream::new(dictionary! { "Length1" => program.len() as i64 }, program);
if let Some(filter) = program_filter {
stream.dict.set("Filter", Object::Name(filter.to_vec()));
}
let font_file = doc.add_object(stream);
let descriptor_id = doc.add_object(dictionary! {
"Type" => "FontDescriptor",
"FontName" => "ABCDEF+Subset",
"Flags" => 4,
"FontBBox" => vec![0.into(), 0.into(), 600.into(), 700.into()],
"ItalicAngle" => 0,
"Ascent" => 700,
"Descent" => 0,
"CapHeight" => 700,
"StemV" => 80,
"FontFile2" => font_file,
});
let cid_to_gid_map = match cid_to_gid {
Some(map) => {
let bytes: Vec<u8> = map.iter().flat_map(|gid| gid.to_be_bytes()).collect();
Object::Reference(doc.add_object(Stream::new(dictionary! {}, bytes)))
}
None => Object::Name(b"Identity".to_vec()),
};
let cid_font_id = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => "CIDFontType2",
"BaseFont" => "ABCDEF+Subset",
"CIDSystemInfo" => dictionary! {
"Registry" => Object::string_literal("Adobe"),
"Ordering" => Object::string_literal("Identity"),
"Supplement" => 0,
},
"FontDescriptor" => descriptor_id,
"DW" => 600,
"CIDToGIDMap" => cid_to_gid_map,
});
let encoding = Object::Name(b"Identity-H".to_vec());
let encoding = if indirect_encoding {
Object::Reference(doc.add_object(encoding))
} else {
encoding
};
let font_id = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => "Type0",
"BaseFont" => "ABCDEF+Subset",
"Encoding" => encoding,
"DescendantFonts" => vec![cid_font_id.into()],
"ToUnicode" => tounicode_id,
});
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, tounicode_id.0, page_id)
}
fn decode_simple_font_string(
bfchar: &str,
program: Option<Vec<u8>>,
encoding: Option<Object>,
bytes: &[u8],
) -> String {
let (doc, tounicode_obj, page_id) = simple_font_doc(bfchar, program, None, encoding, false);
decode_page_font_string(&doc, tounicode_obj, page_id, false, bytes)
}
fn decode_page_font_string(
doc: &Document,
tounicode_obj: u32,
page_id: lopdf::ObjectId,
is_cid: bool,
bytes: &[u8],
) -> String {
decode_page_font_string_as(doc, "F1", tounicode_obj, page_id, is_cid, bytes)
}
fn decode_page_font_string_as(
doc: &Document,
font: &str,
tounicode_obj: u32,
page_id: lopdf::ObjectId,
is_cid: bool,
bytes: &[u8],
) -> String {
let cmaps = FontCMaps::from_doc(doc);
let fonts = doc.get_page_fonts(page_id).unwrap();
let (font_encodings, _) =
build_font_encodings(doc, &fonts, &cmaps, &mut FontStyleCache::new());
let mut encoding_cache: HashMap<String, Encoding<'_>> = HashMap::new();
if let Ok(encoding) = fonts[font.as_bytes()].get_font_encoding(doc) {
encoding_cache.insert(font.to_string(), encoding);
}
let mut font_tounicode_refs: HashMap<String, u32> = HashMap::new();
font_tounicode_refs.insert(font.to_string(), tounicode_obj);
let mut font_widths: PageFontWidths = HashMap::new();
font_widths.insert(font.to_string(), make_font_info(&[], 1000, is_cid));
let (text, _) = extract_text_from_operand(
&Object::String(bytes.to_vec(), lopdf::StringFormat::Hexadecimal),
font,
Some("ABCDEF+Subset"),
&cmaps,
&font_tounicode_refs,
&HashMap::new(),
&font_encodings,
&encoding_cache,
&mut CMapDecisionCache::new(),
&font_widths,
&build_font_kinds(&fonts),
)
.expect("text decoded");
text
}
#[test]
fn an_odd_length_string_reads_a_control_destination_through_the_other_cmaps() {
use crate::tounicode::{CMapEntry, ToUnicodeCMap};
let two_byte = |entries: &[(u16, &str)]| {
let mut cmap = ToUnicodeCMap::new();
cmap.code_byte_length = 2;
for &(code, text) in entries {
cmap.char_map.insert(code, text.to_string());
}
cmap.refresh_gap_fills();
cmap
};
let primary = two_byte(&[(1, "c"), (2, "o"), (3, "\u{3}"), (4, "e")]);
let decode = |entry: CMapEntry, remap_chosen: bool| -> (String, u32) {
let mut decisions = CMapDecisionCache::new();
if remap_chosen {
let sample = "the and of to in a is that for with on as by from ".repeat(6);
decisions.consider(0, "", &sample, 240);
assert_eq!(decisions.get_choice(0), Some(CMapChoice::Remapped));
}
let mut inline_cmaps = HashMap::new();
inline_cmaps.insert("F1".to_string(), entry);
let mut font_widths: PageFontWidths = HashMap::new();
font_widths.insert("F1".to_string(), make_font_info(&[], 1000, true));
let (text, _) = extract_text_from_operand(
&Object::String(vec![1, 2, 3, 4, 4], lopdf::StringFormat::Hexadecimal),
"F1",
Some("ABCDEF+Subset"),
&FontCMaps::default(),
&HashMap::new(),
&inline_cmaps,
&HashMap::new(),
&HashMap::new(),
&mut decisions,
&font_widths,
&font_kinds("F1", true),
)
.expect("text decoded");
let unmapped: u32 = decisions
.take_run_coverage()
.iter()
.map(|(_, stats)| stats.unmapped)
.sum();
(text, unmapped)
};
let remap = || Some(two_byte(&[(3, "ff")]));
assert_eq!(
decode(
CMapEntry {
primary: primary.clone(),
remapped: remap(),
fallback: None,
},
true
),
("coffee".to_string(), 0)
);
assert_eq!(
decode(
CMapEntry {
primary: primary.clone(),
remapped: remap(),
fallback: None,
},
false
),
("co\u{FFFD}ee".to_string(), 1)
);
assert_eq!(
decode(
CMapEntry {
primary: primary.clone(),
remapped: None,
fallback: remap(),
},
false
),
("coffee".to_string(), 0)
);
assert_eq!(
decode(
CMapEntry {
primary: primary.clone(),
remapped: Some(two_byte(&[(9, "x")])),
fallback: None,
},
true
),
("co\u{FFFD}ee".to_string(), 1)
);
assert_eq!(
decode(
CMapEntry {
primary,
remapped: None,
fallback: None,
},
false
),
("co\u{FFFD}ee".to_string(), 1)
);
}
fn set_cid_font_widths(doc: &mut Document, first: i64, widths: &[i64]) {
let widths: Vec<Object> = widths.iter().map(|&w| w.into()).collect();
for object in doc.objects.values_mut() {
if let Object::Dictionary(dict) = object {
if dict.get(b"Subtype").ok().and_then(|o| o.as_name().ok())
== Some(&b"CIDFontType2"[..])
{
dict.set("W", vec![first.into(), Object::Array(widths.clone())]);
}
}
}
}
#[test]
fn a_control_destination_is_repaired_in_the_original_cmap_beside_its_remap() {
let bfchar = LIGATURE_INDEX_BFCHAR.replace("<2", "<002");
let mut names = vec![None; 0x2D];
names[0x23] = Some("f_f");
let (mut doc, tounicode_obj, page_id) =
cid_font_doc(&bfchar, sfnt_with_glyph_names(&names), None, false, None);
let mut widths = [600; 13];
widths[3] = 0;
set_cid_font_widths(&mut doc, 0, &widths);
let cmaps = FontCMaps::from_doc(&doc);
let entry = cmaps.get_by_obj(tounicode_obj).expect("the font's CMaps");
let remapped = entry.remapped.as_ref().expect("the sequential remap");
assert_eq!(entry.primary.lookup(0x23).as_deref(), Some("ff"));
assert_eq!(remapped.lookup(1).as_deref(), Some("c"));
assert_eq!(remapped.lookup_code(3), CodeMapping::ControlDestination);
assert_eq!(
decode_page_font_string(
&doc,
tounicode_obj,
page_id,
true,
&[0, 0x21, 0, 0x22, 0, 0x23, 0, 0x24, 0, 0x24]
),
"coffee"
);
}
#[test]
fn an_unreadable_differences_name_marks_a_control_destination_before_the_fallback_reads_it() {
use crate::tounicode::{CMapEntry, ToUnicodeCMap};
let one_byte = |entries: &[(u16, &str)]| {
let mut cmap = ToUnicodeCMap::new();
cmap.code_byte_length = 1;
for &(code, text) in entries {
cmap.char_map.insert(code, text.to_string());
}
cmap
};
let decode = |named_codes: &[u8]| -> String {
let mut inline_cmaps = HashMap::new();
inline_cmaps.insert(
"F1".to_string(),
CMapEntry {
primary: one_byte(&[(0x21, "c"), (0x22, "o"), (0x23, "\u{3}"), (0x24, "e")]),
remapped: None,
fallback: Some(one_byte(&[(0x23, "x")])),
},
);
let mut font_encodings: PageFontEncodings = HashMap::new();
font_encodings.insert(
"F1".to_string(),
FontEncoding {
differences: [(0x22, 'o')].into_iter().collect(),
identity_overrides: Default::default(),
blank_codes: Default::default(),
base: None,
named: None,
named_codes: named_codes.iter().copied().collect(),
sequences: Default::default(),
},
);
let mut font_widths: PageFontWidths = HashMap::new();
font_widths.insert("F1".to_string(), make_font_info(&[], 1000, false));
let (text, _) = extract_text_from_operand(
&Object::String(
LIGATURE_INDEX_BYTES.to_vec(),
lopdf::StringFormat::Hexadecimal,
),
"F1",
Some("ABCDEF+Subset"),
&FontCMaps::default(),
&HashMap::new(),
&inline_cmaps,
&font_encodings,
&HashMap::new(),
&mut CMapDecisionCache::new(),
&font_widths,
&font_kinds("F1", false),
)
.expect("text decoded");
text
};
assert_eq!(decode(&[0x22, 0x23]), "co\u{FFFD}ee");
assert_eq!(decode(&[0x22]), "coxee");
}
#[test]
fn a_sparse_cmaps_control_destination_is_repaired_where_the_cmap_ends_up() {
let mut names = vec![None; 5];
names[3] = Some("f_f");
let (doc, tounicode_obj, page_id) = cid_font_doc(
"<0001> <0063>\n<0002> <006F>\n<0003> <0003>\n<0004> <0065>",
sfnt_with_glyph_names(&names),
None,
false,
None,
);
let cmaps = FontCMaps::from_doc(&doc);
let entry = cmaps.get_by_obj(tounicode_obj).expect("the font's CMaps");
assert_eq!(entry.primary.lookup(3).as_deref(), Some("ff"));
assert_eq!(entry.primary.lookup_code(1), CodeMapping::Unmapped);
let alternative = entry
.remapped
.as_ref()
.expect("the CMap kept as the alternative");
assert_eq!(alternative.lookup(3).as_deref(), Some("ff"));
assert_eq!(alternative.lookup(1).as_deref(), Some("c"));
assert!(alternative.control_destination_codes().is_empty());
assert!(entry.fallback.is_none());
assert_eq!(
decode_page_font_string(
&doc,
tounicode_obj,
page_id,
true,
&[0, 1, 0, 2, 0, 3, 0, 4, 0, 4]
),
"coffee"
);
}
const LIGATURE_INDEX_BFCHAR: &str = "<21> <0063>\n<22> <006F>\n<23> <0003>\n<24> <0065>\n\
<25> <0074>\n<26> <0061>\n<27> <0062>\n<28> <006C>\n<29> <0073>\n<2A> <0075>\n\
<2B> <006E>\n<2C> <0064>";
const LIGATURE_INDEX_BYTES: [u8; 5] = [0x21, 0x22, 0x23, 0x24, 0x24];
#[test]
fn a_control_destination_code_reads_as_the_marker_not_the_byte() {
assert_eq!(
decode_simple_font_string(LIGATURE_INDEX_BFCHAR, None, None, &LIGATURE_INDEX_BYTES),
"co\u{FFFD}ee"
);
assert_eq!(
decode_simple_font_string(
LIGATURE_INDEX_BFCHAR,
Some(sfnt_with_glyph_names(&[None; 0x25])),
None,
&LIGATURE_INDEX_BYTES
),
"co\u{FFFD}ee"
);
}
#[test]
fn a_control_destination_code_reads_through_the_programs_glyph_name() {
let mut names = vec![None; 0x25];
names[0x21] = Some("x"); names[0x23] = Some("f_f");
assert_eq!(
decode_simple_font_string(
LIGATURE_INDEX_BFCHAR,
Some(sfnt_with_glyph_names(&names)),
None,
&LIGATURE_INDEX_BYTES
),
"coffee"
);
}
fn program_stream_content(doc: &Document) -> lopdf::Result<Vec<u8>> {
doc.objects
.values()
.find_map(|object| {
let stream = object.as_stream().ok()?;
stream.dict.get(b"Length1").ok()?;
Some(stream)
})
.expect("the program stream")
.decompressed_content()
}
#[test]
fn a_program_in_a_stream_that_does_not_decompress_is_read_as_it_is() {
let mut names = vec![None; 0x25];
names[0x23] = Some("f_f");
let program = sfnt_with_glyph_names(&names);
let (doc, tounicode_obj, page_id) = simple_font_doc(
LIGATURE_INDEX_BFCHAR,
Some(program.clone()),
None,
None,
false,
);
assert_eq!(
program_stream_content(&doc).ok().as_deref(),
Some(&program[..]),
"the unfiltered stream reads as its bytes"
);
assert_eq!(
decode_page_font_string(&doc, tounicode_obj, page_id, false, &LIGATURE_INDEX_BYTES),
"coffee"
);
let (doc, tounicode_obj, page_id) = simple_font_doc(
LIGATURE_INDEX_BFCHAR,
Some(program.clone()),
Some(b"DCTDecode"),
None,
false,
);
assert!(
program_stream_content(&doc).is_err(),
"the decoder must reject the filter"
);
assert_eq!(
decode_page_font_string(&doc, tounicode_obj, page_id, false, &LIGATURE_INDEX_BYTES),
"coffee"
);
let (doc, tounicode_obj, page_id) = simple_font_doc(
LIGATURE_INDEX_BFCHAR,
Some(program),
Some(b"FlateDecode"),
None,
false,
);
assert!(
matches!(program_stream_content(&doc), Ok(data) if data.is_empty()),
"the decoder must yield nothing for plain bytes"
);
assert_eq!(
decode_page_font_string(&doc, tounicode_obj, page_id, false, &LIGATURE_INDEX_BYTES),
"coffee"
);
let mut names = vec![None; 5];
names[3] = Some("f_f");
for filter in [&b"DCTDecode"[..], &b"FlateDecode"[..]] {
let (doc, tounicode_obj, page_id) = cid_font_doc(
"<0001> <0063>\n<0002> <006F>\n<0003> <0003>\n<0004> <0065>",
sfnt_with_glyph_names(&names),
Some(filter),
false,
None,
);
assert_eq!(
decode_page_font_string(
&doc,
tounicode_obj,
page_id,
true,
&[0, 1, 0, 2, 0, 3, 0, 4, 0, 4]
),
"coffee",
"{}",
String::from_utf8_lossy(filter)
);
}
}
#[test]
fn a_type0_encoding_declared_by_an_indirect_name_still_yields_the_program() {
let mut names = vec![None; 5];
names[3] = Some("f_f");
let (doc, tounicode_obj, page_id) = cid_font_doc(
"<0001> <0063>\n<0002> <006F>\n<0003> <0003>\n<0004> <0065>",
sfnt_with_glyph_names(&names),
None,
true,
None,
);
assert_eq!(
decode_page_font_string(
&doc,
tounicode_obj,
page_id,
true,
&[0, 1, 0, 2, 0, 3, 0, 4, 0, 4]
),
"coffee"
);
}
#[test]
fn a_cid_past_the_cid_to_gid_map_has_no_blank_glyph_to_read_as_a_space() {
let bfchar = "<0001> <0063>\n<0002> <006F>\n<0003> <0003>\n<0004> <0065>";
let coffee = [0, 1, 0, 2, 0, 3, 0, 4, 0, 4];
let (doc, tounicode_obj, page_id) = cid_font_doc(
bfchar,
sfnt_with_glyph_names(&[None; 5]),
None,
false,
Some(&[0, 1]),
);
assert_eq!(
decode_page_font_string(&doc, tounicode_obj, page_id, true, &coffee),
"co\u{FFFD}ee"
);
let (doc, tounicode_obj, page_id) = cid_font_doc(
bfchar,
sfnt_with_glyph_names(&[None; 5]),
None,
false,
Some(&[0, 1, 2, 3, 4]),
);
assert_eq!(
decode_page_font_string(&doc, tounicode_obj, page_id, true, &coffee),
"co ee"
);
}
#[test]
fn a_string_of_mostly_control_destinations_reads_as_the_letter_and_the_markers() {
use crate::tounicode::ToUnicodeCMap;
let mut primary = ToUnicodeCMap {
code_byte_length: 2,
..Default::default()
};
primary.char_map.insert(1, "c".to_string());
for code in 2..=4u16 {
primary.char_map.insert(code, "\u{3}".to_string());
}
primary.refresh_gap_fills();
let (text, coverage) = decode_through(primary, vec![0, 1, 0, 2, 0, 3, 0, 4]);
assert_eq!(text.as_deref(), Some("c\u{FFFD}\u{FFFD}\u{FFFD}"));
assert_eq!(
coverage,
vec![(
FontLabel::from("AAAAAA+Font"),
CidDecodeStats {
codes: 4,
interpolated: 0,
unmapped: 3
}
)]
);
}
#[test]
fn an_odd_length_string_read_cleanly_two_bytes_at_a_time_counts_that_reading() {
use crate::tounicode::ToUnicodeCMap;
let mut primary = ToUnicodeCMap {
code_byte_length: 2,
..Default::default()
};
primary.char_map.insert(0x0141, "A".to_string());
primary.char_map.insert(0x0142, "B".to_string());
primary.refresh_gap_fills();
let (text, coverage) = decode_through(primary, vec![0x01, 0x41, 0x01, 0x42, 0x01]);
assert_eq!(text.as_deref(), Some("AB"));
assert_eq!(
coverage,
vec![(
FontLabel::from("AAAAAA+Font"),
CidDecodeStats {
codes: 2,
interpolated: 0,
unmapped: 0
}
)]
);
}
#[test]
fn a_cid_past_the_programs_glyph_count_has_no_blank_glyph_to_read_as_a_space() {
let (doc, tounicode_obj, page_id) = cid_font_doc(
"<0001> <0063>\n<0002> <0002>\n<0003> <0003>\n<0004> <0065>",
sfnt_with_glyph_names(&[None; 3]),
None,
false,
None,
);
assert_eq!(
decode_page_font_string(
&doc,
tounicode_obj,
page_id,
true,
&[0, 1, 0, 2, 0, 3, 0, 4, 0, 4]
),
"c \u{FFFD}ee"
);
}
#[test]
fn a_program_without_an_outline_table_tells_no_blank_glyph() {
let bfchar = "<0001> <0063>\n<0002> <006F>\n<0003> <0003>\n<0004> <0065>";
let coffee = [0, 1, 0, 2, 0, 3, 0, 4, 0, 4];
let decode = |outlines: bool| {
let (doc, tounicode_obj, page_id) = cid_font_doc(
bfchar,
sfnt_with_glyph_names_and_outlines(&[None; 5], outlines),
None,
false,
None,
);
decode_page_font_string(&doc, tounicode_obj, page_id, true, &coffee)
};
assert_eq!(decode(false), "co\u{FFFD}ee");
assert_eq!(decode(true), "co ee");
}
#[test]
fn a_program_outlining_nothing_tells_no_blank_for_an_invisible_layer() {
let (doc, tounicode_obj, page_id) = cid_font_doc(
"<0001> <0063>\n<0002> <0002>\n<0003> <0003>\n<0004> <0004>",
sfnt_with_glyph_names(&[None; 5]),
None,
false,
None,
);
assert_eq!(
decode_page_font_string(
&doc,
tounicode_obj,
page_id,
true,
&[0, 1, 0, 2, 0, 3, 0, 4]
),
"c\u{FFFD}\u{FFFD}\u{FFFD}"
);
}
#[test]
fn the_invisible_layer_guard_counts_only_cids_with_a_glyph() {
let (doc, tounicode_obj, page_id) = cid_font_doc(
"<0001> <0063>\n<0002> <0002>\n<0005> <0005>\n<0006> <0006>",
sfnt_with_glyph_names(&[None; 3]),
None,
false,
None,
);
assert_eq!(
decode_page_font_string(
&doc,
tounicode_obj,
page_id,
true,
&[0, 1, 0, 2, 0, 5, 0, 6]
),
"c \u{FFFD}\u{FFFD}"
);
}
#[test]
fn an_odd_length_string_none_of_whose_bytes_reads_counts_them_once() {
use crate::tounicode::ToUnicodeCMap;
let mut primary = ToUnicodeCMap {
code_byte_length: 2,
..Default::default()
};
primary.char_map.insert(1, "A".to_string());
primary.refresh_gap_fills();
let (_, coverage) = decode_through(primary, vec![0x80, 0x81, 0x82]);
assert_eq!(
coverage,
vec![(
FontLabel::from("AAAAAA+Font"),
CidDecodeStats {
codes: 3,
interpolated: 0,
unmapped: 3
}
)]
);
}
#[test]
fn a_control_destination_code_reads_through_its_differences_name() {
let encoding = dictionary! {
"Type" => "Encoding",
"Differences" => vec![0x23.into(), Object::Name(b"f_f".to_vec())],
};
assert_eq!(
decode_simple_font_string(
LIGATURE_INDEX_BFCHAR,
None,
Some(Object::Dictionary(encoding)),
&LIGATURE_INDEX_BYTES
),
"coffee"
);
}
#[test]
fn a_control_destination_code_with_an_unreadable_differences_name_reads_as_the_marker() {
let differences = |name: &[u8]| {
Object::Dictionary(dictionary! {
"Type" => "Encoding",
"Differences" => vec![
0x22.into(),
Object::Name(b"o".to_vec()),
Object::Name(name.to_vec()),
],
})
};
assert_eq!(
decode_simple_font_string(
LIGATURE_INDEX_BFCHAR,
None,
Some(differences(b"f_zzz")),
&LIGATURE_INDEX_BYTES
),
"co\u{FFFD}ee"
);
let unmapped = LIGATURE_INDEX_BFCHAR.replace("<23> <0003>\n", "");
assert_eq!(
decode_simple_font_string(
&unmapped,
None,
Some(differences(b"f_zzz")),
&LIGATURE_INDEX_BYTES
),
"coee"
);
}
#[test]
fn a_control_destination_code_reads_through_an_encoding_declared_by_an_indirect_name() {
let (doc, tounicode_obj, page_id) = simple_font_doc(
LIGATURE_INDEX_BFCHAR,
None,
None,
Some(Object::Name(b"WinAnsiEncoding".to_vec())),
true,
);
assert_eq!(
decode_page_font_string(&doc, tounicode_obj, page_id, false, &LIGATURE_INDEX_BYTES),
"co#ee"
);
}
#[test]
fn a_differences_glyph_wins_over_the_programs_reading_of_the_raw_code() {
let mut names = vec![None; 0x25];
names[0x23] = Some("x");
let differences = Object::Dictionary(dictionary! {
"Type" => "Encoding",
"Differences" => vec![
0x22.into(),
Object::Name(b"o".to_vec()),
Object::Name(b"f_f".to_vec()),
],
});
assert_eq!(
decode_simple_font_string(
LIGATURE_INDEX_BFCHAR,
Some(sfnt_with_glyph_names(&names)),
Some(differences),
&LIGATURE_INDEX_BYTES
),
"coffee"
);
assert_eq!(
decode_simple_font_string(
LIGATURE_INDEX_BFCHAR,
Some(sfnt_with_glyph_names(&names)),
None,
&LIGATURE_INDEX_BYTES
),
"coxee"
);
}
#[test]
fn fonts_sharing_a_tounicode_stream_read_a_control_destination_each_by_its_own_name() {
let differences = |name: &[u8]| {
Object::Dictionary(dictionary! {
"Type" => "Encoding",
"Differences" => vec![
0x22.into(),
Object::Name(b"o".to_vec()),
Object::Name(name.to_vec()),
],
})
};
let (doc, tounicode_obj, page_id) = simple_fonts_sharing_a_tounicode_doc(
LIGATURE_INDEX_BFCHAR,
[differences(b"f_f"), differences(b"f_i")],
);
let cmaps = FontCMaps::from_doc(&doc);
let entry = cmaps.get_by_obj(tounicode_obj).expect("the shared CMap");
assert_eq!(
entry.primary.lookup_code(0x23),
CodeMapping::ControlDestination
);
assert_eq!(
decode_page_font_string_as(
&doc,
"F1",
tounicode_obj,
page_id,
false,
&LIGATURE_INDEX_BYTES
),
"coffee"
);
assert_eq!(
decode_page_font_string_as(
&doc,
"F2",
tounicode_obj,
page_id,
false,
&LIGATURE_INDEX_BYTES
),
"cofiee"
);
}
#[test]
fn a_control_destination_code_reads_through_the_dictionarys_base_encoding() {
let encoding = Object::Dictionary(dictionary! {
"Type" => "Encoding",
"BaseEncoding" => "WinAnsiEncoding",
"Differences" => vec![0x22.into(), Object::Name(b"o".to_vec())],
});
assert_eq!(
decode_simple_font_string(
LIGATURE_INDEX_BFCHAR,
None,
Some(encoding),
&LIGATURE_INDEX_BYTES
),
"co#ee"
);
}
#[test]
fn a_control_destination_code_reads_through_the_encoding_the_font_declares() {
assert_eq!(
decode_simple_font_string(
LIGATURE_INDEX_BFCHAR,
None,
Some(Object::Name(b"WinAnsiEncoding".to_vec())),
&LIGATURE_INDEX_BYTES
),
"co#ee"
);
}
#[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,
&font_kinds("F1", false),
)
.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,
&font_kinds("F1", false),
)
.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 type3_procedure_names_never_flag_the_page() {
let mut doc = Document::with_version("1.4");
let differences = || {
Object::Array(vec![
2.into(),
Object::Name(b"g2".to_vec()),
Object::Name(b"g10".to_vec()),
])
};
let type3 = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => "Type3",
"FontBBox" => vec![0.into(), 0.into(), 1000.into(), 1000.into()],
"FontMatrix" => vec![0.001.into(), 0.into(), 0.into(), 0.001.into(), 0.into(), 0.into()],
"CharProcs" => dictionary! {},
"Encoding" => dictionary! { "Type" => "Encoding", "Differences" => differences() },
"FirstChar" => 2,
"LastChar" => 3,
"Widths" => vec![500.into(), 500.into()],
});
let truetype = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => "TrueType",
"BaseFont" => "SyntheticSubset",
"Encoding" => dictionary! { "Type" => "Encoding", "Differences" => differences() },
});
let cmaps = FontCMaps::from_doc(&doc);
for (font_id, expected) in [(type3, false), (truetype, true)] {
let font_dict = doc.get_dictionary(font_id).unwrap().clone();
let fonts = std::collections::BTreeMap::from([(b"F1".to_vec(), &font_dict)]);
let (_, has_gid_fonts) =
build_font_encodings(&doc, &fonts, &cmaps, &mut FontStyleCache::new());
assert_eq!(has_gid_fonts, expected);
}
}
#[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));
}
#[test]
fn descriptor_fixed_pitch_flag_is_read_only_as_a_yes() {
let (doc, font_dict) = doc_with_descriptor(dictionary! {
"Type" => "FontDescriptor",
"FontName" => "Tc1",
"ItalicAngle" => 0,
"Flags" => 1 | 32, });
assert_eq!(
font_style(&doc, &font_dict, &mut FontStyleCache::new()).fixed_pitch,
Some(true)
);
let (doc, font_dict) = doc_with_descriptor(dictionary! {
"Type" => "FontDescriptor",
"FontName" => "Tc1",
"ItalicAngle" => 0,
"Flags" => 4,
});
assert_eq!(
font_style(&doc, &font_dict, &mut FontStyleCache::new()).fixed_pitch,
None
);
}
#[test]
fn indirect_flags_are_resolved_before_their_bits_are_read() {
let mut doc = Document::with_version("1.4");
let flags_id = doc.add_object(Object::Integer(1 | (1 << 18))); let desc_id = doc.add_object(dictionary! {
"Type" => "FontDescriptor",
"FontName" => "Tc1",
"ItalicAngle" => 0,
"Flags" => flags_id,
});
let font_dict = dictionary! {
"Type" => "Font",
"Subtype" => "TrueType",
"BaseFont" => "Tc1",
"FontDescriptor" => desc_id,
};
let style = font_style(&doc, &font_dict, &mut FontStyleCache::new());
assert!(style.bold);
assert_eq!(style.bold_source, Some(BoldSource::FontFlags));
assert_eq!(style.fixed_pitch, Some(true));
}
#[test]
fn embedded_post_table_declares_fixed_pitch() {
let (doc, font_dict) = embedded_font(
"ABCDEF+OpaqueFace",
sfnt_with_tables(0, Some((1 << 6, 400)), Some(true)),
dictionary! {},
);
let style = font_style(&doc, &font_dict, &mut FontStyleCache::new());
assert_eq!(style.fixed_pitch, Some(true));
assert!(!style.bold && style.bold_source.is_none());
let (doc, font_dict) = embedded_font(
"ABCDEF+OpaqueFace",
sfnt_with_tables(0, Some((1 << 6, 400)), Some(false)),
dictionary! {},
);
assert_eq!(
font_style(&doc, &font_dict, &mut FontStyleCache::new()).fixed_pitch,
None
);
}
#[test]
fn bold_source_names_the_descriptor_flag_or_the_program() {
let (doc, font_dict) = doc_with_descriptor(dictionary! {
"Type" => "FontDescriptor",
"FontName" => "Tc1",
"ItalicAngle" => 0,
"Flags" => 1 << 18,
});
let style = font_style(&doc, &font_dict, &mut FontStyleCache::new());
assert!(style.bold);
assert_eq!(style.bold_source, Some(BoldSource::FontFlags));
let (doc, font_dict) = embedded_font(
"ABCDEF+OpaqueFace",
sfnt_with_style_and_weight(0, Some((1 << 5, 700))),
dictionary! {},
);
let style = font_style(&doc, &font_dict, &mut FontStyleCache::new());
assert!(style.bold);
assert_eq!(style.bold_source, Some(BoldSource::FontFlags));
assert_eq!(style.weight, Some(700));
let (doc, font_dict) = embedded_font(
"ABCDEF+OpaqueFace",
sfnt_with_style_and_weight(0, Some((1 << 6, 700))),
dictionary! {},
);
let style = font_style(&doc, &font_dict, &mut FontStyleCache::new());
assert!(!style.bold);
assert_eq!(style.bold_source, None);
}
#[test]
fn name_style_outranks_the_flags_as_the_bold_source() {
let flagged = FontStyle {
italic: false,
bold: true,
bold_source: Some(BoldSource::FontFlags),
weight: Some(700),
fixed_pitch: None,
};
assert_eq!(
flagged.with_name("Foo-Bold").bold_source,
Some(BoldSource::FontName)
);
assert_eq!(flagged.with_name("Tc1"), flagged);
let plain = FontStyle::default();
let named = plain.with_name("ABCDEF+Face-Demi");
assert!(named.bold);
assert_eq!(named.bold_source, Some(BoldSource::FontName));
assert!(plain.with_name("Foo-Italic").italic);
assert_eq!(plain.with_name("F1"), plain);
}
#[test]
fn measured_pitch_fills_only_an_unknown() {
let uniform: Vec<(u16, u16)> = (65u16..77).map(|code| (code, 600)).collect();
let info = make_font_info(&uniform, 0, false);
assert_eq!(
FontStyle::default()
.with_measured_pitch(Some(&info))
.fixed_pitch,
Some(true)
);
assert_eq!(
FontStyle::default().with_measured_pitch(None).fixed_pitch,
None
);
let declared = FontStyle {
fixed_pitch: Some(true),
..FontStyle::default()
};
let varied = make_font_info(&[(65, 600), (66, 300)], 0, false);
assert_eq!(
declared.with_measured_pitch(Some(&varied)).fixed_pitch,
Some(true)
);
}
#[test]
fn fixed_pitch_by_advance_needs_a_dozen_glyphs_for_a_yes_and_two_for_a_no() {
let digits: Vec<(u16, u16)> = (48u16..58).map(|code| (code, 556)).collect();
assert_eq!(
make_font_info(&digits, 0, false).fixed_pitch_by_advance(),
None
);
let mut dozen = digits.clone();
dozen.extend([(43, 556), (45, 556)]);
assert_eq!(
make_font_info(&dozen, 0, false).fixed_pitch_by_advance(),
Some(true)
);
let mut rounded = dozen.clone();
rounded[0].1 = 555;
assert_eq!(
make_font_info(&rounded, 0, false).fixed_pitch_by_advance(),
Some(true)
);
assert_eq!(
make_font_info(&[(65, 600), (66, 500)], 0, false).fixed_pitch_by_advance(),
Some(false)
);
let mut with_gaps = dozen.clone();
with_gaps.extend([(1, 0), (2, 0)]);
assert_eq!(
make_font_info(&with_gaps, 0, false).fixed_pitch_by_advance(),
Some(true)
);
assert_eq!(
make_font_info(&[], 1000, true).fixed_pitch_by_advance(),
None
);
}
}
#[cfg(test)]
#[path = "stale_cmap_tests.rs"]
mod stale_cmap_tests;
#[cfg(test)]
#[path = "blank_glyph_tests.rs"]
pub(crate) mod blank_glyph_tests;