use crate::glyph_names::glyph_to_char;
use crate::tounicode::FontCMaps;
use crate::types::{FontEncodingMap, FontWidthInfo, PageFontEncodings, PageFontWidths};
use log::debug;
use lopdf::{Document, Encoding, Object};
use std::collections::HashMap;
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub(crate) enum CMapChoice {
Primary,
Remapped,
}
#[derive(Debug, Default, Clone)]
pub(crate) struct CMapDecisionCache {
decisions: HashMap<u32, CMapDecision>,
}
#[derive(Debug, Default, Clone)]
struct CMapDecision {
primary_sample: String,
remapped_sample: String,
sample_bytes: usize,
choice: Option<CMapChoice>,
}
impl CMapDecisionCache {
pub(crate) fn new() -> Self {
Self::default()
}
pub(crate) fn get_choice(&self, obj_num: u32) -> Option<CMapChoice> {
self.decisions.get(&obj_num).and_then(|d| d.choice)
}
pub(crate) fn consider(
&mut self,
obj_num: u32,
primary: &str,
remapped: &str,
bytes_len: usize,
) -> Option<CMapChoice> {
const SAMPLE_TARGET_BYTES: usize = 240;
let entry = self.decisions.entry(obj_num).or_default();
entry.sample_bytes = entry.sample_bytes.saturating_add(bytes_len);
entry.primary_sample.push_str(primary);
entry.remapped_sample.push_str(remapped);
if entry.choice.is_none() && entry.sample_bytes >= SAMPLE_TARGET_BYTES {
let score_primary = score_text(&entry.primary_sample);
let score_remap = score_text(&entry.remapped_sample);
entry.choice = if score_remap > score_primary + 5 {
Some(CMapChoice::Remapped)
} else {
Some(CMapChoice::Primary)
};
}
entry.choice
}
}
pub(crate) fn resolve_array<'a>(doc: &'a Document, obj: &'a Object) -> Option<&'a Vec<Object>> {
match obj {
Object::Array(arr) => Some(arr),
Object::Reference(r) => {
if let Ok(Object::Array(arr)) = doc.get_object(*r) {
Some(arr)
} else {
None
}
}
_ => None,
}
}
pub(crate) fn resolve_dict<'a>(
doc: &'a Document,
obj: &'a Object,
) -> Option<&'a lopdf::Dictionary> {
match obj {
Object::Dictionary(d) => Some(d),
Object::Reference(r) => doc.get_dictionary(*r).ok(),
_ => None,
}
}
pub(crate) fn build_font_widths(
doc: &Document,
fonts: &std::collections::BTreeMap<Vec<u8>, &lopdf::Dictionary>,
) -> PageFontWidths {
let mut widths = PageFontWidths::new();
for (font_name, font_dict) in fonts {
let resource_name = String::from_utf8_lossy(font_name).to_string();
let subtype = font_dict
.get(b"Subtype")
.ok()
.and_then(|o| o.as_name().ok())
.map(|n| String::from_utf8_lossy(n).to_string())
.unwrap_or_default();
let base_font = font_dict
.get(b"BaseFont")
.ok()
.and_then(|o| o.as_name().ok())
.map(|n| String::from_utf8_lossy(n).to_string())
.unwrap_or_default();
let has_tounicode = font_dict.get(b"ToUnicode").is_ok();
let has_descendants = font_dict.get(b"DescendantFonts").is_ok();
let encoding_str = font_dict
.get(b"Encoding")
.ok()
.map(|o| match o {
Object::Name(n) => String::from_utf8_lossy(n).to_string(),
Object::Reference(_) => "ref(dict)".to_string(),
Object::Dictionary(_) => "dict".to_string(),
_ => format!("{:?}", o),
})
.unwrap_or_else(|| "none".to_string());
debug!(
"font {:<10} sub={:<12} base={:<45} toUni={:<6} enc={:<20} cid={}",
resource_name, subtype, base_font, has_tounicode, encoding_str, has_descendants
);
if let Some(info) = parse_font_widths(doc, font_dict) {
widths.insert(resource_name, info);
}
}
widths
}
pub(crate) fn parse_font_widths(
doc: &Document,
font_dict: &lopdf::Dictionary,
) -> Option<FontWidthInfo> {
let subtype = font_dict.get(b"Subtype").ok()?;
let subtype_name = subtype.as_name().ok()?;
match subtype_name {
b"Type0" => parse_type0_widths(doc, font_dict),
b"Type1" | b"TrueType" | b"MMType1" | b"Type3" => parse_simple_font_widths(doc, font_dict),
_ => None,
}
}
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;
while i < w_array.len() {
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() {
let w = match w_obj {
Object::Integer(n) => *n as u16,
Object::Real(n) => *n as u16,
_ => continue,
};
widths.insert(start_cid + j as u16, w);
}
i += 1;
}
Object::Reference(r) => {
if let Ok(Object::Array(arr)) = doc.get_object(*r) {
for (j, w_obj) in arr.iter().enumerate() {
let w = match w_obj {
Object::Integer(n) => *n as u16,
Object::Real(n) => *n as u16,
_ => continue,
};
widths.insert(start_cid + j as u16, w);
}
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;
}
};
for cid in start_cid..=end {
widths.insert(cid, w);
}
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;
}
};
for cid in start_cid..=end {
widths.insert(cid, w);
}
i += 1;
}
_ => {
i += 1;
}
}
}
}
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>,
) -> (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();
if let Some(result) = parse_font_encoding(doc, font_dict) {
if result.gid_glyph_count > 0 {
has_gid_fonts = true;
}
if !result.map.is_empty() {
encodings.insert(resource_name, result.map);
}
}
}
(encodings, has_gid_fonts)
}
pub(crate) fn parse_font_encoding(
doc: &Document,
font_dict: &lopdf::Dictionary,
) -> Option<EncodingResult> {
let encoding_obj = font_dict.get(b"Encoding").ok()?;
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)
} else {
None
}
}
Object::Dictionary(enc_dict) => parse_encoding_dictionary(doc, enc_dict),
_ => None,
}
}
pub(crate) struct EncodingResult {
pub map: FontEncodingMap,
pub gid_glyph_count: u32,
}
pub(crate) fn parse_encoding_dictionary(
doc: &Document,
enc_dict: &lopdf::Dictionary,
) -> Option<EncodingResult> {
let differences = enc_dict.get(b"Differences").ok()?;
let diff_array = match differences {
Object::Array(arr) => arr.clone(),
Object::Reference(obj_ref) => {
if let Ok(Object::Array(arr)) = doc.get_object(*obj_ref) {
arr.clone()
} else {
return None;
}
}
_ => return None,
};
let mut encoding_map = FontEncodingMap::new();
let mut current_code: u8 = 0;
let mut ligature_count = 0u32;
let mut gid_glyph_count = 0u32;
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();
if glyph_name == "fi"
|| glyph_name == "fl"
|| glyph_name == "ffi"
|| glyph_name == "ffl"
{
debug!(
" Differences: code=0x{:02X} glyph={:?} (ligature)",
current_code, glyph_name
);
ligature_count += 1;
}
if glyph_name.starts_with("gid")
&& glyph_name.len() >= 4
&& glyph_name[3..].chars().all(|c| c.is_ascii_digit())
{
gid_glyph_count += 1;
}
if let Some(ch) = glyph_to_char(&glyph_name) {
encoding_map.insert(current_code, ch);
} else {
debug!(
" Differences: code=0x{:02X} glyph={:?} (unmapped)",
current_code, glyph_name
);
}
current_code = current_code.wrapping_add(1);
}
_ => {}
}
}
if ligature_count > 0 {
debug!(
" Differences: {} total entries, {} ligatures",
encoding_map.len(),
ligature_count
);
}
if gid_glyph_count > 0 {
debug!(
" Differences: {} gid-encoded glyphs (unresolvable without original font)",
gid_glyph_count
);
}
Some(EncodingResult {
map: encoding_map,
gid_glyph_count,
})
}
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)
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn extract_text_from_operand(
obj: &Object,
current_font: &str,
base_font_name: Option<&str>,
font_cmaps: &FontCMaps,
font_tounicode_refs: &std::collections::HashMap<String, u32>,
inline_cmaps: &std::collections::HashMap<String, crate::tounicode::CMapEntry>,
font_encodings: &PageFontEncodings,
encoding_cache: &HashMap<String, Encoding<'_>>,
cmap_decisions: &mut CMapDecisionCache,
font_widths: &PageFontWidths,
) -> Option<String> {
let is_type0_cid_font = font_widths
.get(current_font)
.is_some_and(|info| info.is_cid);
let result = (|| -> Option<String> {
if let Object::String(bytes, _) = obj {
let mut decode_with_entry = |entry: &crate::tounicode::CMapEntry| -> Option<String> {
if entry.primary.code_byte_length == 1 {
let encoding_map = font_encodings.get(current_font);
let decoded: String = bytes
.iter()
.filter_map(|&b| {
let code = b as u16;
if let Some(s) = entry.primary.lookup(code) {
if !s.contains('\u{FFFD}') {
return Some(s);
}
}
if let Some(fb) = entry.fallback.as_ref().and_then(|c| c.lookup(code)) {
if !fb.contains('\u{FFFD}') {
return Some(fb);
}
}
if let Some(map) = encoding_map {
if let Some(&ch) = map.get(&b) {
return Some(ch.to_string());
}
}
if b >= 0x20 {
return Some((b as char).to_string());
}
None
})
.collect();
if !decoded.is_empty() {
return Some(decoded);
}
return None;
}
if bytes.len() % 2 == 1 {
let lookups = entry.primary.lookup_bytes(bytes);
let decoded: String = lookups
.iter()
.filter_map(|&(_b, ref cmap_result)| cmap_result.clone())
.collect();
if !decoded.is_empty() {
return Some(decoded);
}
}
let decoded_primary = entry.primary.decode_cids(bytes);
if let Some(remapped) = entry.remapped.as_ref() {
let decoded_remap = remapped.decode_cids(bytes);
let decoded_fallback = entry.fallback.as_ref().map(|c| c.decode_cids(bytes));
if let Some(choice) = cmap_decisions
.get_choice(font_tounicode_refs.get(current_font).copied().unwrap_or(0))
{
let decoded = match choice {
CMapChoice::Primary => decoded_primary.clone(),
CMapChoice::Remapped => decoded_remap.clone(),
};
if !decoded.is_empty() {
return Some(decoded);
}
}
let choice = cmap_decisions.consider(
font_tounicode_refs.get(current_font).copied().unwrap_or(0),
&decoded_primary,
&decoded_remap,
bytes.len(),
);
let mut decoded = match choice {
Some(CMapChoice::Primary) => decoded_primary,
Some(CMapChoice::Remapped) => decoded_remap,
None => choose_best_cmap_decode(decoded_primary, decoded_remap),
};
if let Some(fb) = decoded_fallback {
let expected = bytes.len() / 2;
let decoded_len = decoded.chars().count();
let prefer_fallback = (!fb.is_empty() && decoded.is_empty())
|| (!fb.is_empty() && expected > 0 && decoded_len * 2 < expected);
if prefer_fallback || score_text(&fb) > score_text(&decoded) + 3 {
decoded = fb;
}
}
if !decoded.is_empty() {
return Some(decoded);
}
} else if !decoded_primary.is_empty() {
if let Some(fb) = entry.fallback.as_ref().map(|c| c.decode_cids(bytes)) {
let expected = bytes.len() / 2;
let decoded_len = decoded_primary.chars().count();
let prefer_fallback = (!fb.is_empty() && decoded_primary.is_empty())
|| (!fb.is_empty() && expected > 0 && decoded_len * 2 < expected);
if prefer_fallback || score_text(&fb) > score_text(&decoded_primary) + 3 {
return Some(fb);
}
}
return Some(decoded_primary);
}
None
};
let mut has_cmap = false;
if let Some(entry) = inline_cmaps.get(current_font) {
has_cmap = true;
if let Some(decoded) = decode_with_entry(entry) {
return Some(decoded);
}
}
if let Some(&obj_num) = font_tounicode_refs.get(current_font) {
if let Some(entry) = font_cmaps.get_by_obj(obj_num) {
has_cmap = true;
if let Some(decoded) = decode_with_entry(entry) {
return Some(decoded);
}
}
}
if is_type0_cid_font && bytes.iter().any(|&b| b > 0x7F) {
let cid_count = (bytes.len() / 2).max(1);
return Some("\u{FFFD}".repeat(cid_count));
}
if let Some(encoding_map) = font_encodings.get(current_font) {
let has_diff_match = bytes.iter().any(|b| encoding_map.contains_key(b));
if has_diff_match {
let decoded: String = bytes
.iter()
.filter_map(|&b| {
if let Some(&ch) = encoding_map.get(&b) {
Some(ch)
} else if b >= 0x20 {
Some(b as char)
} else {
None }
})
.collect();
if !decoded.is_empty() {
return Some(decoded);
}
}
}
if bytes.len() >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF {
let utf16: Vec<u16> = bytes[2..]
.chunks_exact(2)
.map(|chunk| u16::from_be_bytes([chunk[0], chunk[1]]))
.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
.chunks_exact(2)
.map(|chunk| u16::from_be_bytes([chunk[0], chunk[1]]))
.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) {
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(bytes.iter().map(|&b| b as char).collect())
} else {
None
}
})();
result.map(clean_symbol_pua)
}
fn clean_symbol_pua(text: String) -> String {
if !text.chars().any(|c| ('\u{F000}'..='\u{F0FF}').contains(&c)) {
return text;
}
text.chars()
.map(|c| {
let code = c as u32;
if !(0xF000..=0xF0FF).contains(&code) {
return c;
}
let low = code - 0xF000;
match low {
0xA1 | 0xA7 | 0xB7 => '\u{2022}',
0xFC => '\u{2713}',
0x20..=0xFF => char::from_u32(low).unwrap_or(c),
_ => c,
}
})
.collect()
}
fn decode_symbol_fallback(bytes: &[u8], base_font_name: Option<&str>) -> Option<String> {
let name = base_font_name?.to_ascii_lowercase();
if !name.contains("symbol") && !name.contains("wingdings") && !name.contains("zapfdingbats") {
return None;
}
let mut out = String::new();
for &b in bytes {
if b < 0x20 {
continue;
}
if let Some(ch) = char::from_u32(0xF000 + b as u32) {
out.push(ch);
}
}
if out.is_empty() {
None
} else {
Some(out)
}
}
fn choose_best_cmap_decode(primary: String, remapped: String) -> String {
if primary.is_empty() {
return remapped;
}
if remapped.is_empty() {
return primary;
}
let score_primary = score_text(&primary);
let score_remap = score_text(&remapped);
if score_remap > score_primary + 3 {
remapped
} else {
primary
}
}
fn score_text(text: &str) -> i32 {
const COMMON_WORDS: [&str; 22] = [
"the", "and", "of", "to", "in", "a", "is", "that", "for", "with", "on", "as", "by", "from",
"this", "be", "are", "at", "or", "not", "it", "our",
];
let mut letters = 0i32;
let mut spaces = 0i32;
let mut digits = 0i32;
let mut other = 0i32;
let mut word_hits = 0i32;
let mut current = String::new();
for ch in text.chars() {
if ch.is_ascii_alphabetic() {
letters += 1;
current.push(ch.to_ascii_lowercase());
} else {
if !current.is_empty() {
if COMMON_WORDS.iter().any(|w| *w == current) {
word_hits += 1;
}
current.clear();
}
if ch == ' ' {
spaces += 1;
} else if ch.is_ascii_digit() {
digits += 1;
} else if ch.is_control() || ch == '\u{FFFD}' {
other += 3;
} else if ('\u{4E00}'..='\u{9FFF}').contains(&ch)
|| ('\u{3040}'..='\u{309F}').contains(&ch)
|| ('\u{30A0}'..='\u{30FF}').contains(&ch)
|| ('\u{3400}'..='\u{4DBF}').contains(&ch)
|| ('\u{F900}'..='\u{FAFF}').contains(&ch)
{
letters += 1; } else {
other += 1;
}
}
}
if !current.is_empty() && COMMON_WORDS.iter().any(|w| *w == current) {
word_hits += 1;
}
let mut score = word_hits * 10 + letters + spaces * 2 + digits - other * 2;
if letters > 15 && word_hits == 0 {
score -= 15;
}
score
}
#[cfg(test)]
mod tests {
use super::*;
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,
}
}
#[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));
}
#[test]
fn cid_font_with_unparseable_cmap_does_not_emit_latin1_mojibake() {
let bytes = vec![0xCD_u8, 0xD9, 0xCD, 0xD9];
let obj = Object::String(bytes, lopdf::StringFormat::Hexadecimal);
let font_cmaps = FontCMaps::default();
let mut font_tounicode_refs: HashMap<String, u32> = HashMap::new();
font_tounicode_refs.insert("F0".to_string(), 999);
let inline_cmaps = HashMap::new();
let font_encodings: PageFontEncodings = HashMap::new();
let encoding_cache: HashMap<String, Encoding<'_>> = HashMap::new();
let mut decisions = CMapDecisionCache::new();
let mut font_widths: PageFontWidths = HashMap::new();
font_widths.insert("F0".to_string(), make_font_info(&[], 1000, true));
let result = extract_text_from_operand(
&obj,
"F0",
None,
&font_cmaps,
&font_tounicode_refs,
&inline_cmaps,
&font_encodings,
&encoding_cache,
&mut decisions,
&font_widths,
);
let text = result.expect("CID font fallback should still emit a marker");
assert!(
!text.contains('\u{00CD}') && !text.contains('\u{00D9}'),
"CID font with unparseable CMap leaked Latin-1 mojibake: {text:?}"
);
assert!(
text.contains('\u{FFFD}'),
"CID font with unparseable CMap should emit U+FFFD so detect_encoding_issues fires: {text:?}"
);
}
#[test]
fn simple_font_latin1_fallback_passes_high_bytes_through() {
let bytes = vec![0x24_u8, 0x47, 0xB6, 0x56]; let obj = Object::String(bytes, lopdf::StringFormat::Hexadecimal);
let font_cmaps = FontCMaps::default();
let mut font_tounicode_refs: HashMap<String, u32> = HashMap::new();
font_tounicode_refs.insert("F1".to_string(), 999);
let inline_cmaps = HashMap::new();
let font_encodings: PageFontEncodings = HashMap::new();
let encoding_cache: HashMap<String, Encoding<'_>> = HashMap::new();
let mut decisions = CMapDecisionCache::new();
let mut font_widths: PageFontWidths = HashMap::new();
font_widths.insert("F1".to_string(), make_font_info(&[], 1000, false));
let text = extract_text_from_operand(
&obj,
"F1",
None,
&font_cmaps,
&font_tounicode_refs,
&inline_cmaps,
&font_encodings,
&encoding_cache,
&mut decisions,
&font_widths,
)
.expect("simple font should round-trip Latin-1 bytes");
assert_eq!(text, "$G\u{00B6}V");
assert!(
!text.contains('\u{FFFD}'),
"simple font fallback must not stamp FFFD over legitimate bytes: {text:?}"
);
}
}