diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 4e290a8178..95bf528b09 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -13112,6 +13112,47 @@ fn editing_legacy_thai_shapes_a_run_across_subsets_of_the_same_face() { assert_eq!(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap(), before, "a refused edit changes nothing"); } +/// Every resource name of one THSarabunPSK font object shares its program, decoded once. Distinct +/// subsets of the face are read within one budget for the edit: past it the edit is refused with +/// the reason, and the document is unchanged. +#[test] +fn legacy_thai_subsets_are_read_within_one_budget_per_edit() { + // Padding after the tables: the program reads the same, at 12 MiB. + let mut large = legacy_thai_program(); + large.resize(12 << 20, 0); + let fixture = |extra: &dyn Fn(&mut Document) -> Object| { + let mut doc = text_page("BT /F1 16 Tf 50 650 Td <00d6> Tj ET"); + let font = legacy_thai_font(&mut doc, "ABCDEF+THSarabunPSK", &large, LEGACY_THAI_UNICODE); + doc.set(pdfcraft_cos::ObjRef::new(5, 0), Object::Dict(font)); + let names: Vec<_> = (0..8).map(|i| (format!("T{i}").into_bytes(), extra(&mut doc))).collect(); + doc.update_dict(pdfcraft_cos::ObjRef::new(3, 0), |page| { + let Some(Object::Dict(mut resources)) = page.get(b"Resources").cloned() else { panic!("fixture") }; + let Some(Object::Dict(mut fonts)) = resources.get(b"Font").cloned() else { panic!("fixture") }; + for (name, font) in names { + fonts.set(name, font); + } + resources.set(b"Font".to_vec(), Object::Dict(fonts)); + page.set(b"Resources".to_vec(), Object::Dict(resources)); + }) + .unwrap(); + doc + }; + let style = BlockStyle { width: Some(400.0), ..Default::default() }; + // Eight more names of F1's object: 108 MiB if each name decoded its own copy. + let mut aliases = fixture(&|_| Object::Ref(pdfcraft_cos::ObjRef::new(5, 0))); + text::rewrite_block(&mut aliases, 0, 0, Some("กขค"), &style).expect("the aliases share one program"); + assert_eq!(text::text_lines(&reopen(&aliases), 0).unwrap().iter().map(|l| l.text.as_str()).collect::(), "กขค"); + // Eight more font objects over the program: each is read, and together they are too many. + let mut distinct = fixture(&|doc| { + let font = doc.get(pdfcraft_cos::ObjRef::new(5, 0)).as_ref().clone(); + Object::Ref(doc.add(font)) + }); + let before = pdfcraft_cos::write_full(&distinct, &Default::default()).unwrap(); + let error = text::rewrite_block(&mut distinct, 0, 0, Some("กขค"), &style).unwrap_err().to_string(); + assert!(error.contains("THSarabunPSK") && error.contains("MiB") && error.contains("choose a replacement font"), "{error}"); + assert_eq!(pdfcraft_cos::write_full(&distinct, &Default::default()).unwrap(), before, "a refused edit changes nothing"); +} + /// 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 3042a80d71..2ab50083df 100644 --- a/crates/edit/src/text_regions/styled.rs +++ b/crates/edit/src/text_regions/styled.rs @@ -105,12 +105,19 @@ impl FontResolver<'_> { } struct LegacyFace { - glyphs: Option, + /// Shared by every resource name of one font object: its program is decoded and kept once. + glyphs: Option>, metrics: Metrics, /// Weight and slant ([`FontStyle::of`]). style: FontStyle, + /// The font object the resource names, when it is given by reference. + object: Option, } +/// 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. +const MAX_THAI_PROGRAMS: usize = 64 << 20; + struct PreparedCharacters { values: Vec, substituted: Option, @@ -155,6 +162,7 @@ impl CharacterPreparation<'_> { let Self { doc, first, lines, draft, spans, style, overrides, fonts, metrics, max_glyphs } = self; let thai_choice = builtin_thai_choice(fonts); let mut thai = ThaiShaping::default(); + let mut thai_programs = pdfcraft_cos::DecodeBudget::new(MAX_THAI_PROGRAMS, 0); let mut chars = Vec::new(); let mut substituted = None; let mut fallback = FallbackBuilder::new(max_glyphs); @@ -197,7 +205,7 @@ impl CharacterPreparation<'_> { }), }; match chosen { - Some(chosen) => legacy_thai_font(resolver.doc, resolver.fonts, &chosen, span, &mut thai.legacy)?, + Some(chosen) => legacy_thai_font(resolver.doc, resolver.fonts, &chosen, span, &mut thai.legacy, &mut thai_programs)?, None => None, } } else { @@ -823,13 +831,15 @@ fn apply_range_styles<'a>( } /// Select one existing subset of the exact source face for a Thai span. Never replace its -/// family/weight to make an edit succeed; missing glyphs need an explicit font choice. +/// family/weight to make an edit succeed; missing glyphs need an explicit font choice. The +/// subsets' programs are read within `budget`, shared by the edit's spans. fn legacy_thai_font( doc: &Document, fonts: &Dict, source: &TextLine, span: &str, cache: &mut HashMap, + budget: &mut pdfcraft_cos::DecodeBudget, ) -> Result, EditError> { if !source.base_font.rsplit('+').next().unwrap_or(&source.base_font).starts_with("THSarabunPSK") || !span.chars().any(|c| matches!(u32::from(c), 0x0e00..=0x0e7f)) @@ -843,13 +853,33 @@ fn legacy_thai_font( (pdfcraft_fonts::embedding::face_key(&Metrics::from_dict(doc, &font).base_font) == family).then(|| String::from_utf8_lossy(name).into_owned()) })); for name in candidates.into_iter().take(128) { - if let std::collections::hash_map::Entry::Vacant(entry) = cache.entry(name) { - let Some(font) = fonts.get(entry.key().as_bytes()).and_then(|f| doc.dict(f)) else { continue }; - let metrics = Metrics::from_dict(doc, &font); - let glyphs = pdfcraft_fonts::pdf::LegacyThaiFont::from_pdf(doc, &font, source_space(doc, fonts, &metrics)).ok().flatten(); - let style = FontStyle::of(doc, &font, &metrics); - entry.insert(LegacyFace { glyphs, metrics, style }); + if cache.contains_key(&name) { + continue; } + let Some(resource) = fonts.get(name.as_bytes()) else { continue }; + let Some(font) = doc.dict(resource) else { continue }; + let object = resource.as_ref(); + 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(), style: alias.style, object }, + None => { + let metrics = Metrics::from_dict(doc, &font); + let glyphs = pdfcraft_fonts::pdf::LegacyThaiFont::from_pdf(doc, &font, source_space(doc, fonts, &metrics), budget) + .ok() + .flatten() + .map(std::rc::Rc::new); + let style = FontStyle::of(doc, &font, &metrics); + LegacyFace { glyphs, metrics, style, object } + } + }; + cache.insert(name, face); + } + // A subset left unread would count as unusable, silently changing which subset draws the text. + if budget.remaining() == 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", + source.base_font.rsplit('+').next().unwrap_or(&source.base_font), + MAX_THAI_PROGRAMS >> 20 + ))); } // A face none of whose subsets can be shaped in place (no Thai, or neither positioned nor in // the legacy glyph order) is shaped like any other source font (`shape_source`). diff --git a/crates/fonts/src/pdf/thai.rs b/crates/fonts/src/pdf/thai.rs index 9ec2ff738b..8511472096 100644 --- a/crates/fonts/src/pdf/thai.rs +++ b/crates/fonts/src/pdf/thai.rs @@ -293,7 +293,10 @@ impl LegacyThaiFont { /// another family, a font with no Thai, and a program without Thai mark positioning whose /// 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. - pub fn from_pdf(doc: &Document, font: &Dict, space: f64) -> Result, String> { + /// + /// 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. + pub fn from_pdf(doc: &Document, font: &Dict, space: f64, budget: &mut DecodeBudget) -> Result, String> { let m = Metrics::from_dict(doc, font); if !m.base_font.rsplit('+').next().unwrap_or(&m.base_font).starts_with("THSarabunPSK") { return Ok(None); @@ -301,10 +304,9 @@ impl LegacyThaiFont { if !space.is_finite() || space < 0.0 { return Err("the source space width is invalid".into()); } - let bytes = super::embedded_sfnt(doc, font).ok_or("the source Thai font has no readable glyph program")?; + let bytes = super::embedded_sfnt_within(doc, font, budget).ok_or("the source Thai font has no readable glyph program")?; let positioned = crate::source_shaping::has_thai_layout(&bytes); - let (unicode, codes) = composite_glyph_maps(doc, font, &bytes, &m, &mut DecodeBudget::new(1 << 17, 0)) - .ok_or("the Thai source glyph codes cannot be recovered safely")?; + let (unicode, codes) = composite_glyph_maps(doc, font, &bytes, &m, budget).ok_or("the Thai source glyph codes cannot be recovered safely")?; // A subset keeps only the glyphs its document used, so no particular letter or alternate // is required here: [`Self::shape`] refuses a text whose glyphs, or required alternates // ([`require_pua`]), the subset lacks. Without its own positioning, every Thai glyph it @@ -579,7 +581,8 @@ mod tests { let metrics = Metrics::from_dict(&doc, &font); let (map, _) = composite_glyph_maps(&doc, &font, &stripped, &metrics, &mut DecodeBudget::new(1 << 20, 0)).unwrap(); assert_eq!(map.get(&0x0e01), Some(&214), "the CIDToGIDMap is read with its predictor"); - let shaped = LegacyThaiFont::from_pdf(&doc, &font, 0.25).unwrap().expect("a legacy subset").shape("ก่").unwrap(); + let shaped = + LegacyThaiFont::from_pdf(&doc, &font, 0.25, &mut DecodeBudget::new(1 << 26, 0)).unwrap().expect("a legacy subset").shape("ก่").unwrap(); assert_eq!(shaped.shaped.glyphs.iter().map(|g| g.id.to_u32()).collect::>(), [214, 352]); } @@ -592,7 +595,8 @@ mod tests { let latin = skrifa::FontRef::new(&program).unwrap().charmap().map('A').unwrap(); let mut doc = Document::new_empty(); let font = thai_font(&mut doc, &program, INTACT); - let shaped = LegacyThaiFont::from_pdf(&doc, &font, 0.25).unwrap().expect("a legacy subset").shape("ก่").unwrap(); + let shaped = + LegacyThaiFont::from_pdf(&doc, &font, 0.25, &mut DecodeBudget::new(1 << 26, 0)).unwrap().expect("a legacy subset").shape("ก่").unwrap(); assert_eq!(shaped.shaped.glyphs.iter().map(|g| g.id.to_u32()).collect::>(), [214, 352]); let editor = crate::pdf::embedded_sfnt(&doc, &font).unwrap(); assert_eq!(skrifa::FontRef::new(&editor).unwrap().charmap().map('A'), Some(latin), "the program's own mappings stay"); @@ -608,7 +612,7 @@ mod tests { let mode = |program: &[u8], unicode: &[u8]| { let mut doc = Document::new_empty(); let font = thai_font(&mut doc, program, unicode); - LegacyThaiFont::from_pdf(&doc, &font, 0.25).unwrap().map(|f| f.positioned) + LegacyThaiFont::from_pdf(&doc, &font, 0.25, &mut DecodeBudget::new(1 << 26, 0)).unwrap().map(|f| f.positioned) }; let stripped = without_tables(&thai_ordered_program(), &[b"cmap", b"GSUB", b"GPOS"]); assert_eq!(mode(&stripped, unicode), Some(false), "a legacy positional subset");