Read each THSarabunPSK subset of an edit once, within one budget

Every resource name of a page's Thai subsets decoded and kept its own copy
of the font program, so many names of one large program could hold about
2 GiB for a single edit. Names of one font object now share its program,
the subsets of an edit are decoded within a shared 64 MiB allowance, and
an edit that needs more is refused with the reason, changing nothing.
This commit is contained in:
bflatastic
2026-11-08 23:51:51 +07:00
parent dff6c2cc3e
commit fbc2dc77a7
3 changed files with 91 additions and 16 deletions
+41
View File
@@ -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::<String>(), "กขค");
// 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).
+39 -9
View File
@@ -105,12 +105,19 @@ impl FontResolver<'_> {
}
struct LegacyFace {
glyphs: Option<pdfcraft_fonts::pdf::LegacyThaiFont>,
/// Shared by every resource name of one font object: its program is decoded and kept once.
glyphs: Option<std::rc::Rc<pdfcraft_fonts::pdf::LegacyThaiFont>>,
metrics: Metrics,
/// Weight and slant ([`FontStyle::of`]).
style: FontStyle,
/// The font object the resource names, when it is given by reference.
object: Option<pdfcraft_cos::ObjRef>,
}
/// 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<StyledChar>,
substituted: Option<String>,
@@ -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<String, LegacyFace>,
budget: &mut pdfcraft_cos::DecodeBudget,
) -> Result<Option<ThaiSpan>, 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`).
+11 -7
View File
@@ -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<Option<Self>, 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<Option<Self>, 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::<Vec<_>>(), [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::<Vec<_>>(), [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");