diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs index 240b1c4d55..a2f28702ab 100644 --- a/crates/fonts/src/pdf.rs +++ b/crates/fonts/src/pdf.rs @@ -14,7 +14,7 @@ use std::borrow::Cow; use std::collections::{BinaryHeap, HashMap, HashSet}; -use pdfcraft_cos::{DecodeBudget, Dict, Document, Object}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, Object, Stream}; /// How the bytes of a string map to codes. #[derive(Clone, Debug, PartialEq)] @@ -67,6 +67,10 @@ pub struct Metrics { /// Subset CIDFontType2 only: one bit per CID (below 65 536) whose glyph has an outline in the /// embedded program. See [`cids_with_outlines`]. outlined_cids: Vec, + /// A stream these metrics come from (a CMap, the program, `/CIDToGIDMap`) was left unread + /// because the decode allowance [`Metrics::from_dict_within`] was given was spent: what it + /// would have decided (code lengths, CIDs, widths, meanings) is a guess. + pub past_allowance: bool, } fn nums(doc: &Document, o: Option<&Object>) -> Vec { @@ -92,14 +96,17 @@ fn legacy_indic_font(base_font: &str) -> bool { /// A source PDF's sfnt program for an in-memory editor. Never a bundled font or an export /// fallback. Type 1/Type 3 programs cannot be consumed by an sfnt text renderer. pub fn embedded_sfnt(doc: &Document, font: &Dict) -> Option> { - embedded_sfnt_within(doc, font, &mut DecodeBudget::new(2 * MAX_SFNT, 0)) + // No total: one font's two programs, and the metrics read that may adapt one, are each + // bounded by that stream's own limit. + embedded_sfnt_within(doc, font, &mut DecodeBudget::new(usize::MAX, 0)) } /// The largest embedded sfnt program read for the editor. const MAX_SFNT: usize = 16 << 20; /// [`embedded_sfnt`], decoding against `budget`, which an enumeration of a page's fonts shares: -/// each program decoded is charged whether or not it turns out to be usable. +/// each program decoded is charged whether or not it turns out to be usable, and so are the +/// streams of the metrics read that adapts a program without a Unicode cmap. pub fn embedded_sfnt_within(doc: &Document, font: &Dict, budget: &mut DecodeBudget) -> Option> { use skrifa::{MetadataProvider as _, raw::TableProvider as _}; let descendant = dict(doc, font.get(b"DescendantFonts").and_then(|o| doc.resolve(o).as_array().and_then(|a| a.first()).cloned()).as_ref()); @@ -120,8 +127,13 @@ pub fn embedded_sfnt_within(doc: &Document, font: &Dict, budget: &mut DecodeBudg // format-4/12 cmaps. Combine the PDF's Unicode meanings with the source glyph IDs; // only this in-memory copy gets a new cmap, never the working PDF or a font asset. let Ok(cmap) = face.cmap() else { continue }; - let metrics = Metrics::from_dict(doc, font); - if metrics.composite { + // A composite font's codes aren't these, so its metrics aren't read (Metrics::composite + // is this test). Meanings read past the allowance would be guesses: no copy is adapted. + if font.name(b"Subtype") == Some(b"Type0") { + continue; + } + let metrics = Metrics::from_dict_within(doc, font, budget); + if metrics.past_allowance { continue; } let mut mappings = std::collections::BTreeMap::new(); @@ -147,7 +159,7 @@ pub fn embedded_sfnt_within(doc: &Document, font: &Dict, budget: &mut DecodeBudg /// The CIDs (below 65 536) of a Type 0 font over an embedded TrueType program whose glyph has /// outline data, through `/CIDToGIDMap`; empty when the program or the map can't be read. /// Subsetters keep glyph ids and empty the glyphs they drop, so an empty glyph is a missing one. -fn cids_with_outlines(doc: &Document, font: &Dict) -> Vec { +fn cids_with_outlines(doc: &Document, font: &Dict, reads: &mut Reads<'_>) -> Vec { use skrifa::raw::TableProvider as _; let Some(descendant) = dict(doc, font.get(b"DescendantFonts").and_then(|o| doc.resolve(o).as_array().and_then(|a| a.first()).cloned()).as_ref()) else { @@ -156,13 +168,13 @@ fn cids_with_outlines(doc: &Document, font: &Dict) -> Vec { let Some(program) = dict(doc, descendant.get(b"FontDescriptor")).and_then(|d| d.get(b"FontFile2").cloned()) else { return Vec::new() }; let program = doc.resolve(&program); let Object::Stream(stream) = program.as_ref() else { return Vec::new() }; - let Ok(bytes) = stream.decoded_within(16 << 20) else { return Vec::new() }; + let Some(bytes) = reads.decode(stream, MAX_SFNT) else { return Vec::new() }; let Some(loca) = skrifa::FontRef::from_index(&bytes, 0).ok().and_then(|face| face.loca(None).ok()) else { return Vec::new() }; let outlined = |gid: u32| loca.get_raw(gid as usize).zip(loca.get_raw(gid as usize + 1)).is_some_and(|(start, end)| end > start); match descendant.get(b"CIDToGIDMap").map(|o| doc.resolve(o)).as_deref() { None | Some(Object::Name(_)) => bit_set_below_64k((0..loca.len().min(0x1_0000) as u32).filter(|&gid| outlined(gid))), Some(Object::Stream(map)) => { - let Ok(map) = map.decoded_within(1 << 17) else { return Vec::new() }; + let Some(map) = reads.decode(map, MAX_CID_TO_GID) else { return Vec::new() }; bit_set_below_64k( map.as_chunks::<2>() .0 @@ -181,7 +193,7 @@ fn cids_with_outlines(doc: &Document, font: &Dict) -> Vec { /// wider and leave the end of a line outside a redaction mark (#2130). The glyph is looked up /// the way the renderer does: the encoding's character in the Unicode cmap, else the code itself /// (as in a symbolic cmap), else `.notdef`. Only sfnt programs are read; others keep the guess. -fn embedded_widths(doc: &Document, font: &Dict, m: &mut Metrics) { +fn embedded_widths(doc: &Document, font: &Dict, m: &mut Metrics, reads: &mut Reads<'_>) { use skrifa::instance::{LocationRef, Size}; use skrifa::{GlyphId, MetadataProvider as _, raw::TableProvider as _}; let Some(descriptor) = dict(doc, font.get(b"FontDescriptor")) else { return }; @@ -189,7 +201,7 @@ fn embedded_widths(doc: &Document, font: &Dict, m: &mut Metrics) { let Some(object) = descriptor.get(key) else { continue }; let object = doc.resolve(object); let Object::Stream(stream) = object.as_ref() else { continue }; - let Ok(bytes) = stream.decoded_within(16 << 20) else { continue }; + let Some(bytes) = reads.decode(stream, MAX_SFNT) else { continue }; let Ok(face) = skrifa::FontRef::from_index(&bytes, 0) else { continue }; let upem = f64::from(face.metrics(Size::unscaled(), LocationRef::default()).units_per_em); if upem == 0.0 { @@ -287,6 +299,33 @@ const MAX_CMAP_RANGES: usize = 1 << 16; /// Largest decoded Encoding or ToUnicode CMap stream read; real CMaps stay well under it, and a larger /// one is not decoded at all. const MAX_CMAP_BYTES: usize = 16 << 20; +/// Largest decoded `/CIDToGIDMap` stream read: two bytes for each CID below 65,536. +const MAX_CID_TO_GID: usize = 1 << 17; + +/// The streams one reading of a font decodes, against the caller's allowance, and whether one was +/// left unread because that allowance was spent. +struct Reads<'a> { + budget: &'a mut DecodeBudget, + refused: bool, +} + +impl<'a> Reads<'a> { + fn new(budget: &'a mut DecodeBudget) -> Self { + Self { budget, refused: false } + } + + /// `stream` decoded within `max` and the allowance; `None` when it can't be, noting when the + /// allowance is why. A stream past `max` is unread as before allowances, not refused. + fn decode(&mut self, stream: &Stream, max: usize) -> Option> { + match self.budget.decode_within(stream, max) { + Ok(data) => Some(data), + Err(_) => { + self.refused |= self.budget.remaining() == 0; + None + } + } + } +} /// Code → Unicode of a font. Explicit mappings and sequential ranges are kept as declared, so a range /// costs one record however many codes it covers; where two overlap, the one declared later wins. @@ -628,11 +667,24 @@ impl Metrics { code_len: 1, explicit_reverse: (32..127u8).map(|c| (char::from(c), u32::from(c))).collect(), outlined_cids: Vec::new(), + past_allowance: false, } } + /// The metrics of `font`. Each stream they come from decodes within its own limit (16 MiB for + /// a CMap or a program), and again on every call: a caller that reads many fonts, or one font + /// many times, shares an allowance through [`Metrics::from_dict_within`]. pub fn from_dict(doc: &Document, font: &Dict) -> Self { - let mut m = Self::read_metrics(doc, font); + // No total, so nothing is refused: each stream stays within its own limit, as it always has. + Self::from_dict_within(doc, font, &mut DecodeBudget::new(usize::MAX, 0)) + } + + /// [`Metrics::from_dict`], decoding against `budget`, which the caller shares across the fonts + /// it reads (a page's, say): each stream pays for what it decodes, usable or not. A stream the + /// allowance can't pay for is left unread, and [`Metrics::past_allowance`] says so. + pub fn from_dict_within(doc: &Document, font: &Dict, budget: &mut DecodeBudget) -> Self { + let mut reads = Reads::new(budget); + let mut m = Self::read_metrics(doc, font, &mut reads); let base = font .name(b"BaseFont") .map(|name| String::from_utf8_lossy(name).into_owned()) @@ -649,17 +701,18 @@ impl Metrics { m.italic |= italic; m.base_font = base; if m.composite && m.subset { - m.outlined_cids = cids_with_outlines(doc, font); + m.outlined_cids = cids_with_outlines(doc, font, &mut reads); } 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.unicode = unicode_map(doc, font, m.composite, &mut reads); if m.std14.is_some() { - embedded_widths(doc, font, &mut m); + embedded_widths(doc, font, &mut m, &mut reads); } + m.past_allowance = reads.refused; let mut reverse = HashMap::new(); for (&code, text) in m.unicode.explicit.iter().map(|(c, (_, t))| (c, t)).chain(m.unicode.encoding.iter()) { if let Some(ch) = only_char(text) @@ -680,7 +733,7 @@ impl Metrics { m } - fn read_metrics(doc: &Document, font: &Dict) -> Self { + fn read_metrics(doc: &Document, font: &Dict, reads: &mut Reads<'_>) -> Self { let mut m = Metrics::fallback(); m.std14 = None; let subtype = font.name(b"Subtype").unwrap_or(b"Type1").to_vec(); @@ -691,7 +744,7 @@ impl Metrics { 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_within(MAX_CMAP_BYTES) + && let Some(data) = reads.decode(s, MAX_CMAP_BYTES) { let (spaces, cids) = parse_cmap(&data); if !spaces.is_empty() { @@ -1229,7 +1282,7 @@ pub fn glyph_unicode(name: &str) -> Option { /// Code → Unicode for a font: its ToUnicode CMap (for a composite font, also the name /// `/Identity-H` or `/Identity-V`, read as code = Unicode), else (simple fonts) its encoding. -fn unicode_map(doc: &Document, font: &Dict, composite: bool) -> Unicode { +fn unicode_map(doc: &Document, font: &Dict, composite: bool, reads: &mut Reads<'_>) -> Unicode { let mut out = Unicode::default(); if !composite { let enc = font.get(b"Encoding").map(|e| doc.resolve(e)); @@ -1293,7 +1346,7 @@ fn unicode_map(doc: &Document, font: &Dict, composite: bool) -> Unicode { } match font.get(b"ToUnicode").map(|t| doc.resolve(t)).as_deref() { Some(Object::Stream(s)) => { - if let Ok(data) = s.decoded_within(MAX_CMAP_BYTES) { + if let Some(data) = reads.decode(s, MAX_CMAP_BYTES) { parse_to_unicode(&data, &mut out); } } diff --git a/crates/fonts/src/pdf/tests.rs b/crates/fonts/src/pdf/tests.rs index c2acef191c..798c619018 100644 --- a/crates/fonts/src/pdf/tests.rs +++ b/crates/fonts/src/pdf/tests.rs @@ -154,9 +154,10 @@ fn a_font_without_widths_measures_with_its_embedded_program() { assert!(m.width(u32::from('i')) < 0.4, "not Courier's 0.6"); } -#[test] -fn mac_subset_cmap_uses_pdf_unicode_without_changing_source_program() { - use skrifa::{MetadataProvider as _, raw::TableProvider as _}; +/// Inter with its cmap replaced by a Mac format-6 one mapping code 65 to Ω's glyph, which egui +/// can't use; and that glyph. +fn mac_subset() -> (Vec, skrifa::GlyphId) { + use skrifa::MetadataProvider as _; let original = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf"); let face = skrifa::FontRef::new(original).unwrap(); let expected = face.charmap().map('Ω').unwrap(); @@ -170,6 +171,13 @@ fn mac_subset_cmap_uses_pdf_unicode_without_changing_source_program() { subset[entry + 8..entry + 12].copy_from_slice(&offset.to_be_bytes()); subset[entry + 12..entry + 16].copy_from_slice(&(cmap.len() as u32).to_be_bytes()); subset.extend_from_slice(&cmap); + (subset, expected) +} + +#[test] +fn mac_subset_cmap_uses_pdf_unicode_without_changing_source_program() { + use skrifa::{MetadataProvider as _, raw::TableProvider as _}; + let (subset, expected) = mac_subset(); let face = skrifa::FontRef::new(&subset).unwrap(); assert!(!face.charmap().has_map(), "egui cannot directly use Mac format 6"); assert_eq!(face.cmap().unwrap().map_codepoint(65_u32), Some(expected)); @@ -190,6 +198,128 @@ fn mac_subset_cmap_uses_pdf_unicode_without_changing_source_program() { assert!(append_editor_cmap(&mut broken, &std::collections::BTreeMap::from([(65, 1)])).is_none()); } +/// The metrics read that adapts a program's cmap is paid for from the editor's allowance too: a +/// step short of the program, its ToUnicode and the program again for its widths, no copy is +/// adapted from guessed meanings. +#[test] +fn adapting_a_programs_cmap_is_paid_for() { + use skrifa::MetadataProvider as _; + let (subset, expected) = mac_subset(); + let mut doc = Document::new_empty(); + let program = doc.add(Object::Stream(Stream::flate(Dict::new(), &subset))); + let mut descriptor = Dict::new(); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); + let cmap = b"1 beginbfchar <41> <03A9> endbfchar"; + let unicode = doc.add(Object::Stream(Stream::flate(Dict::new(), cmap))); + let source = font(&mut doc, vec![("FontDescriptor", Object::Dict(descriptor)), ("ToUnicode", Object::Ref(unicode))]); + let cost = 2 * subset.len() + cmap.len(); + let mut budget = DecodeBudget::new(cost, 0); + let editor = embedded_sfnt_within(&doc, &source, &mut budget).unwrap(); + assert_eq!(skrifa::FontRef::new(&editor).unwrap().charmap().map('Ω'), Some(expected)); + assert_eq!(budget.remaining(), 0); + assert!(embedded_sfnt_within(&doc, &source, &mut DecodeBudget::new(cost - 1, 0)).is_none()); + // A composite font's metrics aren't read at all: the program is all it pays for. + let mut budget = DecodeBudget::new(cost, 0); + let mut composite = source.clone(); + composite.set(b"Subtype".to_vec(), Object::name("Type0")); + assert!(embedded_sfnt_within(&doc, &composite, &mut budget).is_none()); + assert_eq!(budget.remaining(), cost - subset.len()); +} + +/// A Type 0 subset font whose `/Encoding`, program, `/CIDToGIDMap` and ToUnicode are streams, and +/// what each decodes to. +fn subset_type0(doc: &mut Document, to_unicode: &[u8]) -> (Dict, [usize; 4]) { + let encoding = b"1 begincodespacerange <0000> endcodespacerange"; + let program = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf"); + let map = [0u8, 0, 0, 1, 0, 2]; + let mut stream = |data: &[u8]| Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), data)))); + let mut descriptor = Dict::new(); + descriptor.set(b"FontFile2".to_vec(), stream(program)); + let mut descendant = Dict::new(); + descendant.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); + descendant.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor)); + descendant.set(b"CIDToGIDMap".to_vec(), stream(&map)); + let entries = vec![ + ("Subtype", Object::name("Type0")), + ("BaseFont", Object::name("ABCDEF+Inter")), + ("Encoding", stream(encoding)), + ("DescendantFonts", Object::Array(vec![Object::Dict(descendant)])), + ("ToUnicode", stream(to_unicode)), + ]; + (font(doc, entries), [encoding.len(), program.len(), map.len(), to_unicode.len()]) +} + +/// Each stream a font's metrics come from is paid for from the allowance they are read within, +/// down to the byte; a stream it can't pay for is left unread, and the metrics say so. +#[test] +fn a_fonts_streams_are_paid_for_from_its_allowance() { + let mut doc = Document::new_empty(); + let (f, sizes) = subset_type0(&mut doc, b"1 beginbfchar <0001> <0058> endbfchar"); + let cost: usize = sizes.iter().sum(); + let whole = Metrics::from_dict(&doc, &f); + assert!(!whole.past_allowance); + assert_eq!((whole.code_len, whole.text_of(1).as_deref()), (2, Some("X"))); + assert!(!whole.outlined_cids.is_empty(), "the program and its map were read"); + let mut budget = DecodeBudget::new(cost, 0); + assert_eq!(Metrics::from_dict_within(&doc, &f, &mut budget), whole); + assert_eq!(budget.remaining(), 0); + // A byte short, the ToUnicode, read last, is left unread; what came before is kept. + let mut budget = DecodeBudget::new(cost - 1, 0); + let m = Metrics::from_dict_within(&doc, &f, &mut budget); + assert!(m.past_allowance); + assert_eq!((m.code_len, m.text_of(1)), (2, None)); + assert_eq!(m.outlined_cids, whole.outlined_cids); + // With nothing left, none of them is read. + let m = Metrics::from_dict_within(&doc, &f, &mut budget); + assert!(m.past_allowance && m.outlined_cids.is_empty()); + assert_eq!(m.codes, Codes::Two, "the Encoding CMap was left unread"); + // Only a subset's program says which glyphs it lacks: the whole font pays for its CMaps alone. + let mut whole_font = f.clone(); + whole_font.set(b"BaseFont".to_vec(), Object::name("Inter")); + let mut budget = DecodeBudget::new(cost, 0); + let m = Metrics::from_dict_within(&doc, &whole_font, &mut budget); + assert!(!m.past_allowance && m.outlined_cids.is_empty()); + assert_eq!(cost - budget.remaining(), sizes[0] + sizes[3]); +} + +/// A stream past its own limit is left unread as it always was, paying for the limit, and is no +/// reason to call the font read past the allowance. +#[test] +fn a_cmap_past_its_own_limit_is_not_past_the_allowance() { + let mut doc = Document::new_empty(); + let mut cmap = b"1 beginbfchar <0001> <0058> endbfchar".to_vec(); + cmap.resize(MAX_CMAP_BYTES + 1, b' '); + let (f, sizes) = subset_type0(&mut doc, &cmap); + let mut budget = DecodeBudget::new(usize::MAX / 2, 0); + let m = Metrics::from_dict_within(&doc, &f, &mut budget); + assert!(!m.past_allowance); + assert_eq!(m.text_of(1), None); + assert_eq!(usize::MAX / 2 - budget.remaining(), sizes[..3].iter().sum::() + MAX_CMAP_BYTES); +} + +/// Fonts that each name one large ToUnicode share the allowance they are read within: it pays for +/// as many readings as it holds, and the rest are left unread, where each reading used to decode +/// it afresh. +#[test] +fn fonts_naming_one_cmap_share_one_allowance() { + let mut doc = Document::new_empty(); + let mut cmap = b"1 beginbfchar <41> <0058> endbfchar".to_vec(); + cmap.resize(1_000_000, b' '); + let unicode = doc.add(Object::Stream(Stream::flate(Dict::new(), &cmap))); + let fonts: Vec = (0..100) + .map(|_| { + font(&mut doc, vec![("Subtype", Object::name("Type1")), ("BaseFont", Object::name("Helvetica")), ("ToUnicode", Object::Ref(unicode))]) + }) + .collect(); + let mut budget = DecodeBudget::new(64 << 20, 0); + let read: Vec = fonts.iter().map(|f| Metrics::from_dict_within(&doc, f, &mut budget)).collect(); + let whole = read.iter().filter(|m| !m.past_allowance).count(); + assert_eq!(whole, (64 << 20) / cmap.len()); + assert!(read[..whole].iter().all(|m| m.text_of(0x41).as_deref() == Some("X"))); + assert!(read[whole..].iter().all(|m| m.text_of(0x41).as_deref() == Some("A")), "the encoding's meaning, unread past the allowance"); + assert_eq!(budget.remaining(), 0); +} + fn metrics_with_flags(flags: Option) -> Metrics { let mut doc = Document::new_empty(); let mut descriptor = Dict::new(); @@ -565,7 +695,7 @@ fn malformed_cmaps_never_panic() { let mut enc = Dict::new(); enc.set(b"Differences".to_vec(), Object::Array(vec![Object::Int(i64::MAX), Object::name("A"), Object::name("B")])); let f = font(&mut doc, vec![("Subtype", Object::name("Type1")), ("Encoding", Object::Dict(enc))]); - assert_eq!(unicode_map(&doc, &f, false).text(u32::MAX).as_deref(), Some("B")); + assert_eq!(unicode_map(&doc, &f, false, &mut Reads::new(&mut DecodeBudget::new(usize::MAX, 0))).text(u32::MAX).as_deref(), Some("B")); } #[test]