mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 13:01:29 +00:00
M7: font reads decode through a view of the stream, and refuse before resolving its parameters
CodeRabbit's review of 674a20899:
- Each budgeted read of a font's stream went through
`Document::with_direct_parms`, which copies the whole stream when its
/DecodeParms is given by reference: an unrelated value in the
dictionary, however large, was copied again for every read.
`DecodeBudget::decode_stages_within` now takes the document and reads
through `Document::decoding_view`, as `decode_strict` and
`decode_with_damage` do: the data is shared, and only /Filter and the
parameters made direct are copied. `Reads::decode` and the editor's
program read use it, a Type 3 font's glyph descriptions read through
`decode_with_damage`, and so does Repair Thai maps' program read.
- A Type 3 font's glyph descriptions resolved one description's
parameters a font before the spent allowance refused it. They now ask
`DecodeBudget::refuses` first, as `decode_stages_within` does for the
others: once the allowance is spent a stream with data is refused, and
noted, before its parameters are resolved.
- `DecodeBudget::refused` counts the decodes refused for want of the
allowance, where `remaining() == 0` can't tell a refusal from an
allowance spent to the byte.
Test: `reading_a_fonts_streams_copies_no_dictionary` (`pdfcraft-fonts`,
a test binary of its own): a font whose program and ToUnicode hold an
unrelated array of a million numbers and /DecodeParms by reference is
read eight times by the editor's program read and the metrics read,
and the process's peak grows by less than half of what one array
holds. That a Type 3 font refuses before resolving parameters changes
no result, only the time it takes.
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
674a208990
commit
8c5333bfe7
4 files changed
+119
-35
No files matched your search
+41
-16
@@ -246,12 +246,14 @@ pub struct DecodeBudget {
|
||||
bytes: usize,
|
||||
forms: usize,
|
||||
seen: HashSet<ObjRef>,
|
||||
/// 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
|
||||
@@ -284,16 +302,21 @@ impl DecodeBudget {
|
||||
}
|
||||
|
||||
/// 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. A
|
||||
/// [`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, stream: &Stream, max: usize) -> Result<Vec<u8>, CosError> {
|
||||
if self.bytes == 0 && !stream.raw.is_empty() {
|
||||
pub fn decode_stages_within(&mut self, doc: &Document, stream: &Stream, max: usize) -> Result<Vec<u8>, 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) {
|
||||
@@ -313,6 +336,7 @@ impl DecodeBudget {
|
||||
}
|
||||
Err((FilterError::LimitExceeded(_), _)) => {
|
||||
self.bytes = 0;
|
||||
self.refusals += 1;
|
||||
Err(CosError::Filter(FilterError::LimitExceeded(self.limit).to_string()))
|
||||
}
|
||||
Err((e, spent)) => {
|
||||
@@ -343,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<u8>, 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 {
|
||||
@@ -363,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)) => {
|
||||
@@ -1087,20 +1112,20 @@ mod tests {
|
||||
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(&fits, max).map(|d| d.len()).ok(), Some(max));
|
||||
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(&bomb, max).is_err());
|
||||
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(&wide, 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(&fits, max).is_err());
|
||||
assert!(budget.decode_stages_within(&Document::new_empty(), &fits, max).is_err());
|
||||
assert_eq!(budget.remaining(), 0);
|
||||
}
|
||||
|
||||
@@ -1128,12 +1153,12 @@ mod tests {
|
||||
// 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(&two(&bomb), max), max));
|
||||
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(&two(&bomb), max), inner + max));
|
||||
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
|
||||
@@ -1141,22 +1166,22 @@ mod tests {
|
||||
let mut padded = bomb.clone();
|
||||
padded.resize(max, 0);
|
||||
let mut budget = DecodeBudget::new(10 * max, 0);
|
||||
assert!(says(budget.decode_stages_within(&two(&padded), max), 2 * max));
|
||||
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(&two(&padded), max), 3 * max));
|
||||
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(&two(&padded), max), max));
|
||||
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(&Stream::flate(Dict::new(), b""), max), 0));
|
||||
assert_eq!(budget.decode_stages_within(&Stream::from_raw(Dict::new(), Vec::new()), max).ok(), Some(Vec::new()));
|
||||
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()));
|
||||
}
|
||||
}
|
||||
+15
-18
@@ -122,14 +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 };
|
||||
// As in `Reads::decode`: once the allowance is spent the decode refuses a program with
|
||||
// data, so it is passed over before its parameters are resolved, work the allowance doesn't
|
||||
// count; an empty one is no program either way.
|
||||
if budget.remaining() == 0 {
|
||||
continue;
|
||||
}
|
||||
// As in `Reads::decode`: each stage within the limit, the program, the last, too.
|
||||
let Ok(mut bytes) = budget.decode_stages_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;
|
||||
@@ -408,8 +404,8 @@ impl<'a> Reads<'a> {
|
||||
}
|
||||
|
||||
/// `stream` decoded within `max` and the allowance, its `/DecodeParms` read where they are given
|
||||
/// by reference too ([`Document::with_direct_parms`]); `None` when it can't be, noting when the
|
||||
/// allowance is why.
|
||||
/// 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
|
||||
@@ -421,13 +417,7 @@ impl<'a> Reads<'a> {
|
||||
/// 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<Vec<u8>> {
|
||||
// Once the allowance is spent the decode refuses the stream; refused here, before the
|
||||
// parameters are resolved, which is work the allowance doesn't count.
|
||||
if self.budget.remaining() == 0 && !stream.raw.is_empty() {
|
||||
self.refused = true;
|
||||
return None;
|
||||
}
|
||||
match self.budget.decode_stages_within(&self.doc.with_direct_parms(stream), max) {
|
||||
match self.budget.decode_stages_within(self.doc, stream, max) {
|
||||
Ok(data) => Some(data),
|
||||
Err(_) => {
|
||||
self.refused |= self.budget.remaining() == 0;
|
||||
@@ -488,8 +478,15 @@ fn type3_glyph_box(doc: &Document, font: &Dict, reads: &mut Reads<'_>) -> Option
|
||||
let mut union: Option<[f64; 4]> = None;
|
||||
for proc in chosen {
|
||||
let Object::Stream(s) = &*doc.resolve(proc) else { continue };
|
||||
// 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_within(&doc.with_direct_parms(s), MAX_TYPE3_GLYPH_BYTES.min(left));
|
||||
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 {
|
||||
|
||||
@@ -173,7 +173,7 @@ fn embedded_truetype(doc: &Document, descendant: &Dict) -> Option<Vec<u8>> {
|
||||
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
|
||||
|
||||
@@ -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<T>(f: impl FnOnce() -> T) -> Option<(T, u64)> {
|
||||
fn peak_kib() -> Option<u64> {
|
||||
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::<Object>() << 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");
|
||||
}
|
||||
Reference in new issue
Block a user