From de3286d9df74a692c1addc34d972a876cb073978 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 2 Aug 2026 23:03:53 +0300 Subject: [PATCH] pdf-font: unify best-effort fallback construction Route every missing or unreadable font through one infallible TrueType fallback builder. Preserve valid widths, encoding, and ToUnicode data while treating malformed optional metadata as absent. Use the same fallback for Type0 font programs and ignore malformed CID mapping metadata so damaged resources remain renderable. Co-authored-by: Codex --- crates/pdf-font/src/fallback.rs | 199 ++++++++++++++++---------- crates/pdf-font/src/font.rs | 31 ++-- crates/pdf-font/src/true_type_font.rs | 10 +- crates/pdf-font/src/type0_font.rs | 52 +++++-- 4 files changed, 185 insertions(+), 107 deletions(-) diff --git a/crates/pdf-font/src/fallback.rs b/crates/pdf-font/src/fallback.rs index fa47cfd7..ae6e1247 100644 --- a/crates/pdf-font/src/fallback.rs +++ b/crates/pdf-font/src/fallback.rs @@ -2,41 +2,12 @@ use pdf_cmap::ToUnicodeCMap; use pdf_object::{dictionary::Dictionary, object_resolver::ObjectResolver}; use crate::{ - cid_system_info::CidOrdering, encoding::Encoding, error::FontError, flags::FontFlags, + cid_system_info::CidOrdering, encoding::Encoding, flags::FontFlags, simple_font_glyph_map::SimpleFontGlyphWidthsMap, standard14::Standard14Font, true_type_font::TrueTypeFont, }; -pub(crate) struct FallbackFontProgram { - pub(crate) font_file: &'static [u8], - pub(crate) standard14: Standard14Font, - pub(crate) flags: FontFlags, -} - -impl FallbackFontProgram { - const NOTO_SANS_CJK_JP_REGULAR: &[u8] = include_bytes!("../assets/NotoSansCJKjp-Regular.otf"); - - /// Select fallback font bytes and metadata for a font dictionary. - pub(crate) fn from_dictionary( - dictionary: &Dictionary, - objects: &dyn ObjectResolver, - ) -> Result { - let flags = FontFlags::from_dictionary(dictionary, objects)?; - let standard14 = Standard14Font::from_dictionary(dictionary, objects, flags); - let is_cjk = is_cjk_cid_font(dictionary, objects)?; - let font_file = if is_cjk { - Self::NOTO_SANS_CJK_JP_REGULAR - } else { - standard14.fallback_font_bytes() - }; - - Ok(Self { - font_file, - standard14, - flags, - }) - } -} +const NOTO_SANS_CJK_JP_REGULAR: &[u8] = include_bytes!("../assets/NotoSansCJKjp-Regular.otf"); /// Build a synthetic TrueType font from fallback font data. /// @@ -48,52 +19,35 @@ impl FallbackFontProgram { /// # Returns /// /// A [`TrueTypeFont`] backed by fallback font bytes, simple font widths, -/// optional encoding, optional ToUnicode data, and descriptor flags. +/// optional encoding, optional ToUnicode data, and descriptor flags. Each +/// metadata field is parsed independently and ignored when malformed. pub(crate) fn fallback_true_type_from_dictionary( dictionary: &Dictionary, objects: &dyn ObjectResolver, -) -> Result { - let fallback = FallbackFontProgram::from_dictionary(dictionary, objects)?; - let widths = SimpleFontGlyphWidthsMap::from_dictionary(dictionary, objects)?; - let encoding = Encoding::from_dictionary(dictionary, objects) - .ok() - .flatten(); - let to_unicode = ToUnicodeCMap::from_dictionary(dictionary, objects)?; - - Ok(TrueTypeFont { - font_file: fallback.font_file.into(), - widths, - encoding, - to_unicode, - standard14: Some(fallback.standard14), - flags: fallback.flags, - }) -} - -/// Build a synthetic TrueType font from fallback font data without failing on -/// malformed optional metadata. -/// -/// This is used by higher-level resource loading when a font resource is -/// otherwise unreadable and the parser should preserve best-effort rendering. -pub(crate) fn fallback_true_type_from_dictionary_best_effort( - dictionary: &Dictionary, - objects: &dyn ObjectResolver, ) -> TrueTypeFont { let flags = FontFlags::from_dictionary(dictionary, objects).unwrap_or_default(); - let is_cjk = is_cjk_cid_font(dictionary, objects).unwrap_or(false); let standard14 = Standard14Font::from_dictionary(dictionary, objects, flags); - let font_file = if is_cjk { - FallbackFontProgram::NOTO_SANS_CJK_JP_REGULAR + let font_file = if is_cjk_cid_font(dictionary, objects) { + NOTO_SANS_CJK_JP_REGULAR } else { standard14.fallback_font_bytes() }; - let fallback = FallbackFontProgram { - font_file, - standard14, - flags, - }; - let mut font = TrueTypeFont::from_bytes(fallback.font_file, Some(fallback.standard14)); - font.flags = fallback.flags; + let widths = SimpleFontGlyphWidthsMap::from_dictionary(dictionary, objects) + .ok() + .flatten(); + let encoding = Encoding::from_dictionary(dictionary, objects) + .ok() + .flatten(); + let to_unicode = ToUnicodeCMap::from_dictionary(dictionary, objects) + .ok() + .flatten(); + let mut font = TrueTypeFont::from_bytes(font_file, Some(standard14)); + font.widths = widths; + if encoding.is_some() { + font.encoding = encoding; + } + font.to_unicode = to_unicode; + font.flags = flags; font } @@ -109,9 +63,108 @@ pub(crate) fn fallback_true_type_from_dictionary_best_effort( /// /// `true` when the dictionary declares a supported CJK CID ordering; otherwise /// `false`. -fn is_cjk_cid_font( - dictionary: &Dictionary, - objects: &dyn ObjectResolver, -) -> Result { - Ok(CidOrdering::from_dictionary(dictionary, objects)?.is_some()) +fn is_cjk_cid_font(dictionary: &Dictionary, objects: &dyn ObjectResolver) -> bool { + CidOrdering::from_dictionary(dictionary, objects) + .ok() + .flatten() + .is_some() +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use std::collections::BTreeMap; + + use pdf_object::{ + object_resolver::PassthroughResolver, object_variant::ObjectVariant, stream::StreamObject, + }; + + use super::*; + + #[test] + fn fallback_salvages_valid_metadata_independently() { + let to_unicode = ObjectVariant::Stream(StreamObject::new( + 1, + 0, + Box::new(Dictionary::new(BTreeMap::new())), + b"beginbfchar\n<41> <0042>\nendbfchar\n".to_vec(), + )); + let dictionary = Dictionary::new(BTreeMap::from([ + ( + "BaseFont".to_string(), + ObjectVariant::Name(b"Helvetica-Bold".to_vec()), + ), + ("FontDescriptor".to_string(), ObjectVariant::Integer(1)), + ("FirstChar".to_string(), ObjectVariant::Integer(65)), + ("LastChar".to_string(), ObjectVariant::Integer(65)), + ( + "Widths".to_string(), + ObjectVariant::Array(vec![ObjectVariant::Integer(625)]), + ), + ("Encoding".to_string(), ObjectVariant::Integer(1)), + ("ToUnicode".to_string(), to_unicode), + ])); + + let font = fallback_true_type_from_dictionary(&dictionary, &PassthroughResolver); + + assert_eq!(font.standard14, Some(Standard14Font::HelveticaBold)); + assert!(font.flags.is_empty()); + assert_eq!( + font.widths.as_ref().and_then(|widths| widths.get(&65)), + Some(&625.0) + ); + assert_eq!( + font.encoding + .as_ref() + .and_then(|encoding| encoding.names.get(65)) + .map(std::borrow::Cow::as_ref), + Some("A") + ); + assert_eq!( + font.to_unicode + .as_ref() + .and_then(|cmap| cmap.map_char_code(0x41)), + Some(['B'].as_slice()) + ); + } + + #[test] + fn fallback_ignores_malformed_widths_to_unicode_and_cid_info() { + let malformed_to_unicode = ObjectVariant::Stream(StreamObject::new( + 1, + 0, + Box::new(Dictionary::new(BTreeMap::new())), + b">".to_vec(), + )); + let dictionary = Dictionary::new(BTreeMap::from([ + ("Widths".to_string(), ObjectVariant::Integer(1)), + ("ToUnicode".to_string(), malformed_to_unicode), + ("CIDSystemInfo".to_string(), ObjectVariant::Integer(1)), + ])); + + let font = fallback_true_type_from_dictionary(&dictionary, &PassthroughResolver); + + assert!(font.widths.is_none()); + assert!(font.to_unicode.is_none()); + assert_eq!(font.standard14, Some(Standard14Font::Helvetica)); + assert_eq!( + font.font_file.as_ref(), + Standard14Font::Helvetica.fallback_font_bytes() + ); + } + + #[test] + fn fallback_uses_cjk_program_for_known_cid_ordering() { + let dictionary = Dictionary::new(BTreeMap::from([( + "CIDSystemInfo".to_string(), + ObjectVariant::Dictionary(Box::new(Dictionary::new(BTreeMap::from([( + "Ordering".to_string(), + ObjectVariant::LiteralString(b"Japan1".to_vec()), + )])))), + )])); + + let fallback = fallback_true_type_from_dictionary(&dictionary, &PassthroughResolver); + + assert_eq!(fallback.font_file.as_ref(), NOTO_SANS_CJK_JP_REGULAR); + } } diff --git a/crates/pdf-font/src/font.rs b/crates/pdf-font/src/font.rs index 3d42330d..4645a8d1 100644 --- a/crates/pdf-font/src/font.rs +++ b/crates/pdf-font/src/font.rs @@ -9,16 +9,9 @@ use read_fonts::TableProvider; use skrifa::{FontRef, MetadataProvider}; use crate::{ - char_vec::CharVec, - error::FontError, - fallback::{ - fallback_true_type_from_dictionary, fallback_true_type_from_dictionary_best_effort, - }, - glyph_name_to_unicode::glyph_name_to_unicode, - standard14::Standard14Font, - true_type_font::TrueTypeFont, - type0_font::Type0Font, - type1_font::Type1Font, + char_vec::CharVec, error::FontError, fallback::fallback_true_type_from_dictionary, + glyph_name_to_unicode::glyph_name_to_unicode, standard14::Standard14Font, + true_type_font::TrueTypeFont, type0_font::Type0Font, type1_font::Type1Font, type3_font::Type3Font, }; @@ -51,7 +44,7 @@ impl Font { } "Type1" => match Type1Font::from_dictionary(dictionary, objects) { Err(FontError::MissingFontFile) => Ok(Font::TrueType( - fallback_true_type_from_dictionary(dictionary, objects)?, + fallback_true_type_from_dictionary(dictionary, objects), )), Ok(type1_font) => Ok(Font::Type1(type1_font)), Err(e) => Err(e), @@ -70,14 +63,12 @@ impl Font { } } - /// Build a minimal Standard 14-backed fallback font for best-effort - /// resource recovery. + /// Build a Standard 14-backed fallback font for best-effort resource + /// recovery. /// - /// This is intentionally narrower than the normal font parsing path: - /// once the original font dictionary has already failed to parse, this - /// fallback does not attempt to preserve `/Widths`, `/Encoding`, or - /// `/ToUnicode` data from that failed font. The synthetic font keeps only - /// the bundled fallback program and the selected Standard 14 identity. + /// Valid `/Widths`, `/Encoding`, and `/ToUnicode` entries are retained + /// independently. Malformed metadata is treated as absent so an unreadable + /// font cannot prevent the rest of the resource dictionary from loading. /// /// Callers should use this only at higher-level recovery boundaries, such /// as page resource loading, where replacing an unreadable font is better @@ -86,9 +77,7 @@ impl Font { dictionary: &Dictionary, objects: &dyn ObjectResolver, ) -> Self { - Self::TrueType(fallback_true_type_from_dictionary_best_effort( - dictionary, objects, - )) + Self::TrueType(fallback_true_type_from_dictionary(dictionary, objects)) } } diff --git a/crates/pdf-font/src/true_type_font.rs b/crates/pdf-font/src/true_type_font.rs index 55c820b1..2ac5057b 100644 --- a/crates/pdf-font/src/true_type_font.rs +++ b/crates/pdf-font/src/true_type_font.rs @@ -6,8 +6,8 @@ use pdf_object::{ }; use crate::{ - encoding::Encoding, error::FontError, fallback::FallbackFontProgram, flags::FontFlags, - font_data::FontData, simple_font_glyph_map::SimpleFontGlyphWidthsMap, + encoding::Encoding, error::FontError, fallback::fallback_true_type_from_dictionary, + flags::FontFlags, font_data::FontData, simple_font_glyph_map::SimpleFontGlyphWidthsMap, standard14::Standard14Font, }; @@ -155,10 +155,10 @@ impl TrueTypeFont { } } - let fallback = FallbackFontProgram::from_dictionary(dictionary, objects)?; + let fallback = fallback_true_type_from_dictionary(dictionary, objects); Ok(TrueTypeFontProgram { - font_file: fallback.font_file.into(), - standard14: Some(fallback.standard14), + font_file: fallback.font_file, + standard14: fallback.standard14, flags: fallback.flags, }) } diff --git a/crates/pdf-font/src/type0_font.rs b/crates/pdf-font/src/type0_font.rs index 2408850a..2bbf8caa 100644 --- a/crates/pdf-font/src/type0_font.rs +++ b/crates/pdf-font/src/type0_font.rs @@ -11,7 +11,7 @@ pub use crate::cid_font_subtype::CidFontSubType; use crate::{ cid_system_info::CidOrdering, error::FontError, - fallback::FallbackFontProgram, + fallback::fallback_true_type_from_dictionary, font_data::FontData, glyph_widths_map::GlyphWidthsMap, true_type_font::TrueTypeFont, @@ -263,7 +263,7 @@ fn read_cid_font_type0_program( Ok((_, Type1FontProgramFormat::ClassicType1)) => Err(FontError::UnsupportedFontSubtype { subtype: "FontFile".to_string(), }), - Err(FontError::MissingFontFile) => fallback_type0_program(dictionary, objects), + Err(FontError::MissingFontFile) => Ok(fallback_type0_program(dictionary, objects)), Err(err) => Err(err), } } @@ -282,17 +282,17 @@ fn read_cid_font_type0_program( fn fallback_type0_program( dictionary: &Dictionary, objects: &dyn ObjectResolver, -) -> Result { - let fallback = FallbackFontProgram::from_dictionary(dictionary, objects)?; - let fallback_cid_to_unicode = cid_to_unicode_map(dictionary, objects)?; +) -> Type0FontProgram { + let fallback = fallback_true_type_from_dictionary(dictionary, objects); + let fallback_cid_to_unicode = cid_to_unicode_map(dictionary, objects).ok().flatten(); - Ok(Type0FontProgram { - font_file: fallback.font_file.into(), + Type0FontProgram { + font_file: fallback.font_file, program_format: Type0FontProgramFormat::TrueType { cid_to_unicode: fallback_cid_to_unicode.is_some(), }, fallback_cid_to_unicode, - }) + } } /// Select the Unicode fallback map for decoded Type0 CIDs. @@ -623,6 +623,42 @@ mod tests { ); } + #[test] + fn cid_font_type0_fallback_ignores_malformed_cid_system_info() { + let descendant = Dictionary::new(BTreeMap::from([ + ( + "Subtype".to_string(), + ObjectVariant::Name(b"CIDFontType0".to_vec()), + ), + ( + "FontDescriptor".to_string(), + ObjectVariant::Dictionary(Box::new(Dictionary::new(BTreeMap::new()))), + ), + ("CIDSystemInfo".to_string(), ObjectVariant::Integer(1)), + ])); + let dictionary = Dictionary::new(BTreeMap::from([ + ( + "Subtype".to_string(), + ObjectVariant::Name(b"Type0".to_vec()), + ), + ( + "DescendantFonts".to_string(), + ObjectVariant::Array(vec![ObjectVariant::Dictionary(Box::new(descendant))]), + ), + ])); + + let font = Type0Font::from_dictionary(&dictionary, &PassthroughResolver).unwrap(); + + assert_eq!( + font.program_format, + Type0FontProgramFormat::TrueType { + cid_to_unicode: false + } + ); + assert!(font.glyph_to_unicode.is_none()); + assert!(!font.font_file.is_empty()); + } + #[test] fn cid_font_type0_cjk_fallback_decodes_issue_13343_text() { let font = issue_13343_font();