use std::collections::HashMap;
use crate::fonts::FontInfo;
use crate::fonts::truetype_cmap::TrueTypeCMap;
#[derive(Debug, Clone, Copy, Default)]
pub(crate) struct DecodePolicy {
pub preserve_unmapped: bool,
pub decompose_ligatures: bool,
pub question_mark_for_invalid: bool,
}
#[derive(Debug, Default)]
pub(crate) struct GlyphDropTally {
count: usize,
first: Option<(&'static str, u32, u16)>,
}
impl GlyphDropTally {
pub(crate) fn record(&mut self, reason: &'static str, char_code: u32, gid: u16) {
self.count += 1;
self.first.get_or_insert((reason, char_code, gid));
}
pub(crate) fn reason(&self) -> Option<&'static str> {
self.first.map(|(reason, _, _)| reason)
}
pub(crate) fn warning(&self, font_name: &str) -> Option<crate::extractors::warnings::Warning> {
let (reason, char_code, gid) = self.first?;
Some(crate::extractors::warnings::Warning {
category: crate::extractors::warnings::WarningCategory::GlyphDropped,
page: None,
message: format!(
"font '{font_name}' painted nothing for {} glyph(s) while advancing the cursor; \
first was code 0x{char_code:X} (glyph {gid}): {reason}. The page renders with \
a gap that reads as whitespace downstream. Reported once per font per page.",
self.count
),
spec_section: Some("9.6.6"),
})
}
pub(crate) fn warning_omitted(&self, font_name: &str) -> Option<crate::extractors::warnings::Warning> {
let (reason, char_code, _) = self.first?;
Some(crate::extractors::warnings::Warning {
category: crate::extractors::warnings::WarningCategory::GlyphDropped,
page: None,
message: format!(
"font '{font_name}' omitted {} glyph(s) from fallback shaping ({reason}); \
first was code 0x{char_code:X}. The run paints without these glyphs. \
Reported once per font per page.",
self.count
),
spec_section: Some("9.10.2"),
})
}
}
pub(crate) fn fallback_char_to_unicode(char_code: u32) -> String {
if let Some(s) = fallback_typography_symbol(char_code)
.or_else(|| fallback_math_symbol(char_code))
.or_else(|| fallback_greek_letter(char_code))
.or_else(|| fallback_currency_symbol(char_code))
{
return s.to_string();
}
if let Some(ch) = char::from_u32(char_code) {
if (0xE000..=0xF8FF).contains(&char_code) {
tracing::trace!("Private Use Area character: U+{:04X}", char_code);
}
ch.to_string()
} else {
tracing::trace!("Character code 0x{:04X} is not a valid Unicode code point", char_code);
"?".to_string()
}
}
fn fallback_typography_symbol(code: u32) -> Option<&'static str> {
Some(match code {
0x2014 => "—",
0x2013 => "–",
0x2018 => "\u{2018}",
0x2019 => "\u{2019}",
0x201C => "\u{201C}",
0x201D => "\u{201D}",
0x2022 => "•",
0x2026 => "…",
0x00B0 => "°",
_ => return None,
})
}
fn fallback_math_symbol(code: u32) -> Option<&'static str> {
Some(match code {
0x00B1 => "±",
0x00D7 => "×",
0x00F7 => "÷",
0x2202 => "∂",
0x2207 => "∇",
0x220F => "∏",
0x2211 => "∑",
0x221A => "√",
0x221E => "∞",
0x2260 => "≠",
0x2261 => "≡",
0x2264 => "≤",
0x2265 => "≥",
0x222B => "∫",
0x2248 => "≈",
0x2282 => "⊂",
0x2283 => "⊃",
0x2286 => "⊆",
0x2287 => "⊇",
0x2208 => "∈",
0x2209 => "∉",
0x2200 => "∀",
0x2203 => "∃",
0x2205 => "∅",
0x2227 => "∧",
0x2228 => "∨",
0x00AC => "¬",
0x2192 => "→",
0x2190 => "←",
0x2194 => "↔",
0x21D2 => "⇒",
0x21D4 => "⇔",
_ => return None,
})
}
fn fallback_greek_letter(code: u32) -> Option<&'static str> {
Some(match code {
0x03B1 => "α",
0x03B2 => "β",
0x03B3 => "γ",
0x03B4 => "δ",
0x03B5 => "ε",
0x03B6 => "ζ",
0x03B7 => "η",
0x03B8 => "θ",
0x03B9 => "ι",
0x03BA => "κ",
0x03BB => "λ",
0x03BC => "μ",
0x03BD => "ν",
0x03BE => "ξ",
0x03BF => "ο",
0x03C0 => "π",
0x03C1 => "ρ",
0x03C2 => "ς",
0x03C3 => "σ",
0x03C4 => "τ",
0x03C5 => "υ",
0x03C6 => "φ",
0x03C7 => "χ",
0x03C8 => "ψ",
0x03C9 => "ω",
0x0391 => "Α",
0x0392 => "Β",
0x0393 => "Γ",
0x0394 => "Δ",
0x0395 => "Ε",
0x0396 => "Ζ",
0x0397 => "Η",
0x0398 => "Θ",
0x0399 => "Ι",
0x039A => "Κ",
0x039B => "Λ",
0x039C => "Μ",
0x039D => "Ν",
0x039E => "Ξ",
0x039F => "Ο",
0x03A0 => "Π",
0x03A1 => "Ρ",
0x03A3 => "Σ",
0x03A4 => "Τ",
0x03A5 => "Υ",
0x03A6 => "Φ",
0x03A7 => "Χ",
0x03A8 => "Ψ",
0x03A9 => "Ω",
_ => return None,
})
}
fn fallback_currency_symbol(code: u32) -> Option<&'static str> {
Some(match code {
0x20AC => "€",
0x00A3 => "£",
0x00A5 => "¥",
0x00A2 => "¢",
0x20A3 => "₣",
0x20A4 => "₤",
0x20A9 => "₩",
0x20AA => "₪",
0x20AB => "₫",
0x20B9 => "₹",
_ => return None,
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ByteMode {
OneByte,
TwoByte,
ShiftJIS,
Codespace(std::sync::Arc<crate::fonts::cid_cmap::CidCMap>),
}
pub(crate) fn font_has_utf8_cmap(font: &FontInfo) -> bool {
if font.subtype != "Type0" {
return false;
}
if let crate::fonts::Encoding::Standard(name) = &font.encoding {
let lower = name.to_ascii_lowercase();
lower.contains("utf8") || lower.contains("utf-8")
} else {
false
}
}
pub(crate) fn get_byte_mode(font: Option<&FontInfo>) -> ByteMode {
let Some(font) = font else {
return ByteMode::OneByte;
};
if font.subtype != "Type0" {
return ByteMode::OneByte;
}
if let Some(mode) = byte_mode_from_embedded_codespace(font) {
return mode;
}
if let Some(ref lazy_cmap) = font.to_unicode
&& lazy_cmap.code_width() == 2
{
return ByteMode::TwoByte;
}
byte_mode_from_encoding(&font.encoding)
}
fn byte_mode_from_embedded_codespace(font: &FontInfo) -> Option<ByteMode> {
let cid_map = font.embedded_cid_map.as_ref()?;
match cid_map.codespace_widths().as_slice() {
[1] => Some(ByteMode::OneByte),
[2] => Some(ByteMode::TwoByte),
[] => None,
_ => Some(ByteMode::Codespace(std::sync::Arc::clone(cid_map))),
}
}
fn byte_mode_from_encoding(encoding: &crate::fonts::Encoding) -> ByteMode {
match encoding {
crate::fonts::Encoding::Identity => ByteMode::TwoByte,
crate::fonts::Encoding::Standard(name) => byte_mode_from_standard_encoding_name(name),
_ => ByteMode::OneByte,
}
}
fn byte_mode_from_standard_encoding_name(name: &str) -> ByteMode {
if (name.contains("Identity") && !name.contains("OneByteIdentity"))
|| name.contains("UCS2")
|| name.contains("UTF16")
|| name == "H"
|| name == "V"
{
ByteMode::TwoByte
} else if name.contains("RKSJ") {
ByteMode::ShiftJIS
} else if name.contains("EUC")
|| name.contains("GBK")
|| name.contains("GBpc")
|| name.contains("GB-")
|| name.contains("CNS")
|| name.contains("B5")
|| name.contains("KSC")
|| name.contains("KSCms")
{
ByteMode::TwoByte
} else {
ByteMode::OneByte
}
}
pub(crate) struct TextCharIter<'a> {
bytes: &'a [u8],
byte_mode: ByteMode,
index: usize,
}
impl<'a> TextCharIter<'a> {
pub(crate) fn new(bytes: &'a [u8], font: Option<&FontInfo>) -> Self {
Self {
bytes,
byte_mode: get_byte_mode(font),
index: 0,
}
}
}
impl<'a> Iterator for TextCharIter<'a> {
type Item = (u16, usize);
fn next(&mut self) -> Option<Self::Item> {
if self.index >= self.bytes.len() {
return None;
}
let (char_code, bytes_consumed) = match &self.byte_mode {
ByteMode::TwoByte if self.index + 1 < self.bytes.len() => (
((self.bytes[self.index] as u16) << 8) | (self.bytes[self.index + 1] as u16),
2,
),
ByteMode::ShiftJIS => {
let b = self.bytes[self.index];
let is_lead = (0x81..=0x9F).contains(&b) || (0xE0..=0xFC).contains(&b);
if is_lead && self.index + 1 < self.bytes.len() {
(((b as u16) << 8) | (self.bytes[self.index + 1] as u16), 2)
} else {
(b as u16, 1)
}
}
ByteMode::Codespace(cid_map) => {
let len = cid_map.code_length(self.bytes, self.index).max(1);
let mut code: u32 = 0;
for &b in &self.bytes[self.index..(self.index + len).min(self.bytes.len())] {
code = (code << 8) | b as u32;
}
(u16::try_from(code).unwrap_or(0), len)
}
_ => (self.bytes[self.index] as u16, 1),
};
self.index += bytes_consumed;
Some((char_code, bytes_consumed))
}
}
fn utf8_codes(bytes: &[u8]) -> impl Iterator<Item = u32> + '_ {
utf8_codes_with_len(bytes).map(|(code, _)| code)
}
fn utf8_codes_with_len(bytes: &[u8]) -> impl Iterator<Item = (u32, usize)> + '_ {
let mut i = 0;
std::iter::from_fn(move || {
if i >= bytes.len() {
return None;
}
let width = match bytes[i] {
0x00..=0x7F => 1,
0xC0..=0xDF => 2,
0xE0..=0xEF => 3,
0xF0..=0xF7 => 4,
_ => 1,
}
.min(bytes.len() - i);
let mut code: u32 = 0;
for &b in &bytes[i..i + width] {
code = (code << 8) | b as u32;
}
i += width;
Some((code, width))
})
}
pub(crate) fn char_codes(bytes: &[u8], font: &FontInfo) -> Vec<u32> {
char_codes_with_len(bytes, font)
.into_iter()
.map(|(code, _)| code)
.collect()
}
pub(crate) fn char_codes_with_len(bytes: &[u8], font: &FontInfo) -> Vec<(u32, usize)> {
if font_has_utf8_cmap(font) {
utf8_codes_with_len(bytes).collect()
} else {
TextCharIter::new(bytes, Some(font))
.map(|(code, nbytes)| (code as u32, nbytes))
.collect()
}
}
fn resolve_char(font: &FontInfo, code: u32, policy: DecodePolicy) -> String {
if let Some(char_str) = font.char_to_unicode(code) {
return char_str;
}
if char::from_u32(code).is_none() && !policy.question_mark_for_invalid {
return "\u{FFFD}".to_string();
}
fallback_char_to_unicode(code)
}
pub(crate) fn decode_text_to_unicode(
bytes: &[u8],
font: Option<&FontInfo>,
policy: DecodePolicy,
drops: Option<&mut GlyphDropTally>,
) -> String {
let raw_result = if let Some(font) = font {
if font.subtype != "Type0" {
decode_simple_font_bytes(font, bytes, policy, drops)
} else if font_has_utf8_cmap(font) {
decode_utf8_cmap_bytes(font, bytes, policy, drops)
} else {
decode_generic_type0_bytes(font, bytes, policy, drops)
}
} else {
decode_latin1_fallback(bytes)
};
filter_decoded_text(&raw_result, policy)
}
fn decode_simple_font_bytes(
font: &FontInfo,
bytes: &[u8],
policy: DecodePolicy,
mut drops: Option<&mut GlyphDropTally>,
) -> String {
let mut result = String::new();
let table = font.get_byte_to_char_table();
for &byte in bytes {
let c = table[byte as usize];
if c != '\0' {
result.push(c);
} else {
let char_str = resolve_char(font, byte as u32, policy);
if char_str != "\u{FFFD}" || policy.preserve_unmapped {
result.push_str(&char_str);
} else if let Some(tally) = drops.as_deref_mut() {
tally.record("no Unicode mapping", byte as u32, 0);
}
}
}
result
}
fn decode_utf8_cmap_bytes(
font: &FontInfo,
bytes: &[u8],
policy: DecodePolicy,
mut drops: Option<&mut GlyphDropTally>,
) -> String {
let mut result = String::new();
for code in utf8_codes(bytes) {
let char_str = resolve_char(font, code, policy);
if char_str != "\u{FFFD}" || policy.preserve_unmapped {
result.push_str(&char_str);
} else if let Some(tally) = drops.as_deref_mut() {
tally.record("no Unicode mapping", code, 0);
}
}
result
}
fn decode_generic_type0_bytes(
font: &FontInfo,
bytes: &[u8],
policy: DecodePolicy,
mut drops: Option<&mut GlyphDropTally>,
) -> String {
let mut result = String::new();
for (char_code, _) in TextCharIter::new(bytes, Some(font)) {
let char_str = resolve_char(font, char_code as u32, policy);
if char_str != "\u{FFFD}" || policy.preserve_unmapped {
result.push_str(&char_str);
} else if let Some(tally) = drops.as_deref_mut() {
tally.record("no Unicode mapping", char_code as u32, 0);
}
}
result
}
fn decode_latin1_fallback(bytes: &[u8]) -> String {
crate::extractors::recovery_tally::record(|counts| {
counts
.missing_font_decodes
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
counts
.missing_font_bytes
.fetch_add(bytes.len() as u64, std::sync::atomic::Ordering::Relaxed);
});
tracing::trace!(
"No font provided for {} bytes, using Latin-1 fallback (PDF spec compliant)",
bytes.len()
);
bytes.iter().map(|&b| char::from(b)).collect()
}
fn filter_decoded_text(raw_result: &str, policy: DecodePolicy) -> String {
let mut filtered = String::with_capacity(raw_result.len());
for c in raw_result.chars() {
if c < '\x20' && c != '\t' && c != '\n' && c != '\r' {
continue;
}
if policy.decompose_ligatures
&& let Some(components) = crate::text::ligature_processor::get_ligature_components(c)
{
filtered.push_str(components);
continue;
}
filtered.push(c);
}
filtered
}
pub(crate) fn strip_subset_prefix(name: &str) -> &str {
if name.len() > 7 && name.as_bytes()[6] == b'+' && name[..6].chars().all(|c| c.is_ascii_uppercase()) {
&name[7..]
} else {
name
}
}
pub(crate) fn best_truetype_cmaps<'a>(
fonts: impl Iterator<Item = &'a FontInfo>,
) -> HashMap<String, (TrueTypeCMap, String)> {
let mut best: HashMap<String, (TrueTypeCMap, String)> = HashMap::new();
for font in fonts {
if let Some(cmap) = font.truetype_cmap() {
let stripped = strip_subset_prefix(&font.base_font).to_string();
let dominated =
best.get(&stripped)
.is_none_or(|(existing, existing_name)| match cmap.len().cmp(&existing.len()) {
std::cmp::Ordering::Greater => true,
std::cmp::Ordering::Equal => font.base_font < *existing_name,
std::cmp::Ordering::Less => false,
});
if dominated {
best.insert(stripped, (cmap.clone(), font.base_font.clone()));
}
}
}
best
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fonts::{CIDToGIDMap, Encoding, FontInfo, VerticalMetrics};
fn utf8_cmap_font() -> FontInfo {
FontInfo {
base_font: "TestUtf8CMap".to_string(),
subtype: "Type0".to_string(),
encoding: Encoding::Standard("UniFull-UTF8-H".to_string()),
to_unicode: None,
font_weight: None,
flags: None,
stem_v: None,
ascent: 0.95,
descent: -0.35,
embedded_font_data: None,
truetype_cmap: std::sync::OnceLock::new(),
embedded_glyph_names: std::sync::OnceLock::new(),
is_truetype_font: false,
widths: None,
first_char: None,
last_char: None,
font_matrix_a: 0.001,
default_width: 1000.0,
cid_to_gid_map: Some(CIDToGIDMap::Identity),
cid_system_info: None,
cid_font_type: Some("CIDFontType2".to_string()),
cid_widths: None,
cid_default_width: 1000.0,
has_explicit_dw: false,
cff_gid_map: None,
multi_char_map: HashMap::new(),
byte_to_char_table: std::sync::OnceLock::new(),
type0_unicode_memo: std::sync::Arc::new(std::sync::Mutex::new(HashMap::new())),
byte_to_width_table: std::sync::OnceLock::new(),
weight_memo: std::sync::OnceLock::new(),
italic_memo: std::sync::OnceLock::new(),
std14_memo: std::sync::OnceLock::new(),
diff_glyph_names: HashMap::new(),
wmode: 0,
cid_vertical_metrics: None,
cid_default_vertical_metrics: VerticalMetrics::SPEC_DEFAULT,
cjk_substitution: None,
embedded_cid_map: None,
}
}
#[test]
fn utf8_codes_segments_by_lead_byte_width() {
let bytes = [
0x41, 0xC3, 0xA9, 0xE4, 0xB8, 0xAD, 0xF0, 0x9F, 0x98, 0x80, 0x80, 0xE4, 0xB8,
];
let codes: Vec<u32> = utf8_codes(&bytes).collect();
assert_eq!(codes, vec![0x41, 0xC3A9, 0xE4B8AD, 0xF09F_9880, 0x80, 0xE4B8]);
}
#[test]
fn char_codes_uses_decode_segmentation_for_utf8_cmaps() {
let font = utf8_cmap_font();
assert!(font_has_utf8_cmap(&font), "fixture font must select the UTF-8 route");
let bytes = [0x41, 0xC3, 0xA9, 0xE4, 0xB8, 0xAD];
assert_eq!(char_codes(&bytes, &font), vec![0x41, 0xC3A9, 0xE4B8AD]);
}
#[test]
fn char_codes_with_len_reports_the_bytes_each_code_consumed() {
let font = utf8_cmap_font();
let bytes = [0x41, 0xC3, 0xA9, 0xE4, 0xB8, 0xAD];
assert_eq!(
char_codes_with_len(&bytes, &font),
vec![(0x41, 1), (0xC3A9, 2), (0xE4B8AD, 3)]
);
}
#[test]
fn empty_tally_builds_no_warning() {
let tally = GlyphDropTally::default();
assert!(tally.warning("AnyFont").is_none());
assert!(tally.warning_omitted("AnyFont").is_none());
assert!(tally.reason().is_none());
}
}