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 {
match char_code {
0x2014 => "—".to_string(),
0x2013 => "–".to_string(),
0x2018 => "\u{2018}".to_string(),
0x2019 => "\u{2019}".to_string(),
0x201C => "\u{201C}".to_string(),
0x201D => "\u{201D}".to_string(),
0x2022 => "•".to_string(),
0x2026 => "…".to_string(),
0x00B0 => "°".to_string(),
0x00B1 => "±".to_string(),
0x00D7 => "×".to_string(),
0x00F7 => "÷".to_string(),
0x2202 => "∂".to_string(),
0x2207 => "∇".to_string(),
0x220F => "∏".to_string(),
0x2211 => "∑".to_string(),
0x221A => "√".to_string(),
0x221E => "∞".to_string(),
0x2260 => "≠".to_string(),
0x2261 => "≡".to_string(),
0x2264 => "≤".to_string(),
0x2265 => "≥".to_string(),
0x222B => "∫".to_string(),
0x2248 => "≈".to_string(),
0x2282 => "⊂".to_string(),
0x2283 => "⊃".to_string(),
0x2286 => "⊆".to_string(),
0x2287 => "⊇".to_string(),
0x2208 => "∈".to_string(),
0x2209 => "∉".to_string(),
0x2200 => "∀".to_string(),
0x2203 => "∃".to_string(),
0x2205 => "∅".to_string(),
0x2227 => "∧".to_string(),
0x2228 => "∨".to_string(),
0x00AC => "¬".to_string(),
0x2192 => "→".to_string(),
0x2190 => "←".to_string(),
0x2194 => "↔".to_string(),
0x21D2 => "⇒".to_string(),
0x21D4 => "⇔".to_string(),
0x03B1 => "α".to_string(),
0x03B2 => "β".to_string(),
0x03B3 => "γ".to_string(),
0x03B4 => "δ".to_string(),
0x03B5 => "ε".to_string(),
0x03B6 => "ζ".to_string(),
0x03B7 => "η".to_string(),
0x03B8 => "θ".to_string(),
0x03B9 => "ι".to_string(),
0x03BA => "κ".to_string(),
0x03BB => "λ".to_string(),
0x03BC => "μ".to_string(),
0x03BD => "ν".to_string(),
0x03BE => "ξ".to_string(),
0x03BF => "ο".to_string(),
0x03C0 => "π".to_string(),
0x03C1 => "ρ".to_string(),
0x03C2 => "ς".to_string(),
0x03C3 => "σ".to_string(),
0x03C4 => "τ".to_string(),
0x03C5 => "υ".to_string(),
0x03C6 => "φ".to_string(),
0x03C7 => "χ".to_string(),
0x03C8 => "ψ".to_string(),
0x03C9 => "ω".to_string(),
0x0391 => "Α".to_string(),
0x0392 => "Β".to_string(),
0x0393 => "Γ".to_string(),
0x0394 => "Δ".to_string(),
0x0395 => "Ε".to_string(),
0x0396 => "Ζ".to_string(),
0x0397 => "Η".to_string(),
0x0398 => "Θ".to_string(),
0x0399 => "Ι".to_string(),
0x039A => "Κ".to_string(),
0x039B => "Λ".to_string(),
0x039C => "Μ".to_string(),
0x039D => "Ν".to_string(),
0x039E => "Ξ".to_string(),
0x039F => "Ο".to_string(),
0x03A0 => "Π".to_string(),
0x03A1 => "Ρ".to_string(),
0x03A3 => "Σ".to_string(),
0x03A4 => "Τ".to_string(),
0x03A5 => "Υ".to_string(),
0x03A6 => "Φ".to_string(),
0x03A7 => "Χ".to_string(),
0x03A8 => "Ψ".to_string(),
0x03A9 => "Ω".to_string(),
0x20AC => "€".to_string(),
0x00A3 => "£".to_string(),
0x00A5 => "¥".to_string(),
0x00A2 => "¢".to_string(),
0x20A3 => "₣".to_string(),
0x20A4 => "₤".to_string(),
0x20A9 => "₩".to_string(),
0x20AA => "₪".to_string(),
0x20AB => "₫".to_string(),
0x20B9 => "₹".to_string(),
code => {
if let Some(ch) = char::from_u32(code) {
if (0xE000..=0xF8FF).contains(&code) {
tracing::trace!("Private Use Area character: U+{:04X}", code);
}
ch.to_string()
} else {
tracing::trace!("Character code 0x{:04X} is not a valid Unicode code point", code);
"?".to_string()
}
}
}
}
#[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 {
if let Some(font) = font {
if font.subtype == "Type0" {
if let Some(cid_map) = &font.embedded_cid_map {
match cid_map.codespace_widths().as_slice() {
[1] => return ByteMode::OneByte,
[2] => return ByteMode::TwoByte,
[] => {}
_ => return ByteMode::Codespace(std::sync::Arc::clone(cid_map)),
}
}
if let Some(ref lazy_cmap) = font.to_unicode
&& lazy_cmap.code_width() == 2
{
return ByteMode::TwoByte;
}
match &font.encoding {
crate::fonts::Encoding::Identity => ByteMode::TwoByte,
crate::fonts::Encoding::Standard(name) => {
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
}
}
_ => ByteMode::OneByte,
}
} else {
ByteMode::OneByte
}
} 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,
mut drops: Option<&mut GlyphDropTally>,
) -> String {
let raw_result = if let Some(font) = font {
let mut result = String::new();
if font.subtype != "Type0" {
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);
}
}
}
} else if font_has_utf8_cmap(font) {
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);
}
}
} else {
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
} else {
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()
};
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());
}
}