From 5a708388843f8ba813c7e7a8532f8e589c2eeb19 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 00:42:57 -0400 Subject: [PATCH] M7: PDF font tables move to the fonts crate with Unicode decoding/encoding (ToUnicode, WinAnsi/MacRoman/Standard, Differences glyph names, subset glyph checks) Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + crates/fonts/Cargo.toml | 1 + crates/fonts/src/encodings.rs | 268 ++++++++++++++++ crates/fonts/src/lib.rs | 2 + crates/fonts/src/pdf.rs | 573 ++++++++++++++++++++++++++++++++++ crates/redact/src/interp.rs | 2 +- crates/redact/src/lib.rs | 1 - 7 files changed, 846 insertions(+), 2 deletions(-) create mode 100644 crates/fonts/src/encodings.rs create mode 100644 crates/fonts/src/pdf.rs diff --git a/Cargo.lock b/Cargo.lock index 52f41aff..96efd4b0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3487,6 +3487,7 @@ dependencies = [ name = "printcraft-fonts" version = "0.1.0" dependencies = [ + "printcraft-cos", "skrifa 0.44.0", ] diff --git a/crates/fonts/Cargo.toml b/crates/fonts/Cargo.toml index c182a22a..8f3970d9 100644 --- a/crates/fonts/Cargo.toml +++ b/crates/fonts/Cargo.toml @@ -9,5 +9,6 @@ license.workspace = true workspace = true [dependencies] +printcraft-cos = { workspace = true } # Glyph outlines of the bundled script font (typed signatures); already in the tree via egui. skrifa = "0.44" diff --git a/crates/fonts/src/encodings.rs b/crates/fonts/src/encodings.rs new file mode 100644 index 00000000..733421b8 --- /dev/null +++ b/crates/fonts/src/encodings.rs @@ -0,0 +1,268 @@ +//! Single-byte encodings (ISO 32000-2 Annex D) as code → Unicode tables (0 = undefined). + +pub(crate) static WIN_ANSI: [u32; 256] = [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, + 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, + 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, + 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 0, 8364, 0, 8218, 402, 8222, 8230, 8224, 8225, 710, 8240, 352, 8249, 338, + 0, 381, 0, 0, 8216, 8217, 8220, 8221, 8226, 8211, 8212, 732, 8482, 353, 8250, 339, 0, 382, 376, 160, 161, 162, 163, 164, 165, 166, 167, 168, 169, + 170, 171, 172, 173, 174, 175, 176, 177, 178, 179, 180, 181, 182, 183, 184, 185, 186, 187, 188, 189, 190, 191, 192, 193, 194, 195, 196, 197, 198, + 199, 200, 201, 202, 203, 204, 205, 206, 207, 208, 209, 210, 211, 212, 213, 214, 215, 216, 217, 218, 219, 220, 221, 222, 223, 224, 225, 226, 227, + 228, 229, 230, 231, 232, 233, 234, 235, 236, 237, 238, 239, 240, 241, 242, 243, 244, 245, 246, 247, 248, 249, 250, 251, 252, 253, 254, 255, +]; +pub(crate) static MAC_ROMAN: [u32; 256] = [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, + 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, + 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, + 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 0, 196, 197, 199, 201, 209, 214, 220, 225, 224, 226, 228, 227, 229, 231, + 233, 232, 234, 235, 237, 236, 238, 239, 241, 243, 242, 244, 246, 245, 250, 249, 251, 252, 8224, 176, 162, 163, 167, 8226, 182, 223, 174, 169, + 8482, 180, 168, 8800, 198, 216, 8734, 177, 8804, 8805, 165, 181, 8706, 8721, 8719, 960, 8747, 170, 186, 937, 230, 248, 191, 161, 172, 8730, 402, + 8776, 8710, 171, 187, 8230, 160, 192, 195, 213, 338, 339, 8211, 8212, 8220, 8221, 8216, 8217, 247, 9674, 255, 376, 8260, 8364, 8249, 8250, 64257, + 64258, 8225, 183, 8218, 8222, 8240, 194, 202, 193, 203, 200, 205, 206, 207, 204, 211, 212, 63743, 210, 218, 219, 217, 305, 710, 732, 175, 728, + 729, 730, 184, 733, 731, 711, +]; +pub(crate) static STANDARD: [u32; 256] = [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 32, 33, 34, 35, 36, 37, 38, 8217, 40, 41, 42, 43, + 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, + 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 8216, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, + 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 161, 162, 163, 8260, 165, 402, 167, 164, 39, 8220, 171, 8249, 8250, 64257, 64258, 0, 8211, 8224, 8225, 183, 0, + 182, 8226, 8218, 8222, 8221, 187, 8230, 8240, 0, 191, 0, 96, 180, 710, 732, 175, 728, 729, 168, 0, 730, 184, 0, 733, 731, 711, 8212, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 198, 0, 170, 0, 0, 0, 0, 321, 216, 338, 186, 0, 0, 0, 0, 0, 230, 0, 0, 0, 305, 0, 0, 322, 248, 339, 223, 0, + 0, 0, 0, +]; + +/// Glyph names of the Latin character set (Annex D) → Unicode. +pub(crate) static NAMES: &[(&str, u32)] = &[ + ("A", 0x41), + ("AE", 0xc6), + ("Aacute", 0xc1), + ("Acircumflex", 0xc2), + ("Adieresis", 0xc4), + ("Agrave", 0xc0), + ("Aring", 0xc5), + ("Atilde", 0xc3), + ("B", 0x42), + ("C", 0x43), + ("Ccedilla", 0xc7), + ("D", 0x44), + ("E", 0x45), + ("Eacute", 0xc9), + ("Ecircumflex", 0xca), + ("Edieresis", 0xcb), + ("Egrave", 0xc8), + ("Eth", 0xd0), + ("Euro", 0x20ac), + ("F", 0x46), + ("G", 0x47), + ("H", 0x48), + ("I", 0x49), + ("Iacute", 0xcd), + ("Icircumflex", 0xce), + ("Idieresis", 0xcf), + ("Igrave", 0xcc), + ("J", 0x4a), + ("K", 0x4b), + ("L", 0x4c), + ("Lslash", 0x141), + ("M", 0x4d), + ("N", 0x4e), + ("Ntilde", 0xd1), + ("O", 0x4f), + ("OE", 0x152), + ("Oacute", 0xd3), + ("Ocircumflex", 0xd4), + ("Odieresis", 0xd6), + ("Ograve", 0xd2), + ("Oslash", 0xd8), + ("Otilde", 0xd5), + ("P", 0x50), + ("Q", 0x51), + ("R", 0x52), + ("S", 0x53), + ("Scaron", 0x160), + ("T", 0x54), + ("Thorn", 0xde), + ("U", 0x55), + ("Uacute", 0xda), + ("Ucircumflex", 0xdb), + ("Udieresis", 0xdc), + ("Ugrave", 0xd9), + ("V", 0x56), + ("W", 0x57), + ("X", 0x58), + ("Y", 0x59), + ("Yacute", 0xdd), + ("Ydieresis", 0x178), + ("Z", 0x5a), + ("Zcaron", 0x17d), + ("a", 0x61), + ("aacute", 0xe1), + ("acircumflex", 0xe2), + ("acute", 0xb4), + ("adieresis", 0xe4), + ("ae", 0xe6), + ("agrave", 0xe0), + ("ampersand", 0x26), + ("aring", 0xe5), + ("asciicircum", 0x5e), + ("asciitilde", 0x7e), + ("asterisk", 0x2a), + ("at", 0x40), + ("atilde", 0xe3), + ("b", 0x62), + ("backslash", 0x5c), + ("bar", 0x7c), + ("braceleft", 0x7b), + ("braceright", 0x7d), + ("bracketleft", 0x5b), + ("bracketright", 0x5d), + ("breve", 0x2d8), + ("brokenbar", 0xa6), + ("bullet", 0x2022), + ("c", 0x63), + ("caron", 0x2c7), + ("ccedilla", 0xe7), + ("cedilla", 0xb8), + ("cent", 0xa2), + ("circumflex", 0x2c6), + ("colon", 0x3a), + ("comma", 0x2c), + ("copyright", 0xa9), + ("currency", 0xa4), + ("d", 0x64), + ("dagger", 0x2020), + ("daggerdbl", 0x2021), + ("degree", 0xb0), + ("dieresis", 0xa8), + ("divide", 0xf7), + ("dollar", 0x24), + ("dotaccent", 0x2d9), + ("dotlessi", 0x131), + ("e", 0x65), + ("eacute", 0xe9), + ("ecircumflex", 0xea), + ("edieresis", 0xeb), + ("egrave", 0xe8), + ("eight", 0x38), + ("ellipsis", 0x2026), + ("emdash", 0x2014), + ("endash", 0x2013), + ("equal", 0x3d), + ("eth", 0xf0), + ("exclam", 0x21), + ("exclamdown", 0xa1), + ("f", 0x66), + ("fi", 0xfb01), + ("five", 0x35), + ("fl", 0xfb02), + ("florin", 0x192), + ("four", 0x34), + ("fraction", 0x2044), + ("g", 0x67), + ("germandbls", 0xdf), + ("grave", 0x60), + ("greater", 0x3e), + ("guillemotleft", 0xab), + ("guillemotright", 0xbb), + ("guilsinglleft", 0x2039), + ("guilsinglright", 0x203a), + ("h", 0x68), + ("hungarumlaut", 0x2dd), + ("hyphen", 0x2d), + ("i", 0x69), + ("iacute", 0xed), + ("icircumflex", 0xee), + ("idieresis", 0xef), + ("igrave", 0xec), + ("j", 0x6a), + ("k", 0x6b), + ("l", 0x6c), + ("less", 0x3c), + ("logicalnot", 0xac), + ("lslash", 0x142), + ("m", 0x6d), + ("macron", 0xaf), + ("minus", 0x2212), + ("mu", 0xb5), + ("multiply", 0xd7), + ("n", 0x6e), + ("nbspace", 0xa0), + ("nine", 0x39), + ("ntilde", 0xf1), + ("numbersign", 0x23), + ("o", 0x6f), + ("oacute", 0xf3), + ("ocircumflex", 0xf4), + ("odieresis", 0xf6), + ("oe", 0x153), + ("ogonek", 0x2db), + ("ograve", 0xf2), + ("one", 0x31), + ("onehalf", 0xbd), + ("onequarter", 0xbc), + ("onesuperior", 0xb9), + ("ordfeminine", 0xaa), + ("ordmasculine", 0xba), + ("oslash", 0xf8), + ("otilde", 0xf5), + ("p", 0x70), + ("paragraph", 0xb6), + ("parenleft", 0x28), + ("parenright", 0x29), + ("percent", 0x25), + ("period", 0x2e), + ("periodcentered", 0xb7), + ("perthousand", 0x2030), + ("plus", 0x2b), + ("plusminus", 0xb1), + ("q", 0x71), + ("question", 0x3f), + ("questiondown", 0xbf), + ("quotedbl", 0x22), + ("quotedblbase", 0x201e), + ("quotedblleft", 0x201c), + ("quotedblright", 0x201d), + ("quoteleft", 0x2018), + ("quoteright", 0x2019), + ("quotesinglbase", 0x201a), + ("quotesingle", 0x27), + ("r", 0x72), + ("registered", 0xae), + ("ring", 0x2da), + ("s", 0x73), + ("scaron", 0x161), + ("section", 0xa7), + ("semicolon", 0x3b), + ("seven", 0x37), + ("sfthyphen", 0xad), + ("six", 0x36), + ("slash", 0x2f), + ("space", 0x20), + ("sterling", 0xa3), + ("t", 0x74), + ("thorn", 0xfe), + ("three", 0x33), + ("threequarters", 0xbe), + ("threesuperior", 0xb3), + ("tilde", 0x2dc), + ("trademark", 0x2122), + ("two", 0x32), + ("twosuperior", 0xb2), + ("u", 0x75), + ("uacute", 0xfa), + ("ucircumflex", 0xfb), + ("udieresis", 0xfc), + ("ugrave", 0xf9), + ("underscore", 0x5f), + ("v", 0x76), + ("w", 0x77), + ("x", 0x78), + ("y", 0x79), + ("yacute", 0xfd), + ("ydieresis", 0xff), + ("yen", 0xa5), + ("z", 0x7a), + ("zcaron", 0x17e), + ("zero", 0x30), +]; diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 4048e721..e9f07157 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -3,6 +3,8 @@ //! See the README: the metrics are approximations by character class (no vendor metrics files //! are bundled). The full font subsystem lands in M2.2/M7. +mod encodings; +pub mod pdf; mod script; pub use script::{ScriptOutline, script_outline}; diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs new file mode 100644 index 00000000..6c67b35d --- /dev/null +++ b/crates/fonts/src/pdf.rs @@ -0,0 +1,573 @@ +//! What content editing and redaction need from a PDF font: how to split a string into character +//! codes, each code's advance width, the glyph height (ascent, descent), and what each code means +//! (Unicode) — and back, which text the font can show. Widths come from the font dictionary +//! (`/Widths`, `/W`, `/DW`, `/MissingWidth`); the standard 14 fonts without widths use +//! approximations. Meanings come from `/ToUnicode`, else the encoding (`/Encoding` base and +//! `/Differences` glyph names, ISO 32000-2 Annex D). + +use std::collections::HashMap; + +use printcraft_cos::{Dict, Document, Object}; + +/// How the bytes of a string map to codes. +#[derive(Clone, Debug, PartialEq)] +enum Codes { + One, + Two, + /// Codespace ranges `(length, low, high)` from an embedded CMap. + Ranges(Vec<(usize, Vec, Vec)>), +} + +#[derive(Clone, Debug, PartialEq)] +enum Std14 { + Helvetica, + Times, + Courier, + Symbolic, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Metrics { + codes: Codes, + /// Simple fonts: `/FirstChar` and `/Widths`. + first: u32, + widths: Vec, + /// Composite fonts: CID → width, and CID ranges with one width. + cid_widths: HashMap, + cid_ranges: Vec<(u32, u32, f64)>, + /// Code → CID for embedded non-identity CMaps (`cidrange` / `cidchar`). + cid_map: Vec<(u32, u32, u32)>, + default: f64, + std14: Option, + /// Glyph units → text space (0.001, or `/FontMatrix[0]` for Type 3). + pub scale: f64, + /// Glyph box in text space per unit of font size. + pub ascent: f64, + pub descent: f64, + pub composite: bool, + /// Code → Unicode text. + unicode: HashMap, + /// `/BaseFont`, and whether it is a subset (`ABCDEF+Name`: other glyphs are missing). + pub base_font: String, + pub subset: bool, + /// Bytes per code for writing new text (1 for simple fonts, else the codespace length). + code_len: usize, +} + +fn nums(doc: &Document, o: Option<&Object>) -> Vec { + o.map(|o| doc.resolve(o)) + .and_then(|a| a.as_array().map(|a| a.iter().map(|x| doc.resolve(x).as_f64().unwrap_or(0.0)).collect())) + .unwrap_or_default() +} + +fn dict(doc: &Document, o: Option<&Object>) -> Option { + o.and_then(|o| doc.resolve(o).as_dict().cloned()) +} + +fn hex_bytes(tok: &str) -> Option> { + let t = tok.trim_start_matches('<').trim_end_matches('>'); + if !t.len().is_multiple_of(2) || t.is_empty() { + return None; + } + (0..t.len()).step_by(2).map(|i| u8::from_str_radix(&t[i..i + 2], 16).ok()).collect() +} + +fn be(b: &[u8]) -> u32 { + b.iter().fold(0u32, |a, x| (a << 8) | u32::from(*x)) +} + +/// Codespace ranges and CID mappings from an embedded CMap stream. +#[allow(clippy::type_complexity)] +fn parse_cmap(data: &[u8]) -> (Vec<(usize, Vec, Vec)>, Vec<(u32, u32, u32)>) { + let text = String::from_utf8_lossy(data); + let toks: Vec<&str> = text.split_whitespace().collect(); + let (mut spaces, mut cids) = (Vec::new(), Vec::new()); + let mut i = 0; + while i < toks.len() { + match toks[i] { + "begincodespacerange" => { + i += 1; + while i + 1 < toks.len() && toks[i] != "endcodespacerange" { + if let (Some(lo), Some(hi)) = (hex_bytes(toks[i]), hex_bytes(toks[i + 1])) + && lo.len() == hi.len() + { + spaces.push((lo.len(), lo, hi)); + } + i += 2; + } + } + "begincidrange" => { + i += 1; + while i + 2 < toks.len() && toks[i] != "endcidrange" { + if let (Some(lo), Some(hi), Ok(c)) = (hex_bytes(toks[i]), hex_bytes(toks[i + 1]), toks[i + 2].parse::()) { + cids.push((be(&lo), be(&hi), c)); + } + i += 3; + } + } + "begincidchar" => { + i += 1; + while i + 1 < toks.len() && toks[i] != "endcidchar" { + if let (Some(code), Ok(c)) = (hex_bytes(toks[i]), toks[i + 1].parse::()) { + cids.push((be(&code), be(&code), c)); + } + i += 2; + } + } + _ => {} + } + i += 1; + } + spaces.sort_by_key(|s| s.0); + (spaces, cids) +} + +impl Metrics { + /// Metrics for text without a usable font (Helvetica-like). + pub fn fallback() -> Self { + Metrics { + codes: Codes::One, + first: 0, + widths: Vec::new(), + cid_widths: HashMap::new(), + cid_ranges: Vec::new(), + cid_map: Vec::new(), + default: 500.0, + std14: Some(Std14::Helvetica), + scale: 0.001, + ascent: 0.9, + descent: -0.25, + composite: false, + unicode: (32..127u32).map(|c| (c, char::from_u32(c).expect("ascii").to_string())).collect(), + base_font: "Helvetica".into(), + subset: false, + code_len: 1, + } + } + + pub fn from_dict(doc: &Document, font: &Dict) -> Self { + let mut m = Self::read_metrics(doc, font); + let base = String::from_utf8_lossy(font.name(b"BaseFont").unwrap_or(b"")).into_owned(); + m.subset = base.len() > 7 && base.as_bytes()[6] == b'+' && base[..6].bytes().all(|b| b.is_ascii_uppercase()); + m.base_font = base; + m.code_len = match &m.codes { + Codes::One => 1, + Codes::Two => 2, + Codes::Ranges(r) => r.last().map_or(2, |x| x.0), + }; + m.unicode = unicode_map(doc, font, m.composite); + m + } + + fn read_metrics(doc: &Document, font: &Dict) -> Self { + let mut m = Metrics::fallback(); + m.std14 = None; + let subtype = font.name(b"Subtype").unwrap_or(b"Type1").to_vec(); + let descriptor; + if subtype == b"Type0" { + m.composite = true; + m.default = 1000.0; + m.codes = Codes::Two; + if let Some(e) = font.get(b"Encoding").map(|e| doc.resolve(e)) + && let Object::Stream(s) = &*e + && let Ok(data) = s.decoded() + { + let (spaces, cids) = parse_cmap(&data); + if !spaces.is_empty() { + m.codes = Codes::Ranges(spaces); + } + m.cid_map = cids; + } + let desc = font + .get(b"DescendantFonts") + .map(|d| doc.resolve(d)) + .and_then(|a| a.as_array().and_then(|a| a.first().cloned())) + .and_then(|d| doc.resolve(&d).as_dict().cloned()) + .unwrap_or_default(); + if let Some(dw) = desc.get(b"DW").and_then(|d| doc.resolve(d).as_f64()) { + m.default = dw; + } + if let Some(w) = desc.get(b"W").map(|w| doc.resolve(w)).and_then(|w| w.as_array().cloned()) { + let mut i = 0; + while i < w.len() { + let Some(c0) = doc.resolve(&w[i]).as_f64().map(|v| v as u32) else { break }; + match w.get(i + 1).map(|o| doc.resolve(o)) { + Some(a) if a.as_array().is_some() => { + for (k, x) in a.as_array().into_iter().flatten().enumerate() { + if let Some(v) = doc.resolve(x).as_f64() { + m.cid_widths.insert(c0 + k as u32, v); + } + } + i += 2; + } + Some(c1) => { + if let (Some(c1), Some(v)) = (c1.as_f64(), w.get(i + 2).and_then(|x| doc.resolve(x).as_f64())) { + m.cid_ranges.push((c0, c1 as u32, v)); + } + i += 3; + } + None => break, + } + } + } + descriptor = dict(doc, desc.get(b"FontDescriptor")); + } else { + m.first = font.get(b"FirstChar").and_then(|f| doc.resolve(f).as_f64()).unwrap_or(0.0).max(0.0) as u32; + m.widths = nums(doc, font.get(b"Widths")); + descriptor = dict(doc, font.get(b"FontDescriptor")); + m.default = descriptor.as_ref().and_then(|d| d.get(b"MissingWidth")).and_then(|w| doc.resolve(w).as_f64()).unwrap_or(0.0); + if subtype == b"Type3" { + let fm = nums(doc, font.get(b"FontMatrix")); + if fm.len() == 6 { + m.scale = fm[0]; + let bbox = nums(doc, font.get(b"FontBBox")); + if bbox.len() == 4 && fm[3] != 0.0 { + m.ascent = (bbox[3] * fm[3]).max(0.5); + m.descent = (bbox[1] * fm[3]).min(-0.1); + } + } + return m; + } + if m.widths.is_empty() { + let base = String::from_utf8_lossy(font.name(b"BaseFont").unwrap_or(b"")).to_ascii_lowercase(); + m.std14 = Some(if base.contains("courier") { + Std14::Courier + } else if base.contains("times") { + Std14::Times + } else if base.contains("symbol") || base.contains("dingbats") { + Std14::Symbolic + } else { + Std14::Helvetica + }); + } + } + if let Some(d) = descriptor { + let a = d.get(b"Ascent").and_then(|v| doc.resolve(v).as_f64()).unwrap_or(0.0) / 1000.0; + let de = d.get(b"Descent").and_then(|v| doc.resolve(v).as_f64()).unwrap_or(0.0) / 1000.0; + // Fonts often claim 0; never shrink the glyph box below a sensible minimum. + m.ascent = if a > 0.3 { a.min(1.5) } else { 0.9 }; + m.descent = if de < -0.05 { de.max(-0.6) } else { -0.25 }; + } + m + } + + /// Split a string into `(code, byte length)`. + pub fn codes(&self, s: &[u8]) -> Vec<(u32, usize)> { + let mut out = Vec::with_capacity(s.len()); + let mut i = 0; + while i < s.len() { + let len = match &self.codes { + Codes::One => 1, + Codes::Two => 2, + Codes::Ranges(r) => r + .iter() + .find(|(len, lo, hi)| s.get(i..i + len).is_some_and(|b| b.iter().zip(lo.iter().zip(hi)).all(|(x, (l, h))| x >= l && x <= h))) + .map_or(r.first().map_or(1, |x| x.0), |x| x.0), + }; + let len = len.min(s.len() - i).max(1); + out.push((be(&s[i..i + len]), len)); + i += len; + } + out + } + + fn cid(&self, code: u32) -> u32 { + if self.cid_map.is_empty() { + return code; + } + self.cid_map.iter().find(|(lo, hi, _)| code >= *lo && code <= *hi).map_or(0, |(lo, _, c)| c + (code - lo)) + } + + /// The advance of `code` in text space per unit of font size. + pub fn width(&self, code: u32) -> f64 { + if self.composite { + let cid = self.cid(code); + let w = self.cid_widths.get(&cid).copied().or_else(|| self.cid_ranges.iter().find(|(a, b, _)| cid >= *a && cid <= *b).map(|r| r.2)); + return w.unwrap_or(self.default) * self.scale; + } + if let Some(w) = code.checked_sub(self.first).and_then(|i| self.widths.get(i as usize)) { + return w * self.scale; + } + match &self.std14 { + Some(Std14::Courier) => 0.6, + Some(Std14::Symbolic) => 0.75, + Some(f) => { + let c = char::from_u32(code).filter(|c| !c.is_control()).unwrap_or('n'); + let w = crate::helvetica_width(&c.to_string(), 1.0); + if *f == Std14::Times { w * 0.92 } else { w } + } + None => self.default * self.scale, + } + } + + /// Word spacing applies to the single-byte code 32 (§9.3.3). + pub fn is_space(&self, code: u32, len: usize) -> bool { + code == 32 && len == 1 + } +} + +impl Metrics { + /// The text a string shows (codes without a known meaning are left out). + pub fn decode(&self, s: &[u8]) -> String { + self.codes(s).into_iter().filter_map(|(c, _)| self.unicode.get(&c).cloned()).collect() + } + + /// The Unicode text of one code, if known. + pub fn text_of(&self, code: u32) -> Option<&str> { + self.unicode.get(&code).map(String::as_str) + } + + /// Whether the font has a glyph for `code` (subset fonts lack the glyphs they don't use). + fn has_glyph(&self, code: u32) -> bool { + if self.composite { + let cid = self.cid(code); + return self.cid_widths.contains_key(&cid) || self.cid_ranges.iter().any(|(a, b, _)| cid >= *a && cid <= *b) || !self.subset; + } + match code.checked_sub(self.first).and_then(|i| self.widths.get(i as usize)) { + Some(w) => *w > 0.0 || code == 32, + None => self.widths.is_empty() && !self.subset, + } + } + + /// The bytes that show `text` in this font, or `None` if some character has no code or + /// no glyph in it (the caller then substitutes another font). + pub fn encode(&self, text: &str) -> Option> { + let mut reverse: HashMap<&str, u32> = HashMap::new(); + for (code, t) in &self.unicode { + if self.has_glyph(*code) { + reverse.entry(t.as_str()).and_modify(|c| *c = (*c).min(*code)).or_insert(*code); + } + } + let mut out = Vec::with_capacity(text.len() * self.code_len); + let mut buf = [0u8; 4]; + for ch in text.chars() { + let code = *reverse.get(ch.encode_utf8(&mut buf) as &str)?; + let bytes = code.to_be_bytes(); + out.extend_from_slice(&bytes[4 - self.code_len.clamp(1, 4)..]); + } + Some(out) + } +} + +/// UTF-16BE (with surrogates) from CMap hex bytes. +fn utf16(b: &[u8]) -> String { + let units: Vec = b.chunks(2).map(|c| u16::from_be_bytes([c[0], *c.get(1).unwrap_or(&0)])).collect(); + String::from_utf16_lossy(&units) +} + +/// `bfchar`/`bfrange` entries of a ToUnicode CMap. +fn parse_to_unicode(data: &[u8], out: &mut HashMap) { + let text = String::from_utf8_lossy(data); + // Tokenise, keeping arrays' brackets as tokens. + let spaced = text.replace('[', " [ ").replace(']', " ] "); + let toks: Vec<&str> = spaced.split_whitespace().collect(); + let mut i = 0; + while i < toks.len() { + match toks[i] { + "beginbfchar" => { + i += 1; + while i + 1 < toks.len() && toks[i] != "endbfchar" { + if let (Some(c), Some(u)) = (hex_bytes(toks[i]), hex_bytes(toks[i + 1])) { + out.insert(be(&c), utf16(&u)); + } + i += 2; + } + } + "beginbfrange" => { + i += 1; + while i + 2 < toks.len() && toks[i] != "endbfrange" { + let (Some(lo), Some(hi)) = (hex_bytes(toks[i]), hex_bytes(toks[i + 1])) else { break }; + let (lo, hi) = (be(&lo), be(&hi)); + if toks[i + 2] == "[" { + let mut j = i + 3; + let mut code = lo; + while j < toks.len() && toks[j] != "]" { + if let Some(u) = hex_bytes(toks[j]) { + out.insert(code, utf16(&u)); + } + code += 1; + j += 1; + } + i = j + 1; + } else { + if let Some(u) = hex_bytes(toks[i + 2]) + && hi >= lo + && hi - lo < 65536 + { + let mut units: Vec = u.chunks(2).map(|c| u16::from_be_bytes([c[0], *c.get(1).unwrap_or(&0)])).collect(); + for code in lo..=hi { + out.insert(code, String::from_utf16_lossy(&units)); + if let Some(last) = units.last_mut() { + *last = last.wrapping_add(1); + } + } + } + i += 3; + } + } + } + _ => {} + } + i += 1; + } +} + +/// A glyph name's Unicode (Annex D names, `uniXXXX`, `uXXXX[XX]`, single letters). +pub fn glyph_unicode(name: &str) -> Option { + if let Ok(i) = crate::encodings::NAMES.binary_search_by(|(n, _)| (*n).cmp(name)) { + return char::from_u32(crate::encodings::NAMES[i].1); + } + let hex = name.strip_prefix("uni").filter(|h| h.len() == 4).or_else(|| name.strip_prefix('u').filter(|h| (4..=6).contains(&h.len())))?; + u32::from_str_radix(hex, 16).ok().and_then(char::from_u32) +} + +/// Code → Unicode for a font: its ToUnicode CMap, else (simple fonts) its encoding. +fn unicode_map(doc: &Document, font: &Dict, composite: bool) -> HashMap { + let mut out = HashMap::new(); + if !composite { + let enc = font.get(b"Encoding").map(|e| doc.resolve(e)); + let base_name = match enc.as_deref() { + Some(Object::Name(n)) => Some(n.clone()), + Some(Object::Dict(d)) => d.name(b"BaseEncoding").map(<[u8]>::to_vec), + _ => None, + }; + let table: &[u32; 256] = match base_name.as_deref() { + Some(b"WinAnsiEncoding") => &crate::encodings::WIN_ANSI, + Some(b"MacRomanEncoding") => &crate::encodings::MAC_ROMAN, + _ => &crate::encodings::STANDARD, + }; + for (code, u) in table.iter().enumerate() { + if let Some(c) = char::from_u32(*u).filter(|_| *u != 0) { + out.insert(code as u32, c.to_string()); + } + } + if let Some(Object::Dict(d)) = enc.as_deref() + && let Some(diffs) = d.get(b"Differences").map(|x| doc.resolve(x)).and_then(|x| x.as_array().cloned()) + { + let mut code = 0u32; + for item in diffs { + match &item { + Object::Int(n) => code = (*n).max(0) as u32, + Object::Name(n) => { + match glyph_unicode(&String::from_utf8_lossy(n)) { + Some(c) => out.insert(code, c.to_string()), + None => out.remove(&code), + }; + code += 1; + } + _ => {} + } + } + } + } + if let Some(Object::Stream(s)) = font.get(b"ToUnicode").map(|t| doc.resolve(t)).as_deref() + && let Ok(data) = s.decoded() + { + parse_to_unicode(&data, &mut out); + } + out +} + +#[cfg(test)] +mod tests { + use printcraft_cos::{Dict, Document, Object, Stream}; + + use super::*; + + fn font(doc: &mut Document, entries: Vec<(&str, Object)>) -> Dict { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("Font")); + for (k, v) in entries { + d.set(k.as_bytes().to_vec(), v); + } + let _ = doc; + d + } + + #[test] + fn simple_fonts_decode_and_encode_through_their_encoding() { + let mut doc = Document::new_empty(); + let f = font( + &mut doc, + vec![("Subtype", Object::name("Type1")), ("BaseFont", Object::name("Helvetica")), ("Encoding", Object::name("WinAnsiEncoding"))], + ); + let m = Metrics::from_dict(&doc, &f); + assert_eq!(m.decode(b"Caf\xe9 \x80 \x93x\x94"), "Café € “x”"); + assert_eq!(m.encode("Café €").as_deref(), Some(&b"Caf\xe9 \x80"[..])); + assert_eq!(m.encode("Ω"), None, "not in WinAnsi"); + // Differences rename codes by glyph name. + let mut enc = Dict::new(); + enc.set(b"Differences".to_vec(), Object::Array(vec![Object::Int(65), Object::name("eacute"), Object::name("uni03A9")])); + let f = font(&mut doc, vec![("Subtype", Object::name("Type1")), ("BaseFont", Object::name("Custom")), ("Encoding", Object::Dict(enc))]); + let m = Metrics::from_dict(&doc, &f); + assert_eq!(m.decode(b"AB"), "éΩ"); + assert_eq!(m.encode("Ω").as_deref(), Some(&b"B"[..])); + } + + #[test] + fn subset_fonts_only_encode_the_glyphs_they_have() { + let mut doc = Document::new_empty(); + // Widths for a, b only (97, 98); c (99) is 0. + let widths = Object::Array(vec![Object::Int(500), Object::Int(520), Object::Int(0)]); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("TrueType")), + ("BaseFont", Object::name("ABCDEF+Arial")), + ("FirstChar", Object::Int(97)), + ("Widths", widths), + ("Encoding", Object::name("WinAnsiEncoding")), + ], + ); + let m = Metrics::from_dict(&doc, &f); + assert!(m.subset); + assert_eq!(m.encode("ab").as_deref(), Some(&b"ab"[..])); + assert_eq!(m.encode("abc"), None); + } + + #[test] + fn composite_fonts_use_to_unicode() { + let mut doc = Document::new_empty(); + let cmap = b"/CIDInit /ProcSet findresource begin 1 begincodespacerange <0000> endcodespacerange \ + 2 beginbfchar <0003> <0020> <0010> <00E9> endbfchar 1 beginbfrange <0024> <0026> <0041> endbfrange \ + 1 beginbfrange <0030> <0031> [<0048> <0069>] endbfrange end"; + let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap.to_vec()))); + let mut desc = Dict::new(); + desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); + desc.set( + b"W".to_vec(), + Object::Array(vec![ + Object::Int(3), + Object::Array(vec![Object::Int(250)]), + Object::Int(16), + Object::Int(16), + Object::Int(500), + Object::Int(36), + Object::Int(49), + Object::Int(600), + ]), + ); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("Type0")), + ("BaseFont", Object::name("QWERTY+Noto")), + ("Encoding", Object::name("Identity-H")), + ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])), + ("ToUnicode", Object::Ref(tu)), + ], + ); + let m = Metrics::from_dict(&doc, &f); + assert_eq!(m.decode(&[0, 0x24, 0, 0x25, 0, 3, 0, 0x30, 0, 0x31, 0, 0x10]), "AB Hié"); + assert_eq!(m.encode("Hi A").as_deref(), Some(&[0, 0x30, 0, 0x31, 0, 3, 0, 0x24][..])); + assert_eq!(m.encode("Z"), None); + } + + #[test] + fn glyph_names() { + assert_eq!(glyph_unicode("quotedblleft"), Some('“')); + assert_eq!(glyph_unicode("uni20AC"), Some('€')); + assert_eq!(glyph_unicode("u1F600"), Some('😀')); + assert_eq!(glyph_unicode("nonsense"), None); + } +} diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index 81b50c14..6b26e748 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -23,8 +23,8 @@ use std::rc::Rc; use printcraft_content::{Matrix, Op, contains, num, overlaps, parse, serialize_ops, string}; use printcraft_cos::{Dict, Document, ObjRef, Object, Stream}; -use crate::fonts::Metrics; use crate::{Report, image}; +use printcraft_fonts::pdf::Metrics; const MAX_DEPTH: usize = 12; diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 069d0604..f6adf1a1 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -14,7 +14,6 @@ use printcraft_cos::{Dict, Document, ObjRef, Object, Stream}; -mod fonts; mod image; mod interp; pub mod patterns;