M7: a font's metrics are past their allowance only when it refused a read

`Reads::decode` and the Type 3 glyph-box read took any failed read, with
nothing left of the allowance after it, for one the allowance refused. The
Thai edit refuses on what the allowance itself counts
(`DecodeBudget::refused`), so the two could disagree: once the edit's
allowance was spent to the byte, an empty stream behind a filter nothing
decodes, which is unread with any allowance, made a font's metrics read
past it with no refusal counted. The edit then skipped that font of the
face and went through without it, which its refusal is there to prevent.

Both now take a read as refused only when `DecodeBudget::refused` grew
during it, so `Metrics::past_allowance` is set exactly when the allowance
counted a refusal, as its doc now says.

Tests:
- `an_empty_stream_reads_with_nothing_left` adds such a stream: it isn't
  past the allowance, and the flag agrees with the count in each case.
- `a_type3_description_unread_on_its_own_is_not_past_the_allowance`: the
  same for a glyph description after one that spends the allowance to the
  byte; a description with data after it is still refused.
- `a_font_of_the_face_unread_on_its_own_once_the_allowance_is_spent_still_refuses_the_edit`:
  the edit above is refused, where it went through.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
This commit is contained in:
Grzegorz MartensandClaude Opus 5.5 committed 2026-11-10 06:19:08 +01:00
1 parent 274b7a0424
commit 7cea6dfead
3 files changed
+96 -10

No files matched your search

+32
View File
@@ -14337,6 +14337,38 @@ fn a_thai_edit_that_spends_its_allowance_to_the_byte_goes_through() {
assert!(error.contains("THSarabunPSK") && error.contains("MiB of font data"), "{error}");
}
/// Only a read the allowance refuses leaves a font of the face unread for want of it. With the
/// allowance spent to the byte as above, another font of the face, whose ToUnicode is an empty
/// stream behind a filter nothing decodes (unread with any allowance), still has its program read,
/// which the allowance refuses: the edit is refused, rather than going through without that font.
#[test]
fn a_font_of_the_face_unread_on_its_own_once_the_allowance_is_spent_still_refuses_the_edit() {
let mut program = legacy_thai_program();
program.resize((16 << 20) - 192, 0);
let mut unicode = LEGACY_THAI_UNICODE.to_vec();
unicode.resize(256, 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));
// Not a subset, so its metrics read no program, only the ToUnicode.
let mut other = legacy_thai_font(&mut doc, "THSarabunPSK", &legacy_thai_program(), LEGACY_THAI_UNICODE);
let mut dict = Dict::new();
dict.set(b"Filter".to_vec(), Object::name("NoSuchFilter"));
other.set(b"ToUnicode".to_vec(), Object::Ref(doc.add(Object::Stream(Stream::from_raw(dict, Vec::new())))));
let other = doc.add(Object::Dict(other));
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") };
fonts.set(b"F2".to_vec(), Object::Ref(other));
resources.set(b"Font".to_vec(), Object::Dict(fonts));
page.set(b"Resources".to_vec(), Object::Dict(resources));
})
.unwrap();
let style = BlockStyle { width: Some(400.0), ..Default::default() };
let error = text::rewrite_block(&mut doc, 0, 0, Some("กขค"), &style).unwrap_err().to_string();
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).
+13 -4
View File
@@ -70,7 +70,9 @@ pub struct Metrics {
/// 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.
/// Type 3 font's height) is a guess. It is set exactly when the allowance counted a refusal
/// during the read ([`DecodeBudget::refused`]), which callers that share it may refuse on; a
/// stream that fails on its own data doesn't set it, with or without anything left.
pub past_allowance: bool,
}
@@ -420,12 +422,16 @@ impl<'a> Reads<'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.
/// with no more left than that total, it spends all of it and counts as refused. Only what
/// the allowance counts as refused ([`DecodeBudget::refused`]) is: a stream that fails on its
/// own data fails the same with more left, even when it spends the last of it or nothing was
/// left, and callers that refuse on that count must agree with [`Metrics::past_allowance`].
fn decode(&mut self, stream: &Stream, max: usize) -> Option<Vec<u8>> {
let refused = self.budget.refused();
match self.budget.decode_stages_within(self.doc, stream, max) {
Ok(data) => Some(data),
Err(_) => {
self.refused |= self.budget.remaining() == 0;
self.refused |= self.budget.refused() > refused;
None
}
}
@@ -491,10 +497,13 @@ fn type3_glyph_box(doc: &Document, font: &Dict, reads: &mut Reads<'_>) -> Option
break;
}
let before = reads.budget.remaining();
let refused = reads.budget.refused();
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 {
// As in `Reads::decode`, only a refusal the allowance counts: a description that fails
// on its own data, spending the last of it, leaves the rest to be refused above.
if reads.budget.refused() > refused {
// The rest go unread too, for want of the reading's allowance.
reads.refused = true;
complete = false;
+51 -6
View File
@@ -309,17 +309,27 @@ fn a_fonts_streams_are_paid_for_from_its_allowance() {
}
/// 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.
/// call the font read past it; a stream with data is left unread, and is. An empty stream behind a
/// filter nothing decodes is unread with any allowance, so it isn't either: the font is read past
/// its allowance exactly when the allowance counts a refusal, which is what callers refuse on.
#[test]
fn an_empty_stream_reads_with_nothing_left() {
let past = |data: &[u8]| {
let past = |filter: Option<&str>, 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 mut dict = Dict::new();
if let Some(filter) = filter {
dict.set(b"Filter".to_vec(), Object::name(filter));
}
let unicode = Object::Ref(doc.add(Object::Stream(Stream::from_raw(dict, 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
let mut budget = DecodeBudget::new(0, 0);
let past = Metrics::from_dict_within(&doc, &f, &mut budget).past_allowance;
assert_eq!(past, budget.refused() > 0, "{filter:?}");
past
};
assert!(!past(b""));
assert!(past(b"1 beginbfchar <41> <0058> endbfchar"));
assert!(!past(None, b""));
assert!(!past(Some("NoSuchFilter"), b""));
assert!(past(None, 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
@@ -969,6 +979,41 @@ fn type3_glyph_boxes_are_paid_for_from_the_allowance() {
assert!(m.past_allowance, "a byte short, the second description is left unread");
}
/// A glyph description that fails on its own once the allowance is spent to the byte, an empty one
/// behind a filter nothing decodes, fails the same with more left: it doesn't make the metrics read
/// past the allowance, and the descriptions after it are read, or refused by the allowance.
#[test]
fn a_type3_description_unread_on_its_own_is_not_past_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 mut f = type3(&mut doc, [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], [0, 0, 0, 0], &procs);
let mut dict = Dict::new();
dict.set(b"Filter".to_vec(), Object::name("NoSuchFilter"));
let unread = Object::Ref(doc.add(Object::Stream(Stream::from_raw(dict, Vec::new()))));
let Some(Object::Dict(mut charprocs)) = f.get(b"CharProcs").cloned() else { panic!("CharProcs") };
charprocs.set(b"g1".to_vec(), unread);
f.set(b"CharProcs".to_vec(), Object::Dict(charprocs));
with_differences(&mut f, &["g0", "g1", "g2"].map(String::from));
let first = procs[0].len();
let whole = Metrics::from_dict_within(&doc, &f, &mut DecodeBudget::new(usize::MAX, 0));
assert!(!whole.past_allowance);
assert_eq!((whole.ascent, whole.descent), (400.0, -20.0));
// The first description spends it to the byte; the third, which has data, is refused.
let mut budget = DecodeBudget::new(first, 0);
let m = Metrics::from_dict_within(&doc, &f, &mut budget);
assert_eq!(budget.refused(), 1);
assert!(m.past_allowance);
// Without the third, nothing is refused, and the metrics aren't past the allowance.
let Some(Object::Dict(mut charprocs)) = f.get(b"CharProcs").cloned() else { panic!("CharProcs") };
charprocs.remove(b"g2");
f.set(b"CharProcs".to_vec(), Object::Dict(charprocs));
let mut budget = DecodeBudget::new(first, 0);
let m = Metrics::from_dict_within(&doc, &f, &mut budget);
assert_eq!((budget.refused(), budget.remaining()), (0, 0));
assert!(!m.past_allowance);
assert_eq!((m.ascent, m.descent), (300.0, -20.0));
}
/// 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]