From 2f496f6695ec576b8f76480bfc6f0362e52abc45 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 12 Jul 2026 13:20:59 +0300 Subject: [PATCH] pdf-font: add WinAnsi encode/decode helpers Add WinAnsi text encoding helpers for simple fonts and report unsupported encodings and undefined bytes with explicit errors. Co-authored-by: Codex --- crates/pdf-font/src/encoding.rs | 169 ++++++++++++++++++- crates/pdf-font/src/error.rs | 7 + crates/pdf-font/src/simple_font_glyph_map.rs | 4 +- 3 files changed, 176 insertions(+), 4 deletions(-) diff --git a/crates/pdf-font/src/encoding.rs b/crates/pdf-font/src/encoding.rs index 5a07199d..3ceb0be1 100644 --- a/crates/pdf-font/src/encoding.rs +++ b/crates/pdf-font/src/encoding.rs @@ -1,4 +1,4 @@ -use std::borrow::Cow; +use std::{borrow::Cow, convert::TryFrom}; use pdf_object::{object_resolver::ObjectResolver, object_variant::ObjectVariant}; @@ -33,6 +33,40 @@ impl From<&str> for FontEncoding { } } +impl FontEncoding { + /// Encodes UTF-8 text using the font encoding. + pub fn encode(&self, text: &str) -> Result, FontError> { + text.chars() + .map(|character| self.encode_char(character)) + .collect() + } + + /// Encodes a single Unicode scalar value using the font encoding. + pub fn encode_char(&self, character: char) -> Result { + match self { + Self::WinAnsi => encode_win_ansi_char(character), + _ => Err(FontError::UnsupportedTextEncoding(self.clone())), + } + } + + /// Decodes bytes using the font encoding. + pub fn decode(&self, bytes: &[u8]) -> Result { + bytes + .iter() + .copied() + .map(|byte| self.decode_byte(byte)) + .collect() + } + + /// Decodes a single encoded byte using the font encoding. + pub fn decode_byte(&self, byte: u8) -> Result { + match self { + Self::WinAnsi => decode_win_ansi_byte(byte), + _ => Err(FontError::UnsupportedTextEncoding(self.clone())), + } + } +} + #[derive(Debug)] pub struct Encoding { pub names: Vec>, @@ -110,6 +144,83 @@ fn names_from_base(base: &[&'static str]) -> Vec> { base.iter().map(|&nm| Cow::Borrowed(nm)).collect() } +fn encode_win_ansi_char(character: char) -> Result { + let code = u32::from(character); + if code <= 0x7f || (0xa0..=0xff).contains(&code) { + return u8::try_from(code) + .map_err(|_| FontError::UnsupportedWinAnsiCharacter { character }); + } + + let byte = match character { + '\u{20ac}' => 0x80, + '\u{201a}' => 0x82, + '\u{0192}' => 0x83, + '\u{201e}' => 0x84, + '\u{2026}' => 0x85, + '\u{2020}' => 0x86, + '\u{2021}' => 0x87, + '\u{02c6}' => 0x88, + '\u{2030}' => 0x89, + '\u{0160}' => 0x8a, + '\u{2039}' => 0x8b, + '\u{0152}' => 0x8c, + '\u{017d}' => 0x8e, + '\u{2018}' => 0x91, + '\u{2019}' => 0x92, + '\u{201c}' => 0x93, + '\u{201d}' => 0x94, + '\u{2022}' => 0x95, + '\u{2013}' => 0x96, + '\u{2014}' => 0x97, + '\u{02dc}' => 0x98, + '\u{2122}' => 0x99, + '\u{0161}' => 0x9a, + '\u{203a}' => 0x9b, + '\u{0153}' => 0x9c, + '\u{017e}' => 0x9e, + '\u{0178}' => 0x9f, + _ => return Err(FontError::UnsupportedWinAnsiCharacter { character }), + }; + Ok(byte) +} + +fn decode_win_ansi_byte(byte: u8) -> Result { + let character = 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}', + 0x81 | 0x8d | 0x8f | 0x90 | 0x9d => { + return Err(FontError::InvalidWinAnsiByte { byte }); + } + _ => char::from_u32(u32::from(byte)).ok_or(FontError::InvalidWinAnsiByte { byte })?, + }; + Ok(character) +} + const STANDARD_NAMES: [&str; 256] = [ ".notdef", ".notdef", @@ -1145,3 +1256,59 @@ const MAC_EXPERT_NAMES: [&str; 256] = [ ".notdef", ".notdef", ]; + +#[cfg(test)] +mod tests { + use super::{FontEncoding, FontError}; + + #[test] + fn win_ansi_round_trips_supported_text() { + let text = "Euro \u{20ac}, caf\u{e9}, \u{201c}quoted\u{201d}"; + let encoded = FontEncoding::WinAnsi.encode(text).expect("encode"); + assert_eq!( + FontEncoding::WinAnsi.decode(&encoded).expect("decode"), + text + ); + } + + #[test] + fn win_ansi_encodes_special_characters() { + assert_eq!(FontEncoding::WinAnsi.encode_char('\u{20ac}'), Ok(0x80)); + assert_eq!(FontEncoding::WinAnsi.encode_char('\u{201c}'), Ok(0x93)); + assert_eq!(FontEncoding::WinAnsi.encode_char('\u{201d}'), Ok(0x94)); + } + + #[test] + fn win_ansi_rejects_unrepresentable_characters() { + assert_eq!( + FontEncoding::WinAnsi.encode_char('\u{1f642}'), + Err(FontError::UnsupportedWinAnsiCharacter { + character: '\u{1f642}' + }) + ); + } + + #[test] + fn win_ansi_rejects_undefined_bytes() { + assert_eq!( + FontEncoding::WinAnsi.decode_byte(0x81), + Err(FontError::InvalidWinAnsiByte { byte: 0x81 }) + ); + assert_eq!( + FontEncoding::WinAnsi.decode_byte(0x9d), + Err(FontError::InvalidWinAnsiByte { byte: 0x9d }) + ); + } + + #[test] + fn non_win_ansi_variants_are_rejected() { + assert_eq!( + FontEncoding::Standard.encode("test"), + Err(FontError::UnsupportedTextEncoding(FontEncoding::Standard)) + ); + assert_eq!( + FontEncoding::None.decode(b"test"), + Err(FontError::UnsupportedTextEncoding(FontEncoding::None)) + ); + } +} diff --git a/crates/pdf-font/src/error.rs b/crates/pdf-font/src/error.rs index 99a0f757..04845e9d 100644 --- a/crates/pdf-font/src/error.rs +++ b/crates/pdf-font/src/error.rs @@ -3,6 +3,7 @@ use pdf_content_stream_operators::error::PdfOperatorError; use pdf_object::error::ObjectError; use thiserror::Error; +use crate::encoding::FontEncoding; use crate::glyph_widths_map::GlyphWidthsMapError; /// Defines errors that can occur while reading a font object. @@ -26,6 +27,12 @@ pub enum FontError { InvalidDescendantFonts(&'static str), #[error("Unsupported /BaseEncoding value '{0}'")] UnsupportedBaseEncoding(String), + #[error("unsupported text encoding {0:?}")] + UnsupportedTextEncoding(FontEncoding), + #[error("character {character:?} is not representable in WinAnsi")] + UnsupportedWinAnsiCharacter { character: char }, + #[error("byte 0x{byte:02X} is undefined in WinAnsi")] + InvalidWinAnsiByte { byte: u8 }, #[error("{0}")] CMapError(#[from] CMapError), } diff --git a/crates/pdf-font/src/simple_font_glyph_map.rs b/crates/pdf-font/src/simple_font_glyph_map.rs index 2f8d74a0..c0d27ec3 100644 --- a/crates/pdf-font/src/simple_font_glyph_map.rs +++ b/crates/pdf-font/src/simple_font_glyph_map.rs @@ -24,7 +24,7 @@ impl SimpleFontGlyphWidthsMap { // Check /Widths first — Standard 14 fonts may lack both /Widths and // /FirstChar, so we must not error on a missing /FirstChar when there // are no widths to parse. - let Some(widths_obj) = dictionary.get(Self::KEY) else { + let Some(arr) = dictionary.optional_array(Self::KEY, objects)? else { return Ok(None); }; @@ -38,8 +38,6 @@ impl SimpleFontGlyphWidthsMap { return Ok(None); } - let arr = widths_obj.try_array(objects)?; - // Per spec the array should have (LastChar - FirstChar + 1) entries. // Be lenient: use the minimum of the actual length and the expected // count so we never read past the array or map codes beyond LastChar.