diff --git a/crates/cos/src/budget.rs b/crates/cos/src/budget.rs index 56286ce4a0..0067304f66 100644 --- a/crates/cos/src/budget.rs +++ b/crates/cos/src/budget.rs @@ -246,12 +246,14 @@ pub struct DecodeBudget { bytes: usize, forms: usize, seen: HashSet, + /// Decodes refused for lack of allowance ([`DecodeBudget::refused`]). + refusals: usize, } impl DecodeBudget { /// An allowance of `bytes` decoded bytes and `forms` form entries. pub fn new(bytes: usize, forms: usize) -> Self { - Self { limit: bytes, bytes, forms, seen: HashSet::new() } + Self { limit: bytes, bytes, forms, seen: HashSet::new(), refusals: 0 } } /// The allowance for one page: [`MAX_PAGE_DECODED`] bytes and [`MAX_PAGE_FORMS`] forms. @@ -264,6 +266,22 @@ impl DecodeBudget { self.bytes } + /// How many decodes were refused for lack of allowance: one that came once it was spent, or + /// ran past what was left. A decode that spent exactly what was left wasn't refused, so an + /// allowance used up to the byte has none. + pub fn refused(&self) -> usize { + self.refusals + } + + /// Whether the allowance is spent and `stream`, which has data, would be refused, noting the + /// refusal ([`DecodeBudget::refused`]): a caller asks this before resolving the stream's + /// parameters, work the allowance doesn't count. A stream without data still reads. + pub fn refuses(&mut self, stream: &Stream) -> bool { + let refuses = self.bytes == 0 && !stream.raw.is_empty(); + self.refusals += usize::from(refuses); + refuses + } + /// Whether a decode was refused for lack of allowance or a form entry for lack of visits. pub fn exhausted(&self) -> bool { self.bytes == 0 || self.forms == 0 @@ -283,6 +301,51 @@ impl DecodeBudget { self.charge(stream, max, Stream::decoded_within_counted).map(|(data, _)| data) } + /// Like [`DecodeBudget::decode_within`], with every filter stage held to `max`, as + /// [`Stream::decoded_within`] holds it, and the stages together to `max` for each of them, the + /// stream's `/DecodeParms` given by reference read in `doc` through a view of it that shares + /// its data and copies no more of its dictionary than its filters and parameters + /// ([`DecodeBudget::decode_with_damage`] reads it so). A + /// stream so reads here when it reads without an allowance and the allowance can pay for + /// what its stages decode, the output of one stage on top of another's. A stage past `max` + /// is an error that charges what the stages before it decoded and `max`, the work it took to + /// find out; past the stages' total it charges that total, and past what is left it spends + /// all of it, as [`DecodeBudget::decode_within`] does. + pub fn decode_stages_within(&mut self, doc: &Document, stream: &Stream, max: usize) -> Result, CosError> { + // Before the parameters are resolved, work the allowance doesn't count. + if self.refuses(stream) { + return Err(CosError::Filter(FilterError::LimitExceeded(self.limit).to_string())); + } + let stream = doc.decoding_view(stream); + let total = max.saturating_mul(stream.filters().len().max(1)); + let room = total.min(self.bytes); + match stream.decoded_within_each_counted(max, room) { + Ok((data, _, spent)) => { + self.bytes = self.bytes.saturating_sub(spent); + Ok(data) + } + // A stage stopped at `max`, below what was left of the room. + Err((FilterError::LimitExceeded(_), spent)) if room.saturating_sub(spent) > max => { + self.bytes = self.bytes.saturating_sub(spent.saturating_add(max)); + Err(CosError::Filter(FilterError::LimitExceeded(max).to_string())) + } + // At the stages' total, with more left. + Err((FilterError::LimitExceeded(_), _)) if total < self.bytes => { + self.bytes = self.bytes.saturating_sub(total); + Err(CosError::Filter(FilterError::LimitExceeded(total).to_string())) + } + Err((FilterError::LimitExceeded(_), _)) => { + self.bytes = 0; + self.refusals += 1; + Err(CosError::Filter(FilterError::LimitExceeded(self.limit).to_string())) + } + Err((e, spent)) => { + self.bytes = self.bytes.saturating_sub(spent); + Err(CosError::Filter(e.to_string())) + } + } + } + /// [`DecodeBudget::decode_within`] (`usize::MAX` for no bound of its own), with the stream's /// `/DecodeParms` given by reference read in `doc`, also saying whether the stream is damaged: /// `true` when a filter stopped at corrupt data, so the bytes are only what decoded before it. @@ -304,7 +367,7 @@ impl DecodeBudget { /// pays for the bytes decoded before the error, so referencing a stream again and again /// spends the allowance by the work each decode does. fn charge(&mut self, stream: &Stream, max: usize, decode: Decoder) -> Result<(Vec, bool), CosError> { - if self.bytes == 0 && !stream.raw.is_empty() { + if self.refuses(stream) { return Err(CosError::Filter(FilterError::LimitExceeded(self.limit).to_string())); } if max < self.bytes { @@ -324,6 +387,7 @@ impl DecodeBudget { } Err((FilterError::LimitExceeded(_), _)) => { self.bytes = 0; + self.refusals += 1; Err(CosError::Filter(FilterError::LimitExceeded(self.limit).to_string())) } Err((e, spent)) => { @@ -1025,4 +1089,99 @@ mod tests { assert!(budget.decode_strict(&Document::new_empty(), &chained).is_ok()); assert_eq!(budget.remaining(), (10 << 20) - 3 * charged); } + + /// `decode_stages_within` holds every stage to the limit, as a decode without an allowance + /// does, and the stages together to the limit for each of them. A stream behind ASCIIHex and + /// Flate whose stages each stay within it reads, the hex stage's output charged on top; one + /// whose Flate stage would make more stops there, charged what the hex stage made and the + /// limit, the rest of the allowance left; a Flate stage making hex text past the limit leaves + /// the stream unread though the total has room, as `Stream::decoded_within` leaves it; and a + /// stream past what is left spends all of it. + #[test] + fn each_stage_is_held_to_the_limit() { + let chained = |data: &[u8], filters: [&str; 2]| { + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::Array(filters.into_iter().map(Object::name).collect())); + Stream::from_raw(dict, data.to_vec()) + }; + let hexed = |data: &[u8]| { + let flate = Stream::flate(Dict::new(), data); + let hex: Vec = flate.raw.iter().flat_map(|b| format!("{b:02X}").into_bytes()).chain(*b">").collect(); + (chained(&hex, ["ASCIIHexDecode", "FlateDecode"]), flate.raw.len()) + }; + let max = 1 << 16; + let (fits, inner) = hexed(&vec![0u8; max]); + let mut budget = DecodeBudget::new(1 << 20, 0); + assert_eq!(budget.decode_stages_within(&Document::new_empty(), &fits, max).map(|d| d.len()).ok(), Some(max)); + assert_eq!(budget.remaining(), (1 << 20) - inner - max); + let (bomb, inner) = hexed(&vec![0u8; 8 * max]); + let mut budget = DecodeBudget::new(1 << 20, 0); + assert!(budget.decode_stages_within(&Document::new_empty(), &bomb, max).is_err()); + assert_eq!(budget.remaining(), (1 << 20) - inner - max, "the Flate stage stopped at the limit"); + // Flate making 1.2 times the limit of hex text, which ASCIIHex halves: the stages together + // make 1.8 times the limit, within twice it, but the Flate stage alone is past it. + let text = "00".repeat(max * 6 / 10).into_bytes(); + let wide = chained(&Stream::flate(Dict::new(), &text).raw, ["FlateDecode", "ASCIIHexDecode"]); + assert!(wide.decoded_within(max).is_err()); + assert!(DecodeBudget::new(1 << 20, 0).decode_stages_within(&Document::new_empty(), &wide, max).is_err()); + let mut budget = DecodeBudget::new(max, 0); + assert!(budget.decode_stages_within(&Document::new_empty(), &fits, max).is_err()); + assert_eq!(budget.remaining(), 0); + } + + /// What `decode_stages_within` charges, and which limit it reports, on each way a read stops: + /// a stage at the stream's limit with more of the room left charges what came before and the + /// limit; at the stages' total, with more of the allowance left, it charges that total; at + /// what is left of the allowance it spends all of it and reports the allowance's limit, as + /// `decode_within` does. Each boundary between them is read on both sides. + #[test] + fn a_stage_past_its_limit_charges_what_came_before_and_the_limit() { + let max = 1 << 12; + let hex = |data: &[u8]| data.iter().flat_map(|b| format!("{b:02X}").into_bytes()).chain(*b">").collect::>(); + let two = |raw: &[u8]| { + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("ASCIIHexDecode"), Object::name("FlateDecode")])); + Stream::from_raw(dict, hex(raw)) + }; + let says = |read: Result, CosError>, limit: usize| { + read.err().map(|e| e.to_string()) == Some(CosError::Filter(FilterError::LimitExceeded(limit).to_string()).to_string()) + }; + // Flate data making eight times the limit: the hex stage makes `inner` bytes of it, and + // the Flate stage stops at the limit. + let bomb = Stream::flate(Dict::new(), &vec![0u8; 8 * max]).raw.to_vec(); + let inner = bomb.len(); + // A byte more of the room than the hex stage and the limit: the Flate stage stops at the + // limit, so that is what it charges. + let mut budget = DecodeBudget::new(inner + max + 1, 0); + assert!(says(budget.decode_stages_within(&Document::new_empty(), &two(&bomb), max), max)); + assert_eq!(budget.remaining(), 1); + // Exactly the hex stage and the limit: what was left stopped it as much as the limit did, + // so it is past the allowance, spends it and reports the allowance's limit. + let mut budget = DecodeBudget::new(inner + max, 0); + assert!(says(budget.decode_stages_within(&Document::new_empty(), &two(&bomb), max), inner + max)); + assert_eq!(budget.remaining(), 0); + // The same data padded to exactly the limit: the hex stage makes all of it, and the Flate + // stage, at the limit, reaches the stages' total. With more of the allowance left, that + // total is what it charges and reports. + let mut padded = bomb.clone(); + padded.resize(max, 0); + let mut budget = DecodeBudget::new(10 * max, 0); + assert!(says(budget.decode_stages_within(&Document::new_empty(), &two(&padded), max), 2 * max)); + assert_eq!(budget.remaining(), 8 * max); + // With exactly the total left (of an allowance of three times the limit), it is past the + // allowance: all of it is spent, and the allowance's limit reported. + let mut budget = DecodeBudget::new(3 * max, 0); + budget.decode(&Stream::from_raw(Dict::new(), vec![0; max])).unwrap(); + assert!(says(budget.decode_stages_within(&Document::new_empty(), &two(&padded), max), 3 * max)); + assert_eq!(budget.remaining(), 0); + // With less than the total left, the same. + let mut budget = DecodeBudget::new(max, 0); + assert!(says(budget.decode_stages_within(&Document::new_empty(), &two(&padded), max), max)); + assert_eq!(budget.remaining(), 0); + // With nothing left, a stream with data is refused before it is decoded, even one that + // decodes to nothing; an empty one reads. + let mut budget = DecodeBudget::new(0, 0); + assert!(says(budget.decode_stages_within(&Document::new_empty(), &Stream::flate(Dict::new(), b""), max), 0)); + assert_eq!(budget.decode_stages_within(&Document::new_empty(), &Stream::from_raw(Dict::new(), Vec::new()), max).ok(), Some(Vec::new())); + } } diff --git a/crates/cos/src/object.rs b/crates/cos/src/object.rs index 97a3b60ab9..f2c9760c12 100644 --- a/crates/cos/src/object.rs +++ b/crates/cos/src/object.rs @@ -384,6 +384,26 @@ impl Stream { self.decode_chain(max, decode).map(|(v, spent)| (v, damaged, spent)) } + /// [`Stream::decoded_within_counted`] with every filter stage also held to `each`, as + /// [`Stream::decoded_within`] holds it, while `max` bounds the stages together + /// ([`pdfcraft_filters::decode_tolerant_counted_each`]). Without filters the data is held to + /// both. + pub(crate) fn decoded_within_each_counted( + &self, + each: usize, + max: usize, + ) -> Result<(Vec, bool, usize), (pdfcraft_filters::FilterError, usize)> { + let mut damaged = false; + let decode = |chain: &[(Filter, Params)], data: &[u8], max| { + pdfcraft_filters::decode_tolerant_counted_each(chain, data, each.min(MAX_DECODED), max).map(|(v, partial, spent)| { + damaged = partial; + (v, spent) + }) + }; + let max = if self.readable_filters().is_ok_and(|chain| chain.is_empty()) { max.min(each) } else { max }; + self.decode_chain(max, decode).map(|(v, spent)| (v, damaged, spent)) + } + /// [`Stream::decoded_within`] spending from `work`, what an operation may still do across a /// document's images ([`pdfcraft_filters::decode_tolerant_within`]). pub(crate) fn decoded_spending(&self, max: usize, work: &mut u64) -> Result, CosError> { diff --git a/crates/edit/src/rules.rs b/crates/edit/src/rules.rs index 52600ab4dd..2457e2d7ff 100644 --- a/crates/edit/src/rules.rs +++ b/crates/edit/src/rules.rs @@ -26,6 +26,10 @@ pub fn page_rules(doc: &Document, page: usize) -> Result, EditErro let pages = pdfcraft_model::pages(doc); let p = pages.get(page).ok_or(EditError::NoSuchPage(page))?; let streams = crate::text::content_streams(doc, &p.dict)?; + // Unread content can change the graphics state used by every later stream. + if streams.iter().any(|stream| stream.read != crate::text::Read::Whole) { + return Err(EditError::Invalid("page content could not be completely decoded; cannot extract its ruled lines safely".into())); + } let pieces = crate::text::joined(&streams); let ops = pieces.parse().ops; diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index f6f68829c8..f2033a64de 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -13610,6 +13610,24 @@ fn review_inside_scope_group_move_matches_direct_transform_under_outer_scale() { } } +#[test] +fn page_rules_refuse_unread_graphics_state() { + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::name("UnsupportedFilter")); + let unread = Stream::from_raw(dict, b"2 0 0 2 0 0 cm".to_vec()); + let damaged = damaged_flate(b"0 g\n", b"2 0 0 2 0 0 cm"); + for stream in [unread, damaged] { + let mut doc = image_page_with("10 10 m 100 10 l S"); + let incomplete = doc.add(Object::Stream(stream)); + doc.update_dict(ObjRef::new(3, 0), |page| { + page.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(incomplete), Object::Ref(ObjRef::new(4, 0))])); + }) + .unwrap(); + assert!(page_rules(&doc, 0).is_err(), "later geometry cannot be trusted after unread graphics state"); + } + assert_eq!(page_rules(&image_page_with("10 10 m 100 10 l S"), 0).unwrap(), [[10.0, 10.0, 100.0, 10.0]]); +} + #[test] fn image_editing_frame_keeps_page_edge_outside_explicit_clips() { for (prefix, expected) in [ @@ -14296,6 +14314,29 @@ fn legacy_thai_subsets_are_read_within_one_budget_per_edit() { assert_eq!(pdfcraft_cos::write_full(&distinct, &Default::default()).unwrap(), before, "a refused edit changes nothing"); } +/// An edit whose THSarabunPSK subset spends the edit's allowance to the byte goes through: only a +/// read refused for want of it refuses the edit. The subset is read four times over its program +/// (the edit's metrics read, `LegacyThaiFont`'s, the editor's program read and its metrics read) +/// and three times over its ToUnicode, so a program 192 bytes short of 16 MiB and a ToUnicode of +/// 256 bytes cost exactly the 64 MiB; a byte more of ToUnicode is three past it. +#[test] +fn a_thai_edit_that_spends_its_allowance_to_the_byte_goes_through() { + let mut program = legacy_thai_program(); + program.resize((16 << 20) - 192, 0); + let edit = |unicode_len: usize| { + let mut unicode = LEGACY_THAI_UNICODE.to_vec(); + unicode.resize(unicode_len, b' '); + let mut doc = text_page("BT /F1 16 Tf 50 650 Td <00d6> Tj ET"); + let font = legacy_thai_font(&mut doc, "ABCDEF+THSarabunPSK", &program, &unicode); + doc.set(pdfcraft_cos::ObjRef::new(5, 0), Object::Dict(font)); + let style = BlockStyle { width: Some(400.0), ..Default::default() }; + text::rewrite_block(&mut doc, 0, 0, Some("กขค"), &style).map(|_| ()).map_err(|e| e.to_string()) + }; + assert_eq!(edit(256), Ok(())); + let error = edit(257).unwrap_err(); + assert!(error.contains("THSarabunPSK") && error.contains("MiB of font data"), "{error}"); +} + /// A THSarabunPSK-named subset that has OpenType Thai mark positioning is a modern face: it is /// shaped with its own layout like any source font, not by the legacy shaper (which would ask /// for Windows PUA alternates such a face doesn't need). diff --git a/crates/edit/src/text_regions/styled.rs b/crates/edit/src/text_regions/styled.rs index 487bb0e20e..9c2732c35f 100644 --- a/crates/edit/src/text_regions/styled.rs +++ b/crates/edit/src/text_regions/styled.rs @@ -116,8 +116,11 @@ struct LegacyFace { space: f64, } -/// What one edit may decode, and so keep, of the font programs of its THSarabunPSK subsets -/// (each at most 16 MiB): a page may name many of them. +/// What one edit may decode of its THSarabunPSK subsets' font data: their programs (each at most +/// 16 MiB), which it keeps, and the streams of the metrics read to measure them and to adapt their +/// programs' cmaps: their CMaps and glyph maps, and a subset's program again, for which of its +/// glyphs have outlines. A page may name many of them. The metrics read to find the subsets among +/// the page's fonts aren't charged to it. const MAX_THAI_PROGRAMS: usize = 64 << 20; struct PreparedCharacters { @@ -900,6 +903,9 @@ fn legacy_thai_font( } let family = pdfcraft_fonts::embedding::face_key(&source.base_font); let mut candidates = vec![source.font.clone()]; + // Every name on the page is read here for its face name alone, aliases of one font and other + // faces' fonts too, with no total, as before: charged to the edit's allowance, each alias would + // pay again and other faces would spend what the face's subsets need. candidates.extend(fonts.iter().filter_map(|(name, font)| { let font = doc.dict(font)?; (pdfcraft_fonts::embedding::face_key(&Metrics::from_dict(doc, &font).base_font) == family).then(|| String::from_utf8_lossy(name).into_owned()) @@ -914,9 +920,15 @@ fn legacy_thai_font( let face = match object.and_then(|o| cache.values().find(|face| face.object == Some(o))) { Some(alias) => LegacyFace { glyphs: alias.glyphs.clone(), metrics: alias.metrics.clone(), object, ..*alias }, None => { - let metrics = Metrics::from_dict(doc, &font); - let space = source_space(doc, fonts, &metrics); - let glyphs = pdfcraft_fonts::pdf::LegacyThaiFont::from_pdf(doc, &font, space, budget).ok().flatten().map(std::rc::Rc::new); + // Read within the edit's allowance; past it the subset goes unread, and the edit is + // refused below. + let metrics = Metrics::from_dict_within(doc, &font, budget); + let (space, glyphs) = if metrics.past_allowance { + (0.0, None) + } else { + let space = source_space(doc, fonts, &metrics); + (space, pdfcraft_fonts::pdf::LegacyThaiFont::from_pdf(doc, &font, space, budget).ok().flatten().map(std::rc::Rc::new)) + }; let style = FontStyle::of(doc, &font, &metrics); LegacyFace { glyphs, metrics, style, object, space } } @@ -924,9 +936,11 @@ fn legacy_thai_font( cache.insert(name, face); } // A subset left unread would count as unusable, silently changing which subset draws the text. - if budget.remaining() == 0 { + // A read refused for want of the allowance is what refuses the edit: reads that spent it to the + // byte refused nothing. + if budget.refused() > 0 { return Err(EditError::Invalid(format!( - "the page's {} subsets need more than {} MiB of font programs for one edit; choose a replacement font for this text", + "the page's {} subsets need more than {} MiB of font data for one edit; choose a replacement font for this text", source.base_font.rsplit('+').next().unwrap_or(&source.base_font), MAX_THAI_PROGRAMS >> 20 ))); diff --git a/crates/filters/src/lib.rs b/crates/filters/src/lib.rs index 9342213577..4bee6b451c 100644 --- a/crates/filters/src/lib.rs +++ b/crates/filters/src/lib.rs @@ -212,6 +212,18 @@ pub fn decode_tolerant_counted(chain: &[(Filter, Params)], data: &[u8], max_outp run(chain, data, max_output, Some(max_output), false, false, None) } +/// [`decode_tolerant_counted`] with the two bounds apart: `max_stage` bounds every stage, as +/// [`decode_tolerant`]'s bound does, and `max_output` the bytes all stages decode together. A +/// stage stopped by either gives [`FilterError::LimitExceeded`] and the bytes decoded before it. +pub fn decode_tolerant_counted_each( + chain: &[(Filter, Params)], + data: &[u8], + max_stage: usize, + max_output: usize, +) -> Result<(Vec, bool, usize), (FilterError, usize)> { + run(chain, data, max_stage, Some(max_output), false, false, None) +} + /// [`decode_tolerant`], spending from `work`, what decoding may still cost across a run of images: /// a step for each byte of `data`, paid before anything is decoded, [`FLATE_READ_WORK`] for each /// byte a Flate stage reads, paid before it runs (again if it reads them twice), and one for each @@ -451,6 +463,30 @@ mod tests { assert_eq!(super::decode(&chain(super::MAX_CHAIN), &data, 1 << 20).ok(), Some(data)); } + /// [`decode_tolerant_counted_each`] holds every stage to its own bound as well as the stages + /// to their total: a Flate stage that would make more than that bound stops there, though + /// the total has room, and the stage after it gets nothing; within both, the chain reads + /// whole, one stage's output charged on top of another's. + #[test] + fn each_stage_is_held_to_its_own_bound_within_the_total() { + let hex = "00".repeat(1 << 16).into_bytes(); + let data = super::flate::encode(&hex); + let chain = [(Filter::Flate, Params::default()), (Filter::AsciiHex, Params::default())]; + // The total alone lets the Flate stage make all of the hex text, and the hex stage read it. + STAGE_BYTES.with(|c| c.set(0)); + let whole = decode_tolerant_counted(&chain, &data, 3 << 16).map(|(v, _, _)| v); + assert_eq!(whole.ok(), Some(vec![0u8; 1 << 16])); + assert_eq!(STAGE_BYTES.with(|c| c.get()), data.len() + hex.len()); + // Each stage held to 64 KiB, the Flate stage stops there and the hex stage gets nothing. + STAGE_BYTES.with(|c| c.set(0)); + let held = decode_tolerant_counted_each(&chain, &data, 1 << 16, 3 << 16); + assert!(matches!(held, Err((FilterError::LimitExceeded(m), 0)) if m == 1 << 16), "{held:?}"); + assert_eq!(STAGE_BYTES.with(|c| c.get()), data.len()); + // Within both, it reads whole. + let (out, _, spent) = decode_tolerant_counted_each(&chain, &data, 2 << 16, 3 << 16).unwrap(); + assert_eq!((out.len(), spent), (1 << 16, hex.len() + (1 << 16))); + } + use super::*; #[test] diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs index 9d8d0d6b85..f68761e02e 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,11 @@ 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`, a Type 3 glyph + /// description) 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, a + /// Type 3 font's height) is a guess. + pub past_allowance: bool, } fn nums(doc: &Document, o: Option<&Object>) -> Vec { @@ -79,6 +84,12 @@ fn dict(doc: &Document, o: Option<&Object>) -> Option { o.and_then(|o| doc.resolve(o).as_dict().cloned()) } +/// Whether `font`'s `/Encoding` is the name Identity-H, given directly or through a reference, +/// as [`Metrics::from_dict`] reads it (a Type 0 font's `/Encoding` may be indirect). +fn identity_h(doc: &Document, font: &Dict) -> bool { + font.get(b"Encoding").is_some_and(|e| doc.resolve(e).as_name() == Some(b"Identity-H".as_slice())) +} + /// Whether `base_font` (subset tag allowed) names a legacy Indic font family that is laid out in /// visual order (Kruti Dev, DevLys, Chanakya, Shusha, Shivaji, Walkman, Akruti, APS-DV, DV-TT…). fn legacy_indic_font(base_font: &str) -> bool { @@ -92,14 +103,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()); @@ -108,7 +122,10 @@ pub fn embedded_sfnt_within(doc: &Document, font: &Dict, budget: &mut DecodeBudg let Some(object) = descriptor.get(key) else { continue }; let object = doc.resolve(object); let Object::Stream(stream) = object.as_ref() else { continue }; - let Ok(mut bytes) = budget.decode_within(&doc.with_direct_parms(stream), MAX_SFNT) else { continue }; + // As in `Reads::decode`: each stage within the limit, the program, the last, too, read + // through a view of the stream; once the allowance is spent a program with data is refused + // before its parameters are resolved. + let Ok(mut bytes) = budget.decode_stages_within(doc, stream, MAX_SFNT) else { continue }; let Ok(face) = skrifa::FontRef::from_index(&bytes, 0) else { continue }; if face.metrics(skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default()).units_per_em == 0 { continue; @@ -120,13 +137,32 @@ pub fn embedded_sfnt_within(doc: &Document, font: &Dict, budget: &mut DecodeBudg if own_cmap && !base_font.rsplit('+').next().is_some_and(|f| f.starts_with("THSarabunPSK")) { return Some(bytes); } + // A composite font's cmap is rebuilt only through Identity-H codes (`composite_glyph_maps`): + // with any other `/Encoding` nothing is, so its metrics aren't read, and a program with a + // cmap of its own is kept as it is, as when the rebuild finds nothing. + if font.name(b"Subtype") == Some(b"Type0") && !identity_h(doc, font) { + if own_cmap { + return Some(bytes); + } + continue; + } // PDF subsets may remove the Unicode cmap entirely. Rebuild an editor-only // cmap from ToUnicode and the actual CID-to-glyph map; source bytes stay unchanged. - let metrics = Metrics::from_dict(doc, font); + // Metrics read past the allowance, its widths' read of the program again included, + // adapt no copy. + let metrics = Metrics::from_dict_within(doc, font, budget); + if metrics.past_allowance { + continue; + } let mut mappings = std::collections::BTreeMap::new(); if metrics.composite { + let refused = budget.refused(); if let Some((map, _)) = composite_glyph_maps(doc, font, &bytes, &metrics, budget) { mappings = map; + } else if budget.refused() > refused { + // The map the cmap is rebuilt from was left unread for want of the allowance: no + // copy is listed, adapted or as it is, as for metrics read past it. + continue; } if own_cmap { let charmap = face.charmap(); @@ -166,7 +202,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 { @@ -175,13 +211,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) = doc.with_direct_parms(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) = doc.with_direct_parms(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 @@ -200,7 +236,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 }; @@ -208,7 +244,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) = doc.with_direct_parms(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 { @@ -354,8 +390,48 @@ const MAX_CMAP_ENTRIES: usize = 1 << 20; /// thousand. 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. +/// one is left unread (behind one filter not decoded past it; behind several, see `Reads::decode`). 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> { + doc: &'a Document, + budget: &'a mut DecodeBudget, + refused: bool, +} + +impl<'a> Reads<'a> { + fn new(doc: &'a Document, budget: &'a mut DecodeBudget) -> Self { + Self { doc, budget, refused: false } + } + + /// `stream` decoded within `max` and the allowance, its `/DecodeParms` read where they are given + /// by reference too, through a view of the stream that copies no more of its dictionary than + /// its filters and parameters; `None` when it can't be, noting when the allowance is why. + /// + /// Each of its filter stages is held to `max`, as a decode without an allowance holds it, and + /// the stages together to `max` for each of them ([`DecodeBudget::decode_stages_within`]): a + /// map of 65,536 CIDs behind ASCIIHex and Flate, its Flate data smaller than the map, makes + /// all of `MAX_CID_TO_GID` in its last stage, with the hex stage's output on top. So a stream + /// reads here when it reads without an allowance and what is left pays for it. A stream with a + /// stage past `max` is unread. When what is left is more than what the stages before that one + /// made and `max`, it is charged those two and isn't refused; otherwise, with more left than + /// the stages' total (behind one filter, `max`), it is charged that total and isn't refused; + /// with no more left than that total, it spends all of it and counts as refused. + fn decode(&mut self, stream: &Stream, max: usize) -> Option> { + match self.budget.decode_stages_within(self.doc, stream, max) { + Ok(data) => Some(data), + Err(_) => { + self.refused |= self.budget.remaining() == 0; + None + } + } + } +} + /// Most Type 3 glyph descriptions read for their `d1` box. A simple font shows at most 256 codes. const MAX_TYPE3_GLYPHS: usize = 256; /// Largest decoded Type 3 glyph description read for its `d1` box; a larger one is skipped. @@ -372,8 +448,8 @@ const MAX_TYPE3_EXTENT: f64 = 1e6; /// no glyph declares one (`d0` glyphs don't). The descriptions the encoding's `/Differences` /// names are chosen, since it "shall specify the complete character encoding" (Table 110); only a /// font without one has its first descriptions chosen. Reads at most [`MAX_TYPE3_GLYPHS`] -/// descriptions within [`MAX_TYPE3_FONT_BYTES`]. -fn type3_glyph_box(doc: &Document, font: &Dict) -> Option<([f64; 4], bool)> { +/// descriptions within [`MAX_TYPE3_FONT_BYTES`], each charged to the reading's allowance too. +fn type3_glyph_box(doc: &Document, font: &Dict, reads: &mut Reads<'_>) -> Option<([f64; 4], bool)> { let procs = dict(doc, font.get(b"CharProcs"))?; let mut names: Vec<&[u8]> = Vec::new(); // Whether every glyph that can be drawn is read; if not, the union may be short of them. @@ -401,12 +477,30 @@ fn type3_glyph_box(doc: &Document, font: &Dict) -> Option<([f64; 4], bool)> { } else { names.iter().filter_map(|n| procs.get(n)).collect() }; - let mut budget = DecodeBudget::new(MAX_TYPE3_FONT_BYTES, 0); + // What is left of the font's own total: the descriptions decode against the reading's + // allowance, which charges them, and each within what is left of this. + let mut left = MAX_TYPE3_FONT_BYTES; let mut union: Option<[f64; 4]> = None; for proc in chosen { let Object::Stream(s) = &*doc.resolve(proc) else { continue }; - let Ok(data) = budget.decode_within(&doc.with_direct_parms(s), MAX_TYPE3_GLYPH_BYTES) else { - if budget.remaining() == 0 { + // Once the reading's allowance is spent the rest go unread, before their parameters are + // resolved. + if reads.budget.refuses(s) { + reads.refused = true; + complete = false; + break; + } + let before = reads.budget.remaining(); + let read = reads.budget.decode_with_damage(doc, s, MAX_TYPE3_GLYPH_BYTES.min(left)).map(|(data, _)| data); + left = left.saturating_sub(before.saturating_sub(reads.budget.remaining())); + let Ok(data) = read else { + if reads.budget.remaining() == 0 { + // The rest go unread too, for want of the reading's allowance. + reads.refused = true; + complete = false; + break; + } + if left == 0 { // The rest go unread too. complete = false; break; @@ -785,11 +879,25 @@ 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, 4 MiB for a Type 3 font's glyph descriptions together), 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(doc, 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()) @@ -806,17 +914,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) @@ -837,7 +946,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(); @@ -850,7 +959,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) = doc.with_direct_parms(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() { @@ -915,7 +1024,7 @@ impl Metrics { // A union cut short by a limit may miss glyphs that are drawn, so a nonzero // `/FontBBox` comes first then; all zero, the partial union is still better // than no box. A box with no height in text space gives way to the next. - let glyphs = type3_glyph_box(doc, font); + let glyphs = type3_glyph_box(doc, font, reads); // All zero: no assumption about glyph sizes (Table 110). let bbox = <[f64; 4]>::try_from(nums(doc, font.get(b"FontBBox"))).ok().filter(|r| r.iter().any(|&v| v != 0.0)); let union = |complete: bool| glyphs.filter(|g| g.1 == complete).map(|(r, _)| (r, 0.0, 0.0)); @@ -1088,8 +1197,14 @@ impl Metrics { } /// 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). + /// no glyph in it, or the metrics were read past their allowance (the caller then substitutes + /// another font). Empty text needs no verified glyph and always encodes. pub fn encode(&self, text: &str) -> Option> { + // A refused read cannot establish the mappings or glyphs needed to write new text. + // Empty text needs neither and remains usable for erasing existing text. + if self.past_allowance && !text.is_empty() { + return None; + } // The lowest code showing each wanted character. Explicit entries and the encoding are // checked one by one; each range is asked for the wanted characters it can show. let wanted: HashSet = text.chars().collect(); @@ -1432,7 +1547,7 @@ pub fn glyph_unicode(name: &str) -> Option { /// to [`symbolic_mac_roman_texts`]). A code whose glyph has no name with a Unicode value reads /// as WinAnsi, as the renderer reads it (vendored hayro-interpret patch (79)), so the editor and /// extraction agree: `€` for code 0x80, which Standard leaves without a character. -fn symbolic_glyph_texts(doc: &Document, font: &Dict) -> Vec<(u32, String)> { +fn symbolic_glyph_texts(doc: &Document, font: &Dict, reads: &mut Reads<'_>) -> Vec<(u32, String)> { use skrifa::GlyphId; use skrifa::raw::{FontData, FontRead as _, TableProvider as _, tables::cmap::PlatformId, tables::post::Post, types::Tag}; let Some(descriptor) = dict(doc, font.get(b"FontDescriptor")) else { return Vec::new() }; @@ -1443,7 +1558,7 @@ fn symbolic_glyph_texts(doc: &Document, font: &Dict) -> Vec<(u32, String)> { let Some(object) = descriptor.get(b"FontFile2") else { return Vec::new() }; let object = doc.resolve(object); let Object::Stream(stream) = object.as_ref() else { return Vec::new() }; - let Ok(bytes) = doc.with_direct_parms(stream).decoded_within(MAX_SFNT) else { return Vec::new() }; + let Some(bytes) = reads.decode(stream, MAX_SFNT) else { return Vec::new() }; let Ok(face) = skrifa::FontRef::from_index(&bytes, 0) else { return Vec::new() }; let Ok(cmap) = face.cmap() else { return Vec::new() }; // A `post` version written with its 16-bit halves swapped (`00 00 00 02` for 2.0, as ScanSoft @@ -1534,7 +1649,7 @@ fn post_name<'a>(post: &skrifa::raw::tables::post::Post<'a>, custom: &[&'a str], /// 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)); @@ -1579,8 +1694,8 @@ fn unicode_map(doc: &Document, font: &Dict, composite: bool) -> Unicode { // A font whose `post` table names its glyphs reads by those names; one that names none // and keeps the Macintosh order reads as Mac OS Roman, over the WinAnsi fallback. if base_name.is_none() && !symbolic_builtin { - out.encoding.extend(symbolic_glyph_texts(doc, font)); - out.encoding.extend(symbolic_mac_roman_texts(doc, font)); + out.encoding.extend(symbolic_glyph_texts(doc, font, reads)); + out.encoding.extend(symbolic_mac_roman_texts(doc, font, reads)); } 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()) @@ -1606,10 +1721,8 @@ 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)) => { - // With `/DecodeParms` given by reference, as every stream of the font is read here, - // and as the redaction proof reads the map: the two must get the same text, or a copy - // redaction rightly left counts as a survivor. - if let Ok(data) = doc.with_direct_parms(s).decoded_within(MAX_CMAP_BYTES) { + // Reads resolves indirect parameters too, matching the redaction proof's text. + if let Some(data) = reads.decode(s, MAX_CMAP_BYTES) { parse_to_unicode(&data, &mut out); } } @@ -1657,7 +1770,7 @@ fn identity_to_unicode() -> Vec { /// renderer finds it: the Windows (3, 0) and (3, 1) subtables first, with the code and the code at /// 0xF000, 0xF100 and 0xF200, then the Macintosh and Unicode-platform ones. The renderer's text /// layer reads the same codes the same way (vendored hayro-interpret patch (101)). -fn symbolic_mac_roman_texts(doc: &Document, font: &Dict) -> Vec<(u32, String)> { +fn symbolic_mac_roman_texts(doc: &Document, font: &Dict, reads: &mut Reads<'_>) -> Vec<(u32, String)> { use skrifa::GlyphId; use skrifa::raw::{TableProvider as _, tables::cmap::PlatformId, tables::post::DEFAULT_GLYPH_NAMES, types::Tag}; let Some(descriptor) = dict(doc, font.get(b"FontDescriptor")) else { return Vec::new() }; @@ -1668,7 +1781,7 @@ fn symbolic_mac_roman_texts(doc: &Document, font: &Dict) -> Vec<(u32, String)> { let Some(object) = descriptor.get(b"FontFile2") else { return Vec::new() }; let object = doc.resolve(object); let Object::Stream(stream) = object.as_ref() else { return Vec::new() }; - let Ok(bytes) = doc.with_direct_parms(stream).decoded_within(MAX_SFNT) else { return Vec::new() }; + let Some(bytes) = reads.decode(stream, MAX_SFNT) else { return Vec::new() }; let Ok(face) = skrifa::FontRef::from_index(&bytes, 0) else { return Vec::new() }; // A CFF program in `/FontFile2` selects its glyphs by name, not through the `cmap`. A `post` // table of version 1.0 or 2.0 (also with its 16-bit halves swapped, as the renderer reads it) diff --git a/crates/fonts/src/pdf/tests.rs b/crates/fonts/src/pdf/tests.rs index 2d95465944..975b71289e 100644 --- a/crates/fonts/src/pdf/tests.rs +++ b/crates/fonts/src/pdf/tests.rs @@ -178,9 +178,10 @@ fn a_font_without_widths_measures_with_its_embedded_program() { } } -#[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(); @@ -194,6 +195,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)); @@ -214,6 +222,331 @@ 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 and for the +/// readings of its codes by glyph name and as Mac OS Roman, the metrics are read past the allowance +/// and no copy is adapted. +#[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 = 4 * 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 whose codes aren't Identity-H can't be adapted, so its metrics aren't + // read: 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]); +} + +/// With nothing left, an empty stream still reads, as the allowance reads one, and is no reason to +/// call the font read past it; a stream with data is left unread, and is. +#[test] +fn an_empty_stream_reads_with_nothing_left() { + let past = |data: &[u8]| { + let mut doc = Document::new_empty(); + let unicode = Object::Ref(doc.add(Object::Stream(Stream::from_raw(Dict::new(), data.to_vec())))); + let f = font(&mut doc, vec![("Subtype", Object::name("TrueType")), ("ToUnicode", unicode)]); + Metrics::from_dict_within(&doc, &f, &mut DecodeBudget::new(0, 0)).past_allowance + }; + assert!(!past(b"")); + assert!(past(b"1 beginbfchar <41> <0058> endbfchar")); +} + +/// A stream behind one filter past its own limit, with more left than its 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); +} + +/// A `/CIDToGIDMap` as long as a map can be, which compresses well, reads the same behind no +/// filter, Flate, or ASCIIHex and Flate: each stage may make as much as the map's limit, so the hex +/// stage's output on top of it doesn't leave it unread. A map past the limit is unread behind each. +#[test] +fn a_full_cid_map_reads_behind_one_or_two_filters() { + let read = |entries: usize, filters: usize| { + let mut doc = Document::new_empty(); + let (mut f, _) = subset_type0(&mut doc, b""); + let map: Vec = (0..entries).flat_map(|cid| u16::try_from(cid % 2000).unwrap().to_be_bytes()).collect(); + let flate = Stream::flate(Dict::new(), &map); + let stream = match filters { + 0 => Stream::from_raw(Dict::new(), map), + 1 => flate, + _ => { + let hex: String = flate.raw.iter().map(|b| format!("{b:02X}")).chain([">".to_string()]).collect(); + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("ASCIIHexDecode"), Object::name("FlateDecode")])); + Stream::from_raw(dict, hex.into_bytes()) + } + }; + let map = Object::Ref(doc.add(Object::Stream(stream))); + let Some(Object::Array(mut descendants)) = f.get(b"DescendantFonts").cloned() else { panic!("descendant") }; + if let Some(Object::Dict(d)) = descendants.get_mut(0) { + d.set(b"CIDToGIDMap".to_vec(), map); + } + f.set(b"DescendantFonts".to_vec(), Object::Array(descendants)); + Metrics::from_dict(&doc, &f).outlined_cids + }; + let full = read(1 << 16, 1); + assert!(!full.is_empty()); + assert_eq!(read(1 << 16, 0), full); + assert_eq!(read(1 << 16, 2), full); + for filters in 0..=2 { + assert!(read((1 << 16) + 1, filters).is_empty(), "{filters} filters"); + } +} + +/// The editor's program read holds every filter stage to the program's limit: a program behind +/// ASCIIHex and Flate whose Flate stage would make 24 MiB stops at 16 MiB, charged that and what +/// the hex stage made, where the stages' total alone would let it make all 24 MiB. A program +/// within the limit behind the same two filters reads (see also +/// `an_editors_program_reads_behind_ascii_hex_and_flate_within_its_limit`). +#[test] +fn an_editors_program_read_holds_each_stage_to_its_limit() { + let mut doc = Document::new_empty(); + let flate = Stream::flate(Dict::new(), &vec![b' '; 24 << 20]); + let hex: Vec = flate.raw.iter().flat_map(|b| format!("{b:02X}").into_bytes()).chain(*b">").collect(); + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("ASCIIHexDecode"), Object::name("FlateDecode")])); + let program = doc.add(Object::Stream(Stream::from_raw(dict, hex))); + let mut descriptor = Dict::new(); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); + let f = font(&mut doc, vec![("Subtype", Object::name("TrueType")), ("FontDescriptor", Object::Dict(descriptor))]); + let mut budget = DecodeBudget::new(usize::MAX / 2, 0); + assert!(embedded_sfnt_within(&doc, &f, &mut budget).is_none()); + assert_eq!(usize::MAX / 2 - budget.remaining(), flate.raw.len() + MAX_SFNT); +} + +/// Each stage of a stream a font's metrics are read from is held to the stream's limit, as a +/// decode without an allowance holds it: a `/CIDToGIDMap` behind Flate and ASCIIHex whose Flate +/// stage makes hex text past the map's limit is unread, as `Stream::decoded_within` leaves it, +/// though the two stages together make less than twice the limit; with its hex text within the +/// limit it reads. +#[test] +fn each_stage_of_a_metrics_stream_is_held_to_its_limit() { + let read = |cids: usize| { + let mut doc = Document::new_empty(); + let (mut f, _) = subset_type0(&mut doc, b""); + let map: Vec = (0..cids).flat_map(|cid| u16::try_from(cid % 2000).unwrap().to_be_bytes()).collect(); + let hex: Vec = map.iter().flat_map(|b| format!("{b:02X}").into_bytes()).collect(); + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("FlateDecode"), Object::name("ASCIIHexDecode")])); + let stream = Stream::from_raw(dict, Stream::flate(Dict::new(), &hex).raw.to_vec()); + let unread = stream.decoded_within(MAX_CID_TO_GID).is_err(); + let map = Object::Ref(doc.add(Object::Stream(stream))); + let Some(Object::Array(mut descendants)) = f.get(b"DescendantFonts").cloned() else { panic!("descendant") }; + if let Some(Object::Dict(d)) = descendants.get_mut(0) { + d.set(b"CIDToGIDMap".to_vec(), map); + } + f.set(b"DescendantFonts".to_vec(), Object::Array(descendants)); + (Metrics::from_dict(&doc, &f).outlined_cids.is_empty(), unread) + }; + // 0.6 of the limit: its hex text, 1.2 of it, is past the limit; the stages make 1.8 of it. + assert_eq!(read(MAX_CID_TO_GID * 6 / 20), (true, true)); + assert_eq!(read(MAX_CID_TO_GID / 4), (false, false)); +} + +/// The editor's program is held to its limit as the metrics reads' streams are: a 12 MiB program +/// whose Flate data is about 8 MiB reads behind ASCIIHex and Flate as behind Flate alone, though +/// the two stages make about 20 MiB together. A program of the limit reads behind either, and one a +/// byte past it is unread behind either. +#[test] +fn an_editors_program_reads_behind_ascii_hex_and_flate_within_its_limit() { + let program = |len: usize, noise: usize| { + let mut program = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf").to_vec(); + // Bytes Flate can't shrink, then zeros. + let mut x = 1u32; + program.extend((0..noise).map(|_| { + x = x.wrapping_mul(1_103_515_245).wrapping_add(12_345); + x.to_be_bytes()[0] + })); + program.resize(len, 0); + program + }; + let read = |program: &[u8], hex: bool| { + let mut doc = Document::new_empty(); + let flate = Stream::flate(Dict::new(), program); + let stream = if hex { + let digits = b"0123456789ABCDEF"; + let mut data: Vec = flate.raw.iter().flat_map(|b| [digits[usize::from(b >> 4)], digits[usize::from(b & 15)]]).collect(); + data.push(b'>'); + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("ASCIIHexDecode"), Object::name("FlateDecode")])); + Stream::from_raw(dict, data) + } else { + flate + }; + let mut descriptor = Dict::new(); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(doc.add(Object::Stream(stream)))); + let simple = font(&mut doc, vec![("FontDescriptor", Object::Dict(descriptor))]); + embedded_sfnt(&doc, &simple) + }; + let near = program(12 << 20, 8 << 20); + let full = program(MAX_SFNT, 0); + for hex in [false, true] { + assert_eq!(read(&near, hex).as_deref(), Some(near.as_slice()), "behind ASCIIHex too: {hex}"); + assert_eq!(read(&full, hex).as_deref(), Some(full.as_slice()), "behind ASCIIHex too: {hex}"); + assert!(read(&program(MAX_SFNT + 1, 0), hex).is_none(), "behind ASCIIHex too: {hex}"); + } +} + +/// `data` in rows of `columns` bytes behind Flate and a PNG predictor, each row tagged Up (the +/// difference from the row above), so read without the predictor it is garbled; with its +/// `/DecodeParms` given by reference or direct. +fn predicted(doc: &mut Document, data: &[u8], columns: usize, by_reference: bool) -> Object { + let mut above = vec![0u8; columns]; + let mut rows = Vec::new(); + for row in data.chunks(columns) { + rows.push(2); + rows.extend(row.iter().zip(&above).map(|(b, a)| b.wrapping_sub(*a))); + above[..row.len()].copy_from_slice(row); + } + let mut s = Stream::flate(Dict::new(), &rows); + let parms: Dict = + [(b"Predictor".to_vec(), Object::Int(12)), (b"Columns".to_vec(), Object::Int(i64::try_from(columns).unwrap()))].into_iter().collect(); + let parms = if by_reference { Object::Ref(doc.add(Object::Dict(parms))) } else { Object::Dict(parms) }; + s.dict.set(b"DecodeParms".to_vec(), parms); + Object::Ref(doc.add(Object::Stream(s))) +} + +/// A stream whose `/DecodeParms` are given by reference reads as it does with them direct: a PNG +/// predictor's tag bytes are taken out of the map, the CMap and the editor's program, not left in. +#[test] +fn parameters_given_by_reference_are_read() { + let read = |by_reference: bool| { + let mut doc = Document::new_empty(); + let (mut f, _) = subset_type0(&mut doc, b""); + let map: Vec = (0..1000u16).flat_map(u16::to_be_bytes).collect(); + let map = predicted(&mut doc, &map, 2, by_reference); + let mut cmap = b"1 beginbfchar <0001> <0058> endbfchar".to_vec(); + cmap.resize(48, b' '); + let unicode = predicted(&mut doc, &cmap, 16, by_reference); + let Some(Object::Array(mut descendants)) = f.get(b"DescendantFonts").cloned() else { panic!("descendant") }; + if let Some(Object::Dict(d)) = descendants.get_mut(0) { + d.set(b"CIDToGIDMap".to_vec(), map); + } + f.set(b"DescendantFonts".to_vec(), Object::Array(descendants)); + f.set(b"ToUnicode".to_vec(), unicode); + let m = Metrics::from_dict(&doc, &f); + (m.outlined_cids.clone(), m.text_of(1).map(Cow::into_owned)) + }; + let direct = read(false); + assert!(!direct.0.is_empty()); + assert_eq!(direct.1.as_deref(), Some("X")); + assert_eq!(read(true), direct); + let mut program = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf").to_vec(); + program.resize(program.len().next_multiple_of(4), 0); + for by_reference in [false, true] { + let mut doc = Document::new_empty(); + let file = predicted(&mut doc, &program, 4, by_reference); + let mut descriptor = Dict::new(); + descriptor.set(b"FontFile2".to_vec(), file); + let simple = font(&mut doc, vec![("FontDescriptor", Object::Dict(descriptor))]); + assert_eq!(embedded_sfnt(&doc, &simple).as_deref(), Some(program.as_slice()), "by reference: {by_reference}"); + } +} + +/// 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); +} + /// The names read once give what `Post::glyph_name` gives, for every glyph of a font with its /// own names (Inter's version 2.0 table), and none past the last glyph. #[test] @@ -388,6 +721,21 @@ fn a_symbolic_truetype_font_without_an_encoding_reads_its_glyph_names() { ], ); assert_eq!(Metrics::from_dict(&doc, &f).decode(&codes), ""); + // The names are read within the allowance the metrics are read within: the program is paid + // for by the glyph-name reading, by the Mac OS Roman one (which finds the glyphs named and + // reads nothing) and for the widths. A byte short of the first, the names go unread. + 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"Flags".to_vec(), Object::Int(4)); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); + let f = font(&mut doc, vec![("Subtype", Object::name("TrueType")), ("FontDescriptor", Object::Dict(descriptor))]); + let mut budget = DecodeBudget::new(3 * subset.len(), 0); + assert_eq!(Metrics::from_dict_within(&doc, &f, &mut budget), symbolic); + assert_eq!(budget.remaining(), 0); + let short = Metrics::from_dict_within(&doc, &f, &mut DecodeBudget::new(subset.len() - 1, 0)); + assert!(short.past_allowance); + assert_ne!(short.decode(&codes), TEXT, "the names unread"); } /// The glyph list specification's `uni` and `u` forms spell their hex digits in uppercase @@ -603,6 +951,24 @@ fn type3_glyph_byte_limits_leave_honest_metrics() { assert_eq!((m.ascent, m.descent), (5.0, -2.0), "the /FontBBox, not the first glyph's box alone"); } +/// A Type 3 font's glyph descriptions are paid for from the allowance its metrics are read within, +/// each what it decodes to: one the allowance can't pay for is left unread, and the metrics say so. +#[test] +fn type3_glyph_boxes_are_paid_for_from_the_allowance() { + let mut doc = Document::new_empty(); + let procs = ["1 0 0 -20 1 300 d1", "1 0 0 0 1 400 d1"]; + let f = type3(&mut doc, [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], [0, 0, 0, 0], &procs); + let cost: usize = procs.iter().map(|p| p.len()).sum(); + let whole = Metrics::from_dict(&doc, &f); + assert!(!whole.past_allowance); + assert_eq!((whole.ascent, whole.descent), (400.0, -20.0)); + let mut budget = DecodeBudget::new(cost, 0); + assert_eq!(Metrics::from_dict_within(&doc, &f, &mut budget), whole); + assert_eq!(budget.remaining(), 0); + let m = Metrics::from_dict_within(&doc, &f, &mut DecodeBudget::new(cost - 1, 0)); + assert!(m.past_allowance, "a byte short, the second description is left unread"); +} + /// A Type 3 font measured from its glyphs' `d1` boxes keeps them over a descriptor's `/Ascent` /// and `/Descent`, which are optional for Type 3 (ISO 32000-2 Table 120). #[test] @@ -1142,7 +1508,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(&doc, &mut DecodeBudget::new(usize::MAX, 0))).text(u32::MAX).as_deref(), Some("B")); } #[test] @@ -1373,6 +1739,29 @@ fn low_codes_resolve_to_the_covering_range() { assert_eq!((u.covering(0xFFFF).is_some(), u.covering(0x1_0001).is_some(), u.covering(0x1_0002).is_none()), (true, true, true)); } +#[test] +fn refused_subset_reads_do_not_encode_unverified_glyphs() { + let mut doc = Document::new_empty(); + let (mut f, sizes) = subset_type0(&mut doc, b""); + f.set(b"Encoding".to_vec(), Object::name("Identity-H")); + f.set(b"ToUnicode".to_vec(), Object::name("Identity-H")); + if let Some(Object::Array(descendants)) = f.get_mut(b"DescendantFonts") + && let Some(Object::Dict(descendant)) = descendants.first_mut() + { + descendant.set(b"DW".to_vec(), Object::Int(1000)); + } + let whole = Metrics::from_dict(&doc, &f); + assert!(!whole.past_allowance && !whole.outlined_cids.is_empty()); + assert!(whole.encode("\u{ffff}").is_none()); + // Refuse the program, then the CID map after reading the complete program. + for allowance in [0, sizes[1]] { + let partial = Metrics::from_dict_within(&doc, &f, &mut DecodeBudget::new(allowance, 0)); + assert!(partial.past_allowance); + assert_eq!(partial.encode(""), Some(Vec::new())); + assert!(partial.encode("\u{ffff}").is_none(), "allowance {allowance} must not turn /DW into proof of a glyph"); + } +} + /// [`cmap_only_sfnt_named`] without a `post` table. fn cmap_only_sfnt(mac: Option<&[(u8, u8)]>, windows: &[(u16, u16)]) -> Vec { cmap_only_sfnt_named(mac, windows, false) @@ -1457,7 +1846,7 @@ fn a_symbolic_truetype_font_in_the_macintosh_order_reads_its_codes_as_mac_roman( descriptor.set(b"Flags".to_vec(), Object::Int(flags)); descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); let f = font(&mut doc, vec![("Subtype", Object::name("TrueType")), ("FontDescriptor", Object::Dict(descriptor))]); - let u = unicode_map(&doc, &f, false); + let u = unicode_map(&doc, &f, false, &mut Reads::new(&doc, &mut DecodeBudget::new(usize::MAX, 0))); codes.map(|c| u.text(c).unwrap_or_default().into_owned()) }; // `agrave` and `eacute` are glyphs 106 and 112 of the standard Macintosh order, `endash` and @@ -1492,7 +1881,7 @@ fn the_mac_roman_reading_needs_no_base_encoding_and_no_symbolic_builtin() { let mut all = vec![("Subtype", Object::name("TrueType")), ("FontDescriptor", Object::Dict(descriptor))]; all.extend(entries); let f = font(&mut doc, all); - let u = unicode_map(&doc, &f, false); + let u = unicode_map(&doc, &f, false, &mut Reads::new(&doc, &mut DecodeBudget::new(usize::MAX, 0))); [0x88, 0x8E].map(|c| u.text(c).unwrap_or_default().into_owned()) }; assert_eq!(read(vec![]), ["à", "é"]); @@ -1512,8 +1901,40 @@ fn the_mac_roman_reading_needs_no_base_encoding_and_no_symbolic_builtin() { assert_eq!(read(vec![("Encoding", Object::Dict(notdef))]), ["à", ""], "/Differences naming .notdef"); } +/// The program reads that tell a symbolic TrueType font's codes by its glyph names and as Mac OS +/// Roman are each paid for from the allowance the metrics are read within: one it can't pay for is +/// left unread, and the metrics say so. #[test] -fn composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without_replacing_the_program() { +fn the_symbolic_readings_of_a_program_are_paid_for() { + let program = cmap_only_sfnt(Some(&[(0x88, 106)]), &[(0xF088, 106)]); + let mut doc = Document::new_empty(); + let stream = doc.add(Object::Stream(Stream::flate(Dict::new(), &program))); + let mut descriptor = Dict::new(); + descriptor.set(b"Flags".to_vec(), Object::Int(4)); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(stream)); + // With `/Widths` the program isn't read for its advances: these are its only reads. + let entries = vec![ + ("Subtype", Object::name("TrueType")), + ("FontDescriptor", Object::Dict(descriptor)), + ("FirstChar", Object::Int(0x88)), + ("Widths", Object::Array(vec![Object::Int(500)])), + ]; + let f = font(&mut doc, entries); + let whole = Metrics::from_dict(&doc, &f); + assert!(!whole.past_allowance); + assert_eq!(whole.text_of(0x88).as_deref(), Some("à")); + let mut budget = DecodeBudget::new(2 * program.len(), 0); + assert_eq!(Metrics::from_dict_within(&doc, &f, &mut budget), whole); + assert_eq!(budget.remaining(), 0); + let m = Metrics::from_dict_within(&doc, &f, &mut DecodeBudget::new(2 * program.len() - 1, 0)); + assert!(m.past_allowance, "a byte short, the Mac OS Roman reading's read is left unread"); + assert_eq!(m.text_of(0x88), None); +} + +/// A Type 0 font over Inter's program without its `cmap`, Identity-H, whose `/CIDToGIDMap` sends +/// CID 17 to Inter's Ω and whose ToUnicode says code 17 is Ω; also the program object, the +/// program, Ω's glyph and what the ToUnicode and the map decode to. +fn cmapless_composite(doc: &mut Document) -> (Dict, pdfcraft_cos::ObjRef, Vec, skrifa::GlyphId, [usize; 2]) { use skrifa::{MetadataProvider as _, raw::TableProvider as _}; let original = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf"); let expected = skrifa::FontRef::new(original).unwrap().charmap().map('Ω').unwrap(); @@ -1525,7 +1946,6 @@ fn composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without subset[12 + index * 16..28 + index * 16].copy_from_slice(record); } assert!(skrifa::FontRef::new(&subset).unwrap().cmap().is_err()); - let mut doc = Document::new_empty(); let program = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), &subset))); let mut descriptor = Dict::new(); descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); @@ -1535,9 +1955,10 @@ fn composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without let mut descendant = Dict::new(); descendant.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor)); descendant.set(b"CIDToGIDMap".to_vec(), Object::Ref(gids)); - let unicode = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), b"1 beginbfchar <0011> <03a9> endbfchar"))); + let cmap = b"1 beginbfchar <0011> <03a9> endbfchar"; + let unicode = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), cmap))); let source = font( - &mut doc, + doc, vec![ ("Subtype", Object::name("Type0")), ("Encoding", Object::name("Identity-H")), @@ -1545,6 +1966,14 @@ fn composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without ("ToUnicode", Object::Ref(unicode)), ], ); + (source, program, subset, expected, [cmap.len(), 36]) +} + +#[test] +fn composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without_replacing_the_program() { + use skrifa::MetadataProvider as _; + let mut doc = Document::new_empty(); + let (source, program, subset, expected, _) = cmapless_composite(&mut doc); let editor = embedded_sfnt(&doc, &source).expect("the source outlines must be usable in the editor"); assert_eq!(skrifa::FontRef::new(&editor).unwrap().charmap().map('Ω'), Some(expected)); let shaped = crate::source_shaping::shape(&editor, "Ω").expect("the rebuilt cmap must work in the actual shaping engine too"); @@ -1552,3 +1981,74 @@ fn composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without let Object::Stream(saved) = doc.get(program).as_ref().clone() else { panic!("fixture") }; assert_eq!(saved.decoded().unwrap(), subset, "the source PDF font program stays byte-for-byte intact"); } + +/// The metrics read that rebuilds a composite subset's cmap is paid for from the editor's +/// allowance, as a simple font's is: the program, its ToUnicode, then its `/CIDToGIDMap`. With the +/// ToUnicode left unread no copy is adapted, though what is left would pay for the map. +#[test] +fn adapting_a_composite_subsets_cmap_is_paid_for() { + use skrifa::MetadataProvider as _; + let mut doc = Document::new_empty(); + let (source, _, subset, expected, [cmap, map]) = cmapless_composite(&mut doc); + let cost = subset.len() + cmap + map; + 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!(cmap > map); + assert!(embedded_sfnt_within(&doc, &source, &mut DecodeBudget::new(subset.len() + cmap - 1, 0)).is_none()); +} + +/// A composite font whose `/Encoding` isn't Identity-H can't have its cmap rebuilt +/// (`composite_glyph_maps` reads only Identity-H codes), so the editor reads its program and no +/// more: its ToUnicode and `/CIDToGIDMap` are left for what is left of the allowance. +#[test] +fn a_composite_font_that_cant_be_adapted_spends_only_its_program() { + let mut doc = Document::new_empty(); + let (source, _, subset, _, [cmap, map]) = cmapless_composite(&mut doc); + for encoding in ["Identity-V", "UniJIS-UCS2-H"] { + let mut f = source.clone(); + f.set(b"Encoding".to_vec(), Object::name(encoding)); + let mut budget = DecodeBudget::new(subset.len() + cmap + map, 0); + assert!(embedded_sfnt_within(&doc, &f, &mut budget).is_none(), "{encoding}"); + assert_eq!(budget.remaining(), cmap + map, "{encoding}"); + } +} + +/// A composite font's `/Encoding` given through a reference is the name it refers to, as the +/// metrics read it: Identity-H by reference is adapted as Identity-H written directly is. +#[test] +fn a_composite_fonts_encoding_given_by_reference_is_read() { + use skrifa::MetadataProvider as _; + let mut doc = Document::new_empty(); + let (mut source, _, _, expected, _) = cmapless_composite(&mut doc); + let identity = doc.add(Object::name("Identity-H")); + source.set(b"Encoding".to_vec(), Object::Ref(identity)); + assert_eq!(Metrics::from_dict(&doc, &source).code_len, 2); + let editor = embedded_sfnt(&doc, &source).expect("Identity-H by reference is adapted"); + assert_eq!(skrifa::FontRef::new(&editor).unwrap().charmap().map('Ω'), Some(expected)); +} + +/// The `/CIDToGIDMap` a composite subset's cmap is rebuilt from reads as the font's other streams +/// do: a full map, which compresses well, reads behind ASCIIHex and Flate, though the hex stage's +/// output comes on top of the map. +#[test] +fn a_full_cid_map_rebuilds_a_composite_subsets_cmap_behind_two_filters() { + use skrifa::MetadataProvider as _; + let mut doc = Document::new_empty(); + let (mut source, _, _, expected, _) = cmapless_composite(&mut doc); + let mut map = vec![0u8; MAX_CID_TO_GID]; + map[34..36].copy_from_slice(&(expected.to_u32() as u16).to_be_bytes()); + let flate = Stream::flate(Dict::new(), &map); + let hex: String = flate.raw.iter().map(|b| format!("{b:02X}")).chain([">".to_string()]).collect(); + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("ASCIIHexDecode"), Object::name("FlateDecode")])); + let map = Object::Ref(doc.add(Object::Stream(Stream::from_raw(dict, hex.into_bytes())))); + let Some(Object::Array(mut descendants)) = source.get(b"DescendantFonts").cloned() else { panic!("descendant") }; + if let Some(Object::Dict(d)) = descendants.get_mut(0) { + d.set(b"CIDToGIDMap".to_vec(), map); + } + source.set(b"DescendantFonts".to_vec(), Object::Array(descendants)); + let editor = embedded_sfnt(&doc, &source).expect("the map behind two filters is read"); + assert_eq!(skrifa::FontRef::new(&editor).unwrap().charmap().map('Ω'), Some(expected)); +} diff --git a/crates/fonts/src/pdf/thai.rs b/crates/fonts/src/pdf/thai.rs index ac151a5a18..84075f87e8 100644 --- a/crates/fonts/src/pdf/thai.rs +++ b/crates/fonts/src/pdf/thai.rs @@ -7,7 +7,7 @@ //! does (a mark takes none); correct maps and unrelated fonts are never changed. No font //! artwork is bundled. -use super::{Dict, Document, Metrics, dict, only_char, unicode_map}; +use super::{Dict, Document, Metrics, Reads, dict, identity_h, only_char, unicode_map}; use pdfcraft_cos::{DecodeBudget, Object}; use std::collections::BTreeMap; @@ -19,15 +19,18 @@ pub fn recovered_thai_map(doc: &Document, font: &Dict) -> Option Option> { let descriptor = dict(doc, descendant.get(b"FontDescriptor"))?; let program = doc.resolve(descriptor.get(b"FontFile2")?); let Object::Stream(program) = program.as_ref() else { return None }; - DecodeBudget::new(super::MAX_SFNT, 0).decode_within(&doc.with_direct_parms(program), super::MAX_SFNT).ok() + DecodeBudget::new(super::MAX_SFNT, 0).decode_with_damage(doc, program, super::MAX_SFNT).ok().map(|(data, _)| data) } /// Source glyph positions and the original PDF character codes that draw them. Empty codes @@ -190,14 +193,15 @@ pub(super) fn composite_glyph_maps( budget: &mut DecodeBudget, ) -> Option<(BTreeMap, BTreeMap)> { use skrifa::{MetadataProvider as _, raw::TableProvider as _}; - if font.name(b"Encoding") != Some(b"Identity-H") { + if !identity_h(doc, font) { return None; } let descendant = dict(doc, font.get(b"DescendantFonts").and_then(|a| doc.resolve(a).as_array().and_then(|a| a.first()).cloned()).as_ref())?; let cid_map = match descendant.get(b"CIDToGIDMap").map(|m| doc.resolve(m)).as_deref() { None => None, Some(Object::Name(n)) if n == b"Identity" => None, - Some(Object::Stream(s)) => Some(budget.decode_within(&doc.with_direct_parms(s), 1 << 17).ok()?), + // Read as the font's other streams are, a map behind several filters too. + Some(Object::Stream(s)) => Some(Reads::new(doc, budget).decode(s, super::MAX_CID_TO_GID)?), _ => return None, }; let face = skrifa::FontRef::new(bytes).ok()?; @@ -294,13 +298,17 @@ impl LegacyThaiFont { /// Thai glyphs are not in the legacy order: the nominal and alternate marks this shaper /// assumes can't be trusted there, so it is shaped as an ordinary source font. /// - /// Its streams are decoded against `budget`, which the caller shares between every font it - /// reads: what they keep is the programs decoded and the editor cmaps added to them. + /// Its streams, its metrics' too, are decoded against `budget`, which the caller shares + /// between every font it reads: what they keep is the programs decoded and the editor cmaps + /// added to them. Metrics read past it adapt nothing: the font is refused. pub fn from_pdf(doc: &Document, font: &Dict, space: f64, budget: &mut DecodeBudget) -> Result, String> { - let m = Metrics::from_dict(doc, font); + let m = Metrics::from_dict_within(doc, font, budget); if !m.base_font.rsplit('+').next().unwrap_or(&m.base_font).starts_with("THSarabunPSK") { return Ok(None); } + if m.past_allowance { + return Err("the source Thai font's metrics are past the edit's allowance".into()); + } if !space.is_finite() || space < 0.0 { return Err("the source space width is invalid".into()); } @@ -528,6 +536,46 @@ mod tests { /// An intact legacy map: nominal Thai and two tone alternates. const INTACT: &[u8] = b"2 beginbfrange <00d6> <010f> <0e01> <0110> <012a> <0e3f> endbfrange 2 beginbfchar <0160> <0e48> <0161> <0e49> endbfchar"; + /// A THSarabunPSK subset whose own cmap is Latin-only has its Thai mappings rebuilt from its + /// `/CIDToGIDMap`. With that map left unread for want of the editor's allowance, the editor + /// lists no copy, rather than the program without its Thai mappings. + #[test] + fn a_map_left_unread_for_the_allowance_lists_no_copy() { + let mut doc = Document::new_empty(); + let program = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf"); + let file = Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), program)))); + let map: Vec = (0u16..512).flat_map(u16::to_be_bytes).collect(); + let cid_to_gid = Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), &map)))); + let font = thai_font_over(&mut doc, file, cid_to_gid, INTACT); + let mut budget = DecodeBudget::new(usize::MAX / 2, 0); + assert!(crate::pdf::embedded_sfnt_within(&doc, &font, &mut budget).is_some()); + let cost = usize::MAX / 2 - budget.remaining(); + // The map the cmap is rebuilt from is read last: a byte short of the cost, it is refused. + let mut short = DecodeBudget::new(cost - 1, 0); + assert!(crate::pdf::embedded_sfnt_within(&doc, &font, &mut short).is_none(), "the program is listed without its Thai mappings"); + assert_eq!(short.refused(), 1); + } + + /// `LegacyThaiFont::from_pdf` reads its metrics within the edit's allowance too: the subset's + /// program, for its outlined glyphs, and its ToUnicode are paid for on top of what the + /// editor's read of it pays. Metrics read past the allowance refuse the font before its + /// program is read. + #[test] + fn a_legacy_fonts_metrics_are_paid_for_from_the_edits_allowance() { + let mut doc = Document::new_empty(); + let program = thai_ordered_program(); + let font = thai_font(&mut doc, &program, INTACT); + let mut editor = DecodeBudget::new(usize::MAX / 2, 0); + assert!(crate::pdf::embedded_sfnt_within(&doc, &font, &mut editor).is_some()); + let editor_cost = usize::MAX / 2 - editor.remaining(); + let mut budget = DecodeBudget::new(usize::MAX / 2, 0); + LegacyThaiFont::from_pdf(&doc, &font, 0.25, &mut budget).unwrap(); + assert_eq!(usize::MAX / 2 - budget.remaining(), editor_cost + program.len() + INTACT.len()); + let mut short = DecodeBudget::new(program.len() + INTACT.len() - 1, 0); + let error = LegacyThaiFont::from_pdf(&doc, &font, 0.25, &mut short).err().unwrap_or_default(); + assert!(error.contains("past the edit's allowance"), "{error}"); + } + /// The exporter's damaged map: intact anchors, Latin/control destinations for the other Thai /// codes, and the oversized range first. const DAMAGED: &[u8] = b"begincmap 1 begincodespacerange <0000> endcodespacerange \ @@ -547,6 +595,32 @@ mod tests { let recovered: HashMap<_, _> = recovered_thai_map(&doc, &font).unwrap().into_iter().collect(); assert_eq!(recovered[&262], 'ั'); assert_eq!(recovered[&297], '๘'); + // `/Encoding` given through a reference is the name it refers to. + let mut indirect = font.clone(); + indirect.set(b"Encoding".to_vec(), Object::Ref(doc.add(Object::name("Identity-H")))); + assert_eq!(recovered_thai_map(&doc, &indirect), recovered_thai_map(&doc, &font)); + // `/CIDToGIDMap` given through a reference is the name it refers to, and a missing one is + // taken as Identity, the default of PDF 1.x (ISO 32000-1), as the renderer takes it. + let with_map = |map: Option| { + let mut f = font.clone(); + let Some(Object::Array(desc)) = f.get(b"DescendantFonts").cloned() else { panic!("fixture") }; + let Some(Object::Dict(mut desc)) = desc.first().cloned() else { panic!("fixture") }; + match map { + Some(map) => desc.set(b"CIDToGIDMap".to_vec(), map), + None => { + desc.remove(b"CIDToGIDMap"); + } + } + f.set(b"DescendantFonts".to_vec(), Object::Array(vec![Object::Dict(desc)])); + f + }; + let by_reference = with_map(Some(Object::Ref(doc.add(Object::name("Identity"))))); + assert_eq!(recovered_thai_map(&doc, &by_reference), recovered_thai_map(&doc, &font), "a map by reference"); + assert_eq!(recovered_thai_map(&doc, &with_map(None)), recovered_thai_map(&doc, &font), "a missing map"); + // Any other map is refused: one that moves glyphs, and another name. + let moved = Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), &[0, 1, 0, 0])))); + assert_eq!(recovered_thai_map(&doc, &with_map(Some(moved))), None, "a map that moves glyphs"); + assert_eq!(recovered_thai_map(&doc, &with_map(Some(Object::name("Foo")))), None, "another name"); // 259–261 are ฮ, ฯ and ะ, between the last anchor (อ, 258) and the first mark: a font that // put Mai Han-akat one glyph earlier passes every anchor and only this check sees it. let mut shifted = thai_ordered_program(); diff --git a/crates/fonts/tests/stream_views.rs b/crates/fonts/tests/stream_views.rs new file mode 100644 index 0000000000..de210637ae --- /dev/null +++ b/crates/fonts/tests/stream_views.rs @@ -0,0 +1,62 @@ +//! Memory regression for the font reads charged to an allowance: a stream whose `/DecodeParms` +//! is given by reference is read through a view that shares its data and copies no more of its +//! dictionary than its filters and parameters, not through a copy of the whole stream. A test +//! binary of its own, so that the one test is all the process runs while its peak is measured. + +use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, Stream}; +use pdfcraft_fonts::pdf::{Metrics, embedded_sfnt_within}; + +/// How much the process's peak resident memory grows, in KiB, while `f` runs. `None` where the +/// peak can't be reset (`/proc/self/clear_refs` is Linux's). +fn peak_growth(f: impl FnOnce() -> T) -> Option<(T, u64)> { + fn peak_kib() -> Option { + let status = std::fs::read_to_string("/proc/self/status").ok()?; + status.lines().find_map(|l| l.strip_prefix("VmHWM:"))?.split_whitespace().next()?.parse().ok() + } + std::fs::write("/proc/self/clear_refs", "5").ok()?; + let before = peak_kib()?; + let out = f(); + Some((out, peak_kib()?.saturating_sub(before))) +} + +/// A stream of `data`, its `/DecodeParms` the reference `parms`, its dictionary holding `junk`. +fn stream(doc: &mut Document, data: &[u8], parms: ObjRef, junk: &Object) -> Object { + let mut s = Stream::flate(Dict::new(), data); + s.dict.set(b"DecodeParms".to_vec(), Object::Ref(parms)); + s.dict.set(b"Junk".to_vec(), junk.clone()); + Object::Ref(doc.add(Object::Stream(s))) +} + +/// A font whose program and ToUnicode each hold an unrelated array of a million numbers in their +/// dictionaries, with `/DecodeParms` given by reference, read eight times within an allowance by +/// the editor's program read and by the metrics read: neither copies an array. A read through a +/// copy of the stream would add about as much as one array holds. +#[test] +fn reading_a_fonts_streams_copies_no_dictionary() { + let junk = Object::Array(vec![Object::Int(0); 1 << 20]); + let array_kib = (std::mem::size_of::() << 20) as u64 >> 10; + let mut doc = Document::new_empty(); + let parms = doc.add(Object::Dict(Dict::from_iter([(b"Predictor".to_vec(), Object::Int(1))]))); + let program = stream(&mut doc, include_bytes!("../../../assets/fonts/Inter-Regular.ttf"), parms, &junk); + let unicode = stream(&mut doc, b"1 beginbfchar <41> <0058> endbfchar", parms, &junk); + drop(junk); + let mut descriptor = Dict::new(); + descriptor.set(b"FontFile2".to_vec(), program); + let mut font = Dict::new(); + font.set(b"Type".to_vec(), Object::name("Font")); + font.set(b"Subtype".to_vec(), Object::name("TrueType")); + font.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor)); + font.set(b"ToUnicode".to_vec(), unicode); + let Some(((), growth)) = peak_growth(|| { + for _ in 0..8 { + let mut budget = DecodeBudget::new(64 << 20, 0); + assert!(embedded_sfnt_within(&doc, &font, &mut budget).is_some()); + let m = Metrics::from_dict_within(&doc, &font, &mut budget); + assert_eq!(m.text_of(0x41).as_deref(), Some("X")); + } + }) else { + eprintln!("the peak can't be measured here"); + return; + }; + assert!(growth < array_kib / 2, "the peak grew {growth} KiB, an array is {array_kib} KiB"); +}