mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-12 17:31:35 +00:00
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:
1 parent
274b7a0424
commit
7cea6dfead
3 files changed
+96
-10
No files matched your search
@@ -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
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user