Merge pull request #6800 from storytold/merge/fonts-budget-6239

M7: a font's metrics can be read within a caller's decode allowance (#6239, merged with main)
This commit is contained in:
BFlat authored and GitHub committed 2026-11-10 10:43:29 +07:00
commit 274b7a0424
10 files changed
+1087 -64

No files matched your search

+161 -2
View File
@@ -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
@@ -283,6 +301,51 @@ impl DecodeBudget {
self.charge(stream, max, Stream::decoded_within_counted).map(|(data, _)| data)
}
/// 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, 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, 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) {
Ok((data, _, spent)) => {
self.bytes = self.bytes.saturating_sub(spent);
Ok(data)
}
// A stage stopped at `max`, below what was left of the room.
Err((FilterError::LimitExceeded(_), spent)) if room.saturating_sub(spent) > max => {
self.bytes = self.bytes.saturating_sub(spent.saturating_add(max));
Err(CosError::Filter(FilterError::LimitExceeded(max).to_string()))
}
// At the stages' total, with more left.
Err((FilterError::LimitExceeded(_), _)) if total < self.bytes => {
self.bytes = self.bytes.saturating_sub(total);
Err(CosError::Filter(FilterError::LimitExceeded(total).to_string()))
}
Err((FilterError::LimitExceeded(_), _)) => {
self.bytes = 0;
self.refusals += 1;
Err(CosError::Filter(FilterError::LimitExceeded(self.limit).to_string()))
}
Err((e, spent)) => {
self.bytes = self.bytes.saturating_sub(spent);
Err(CosError::Filter(e.to_string()))
}
}
}
/// [`DecodeBudget::decode_within`] (`usize::MAX` for no bound of its own), with the stream's
/// `/DecodeParms` given by reference read in `doc`, also saying whether the stream is damaged:
/// `true` when a filter stopped at corrupt data, so the bytes are only what decoded before it.
@@ -304,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 {
@@ -324,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)) => {
@@ -1025,4 +1089,99 @@ mod tests {
assert!(budget.decode_strict(&Document::new_empty(), &chained).is_ok());
assert_eq!(budget.remaining(), (10 << 20) - 3 * charged);
}
/// `decode_stages_within` holds every stage to the limit, as a decode without an allowance
/// does, and the stages together to the limit for each of them. A stream behind ASCIIHex and
/// Flate whose stages each stay within it reads, the hex stage's output charged on top; one
/// whose Flate stage would make more stops there, charged what the hex stage made and the
/// limit, the rest of the allowance left; a Flate stage making hex text past the limit leaves
/// the stream unread though the total has room, as `Stream::decoded_within` leaves it; and a
/// stream past what is left spends all of it.
#[test]
fn each_stage_is_held_to_the_limit() {
let chained = |data: &[u8], filters: [&str; 2]| {
let mut dict = Dict::new();
dict.set(b"Filter".to_vec(), Object::Array(filters.into_iter().map(Object::name).collect()));
Stream::from_raw(dict, data.to_vec())
};
let hexed = |data: &[u8]| {
let flate = Stream::flate(Dict::new(), data);
let hex: Vec<u8> = flate.raw.iter().flat_map(|b| format!("{b:02X}").into_bytes()).chain(*b">").collect();
(chained(&hex, ["ASCIIHexDecode", "FlateDecode"]), flate.raw.len())
};
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(&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(&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(&Document::new_empty(), &wide, max).is_err());
let mut budget = DecodeBudget::new(max, 0);
assert!(budget.decode_stages_within(&Document::new_empty(), &fits, max).is_err());
assert_eq!(budget.remaining(), 0);
}
/// What `decode_stages_within` charges, and which limit it reports, on each way a read stops:
/// a stage at the stream's limit with more of the room left charges what came before and the
/// limit; at the stages' total, with more of the allowance left, it charges that total; at
/// what is left of the allowance it spends all of it and reports the allowance's limit, as
/// `decode_within` does. Each boundary between them is read on both sides.
#[test]
fn a_stage_past_its_limit_charges_what_came_before_and_the_limit() {
let max = 1 << 12;
let hex = |data: &[u8]| data.iter().flat_map(|b| format!("{b:02X}").into_bytes()).chain(*b">").collect::<Vec<u8>>();
let two = |raw: &[u8]| {
let mut dict = Dict::new();
dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("ASCIIHexDecode"), Object::name("FlateDecode")]));
Stream::from_raw(dict, hex(raw))
};
let says = |read: Result<Vec<u8>, CosError>, limit: usize| {
read.err().map(|e| e.to_string()) == Some(CosError::Filter(FilterError::LimitExceeded(limit).to_string()).to_string())
};
// Flate data making eight times the limit: the hex stage makes `inner` bytes of it, and
// the Flate stage stops at the limit.
let bomb = Stream::flate(Dict::new(), &vec![0u8; 8 * max]).raw.to_vec();
let inner = bomb.len();
// 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(&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(&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
// total is what it charges and reports.
let mut padded = bomb.clone();
padded.resize(max, 0);
let mut budget = DecodeBudget::new(10 * max, 0);
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(&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(&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(&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()));
}
}
+20
View File
@@ -384,6 +384,26 @@ impl Stream {
self.decode_chain(max, decode).map(|(v, spent)| (v, damaged, spent))
}
/// [`Stream::decoded_within_counted`] with every filter stage also held to `each`, as
/// [`Stream::decoded_within`] holds it, while `max` bounds the stages together
/// ([`pdfcraft_filters::decode_tolerant_counted_each`]). Without filters the data is held to
/// both.
pub(crate) fn decoded_within_each_counted(
&self,
each: usize,
max: usize,
) -> Result<(Vec<u8>, bool, usize), (pdfcraft_filters::FilterError, usize)> {
let mut damaged = false;
let decode = |chain: &[(Filter, Params)], data: &[u8], max| {
pdfcraft_filters::decode_tolerant_counted_each(chain, data, each.min(MAX_DECODED), max).map(|(v, partial, spent)| {
damaged = partial;
(v, spent)
})
};
let max = if self.readable_filters().is_ok_and(|chain| chain.is_empty()) { max.min(each) } else { max };
self.decode_chain(max, decode).map(|(v, spent)| (v, damaged, spent))
}
/// [`Stream::decoded_within`] spending from `work`, what an operation may still do across a
/// document's images ([`pdfcraft_filters::decode_tolerant_within`]).
pub(crate) fn decoded_spending(&self, max: usize, work: &mut u64) -> Result<Vec<u8>, CosError> {
+4
View File
@@ -26,6 +26,10 @@ pub fn page_rules(doc: &Document, page: usize) -> Result<Vec<[f64; 4]>, EditErro
let pages = pdfcraft_model::pages(doc);
let p = pages.get(page).ok_or(EditError::NoSuchPage(page))?;
let streams = crate::text::content_streams(doc, &p.dict)?;
// Unread content can change the graphics state used by every later stream.
if streams.iter().any(|stream| stream.read != crate::text::Read::Whole) {
return Err(EditError::Invalid("page content could not be completely decoded; cannot extract its ruled lines safely".into()));
}
let pieces = crate::text::joined(&streams);
let ops = pieces.parse().ops;
+41
View File
@@ -13610,6 +13610,24 @@ fn review_inside_scope_group_move_matches_direct_transform_under_outer_scale() {
}
}
#[test]
fn page_rules_refuse_unread_graphics_state() {
let mut dict = Dict::new();
dict.set(b"Filter".to_vec(), Object::name("UnsupportedFilter"));
let unread = Stream::from_raw(dict, b"2 0 0 2 0 0 cm".to_vec());
let damaged = damaged_flate(b"0 g\n", b"2 0 0 2 0 0 cm");
for stream in [unread, damaged] {
let mut doc = image_page_with("10 10 m 100 10 l S");
let incomplete = doc.add(Object::Stream(stream));
doc.update_dict(ObjRef::new(3, 0), |page| {
page.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(incomplete), Object::Ref(ObjRef::new(4, 0))]));
})
.unwrap();
assert!(page_rules(&doc, 0).is_err(), "later geometry cannot be trusted after unread graphics state");
}
assert_eq!(page_rules(&image_page_with("10 10 m 100 10 l S"), 0).unwrap(), [[10.0, 10.0, 100.0, 10.0]]);
}
#[test]
fn image_editing_frame_keeps_page_edge_outside_explicit_clips() {
for (prefix, expected) in [
@@ -14296,6 +14314,29 @@ fn legacy_thai_subsets_are_read_within_one_budget_per_edit() {
assert_eq!(pdfcraft_cos::write_full(&distinct, &Default::default()).unwrap(), before, "a refused edit changes nothing");
}
/// An edit whose THSarabunPSK subset spends the edit's allowance to the byte goes through: only a
/// read refused for want of it refuses the edit. The subset is read four times over its program
/// (the edit's metrics read, `LegacyThaiFont`'s, the editor's program read and its metrics read)
/// and three times over its ToUnicode, so a program 192 bytes short of 16 MiB and a ToUnicode of
/// 256 bytes cost exactly the 64 MiB; a byte more of ToUnicode is three past it.
#[test]
fn a_thai_edit_that_spends_its_allowance_to_the_byte_goes_through() {
let mut program = legacy_thai_program();
program.resize((16 << 20) - 192, 0);
let edit = |unicode_len: usize| {
let mut unicode = LEGACY_THAI_UNICODE.to_vec();
unicode.resize(unicode_len, 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));
let style = BlockStyle { width: Some(400.0), ..Default::default() };
text::rewrite_block(&mut doc, 0, 0, Some("กขค"), &style).map(|_| ()).map_err(|e| e.to_string())
};
assert_eq!(edit(256), Ok(()));
let error = edit(257).unwrap_err();
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).
+21 -7
View File
@@ -116,8 +116,11 @@ struct LegacyFace {
space: f64,
}
/// 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.
/// What one edit may decode of its THSarabunPSK subsets' font data: their programs (each at most
/// 16 MiB), which it keeps, and the streams of the metrics read to measure them and to adapt their
/// programs' cmaps: their CMaps and glyph maps, and a subset's program again, for which of its
/// glyphs have outlines. A page may name many of them. The metrics read to find the subsets among
/// the page's fonts aren't charged to it.
const MAX_THAI_PROGRAMS: usize = 64 << 20;
struct PreparedCharacters {
@@ -900,6 +903,9 @@ fn legacy_thai_font(
}
let family = pdfcraft_fonts::embedding::face_key(&source.base_font);
let mut candidates = vec![source.font.clone()];
// Every name on the page is read here for its face name alone, aliases of one font and other
// faces' fonts too, with no total, as before: charged to the edit's allowance, each alias would
// pay again and other faces would spend what the face's subsets need.
candidates.extend(fonts.iter().filter_map(|(name, font)| {
let font = doc.dict(font)?;
(pdfcraft_fonts::embedding::face_key(&Metrics::from_dict(doc, &font).base_font) == family).then(|| String::from_utf8_lossy(name).into_owned())
@@ -914,9 +920,15 @@ fn legacy_thai_font(
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(), object, ..*alias },
None => {
let metrics = Metrics::from_dict(doc, &font);
let space = source_space(doc, fonts, &metrics);
let glyphs = pdfcraft_fonts::pdf::LegacyThaiFont::from_pdf(doc, &font, space, budget).ok().flatten().map(std::rc::Rc::new);
// Read within the edit's allowance; past it the subset goes unread, and the edit is
// refused below.
let metrics = Metrics::from_dict_within(doc, &font, budget);
let (space, glyphs) = if metrics.past_allowance {
(0.0, None)
} else {
let space = source_space(doc, fonts, &metrics);
(space, pdfcraft_fonts::pdf::LegacyThaiFont::from_pdf(doc, &font, space, budget).ok().flatten().map(std::rc::Rc::new))
};
let style = FontStyle::of(doc, &font, &metrics);
LegacyFace { glyphs, metrics, style, object, space }
}
@@ -924,9 +936,11 @@ fn legacy_thai_font(
cache.insert(name, face);
}
// A subset left unread would count as unusable, silently changing which subset draws the text.
if budget.remaining() == 0 {
// A read refused for want of the allowance is what refuses the edit: reads that spent it to the
// byte refused nothing.
if budget.refused() > 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",
"the page's {} subsets need more than {} MiB of font data for one edit; choose a replacement font for this text",
source.base_font.rsplit('+').next().unwrap_or(&source.base_font),
MAX_THAI_PROGRAMS >> 20
)));
+36
View File
@@ -212,6 +212,18 @@ pub fn decode_tolerant_counted(chain: &[(Filter, Params)], data: &[u8], max_outp
run(chain, data, max_output, Some(max_output), false, false, None)
}
/// [`decode_tolerant_counted`] with the two bounds apart: `max_stage` bounds every stage, as
/// [`decode_tolerant`]'s bound does, and `max_output` the bytes all stages decode together. A
/// stage stopped by either gives [`FilterError::LimitExceeded`] and the bytes decoded before it.
pub fn decode_tolerant_counted_each(
chain: &[(Filter, Params)],
data: &[u8],
max_stage: usize,
max_output: usize,
) -> Result<(Vec<u8>, bool, usize), (FilterError, usize)> {
run(chain, data, max_stage, Some(max_output), false, false, None)
}
/// [`decode_tolerant`], spending from `work`, what decoding may still cost across a run of images:
/// a step for each byte of `data`, paid before anything is decoded, [`FLATE_READ_WORK`] for each
/// byte a Flate stage reads, paid before it runs (again if it reads them twice), and one for each
@@ -451,6 +463,30 @@ mod tests {
assert_eq!(super::decode(&chain(super::MAX_CHAIN), &data, 1 << 20).ok(), Some(data));
}
/// [`decode_tolerant_counted_each`] holds every stage to its own bound as well as the stages
/// to their total: a Flate stage that would make more than that bound stops there, though
/// the total has room, and the stage after it gets nothing; within both, the chain reads
/// whole, one stage's output charged on top of another's.
#[test]
fn each_stage_is_held_to_its_own_bound_within_the_total() {
let hex = "00".repeat(1 << 16).into_bytes();
let data = super::flate::encode(&hex);
let chain = [(Filter::Flate, Params::default()), (Filter::AsciiHex, Params::default())];
// The total alone lets the Flate stage make all of the hex text, and the hex stage read it.
STAGE_BYTES.with(|c| c.set(0));
let whole = decode_tolerant_counted(&chain, &data, 3 << 16).map(|(v, _, _)| v);
assert_eq!(whole.ok(), Some(vec![0u8; 1 << 16]));
assert_eq!(STAGE_BYTES.with(|c| c.get()), data.len() + hex.len());
// Each stage held to 64 KiB, the Flate stage stops there and the hex stage gets nothing.
STAGE_BYTES.with(|c| c.set(0));
let held = decode_tolerant_counted_each(&chain, &data, 1 << 16, 3 << 16);
assert!(matches!(held, Err((FilterError::LimitExceeded(m), 0)) if m == 1 << 16), "{held:?}");
assert_eq!(STAGE_BYTES.with(|c| c.get()), data.len());
// Within both, it reads whole.
let (out, _, spent) = decode_tolerant_counted_each(&chain, &data, 2 << 16, 3 << 16).unwrap();
assert_eq!((out.len(), spent), (1 << 16, hex.len() + (1 << 16)));
}
use super::*;
#[test]
+148 -35
View File
@@ -14,7 +14,7 @@
use std::borrow::Cow;
use std::collections::{BinaryHeap, HashMap, HashSet};
use pdfcraft_cos::{DecodeBudget, Dict, Document, Object};
use pdfcraft_cos::{DecodeBudget, Dict, Document, Object, Stream};
/// How the bytes of a string map to codes.
#[derive(Clone, Debug, PartialEq)]
@@ -67,6 +67,11 @@ pub struct Metrics {
/// Subset CIDFontType2 only: one bit per CID (below 65 536) whose glyph has an outline in the
/// embedded program. See [`cids_with_outlines`].
outlined_cids: Vec<u64>,
/// 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.
pub past_allowance: bool,
}
fn nums(doc: &Document, o: Option<&Object>) -> Vec<f64> {
@@ -79,6 +84,12 @@ fn dict(doc: &Document, o: Option<&Object>) -> Option<Dict> {
o.and_then(|o| doc.resolve(o).as_dict().cloned())
}
/// Whether `font`'s `/Encoding` is the name Identity-H, given directly or through a reference,
/// as [`Metrics::from_dict`] reads it (a Type 0 font's `/Encoding` may be indirect).
fn identity_h(doc: &Document, font: &Dict) -> bool {
font.get(b"Encoding").is_some_and(|e| doc.resolve(e).as_name() == Some(b"Identity-H".as_slice()))
}
/// Whether `base_font` (subset tag allowed) names a legacy Indic font family that is laid out in
/// visual order (Kruti Dev, DevLys, Chanakya, Shusha, Shivaji, Walkman, Akruti, APS-DV, DV-TT…).
fn legacy_indic_font(base_font: &str) -> bool {
@@ -92,14 +103,17 @@ fn legacy_indic_font(base_font: &str) -> bool {
/// A source PDF's sfnt program for an in-memory editor. Never a bundled font or an export
/// fallback. Type 1/Type 3 programs cannot be consumed by an sfnt text renderer.
pub fn embedded_sfnt(doc: &Document, font: &Dict) -> Option<Vec<u8>> {
embedded_sfnt_within(doc, font, &mut DecodeBudget::new(2 * MAX_SFNT, 0))
// No total: one font's two programs, and the metrics read that may adapt one, are each
// bounded by that stream's own limit.
embedded_sfnt_within(doc, font, &mut DecodeBudget::new(usize::MAX, 0))
}
/// The largest embedded sfnt program read for the editor.
const MAX_SFNT: usize = 16 << 20;
/// [`embedded_sfnt`], decoding against `budget`, which an enumeration of a page's fonts shares:
/// each program decoded is charged whether or not it turns out to be usable.
/// each program decoded is charged whether or not it turns out to be usable, and so are the
/// streams of the metrics read that adapts a program without a Unicode cmap.
pub fn embedded_sfnt_within(doc: &Document, font: &Dict, budget: &mut DecodeBudget) -> Option<Vec<u8>> {
use skrifa::{MetadataProvider as _, raw::TableProvider as _};
let descendant = dict(doc, font.get(b"DescendantFonts").and_then(|o| doc.resolve(o).as_array().and_then(|a| a.first()).cloned()).as_ref());
@@ -108,7 +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 };
let Ok(mut bytes) = budget.decode_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;
@@ -120,13 +137,32 @@ pub fn embedded_sfnt_within(doc: &Document, font: &Dict, budget: &mut DecodeBudg
if own_cmap && !base_font.rsplit('+').next().is_some_and(|f| f.starts_with("THSarabunPSK")) {
return Some(bytes);
}
// A composite font's cmap is rebuilt only through Identity-H codes (`composite_glyph_maps`):
// with any other `/Encoding` nothing is, so its metrics aren't read, and a program with a
// cmap of its own is kept as it is, as when the rebuild finds nothing.
if font.name(b"Subtype") == Some(b"Type0") && !identity_h(doc, font) {
if own_cmap {
return Some(bytes);
}
continue;
}
// PDF subsets may remove the Unicode cmap entirely. Rebuild an editor-only
// cmap from ToUnicode and the actual CID-to-glyph map; source bytes stay unchanged.
let metrics = Metrics::from_dict(doc, font);
// Metrics read past the allowance, its widths' read of the program again included,
// adapt no copy.
let metrics = Metrics::from_dict_within(doc, font, budget);
if metrics.past_allowance {
continue;
}
let mut mappings = std::collections::BTreeMap::new();
if metrics.composite {
let refused = budget.refused();
if let Some((map, _)) = composite_glyph_maps(doc, font, &bytes, &metrics, budget) {
mappings = map;
} else if budget.refused() > refused {
// The map the cmap is rebuilt from was left unread for want of the allowance: no
// copy is listed, adapted or as it is, as for metrics read past it.
continue;
}
if own_cmap {
let charmap = face.charmap();
@@ -166,7 +202,7 @@ pub fn embedded_sfnt_within(doc: &Document, font: &Dict, budget: &mut DecodeBudg
/// The CIDs (below 65 536) of a Type 0 font over an embedded TrueType program whose glyph has
/// outline data, through `/CIDToGIDMap`; empty when the program or the map can't be read.
/// Subsetters keep glyph ids and empty the glyphs they drop, so an empty glyph is a missing one.
fn cids_with_outlines(doc: &Document, font: &Dict) -> Vec<u64> {
fn cids_with_outlines(doc: &Document, font: &Dict, reads: &mut Reads<'_>) -> Vec<u64> {
use skrifa::raw::TableProvider as _;
let Some(descendant) = dict(doc, font.get(b"DescendantFonts").and_then(|o| doc.resolve(o).as_array().and_then(|a| a.first()).cloned()).as_ref())
else {
@@ -175,13 +211,13 @@ fn cids_with_outlines(doc: &Document, font: &Dict) -> Vec<u64> {
let Some(program) = dict(doc, descendant.get(b"FontDescriptor")).and_then(|d| d.get(b"FontFile2").cloned()) else { return Vec::new() };
let program = doc.resolve(&program);
let Object::Stream(stream) = program.as_ref() else { return Vec::new() };
let Ok(bytes) = doc.with_direct_parms(stream).decoded_within(16 << 20) else { return Vec::new() };
let Some(bytes) = reads.decode(stream, MAX_SFNT) else { return Vec::new() };
let Some(loca) = skrifa::FontRef::from_index(&bytes, 0).ok().and_then(|face| face.loca(None).ok()) else { return Vec::new() };
let outlined = |gid: u32| loca.get_raw(gid as usize).zip(loca.get_raw(gid as usize + 1)).is_some_and(|(start, end)| end > start);
match descendant.get(b"CIDToGIDMap").map(|o| doc.resolve(o)).as_deref() {
None | Some(Object::Name(_)) => bit_set_below_64k((0..loca.len().min(0x1_0000) as u32).filter(|&gid| outlined(gid))),
Some(Object::Stream(map)) => {
let Ok(map) = doc.with_direct_parms(map).decoded_within(1 << 17) else { return Vec::new() };
let Some(map) = reads.decode(map, MAX_CID_TO_GID) else { return Vec::new() };
bit_set_below_64k(
map.as_chunks::<2>()
.0
@@ -200,7 +236,7 @@ fn cids_with_outlines(doc: &Document, font: &Dict) -> Vec<u64> {
/// wider and leave the end of a line outside a redaction mark (#2130). The glyph is looked up
/// the way the renderer does: the encoding's character in the Unicode cmap, else the code itself
/// (as in a symbolic cmap), else `.notdef`. Only sfnt programs are read; others keep the guess.
fn embedded_widths(doc: &Document, font: &Dict, m: &mut Metrics) {
fn embedded_widths(doc: &Document, font: &Dict, m: &mut Metrics, reads: &mut Reads<'_>) {
use skrifa::instance::{LocationRef, Size};
use skrifa::{GlyphId, MetadataProvider as _, raw::TableProvider as _};
let Some(descriptor) = dict(doc, font.get(b"FontDescriptor")) else { return };
@@ -208,7 +244,7 @@ fn embedded_widths(doc: &Document, font: &Dict, m: &mut Metrics) {
let Some(object) = descriptor.get(key) else { continue };
let object = doc.resolve(object);
let Object::Stream(stream) = object.as_ref() else { continue };
let Ok(bytes) = doc.with_direct_parms(stream).decoded_within(16 << 20) else { continue };
let Some(bytes) = reads.decode(stream, MAX_SFNT) else { continue };
let Ok(face) = skrifa::FontRef::from_index(&bytes, 0) else { continue };
let upem = f64::from(face.metrics(Size::unscaled(), LocationRef::default()).units_per_em);
if upem == 0.0 {
@@ -354,8 +390,48 @@ const MAX_CMAP_ENTRIES: usize = 1 << 20;
/// thousand.
const MAX_CMAP_RANGES: usize = 1 << 16;
/// Largest decoded Encoding or ToUnicode CMap stream read; real CMaps stay well under it, and a larger
/// one is not decoded at all.
/// one is left unread (behind one filter not decoded past it; behind several, see `Reads::decode`).
const MAX_CMAP_BYTES: usize = 16 << 20;
/// Largest decoded `/CIDToGIDMap` stream read: two bytes for each CID below 65,536.
const MAX_CID_TO_GID: usize = 1 << 17;
/// The streams one reading of a font decodes, against the caller's allowance, and whether one was
/// left unread because that allowance was spent.
struct Reads<'a> {
doc: &'a Document,
budget: &'a mut DecodeBudget,
refused: bool,
}
impl<'a> Reads<'a> {
fn new(doc: &'a Document, budget: &'a mut DecodeBudget) -> Self {
Self { doc, budget, refused: false }
}
/// `stream` decoded within `max` and the allowance, its `/DecodeParms` read where they are given
/// 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
/// map of 65,536 CIDs behind ASCIIHex and Flate, its Flate data smaller than the map, makes
/// all of `MAX_CID_TO_GID` in its last stage, with the hex stage's output on top. So a stream
/// reads here when it reads without an allowance and what is left pays for it. A stream with 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.
fn decode(&mut self, stream: &Stream, max: usize) -> Option<Vec<u8>> {
match self.budget.decode_stages_within(self.doc, stream, max) {
Ok(data) => Some(data),
Err(_) => {
self.refused |= self.budget.remaining() == 0;
None
}
}
}
}
/// Most Type 3 glyph descriptions read for their `d1` box. A simple font shows at most 256 codes.
const MAX_TYPE3_GLYPHS: usize = 256;
/// Largest decoded Type 3 glyph description read for its `d1` box; a larger one is skipped.
@@ -372,8 +448,8 @@ const MAX_TYPE3_EXTENT: f64 = 1e6;
/// no glyph declares one (`d0` glyphs don't). The descriptions the encoding's `/Differences`
/// names are chosen, since it "shall specify the complete character encoding" (Table 110); only a
/// font without one has its first descriptions chosen. Reads at most [`MAX_TYPE3_GLYPHS`]
/// descriptions within [`MAX_TYPE3_FONT_BYTES`].
fn type3_glyph_box(doc: &Document, font: &Dict) -> Option<([f64; 4], bool)> {
/// descriptions within [`MAX_TYPE3_FONT_BYTES`], each charged to the reading's allowance too.
fn type3_glyph_box(doc: &Document, font: &Dict, reads: &mut Reads<'_>) -> Option<([f64; 4], bool)> {
let procs = dict(doc, font.get(b"CharProcs"))?;
let mut names: Vec<&[u8]> = Vec::new();
// Whether every glyph that can be drawn is read; if not, the union may be short of them.
@@ -401,12 +477,30 @@ fn type3_glyph_box(doc: &Document, font: &Dict) -> Option<([f64; 4], bool)> {
} else {
names.iter().filter_map(|n| procs.get(n)).collect()
};
let mut budget = DecodeBudget::new(MAX_TYPE3_FONT_BYTES, 0);
// What is left of the font's own total: the descriptions decode against the reading's
// allowance, which charges them, and each within what is left of this.
let mut left = MAX_TYPE3_FONT_BYTES;
let mut union: Option<[f64; 4]> = None;
for proc in chosen {
let Object::Stream(s) = &*doc.resolve(proc) else { continue };
let Ok(data) = budget.decode_within(&doc.with_direct_parms(s), MAX_TYPE3_GLYPH_BYTES) else {
if budget.remaining() == 0 {
// 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_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 {
// The rest go unread too, for want of the reading's allowance.
reads.refused = true;
complete = false;
break;
}
if left == 0 {
// The rest go unread too.
complete = false;
break;
@@ -785,11 +879,25 @@ impl Metrics {
code_len: 1,
explicit_reverse: (32..127u8).map(|c| (char::from(c), u32::from(c))).collect(),
outlined_cids: Vec::new(),
past_allowance: false,
}
}
/// The metrics of `font`. Each stream they come from decodes within its own limit (16 MiB for
/// a CMap or a program, 4 MiB for a Type 3 font's glyph descriptions together), and again on
/// every call: a caller that reads many fonts, or one font many times, shares an allowance
/// through [`Metrics::from_dict_within`].
pub fn from_dict(doc: &Document, font: &Dict) -> Self {
let mut m = Self::read_metrics(doc, font);
// No total, so nothing is refused: each stream stays within its own limit, as it always has.
Self::from_dict_within(doc, font, &mut DecodeBudget::new(usize::MAX, 0))
}
/// [`Metrics::from_dict`], decoding against `budget`, which the caller shares across the fonts
/// it reads (a page's, say): each stream pays for what it decodes, usable or not. A stream the
/// allowance can't pay for is left unread, and [`Metrics::past_allowance`] says so.
pub fn from_dict_within(doc: &Document, font: &Dict, budget: &mut DecodeBudget) -> Self {
let mut reads = Reads::new(doc, budget);
let mut m = Self::read_metrics(doc, font, &mut reads);
let base = font
.name(b"BaseFont")
.map(|name| String::from_utf8_lossy(name).into_owned())
@@ -806,17 +914,18 @@ impl Metrics {
m.italic |= italic;
m.base_font = base;
if m.composite && m.subset {
m.outlined_cids = cids_with_outlines(doc, font);
m.outlined_cids = cids_with_outlines(doc, font, &mut reads);
}
m.code_len = match &m.codes {
Codes::One => 1,
Codes::Two => 2,
Codes::Ranges(r) => r.last().map_or(2, |x| x.0),
};
m.unicode = unicode_map(doc, font, m.composite);
m.unicode = unicode_map(doc, font, m.composite, &mut reads);
if m.std14.is_some() {
embedded_widths(doc, font, &mut m);
embedded_widths(doc, font, &mut m, &mut reads);
}
m.past_allowance = reads.refused;
let mut reverse = HashMap::new();
for (&code, text) in m.unicode.explicit.iter().map(|(c, (_, t))| (c, t)).chain(m.unicode.encoding.iter()) {
if let Some(ch) = only_char(text)
@@ -837,7 +946,7 @@ impl Metrics {
m
}
fn read_metrics(doc: &Document, font: &Dict) -> Self {
fn read_metrics(doc: &Document, font: &Dict, reads: &mut Reads<'_>) -> Self {
let mut m = Metrics::fallback();
m.std14 = None;
let subtype = font.name(b"Subtype").unwrap_or(b"Type1").to_vec();
@@ -850,7 +959,7 @@ impl Metrics {
m.codes = Codes::Two;
if let Some(e) = font.get(b"Encoding").map(|e| doc.resolve(e))
&& let Object::Stream(s) = &*e
&& let Ok(data) = doc.with_direct_parms(s).decoded_within(MAX_CMAP_BYTES)
&& let Some(data) = reads.decode(s, MAX_CMAP_BYTES)
{
let (spaces, cids) = parse_cmap(&data);
if !spaces.is_empty() {
@@ -915,7 +1024,7 @@ impl Metrics {
// A union cut short by a limit may miss glyphs that are drawn, so a nonzero
// `/FontBBox` comes first then; all zero, the partial union is still better
// than no box. A box with no height in text space gives way to the next.
let glyphs = type3_glyph_box(doc, font);
let glyphs = type3_glyph_box(doc, font, reads);
// All zero: no assumption about glyph sizes (Table 110).
let bbox = <[f64; 4]>::try_from(nums(doc, font.get(b"FontBBox"))).ok().filter(|r| r.iter().any(|&v| v != 0.0));
let union = |complete: bool| glyphs.filter(|g| g.1 == complete).map(|(r, _)| (r, 0.0, 0.0));
@@ -1088,8 +1197,14 @@ impl Metrics {
}
/// The bytes that show `text` in this font, or `None` if some character has no code or
/// no glyph in it (the caller then substitutes another font).
/// no glyph in it, or the metrics were read past their allowance (the caller then substitutes
/// another font). Empty text needs no verified glyph and always encodes.
pub fn encode(&self, text: &str) -> Option<Vec<u8>> {
// A refused read cannot establish the mappings or glyphs needed to write new text.
// Empty text needs neither and remains usable for erasing existing text.
if self.past_allowance && !text.is_empty() {
return None;
}
// The lowest code showing each wanted character. Explicit entries and the encoding are
// checked one by one; each range is asked for the wanted characters it can show.
let wanted: HashSet<char> = text.chars().collect();
@@ -1432,7 +1547,7 @@ pub fn glyph_unicode(name: &str) -> Option<char> {
/// to [`symbolic_mac_roman_texts`]). A code whose glyph has no name with a Unicode value reads
/// as WinAnsi, as the renderer reads it (vendored hayro-interpret patch (79)), so the editor and
/// extraction agree: `€` for code 0x80, which Standard leaves without a character.
fn symbolic_glyph_texts(doc: &Document, font: &Dict) -> Vec<(u32, String)> {
fn symbolic_glyph_texts(doc: &Document, font: &Dict, reads: &mut Reads<'_>) -> Vec<(u32, String)> {
use skrifa::GlyphId;
use skrifa::raw::{FontData, FontRead as _, TableProvider as _, tables::cmap::PlatformId, tables::post::Post, types::Tag};
let Some(descriptor) = dict(doc, font.get(b"FontDescriptor")) else { return Vec::new() };
@@ -1443,7 +1558,7 @@ fn symbolic_glyph_texts(doc: &Document, font: &Dict) -> Vec<(u32, String)> {
let Some(object) = descriptor.get(b"FontFile2") else { return Vec::new() };
let object = doc.resolve(object);
let Object::Stream(stream) = object.as_ref() else { return Vec::new() };
let Ok(bytes) = doc.with_direct_parms(stream).decoded_within(MAX_SFNT) else { return Vec::new() };
let Some(bytes) = reads.decode(stream, MAX_SFNT) else { return Vec::new() };
let Ok(face) = skrifa::FontRef::from_index(&bytes, 0) else { return Vec::new() };
let Ok(cmap) = face.cmap() else { return Vec::new() };
// A `post` version written with its 16-bit halves swapped (`00 00 00 02` for 2.0, as ScanSoft
@@ -1534,7 +1649,7 @@ fn post_name<'a>(post: &skrifa::raw::tables::post::Post<'a>, custom: &[&'a str],
/// Code → Unicode for a font: its ToUnicode CMap (for a composite font, also the name
/// `/Identity-H` or `/Identity-V`, read as code = Unicode), else (simple fonts) its encoding.
fn unicode_map(doc: &Document, font: &Dict, composite: bool) -> Unicode {
fn unicode_map(doc: &Document, font: &Dict, composite: bool, reads: &mut Reads<'_>) -> Unicode {
let mut out = Unicode::default();
if !composite {
let enc = font.get(b"Encoding").map(|e| doc.resolve(e));
@@ -1579,8 +1694,8 @@ fn unicode_map(doc: &Document, font: &Dict, composite: bool) -> Unicode {
// A font whose `post` table names its glyphs reads by those names; one that names none
// and keeps the Macintosh order reads as Mac OS Roman, over the WinAnsi fallback.
if base_name.is_none() && !symbolic_builtin {
out.encoding.extend(symbolic_glyph_texts(doc, font));
out.encoding.extend(symbolic_mac_roman_texts(doc, font));
out.encoding.extend(symbolic_glyph_texts(doc, font, reads));
out.encoding.extend(symbolic_mac_roman_texts(doc, font, reads));
}
if let Some(Object::Dict(d)) = enc.as_deref()
&& let Some(diffs) = d.get(b"Differences").map(|x| doc.resolve(x)).and_then(|x| x.as_array().cloned())
@@ -1606,10 +1721,8 @@ fn unicode_map(doc: &Document, font: &Dict, composite: bool) -> Unicode {
}
match font.get(b"ToUnicode").map(|t| doc.resolve(t)).as_deref() {
Some(Object::Stream(s)) => {
// With `/DecodeParms` given by reference, as every stream of the font is read here,
// and as the redaction proof reads the map: the two must get the same text, or a copy
// redaction rightly left counts as a survivor.
if let Ok(data) = doc.with_direct_parms(s).decoded_within(MAX_CMAP_BYTES) {
// Reads resolves indirect parameters too, matching the redaction proof's text.
if let Some(data) = reads.decode(s, MAX_CMAP_BYTES) {
parse_to_unicode(&data, &mut out);
}
}
@@ -1657,7 +1770,7 @@ fn identity_to_unicode() -> Vec<u8> {
/// renderer finds it: the Windows (3, 0) and (3, 1) subtables first, with the code and the code at
/// 0xF000, 0xF100 and 0xF200, then the Macintosh and Unicode-platform ones. The renderer's text
/// layer reads the same codes the same way (vendored hayro-interpret patch (101)).
fn symbolic_mac_roman_texts(doc: &Document, font: &Dict) -> Vec<(u32, String)> {
fn symbolic_mac_roman_texts(doc: &Document, font: &Dict, reads: &mut Reads<'_>) -> Vec<(u32, String)> {
use skrifa::GlyphId;
use skrifa::raw::{TableProvider as _, tables::cmap::PlatformId, tables::post::DEFAULT_GLYPH_NAMES, types::Tag};
let Some(descriptor) = dict(doc, font.get(b"FontDescriptor")) else { return Vec::new() };
@@ -1668,7 +1781,7 @@ fn symbolic_mac_roman_texts(doc: &Document, font: &Dict) -> Vec<(u32, String)> {
let Some(object) = descriptor.get(b"FontFile2") else { return Vec::new() };
let object = doc.resolve(object);
let Object::Stream(stream) = object.as_ref() else { return Vec::new() };
let Ok(bytes) = doc.with_direct_parms(stream).decoded_within(MAX_SFNT) else { return Vec::new() };
let Some(bytes) = reads.decode(stream, MAX_SFNT) else { return Vec::new() };
let Ok(face) = skrifa::FontRef::from_index(&bytes, 0) else { return Vec::new() };
// A CFF program in `/FontFile2` selects its glyphs by name, not through the `cmap`. A `post`
// table of version 1.0 or 2.0 (also with its 16-bit halves swapped, as the renderer reads it)
+510 -10
View File
@@ -178,9 +178,10 @@ fn a_font_without_widths_measures_with_its_embedded_program() {
}
}
#[test]
fn mac_subset_cmap_uses_pdf_unicode_without_changing_source_program() {
use skrifa::{MetadataProvider as _, raw::TableProvider as _};
/// Inter with its cmap replaced by a Mac format-6 one mapping code 65 to Ω's glyph, which egui
/// can't use; and that glyph.
fn mac_subset() -> (Vec<u8>, skrifa::GlyphId) {
use skrifa::MetadataProvider as _;
let original = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf");
let face = skrifa::FontRef::new(original).unwrap();
let expected = face.charmap().map('Ω').unwrap();
@@ -194,6 +195,13 @@ fn mac_subset_cmap_uses_pdf_unicode_without_changing_source_program() {
subset[entry + 8..entry + 12].copy_from_slice(&offset.to_be_bytes());
subset[entry + 12..entry + 16].copy_from_slice(&(cmap.len() as u32).to_be_bytes());
subset.extend_from_slice(&cmap);
(subset, expected)
}
#[test]
fn mac_subset_cmap_uses_pdf_unicode_without_changing_source_program() {
use skrifa::{MetadataProvider as _, raw::TableProvider as _};
let (subset, expected) = mac_subset();
let face = skrifa::FontRef::new(&subset).unwrap();
assert!(!face.charmap().has_map(), "egui cannot directly use Mac format 6");
assert_eq!(face.cmap().unwrap().map_codepoint(65_u32), Some(expected));
@@ -214,6 +222,331 @@ fn mac_subset_cmap_uses_pdf_unicode_without_changing_source_program() {
assert!(append_editor_cmap(&mut broken, &std::collections::BTreeMap::from([(65, 1)])).is_none());
}
/// The metrics read that adapts a program's cmap is paid for from the editor's allowance too: a
/// step short of the program, its ToUnicode and the program again for its widths and for the
/// readings of its codes by glyph name and as Mac OS Roman, the metrics are read past the allowance
/// and no copy is adapted.
#[test]
fn adapting_a_programs_cmap_is_paid_for() {
use skrifa::MetadataProvider as _;
let (subset, expected) = mac_subset();
let mut doc = Document::new_empty();
let program = doc.add(Object::Stream(Stream::flate(Dict::new(), &subset)));
let mut descriptor = Dict::new();
descriptor.set(b"FontFile2".to_vec(), Object::Ref(program));
let cmap = b"1 beginbfchar <41> <03A9> endbfchar";
let unicode = doc.add(Object::Stream(Stream::flate(Dict::new(), cmap)));
let source = font(&mut doc, vec![("FontDescriptor", Object::Dict(descriptor)), ("ToUnicode", Object::Ref(unicode))]);
let cost = 4 * subset.len() + cmap.len();
let mut budget = DecodeBudget::new(cost, 0);
let editor = embedded_sfnt_within(&doc, &source, &mut budget).unwrap();
assert_eq!(skrifa::FontRef::new(&editor).unwrap().charmap().map('Ω'), Some(expected));
assert_eq!(budget.remaining(), 0);
assert!(embedded_sfnt_within(&doc, &source, &mut DecodeBudget::new(cost - 1, 0)).is_none());
// A composite font whose codes aren't Identity-H can't be adapted, so its metrics aren't
// read: the program is all it pays for.
let mut budget = DecodeBudget::new(cost, 0);
let mut composite = source.clone();
composite.set(b"Subtype".to_vec(), Object::name("Type0"));
assert!(embedded_sfnt_within(&doc, &composite, &mut budget).is_none());
assert_eq!(budget.remaining(), cost - subset.len());
}
/// A Type 0 subset font whose `/Encoding`, program, `/CIDToGIDMap` and ToUnicode are streams, and
/// what each decodes to.
fn subset_type0(doc: &mut Document, to_unicode: &[u8]) -> (Dict, [usize; 4]) {
let encoding = b"1 begincodespacerange <0000> <FFFF> endcodespacerange";
let program = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf");
let map = [0u8, 0, 0, 1, 0, 2];
let mut stream = |data: &[u8]| Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), data))));
let mut descriptor = Dict::new();
descriptor.set(b"FontFile2".to_vec(), stream(program));
let mut descendant = Dict::new();
descendant.set(b"Subtype".to_vec(), Object::name("CIDFontType2"));
descendant.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor));
descendant.set(b"CIDToGIDMap".to_vec(), stream(&map));
let entries = vec![
("Subtype", Object::name("Type0")),
("BaseFont", Object::name("ABCDEF+Inter")),
("Encoding", stream(encoding)),
("DescendantFonts", Object::Array(vec![Object::Dict(descendant)])),
("ToUnicode", stream(to_unicode)),
];
(font(doc, entries), [encoding.len(), program.len(), map.len(), to_unicode.len()])
}
/// Each stream a font's metrics come from is paid for from the allowance they are read within,
/// down to the byte; a stream it can't pay for is left unread, and the metrics say so.
#[test]
fn a_fonts_streams_are_paid_for_from_its_allowance() {
let mut doc = Document::new_empty();
let (f, sizes) = subset_type0(&mut doc, b"1 beginbfchar <0001> <0058> endbfchar");
let cost: usize = sizes.iter().sum();
let whole = Metrics::from_dict(&doc, &f);
assert!(!whole.past_allowance);
assert_eq!((whole.code_len, whole.text_of(1).as_deref()), (2, Some("X")));
assert!(!whole.outlined_cids.is_empty(), "the program and its map were read");
let mut budget = DecodeBudget::new(cost, 0);
assert_eq!(Metrics::from_dict_within(&doc, &f, &mut budget), whole);
assert_eq!(budget.remaining(), 0);
// A byte short, the ToUnicode, read last, is left unread; what came before is kept.
let mut budget = DecodeBudget::new(cost - 1, 0);
let m = Metrics::from_dict_within(&doc, &f, &mut budget);
assert!(m.past_allowance);
assert_eq!((m.code_len, m.text_of(1)), (2, None));
assert_eq!(m.outlined_cids, whole.outlined_cids);
// With nothing left, none of them is read.
let m = Metrics::from_dict_within(&doc, &f, &mut budget);
assert!(m.past_allowance && m.outlined_cids.is_empty());
assert_eq!(m.codes, Codes::Two, "the Encoding CMap was left unread");
// Only a subset's program says which glyphs it lacks: the whole font pays for its CMaps alone.
let mut whole_font = f.clone();
whole_font.set(b"BaseFont".to_vec(), Object::name("Inter"));
let mut budget = DecodeBudget::new(cost, 0);
let m = Metrics::from_dict_within(&doc, &whole_font, &mut budget);
assert!(!m.past_allowance && m.outlined_cids.is_empty());
assert_eq!(cost - budget.remaining(), sizes[0] + sizes[3]);
}
/// 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.
#[test]
fn an_empty_stream_reads_with_nothing_left() {
let past = |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 f = font(&mut doc, vec![("Subtype", Object::name("TrueType")), ("ToUnicode", unicode)]);
Metrics::from_dict_within(&doc, &f, &mut DecodeBudget::new(0, 0)).past_allowance
};
assert!(!past(b""));
assert!(past(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
/// it always was, paying for the limit, and is no reason to call the font read past the allowance.
#[test]
fn a_cmap_past_its_own_limit_is_not_past_the_allowance() {
let mut doc = Document::new_empty();
let mut cmap = b"1 beginbfchar <0001> <0058> endbfchar".to_vec();
cmap.resize(MAX_CMAP_BYTES + 1, b' ');
let (f, sizes) = subset_type0(&mut doc, &cmap);
let mut budget = DecodeBudget::new(usize::MAX / 2, 0);
let m = Metrics::from_dict_within(&doc, &f, &mut budget);
assert!(!m.past_allowance);
assert_eq!(m.text_of(1), None);
assert_eq!(usize::MAX / 2 - budget.remaining(), sizes[..3].iter().sum::<usize>() + MAX_CMAP_BYTES);
}
/// A `/CIDToGIDMap` as long as a map can be, which compresses well, reads the same behind no
/// filter, Flate, or ASCIIHex and Flate: each stage may make as much as the map's limit, so the hex
/// stage's output on top of it doesn't leave it unread. A map past the limit is unread behind each.
#[test]
fn a_full_cid_map_reads_behind_one_or_two_filters() {
let read = |entries: usize, filters: usize| {
let mut doc = Document::new_empty();
let (mut f, _) = subset_type0(&mut doc, b"");
let map: Vec<u8> = (0..entries).flat_map(|cid| u16::try_from(cid % 2000).unwrap().to_be_bytes()).collect();
let flate = Stream::flate(Dict::new(), &map);
let stream = match filters {
0 => Stream::from_raw(Dict::new(), map),
1 => flate,
_ => {
let hex: String = flate.raw.iter().map(|b| format!("{b:02X}")).chain([">".to_string()]).collect();
let mut dict = Dict::new();
dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("ASCIIHexDecode"), Object::name("FlateDecode")]));
Stream::from_raw(dict, hex.into_bytes())
}
};
let map = Object::Ref(doc.add(Object::Stream(stream)));
let Some(Object::Array(mut descendants)) = f.get(b"DescendantFonts").cloned() else { panic!("descendant") };
if let Some(Object::Dict(d)) = descendants.get_mut(0) {
d.set(b"CIDToGIDMap".to_vec(), map);
}
f.set(b"DescendantFonts".to_vec(), Object::Array(descendants));
Metrics::from_dict(&doc, &f).outlined_cids
};
let full = read(1 << 16, 1);
assert!(!full.is_empty());
assert_eq!(read(1 << 16, 0), full);
assert_eq!(read(1 << 16, 2), full);
for filters in 0..=2 {
assert!(read((1 << 16) + 1, filters).is_empty(), "{filters} filters");
}
}
/// The editor's program read holds every filter stage to the program's limit: a program behind
/// ASCIIHex and Flate whose Flate stage would make 24 MiB stops at 16 MiB, charged that and what
/// the hex stage made, where the stages' total alone would let it make all 24 MiB. A program
/// within the limit behind the same two filters reads (see also
/// `an_editors_program_reads_behind_ascii_hex_and_flate_within_its_limit`).
#[test]
fn an_editors_program_read_holds_each_stage_to_its_limit() {
let mut doc = Document::new_empty();
let flate = Stream::flate(Dict::new(), &vec![b' '; 24 << 20]);
let hex: Vec<u8> = flate.raw.iter().flat_map(|b| format!("{b:02X}").into_bytes()).chain(*b">").collect();
let mut dict = Dict::new();
dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("ASCIIHexDecode"), Object::name("FlateDecode")]));
let program = doc.add(Object::Stream(Stream::from_raw(dict, hex)));
let mut descriptor = Dict::new();
descriptor.set(b"FontFile2".to_vec(), Object::Ref(program));
let f = font(&mut doc, vec![("Subtype", Object::name("TrueType")), ("FontDescriptor", Object::Dict(descriptor))]);
let mut budget = DecodeBudget::new(usize::MAX / 2, 0);
assert!(embedded_sfnt_within(&doc, &f, &mut budget).is_none());
assert_eq!(usize::MAX / 2 - budget.remaining(), flate.raw.len() + MAX_SFNT);
}
/// Each stage of a stream a font's metrics are read from is held to the stream's limit, as a
/// decode without an allowance holds it: a `/CIDToGIDMap` behind Flate and ASCIIHex whose Flate
/// stage makes hex text past the map's limit is unread, as `Stream::decoded_within` leaves it,
/// though the two stages together make less than twice the limit; with its hex text within the
/// limit it reads.
#[test]
fn each_stage_of_a_metrics_stream_is_held_to_its_limit() {
let read = |cids: usize| {
let mut doc = Document::new_empty();
let (mut f, _) = subset_type0(&mut doc, b"");
let map: Vec<u8> = (0..cids).flat_map(|cid| u16::try_from(cid % 2000).unwrap().to_be_bytes()).collect();
let hex: Vec<u8> = map.iter().flat_map(|b| format!("{b:02X}").into_bytes()).collect();
let mut dict = Dict::new();
dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("FlateDecode"), Object::name("ASCIIHexDecode")]));
let stream = Stream::from_raw(dict, Stream::flate(Dict::new(), &hex).raw.to_vec());
let unread = stream.decoded_within(MAX_CID_TO_GID).is_err();
let map = Object::Ref(doc.add(Object::Stream(stream)));
let Some(Object::Array(mut descendants)) = f.get(b"DescendantFonts").cloned() else { panic!("descendant") };
if let Some(Object::Dict(d)) = descendants.get_mut(0) {
d.set(b"CIDToGIDMap".to_vec(), map);
}
f.set(b"DescendantFonts".to_vec(), Object::Array(descendants));
(Metrics::from_dict(&doc, &f).outlined_cids.is_empty(), unread)
};
// 0.6 of the limit: its hex text, 1.2 of it, is past the limit; the stages make 1.8 of it.
assert_eq!(read(MAX_CID_TO_GID * 6 / 20), (true, true));
assert_eq!(read(MAX_CID_TO_GID / 4), (false, false));
}
/// The editor's program is held to its limit as the metrics reads' streams are: a 12 MiB program
/// whose Flate data is about 8 MiB reads behind ASCIIHex and Flate as behind Flate alone, though
/// the two stages make about 20 MiB together. A program of the limit reads behind either, and one a
/// byte past it is unread behind either.
#[test]
fn an_editors_program_reads_behind_ascii_hex_and_flate_within_its_limit() {
let program = |len: usize, noise: usize| {
let mut program = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf").to_vec();
// Bytes Flate can't shrink, then zeros.
let mut x = 1u32;
program.extend((0..noise).map(|_| {
x = x.wrapping_mul(1_103_515_245).wrapping_add(12_345);
x.to_be_bytes()[0]
}));
program.resize(len, 0);
program
};
let read = |program: &[u8], hex: bool| {
let mut doc = Document::new_empty();
let flate = Stream::flate(Dict::new(), program);
let stream = if hex {
let digits = b"0123456789ABCDEF";
let mut data: Vec<u8> = flate.raw.iter().flat_map(|b| [digits[usize::from(b >> 4)], digits[usize::from(b & 15)]]).collect();
data.push(b'>');
let mut dict = Dict::new();
dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("ASCIIHexDecode"), Object::name("FlateDecode")]));
Stream::from_raw(dict, data)
} else {
flate
};
let mut descriptor = Dict::new();
descriptor.set(b"FontFile2".to_vec(), Object::Ref(doc.add(Object::Stream(stream))));
let simple = font(&mut doc, vec![("FontDescriptor", Object::Dict(descriptor))]);
embedded_sfnt(&doc, &simple)
};
let near = program(12 << 20, 8 << 20);
let full = program(MAX_SFNT, 0);
for hex in [false, true] {
assert_eq!(read(&near, hex).as_deref(), Some(near.as_slice()), "behind ASCIIHex too: {hex}");
assert_eq!(read(&full, hex).as_deref(), Some(full.as_slice()), "behind ASCIIHex too: {hex}");
assert!(read(&program(MAX_SFNT + 1, 0), hex).is_none(), "behind ASCIIHex too: {hex}");
}
}
/// `data` in rows of `columns` bytes behind Flate and a PNG predictor, each row tagged Up (the
/// difference from the row above), so read without the predictor it is garbled; with its
/// `/DecodeParms` given by reference or direct.
fn predicted(doc: &mut Document, data: &[u8], columns: usize, by_reference: bool) -> Object {
let mut above = vec![0u8; columns];
let mut rows = Vec::new();
for row in data.chunks(columns) {
rows.push(2);
rows.extend(row.iter().zip(&above).map(|(b, a)| b.wrapping_sub(*a)));
above[..row.len()].copy_from_slice(row);
}
let mut s = Stream::flate(Dict::new(), &rows);
let parms: Dict =
[(b"Predictor".to_vec(), Object::Int(12)), (b"Columns".to_vec(), Object::Int(i64::try_from(columns).unwrap()))].into_iter().collect();
let parms = if by_reference { Object::Ref(doc.add(Object::Dict(parms))) } else { Object::Dict(parms) };
s.dict.set(b"DecodeParms".to_vec(), parms);
Object::Ref(doc.add(Object::Stream(s)))
}
/// A stream whose `/DecodeParms` are given by reference reads as it does with them direct: a PNG
/// predictor's tag bytes are taken out of the map, the CMap and the editor's program, not left in.
#[test]
fn parameters_given_by_reference_are_read() {
let read = |by_reference: bool| {
let mut doc = Document::new_empty();
let (mut f, _) = subset_type0(&mut doc, b"");
let map: Vec<u8> = (0..1000u16).flat_map(u16::to_be_bytes).collect();
let map = predicted(&mut doc, &map, 2, by_reference);
let mut cmap = b"1 beginbfchar <0001> <0058> endbfchar".to_vec();
cmap.resize(48, b' ');
let unicode = predicted(&mut doc, &cmap, 16, by_reference);
let Some(Object::Array(mut descendants)) = f.get(b"DescendantFonts").cloned() else { panic!("descendant") };
if let Some(Object::Dict(d)) = descendants.get_mut(0) {
d.set(b"CIDToGIDMap".to_vec(), map);
}
f.set(b"DescendantFonts".to_vec(), Object::Array(descendants));
f.set(b"ToUnicode".to_vec(), unicode);
let m = Metrics::from_dict(&doc, &f);
(m.outlined_cids.clone(), m.text_of(1).map(Cow::into_owned))
};
let direct = read(false);
assert!(!direct.0.is_empty());
assert_eq!(direct.1.as_deref(), Some("X"));
assert_eq!(read(true), direct);
let mut program = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf").to_vec();
program.resize(program.len().next_multiple_of(4), 0);
for by_reference in [false, true] {
let mut doc = Document::new_empty();
let file = predicted(&mut doc, &program, 4, by_reference);
let mut descriptor = Dict::new();
descriptor.set(b"FontFile2".to_vec(), file);
let simple = font(&mut doc, vec![("FontDescriptor", Object::Dict(descriptor))]);
assert_eq!(embedded_sfnt(&doc, &simple).as_deref(), Some(program.as_slice()), "by reference: {by_reference}");
}
}
/// Fonts that each name one large ToUnicode share the allowance they are read within: it pays for
/// as many readings as it holds, and the rest are left unread, where each reading used to decode
/// it afresh.
#[test]
fn fonts_naming_one_cmap_share_one_allowance() {
let mut doc = Document::new_empty();
let mut cmap = b"1 beginbfchar <41> <0058> endbfchar".to_vec();
cmap.resize(1_000_000, b' ');
let unicode = doc.add(Object::Stream(Stream::flate(Dict::new(), &cmap)));
let fonts: Vec<Dict> = (0..100)
.map(|_| {
font(&mut doc, vec![("Subtype", Object::name("Type1")), ("BaseFont", Object::name("Helvetica")), ("ToUnicode", Object::Ref(unicode))])
})
.collect();
let mut budget = DecodeBudget::new(64 << 20, 0);
let read: Vec<Metrics> = fonts.iter().map(|f| Metrics::from_dict_within(&doc, f, &mut budget)).collect();
let whole = read.iter().filter(|m| !m.past_allowance).count();
assert_eq!(whole, (64 << 20) / cmap.len());
assert!(read[..whole].iter().all(|m| m.text_of(0x41).as_deref() == Some("X")));
assert!(read[whole..].iter().all(|m| m.text_of(0x41).as_deref() == Some("A")), "the encoding's meaning, unread past the allowance");
assert_eq!(budget.remaining(), 0);
}
/// The names read once give what `Post::glyph_name` gives, for every glyph of a font with its
/// own names (Inter's version 2.0 table), and none past the last glyph.
#[test]
@@ -388,6 +721,21 @@ fn a_symbolic_truetype_font_without_an_encoding_reads_its_glyph_names() {
],
);
assert_eq!(Metrics::from_dict(&doc, &f).decode(&codes), "");
// The names are read within the allowance the metrics are read within: the program is paid
// for by the glyph-name reading, by the Mac OS Roman one (which finds the glyphs named and
// reads nothing) and for the widths. A byte short of the first, the names go unread.
let mut doc = Document::new_empty();
let program = doc.add(Object::Stream(Stream::flate(Dict::new(), &subset)));
let mut descriptor = Dict::new();
descriptor.set(b"Flags".to_vec(), Object::Int(4));
descriptor.set(b"FontFile2".to_vec(), Object::Ref(program));
let f = font(&mut doc, vec![("Subtype", Object::name("TrueType")), ("FontDescriptor", Object::Dict(descriptor))]);
let mut budget = DecodeBudget::new(3 * subset.len(), 0);
assert_eq!(Metrics::from_dict_within(&doc, &f, &mut budget), symbolic);
assert_eq!(budget.remaining(), 0);
let short = Metrics::from_dict_within(&doc, &f, &mut DecodeBudget::new(subset.len() - 1, 0));
assert!(short.past_allowance);
assert_ne!(short.decode(&codes), TEXT, "the names unread");
}
/// The glyph list specification's `uni` and `u` forms spell their hex digits in uppercase
@@ -603,6 +951,24 @@ fn type3_glyph_byte_limits_leave_honest_metrics() {
assert_eq!((m.ascent, m.descent), (5.0, -2.0), "the /FontBBox, not the first glyph's box alone");
}
/// A Type 3 font's glyph descriptions are paid for from the allowance its metrics are read within,
/// each what it decodes to: one the allowance can't pay for is left unread, and the metrics say so.
#[test]
fn type3_glyph_boxes_are_paid_for_from_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 f = type3(&mut doc, [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], [0, 0, 0, 0], &procs);
let cost: usize = procs.iter().map(|p| p.len()).sum();
let whole = Metrics::from_dict(&doc, &f);
assert!(!whole.past_allowance);
assert_eq!((whole.ascent, whole.descent), (400.0, -20.0));
let mut budget = DecodeBudget::new(cost, 0);
assert_eq!(Metrics::from_dict_within(&doc, &f, &mut budget), whole);
assert_eq!(budget.remaining(), 0);
let m = Metrics::from_dict_within(&doc, &f, &mut DecodeBudget::new(cost - 1, 0));
assert!(m.past_allowance, "a byte short, the second description is left unread");
}
/// 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]
@@ -1142,7 +1508,7 @@ fn malformed_cmaps_never_panic() {
let mut enc = Dict::new();
enc.set(b"Differences".to_vec(), Object::Array(vec![Object::Int(i64::MAX), Object::name("A"), Object::name("B")]));
let f = font(&mut doc, vec![("Subtype", Object::name("Type1")), ("Encoding", Object::Dict(enc))]);
assert_eq!(unicode_map(&doc, &f, false).text(u32::MAX).as_deref(), Some("B"));
assert_eq!(unicode_map(&doc, &f, false, &mut Reads::new(&doc, &mut DecodeBudget::new(usize::MAX, 0))).text(u32::MAX).as_deref(), Some("B"));
}
#[test]
@@ -1373,6 +1739,29 @@ fn low_codes_resolve_to_the_covering_range() {
assert_eq!((u.covering(0xFFFF).is_some(), u.covering(0x1_0001).is_some(), u.covering(0x1_0002).is_none()), (true, true, true));
}
#[test]
fn refused_subset_reads_do_not_encode_unverified_glyphs() {
let mut doc = Document::new_empty();
let (mut f, sizes) = subset_type0(&mut doc, b"");
f.set(b"Encoding".to_vec(), Object::name("Identity-H"));
f.set(b"ToUnicode".to_vec(), Object::name("Identity-H"));
if let Some(Object::Array(descendants)) = f.get_mut(b"DescendantFonts")
&& let Some(Object::Dict(descendant)) = descendants.first_mut()
{
descendant.set(b"DW".to_vec(), Object::Int(1000));
}
let whole = Metrics::from_dict(&doc, &f);
assert!(!whole.past_allowance && !whole.outlined_cids.is_empty());
assert!(whole.encode("\u{ffff}").is_none());
// Refuse the program, then the CID map after reading the complete program.
for allowance in [0, sizes[1]] {
let partial = Metrics::from_dict_within(&doc, &f, &mut DecodeBudget::new(allowance, 0));
assert!(partial.past_allowance);
assert_eq!(partial.encode(""), Some(Vec::new()));
assert!(partial.encode("\u{ffff}").is_none(), "allowance {allowance} must not turn /DW into proof of a glyph");
}
}
/// [`cmap_only_sfnt_named`] without a `post` table.
fn cmap_only_sfnt(mac: Option<&[(u8, u8)]>, windows: &[(u16, u16)]) -> Vec<u8> {
cmap_only_sfnt_named(mac, windows, false)
@@ -1457,7 +1846,7 @@ fn a_symbolic_truetype_font_in_the_macintosh_order_reads_its_codes_as_mac_roman(
descriptor.set(b"Flags".to_vec(), Object::Int(flags));
descriptor.set(b"FontFile2".to_vec(), Object::Ref(program));
let f = font(&mut doc, vec![("Subtype", Object::name("TrueType")), ("FontDescriptor", Object::Dict(descriptor))]);
let u = unicode_map(&doc, &f, false);
let u = unicode_map(&doc, &f, false, &mut Reads::new(&doc, &mut DecodeBudget::new(usize::MAX, 0)));
codes.map(|c| u.text(c).unwrap_or_default().into_owned())
};
// `agrave` and `eacute` are glyphs 106 and 112 of the standard Macintosh order, `endash` and
@@ -1492,7 +1881,7 @@ fn the_mac_roman_reading_needs_no_base_encoding_and_no_symbolic_builtin() {
let mut all = vec![("Subtype", Object::name("TrueType")), ("FontDescriptor", Object::Dict(descriptor))];
all.extend(entries);
let f = font(&mut doc, all);
let u = unicode_map(&doc, &f, false);
let u = unicode_map(&doc, &f, false, &mut Reads::new(&doc, &mut DecodeBudget::new(usize::MAX, 0)));
[0x88, 0x8E].map(|c| u.text(c).unwrap_or_default().into_owned())
};
assert_eq!(read(vec![]), ["à", "é"]);
@@ -1512,8 +1901,40 @@ fn the_mac_roman_reading_needs_no_base_encoding_and_no_symbolic_builtin() {
assert_eq!(read(vec![("Encoding", Object::Dict(notdef))]), ["à", ""], "/Differences naming .notdef");
}
/// The program reads that tell a symbolic TrueType font's codes by its glyph names and as Mac OS
/// Roman are each paid for from the allowance the metrics are read within: one it can't pay for is
/// left unread, and the metrics say so.
#[test]
fn composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without_replacing_the_program() {
fn the_symbolic_readings_of_a_program_are_paid_for() {
let program = cmap_only_sfnt(Some(&[(0x88, 106)]), &[(0xF088, 106)]);
let mut doc = Document::new_empty();
let stream = doc.add(Object::Stream(Stream::flate(Dict::new(), &program)));
let mut descriptor = Dict::new();
descriptor.set(b"Flags".to_vec(), Object::Int(4));
descriptor.set(b"FontFile2".to_vec(), Object::Ref(stream));
// With `/Widths` the program isn't read for its advances: these are its only reads.
let entries = vec![
("Subtype", Object::name("TrueType")),
("FontDescriptor", Object::Dict(descriptor)),
("FirstChar", Object::Int(0x88)),
("Widths", Object::Array(vec![Object::Int(500)])),
];
let f = font(&mut doc, entries);
let whole = Metrics::from_dict(&doc, &f);
assert!(!whole.past_allowance);
assert_eq!(whole.text_of(0x88).as_deref(), Some("à"));
let mut budget = DecodeBudget::new(2 * program.len(), 0);
assert_eq!(Metrics::from_dict_within(&doc, &f, &mut budget), whole);
assert_eq!(budget.remaining(), 0);
let m = Metrics::from_dict_within(&doc, &f, &mut DecodeBudget::new(2 * program.len() - 1, 0));
assert!(m.past_allowance, "a byte short, the Mac OS Roman reading's read is left unread");
assert_eq!(m.text_of(0x88), None);
}
/// A Type 0 font over Inter's program without its `cmap`, Identity-H, whose `/CIDToGIDMap` sends
/// CID 17 to Inter's Ω and whose ToUnicode says code 17 is Ω; also the program object, the
/// program, Ω's glyph and what the ToUnicode and the map decode to.
fn cmapless_composite(doc: &mut Document) -> (Dict, pdfcraft_cos::ObjRef, Vec<u8>, skrifa::GlyphId, [usize; 2]) {
use skrifa::{MetadataProvider as _, raw::TableProvider as _};
let original = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf");
let expected = skrifa::FontRef::new(original).unwrap().charmap().map('Ω').unwrap();
@@ -1525,7 +1946,6 @@ fn composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without
subset[12 + index * 16..28 + index * 16].copy_from_slice(record);
}
assert!(skrifa::FontRef::new(&subset).unwrap().cmap().is_err());
let mut doc = Document::new_empty();
let program = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), &subset)));
let mut descriptor = Dict::new();
descriptor.set(b"FontFile2".to_vec(), Object::Ref(program));
@@ -1535,9 +1955,10 @@ fn composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without
let mut descendant = Dict::new();
descendant.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor));
descendant.set(b"CIDToGIDMap".to_vec(), Object::Ref(gids));
let unicode = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), b"1 beginbfchar <0011> <03a9> endbfchar")));
let cmap = b"1 beginbfchar <0011> <03a9> endbfchar";
let unicode = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), cmap)));
let source = font(
&mut doc,
doc,
vec![
("Subtype", Object::name("Type0")),
("Encoding", Object::name("Identity-H")),
@@ -1545,6 +1966,14 @@ fn composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without
("ToUnicode", Object::Ref(unicode)),
],
);
(source, program, subset, expected, [cmap.len(), 36])
}
#[test]
fn composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without_replacing_the_program() {
use skrifa::MetadataProvider as _;
let mut doc = Document::new_empty();
let (source, program, subset, expected, _) = cmapless_composite(&mut doc);
let editor = embedded_sfnt(&doc, &source).expect("the source outlines must be usable in the editor");
assert_eq!(skrifa::FontRef::new(&editor).unwrap().charmap().map('Ω'), Some(expected));
let shaped = crate::source_shaping::shape(&editor, "Ω").expect("the rebuilt cmap must work in the actual shaping engine too");
@@ -1552,3 +1981,74 @@ fn composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without
let Object::Stream(saved) = doc.get(program).as_ref().clone() else { panic!("fixture") };
assert_eq!(saved.decoded().unwrap(), subset, "the source PDF font program stays byte-for-byte intact");
}
/// The metrics read that rebuilds a composite subset's cmap is paid for from the editor's
/// allowance, as a simple font's is: the program, its ToUnicode, then its `/CIDToGIDMap`. With the
/// ToUnicode left unread no copy is adapted, though what is left would pay for the map.
#[test]
fn adapting_a_composite_subsets_cmap_is_paid_for() {
use skrifa::MetadataProvider as _;
let mut doc = Document::new_empty();
let (source, _, subset, expected, [cmap, map]) = cmapless_composite(&mut doc);
let cost = subset.len() + cmap + map;
let mut budget = DecodeBudget::new(cost, 0);
let editor = embedded_sfnt_within(&doc, &source, &mut budget).unwrap();
assert_eq!(skrifa::FontRef::new(&editor).unwrap().charmap().map('Ω'), Some(expected));
assert_eq!(budget.remaining(), 0);
assert!(cmap > map);
assert!(embedded_sfnt_within(&doc, &source, &mut DecodeBudget::new(subset.len() + cmap - 1, 0)).is_none());
}
/// A composite font whose `/Encoding` isn't Identity-H can't have its cmap rebuilt
/// (`composite_glyph_maps` reads only Identity-H codes), so the editor reads its program and no
/// more: its ToUnicode and `/CIDToGIDMap` are left for what is left of the allowance.
#[test]
fn a_composite_font_that_cant_be_adapted_spends_only_its_program() {
let mut doc = Document::new_empty();
let (source, _, subset, _, [cmap, map]) = cmapless_composite(&mut doc);
for encoding in ["Identity-V", "UniJIS-UCS2-H"] {
let mut f = source.clone();
f.set(b"Encoding".to_vec(), Object::name(encoding));
let mut budget = DecodeBudget::new(subset.len() + cmap + map, 0);
assert!(embedded_sfnt_within(&doc, &f, &mut budget).is_none(), "{encoding}");
assert_eq!(budget.remaining(), cmap + map, "{encoding}");
}
}
/// A composite font's `/Encoding` given through a reference is the name it refers to, as the
/// metrics read it: Identity-H by reference is adapted as Identity-H written directly is.
#[test]
fn a_composite_fonts_encoding_given_by_reference_is_read() {
use skrifa::MetadataProvider as _;
let mut doc = Document::new_empty();
let (mut source, _, _, expected, _) = cmapless_composite(&mut doc);
let identity = doc.add(Object::name("Identity-H"));
source.set(b"Encoding".to_vec(), Object::Ref(identity));
assert_eq!(Metrics::from_dict(&doc, &source).code_len, 2);
let editor = embedded_sfnt(&doc, &source).expect("Identity-H by reference is adapted");
assert_eq!(skrifa::FontRef::new(&editor).unwrap().charmap().map('Ω'), Some(expected));
}
/// The `/CIDToGIDMap` a composite subset's cmap is rebuilt from reads as the font's other streams
/// do: a full map, which compresses well, reads behind ASCIIHex and Flate, though the hex stage's
/// output comes on top of the map.
#[test]
fn a_full_cid_map_rebuilds_a_composite_subsets_cmap_behind_two_filters() {
use skrifa::MetadataProvider as _;
let mut doc = Document::new_empty();
let (mut source, _, _, expected, _) = cmapless_composite(&mut doc);
let mut map = vec![0u8; MAX_CID_TO_GID];
map[34..36].copy_from_slice(&(expected.to_u32() as u16).to_be_bytes());
let flate = Stream::flate(Dict::new(), &map);
let hex: String = flate.raw.iter().map(|b| format!("{b:02X}")).chain([">".to_string()]).collect();
let mut dict = Dict::new();
dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("ASCIIHexDecode"), Object::name("FlateDecode")]));
let map = Object::Ref(doc.add(Object::Stream(Stream::from_raw(dict, hex.into_bytes()))));
let Some(Object::Array(mut descendants)) = source.get(b"DescendantFonts").cloned() else { panic!("descendant") };
if let Some(Object::Dict(d)) = descendants.get_mut(0) {
d.set(b"CIDToGIDMap".to_vec(), map);
}
source.set(b"DescendantFonts".to_vec(), Object::Array(descendants));
let editor = embedded_sfnt(&doc, &source).expect("the map behind two filters is read");
assert_eq!(skrifa::FontRef::new(&editor).unwrap().charmap().map('Ω'), Some(expected));
}
+84 -10
View File
@@ -7,7 +7,7 @@
//! does (a mark takes none); correct maps and unrelated fonts are never changed. No font
//! artwork is bundled.
use super::{Dict, Document, Metrics, dict, only_char, unicode_map};
use super::{Dict, Document, Metrics, Reads, dict, identity_h, only_char, unicode_map};
use pdfcraft_cos::{DecodeBudget, Object};
use std::collections::BTreeMap;
@@ -19,15 +19,18 @@ pub fn recovered_thai_map(doc: &Document, font: &Dict) -> Option<Vec<(u16, char)
let family = base.rsplit('+').next()?;
if !matches!(family, "THSarabunPSK" | "THSarabunPSK-Bold" | "THSarabunPSK-Italic" | "THSarabunPSK-BoldItalic")
|| font.name(b"Subtype") != Some(b"Type0")
|| font.name(b"Encoding") != Some(b"Identity-H")
|| !identity_h(doc, font)
{
return None;
}
let desc = dict(doc, font.get(b"DescendantFonts").and_then(|a| doc.resolve(a).as_array().and_then(|a| a.first()).cloned()).as_ref())?;
if desc.name(b"CIDToGIDMap") != Some(b"Identity") {
// A map given by reference is the name it refers to. A missing one is taken as Identity, the
// default of PDF 1.x (ISO 32000-1), as `composite_glyph_maps` and the renderer take it; PDF 2.0
// gives it no default and requires it with a `/FontFile2`.
if desc.get(b"CIDToGIDMap").is_some_and(|m| doc.resolve(m).as_name() != Some(b"Identity".as_slice())) {
return None;
}
let map = unicode_map(doc, font, true);
let map = unicode_map(doc, font, true, &mut Reads::new(doc, &mut DecodeBudget::new(usize::MAX, 0)));
// Five separated consonants and three contextual marks must agree, together with the
// corrupt destinations and corrupt range. This is deliberately stricter than font-name
// detection: a renamed/subsetted font with different glyph order must not be guessed.
@@ -170,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
@@ -190,14 +193,15 @@ pub(super) fn composite_glyph_maps(
budget: &mut DecodeBudget,
) -> Option<(BTreeMap<u32, u32>, BTreeMap<u32, u16>)> {
use skrifa::{MetadataProvider as _, raw::TableProvider as _};
if font.name(b"Encoding") != Some(b"Identity-H") {
if !identity_h(doc, font) {
return None;
}
let descendant = dict(doc, font.get(b"DescendantFonts").and_then(|a| doc.resolve(a).as_array().and_then(|a| a.first()).cloned()).as_ref())?;
let cid_map = match descendant.get(b"CIDToGIDMap").map(|m| doc.resolve(m)).as_deref() {
None => None,
Some(Object::Name(n)) if n == b"Identity" => None,
Some(Object::Stream(s)) => Some(budget.decode_within(&doc.with_direct_parms(s), 1 << 17).ok()?),
// Read as the font's other streams are, a map behind several filters too.
Some(Object::Stream(s)) => Some(Reads::new(doc, budget).decode(s, super::MAX_CID_TO_GID)?),
_ => return None,
};
let face = skrifa::FontRef::new(bytes).ok()?;
@@ -294,13 +298,17 @@ impl LegacyThaiFont {
/// 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.
///
/// 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.
/// Its streams, its metrics' too, 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. Metrics read past it adapt nothing: the font is refused.
pub fn from_pdf(doc: &Document, font: &Dict, space: f64, budget: &mut DecodeBudget) -> Result<Option<Self>, String> {
let m = Metrics::from_dict(doc, font);
let m = Metrics::from_dict_within(doc, font, budget);
if !m.base_font.rsplit('+').next().unwrap_or(&m.base_font).starts_with("THSarabunPSK") {
return Ok(None);
}
if m.past_allowance {
return Err("the source Thai font's metrics are past the edit's allowance".into());
}
if !space.is_finite() || space < 0.0 {
return Err("the source space width is invalid".into());
}
@@ -528,6 +536,46 @@ mod tests {
/// An intact legacy map: nominal Thai and two tone alternates.
const INTACT: &[u8] = b"2 beginbfrange <00d6> <010f> <0e01> <0110> <012a> <0e3f> endbfrange 2 beginbfchar <0160> <0e48> <0161> <0e49> endbfchar";
/// A THSarabunPSK subset whose own cmap is Latin-only has its Thai mappings rebuilt from its
/// `/CIDToGIDMap`. With that map left unread for want of the editor's allowance, the editor
/// lists no copy, rather than the program without its Thai mappings.
#[test]
fn a_map_left_unread_for_the_allowance_lists_no_copy() {
let mut doc = Document::new_empty();
let program = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf");
let file = Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), program))));
let map: Vec<u8> = (0u16..512).flat_map(u16::to_be_bytes).collect();
let cid_to_gid = Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), &map))));
let font = thai_font_over(&mut doc, file, cid_to_gid, INTACT);
let mut budget = DecodeBudget::new(usize::MAX / 2, 0);
assert!(crate::pdf::embedded_sfnt_within(&doc, &font, &mut budget).is_some());
let cost = usize::MAX / 2 - budget.remaining();
// The map the cmap is rebuilt from is read last: a byte short of the cost, it is refused.
let mut short = DecodeBudget::new(cost - 1, 0);
assert!(crate::pdf::embedded_sfnt_within(&doc, &font, &mut short).is_none(), "the program is listed without its Thai mappings");
assert_eq!(short.refused(), 1);
}
/// `LegacyThaiFont::from_pdf` reads its metrics within the edit's allowance too: the subset's
/// program, for its outlined glyphs, and its ToUnicode are paid for on top of what the
/// editor's read of it pays. Metrics read past the allowance refuse the font before its
/// program is read.
#[test]
fn a_legacy_fonts_metrics_are_paid_for_from_the_edits_allowance() {
let mut doc = Document::new_empty();
let program = thai_ordered_program();
let font = thai_font(&mut doc, &program, INTACT);
let mut editor = DecodeBudget::new(usize::MAX / 2, 0);
assert!(crate::pdf::embedded_sfnt_within(&doc, &font, &mut editor).is_some());
let editor_cost = usize::MAX / 2 - editor.remaining();
let mut budget = DecodeBudget::new(usize::MAX / 2, 0);
LegacyThaiFont::from_pdf(&doc, &font, 0.25, &mut budget).unwrap();
assert_eq!(usize::MAX / 2 - budget.remaining(), editor_cost + program.len() + INTACT.len());
let mut short = DecodeBudget::new(program.len() + INTACT.len() - 1, 0);
let error = LegacyThaiFont::from_pdf(&doc, &font, 0.25, &mut short).err().unwrap_or_default();
assert!(error.contains("past the edit's allowance"), "{error}");
}
/// The exporter's damaged map: intact anchors, Latin/control destinations for the other Thai
/// codes, and the oversized range first.
const DAMAGED: &[u8] = b"begincmap 1 begincodespacerange <0000> <ffff> endcodespacerange \
@@ -547,6 +595,32 @@ mod tests {
let recovered: HashMap<_, _> = recovered_thai_map(&doc, &font).unwrap().into_iter().collect();
assert_eq!(recovered[&262], 'ั');
assert_eq!(recovered[&297], '๘');
// `/Encoding` given through a reference is the name it refers to.
let mut indirect = font.clone();
indirect.set(b"Encoding".to_vec(), Object::Ref(doc.add(Object::name("Identity-H"))));
assert_eq!(recovered_thai_map(&doc, &indirect), recovered_thai_map(&doc, &font));
// `/CIDToGIDMap` given through a reference is the name it refers to, and a missing one is
// taken as Identity, the default of PDF 1.x (ISO 32000-1), as the renderer takes it.
let with_map = |map: Option<Object>| {
let mut f = font.clone();
let Some(Object::Array(desc)) = f.get(b"DescendantFonts").cloned() else { panic!("fixture") };
let Some(Object::Dict(mut desc)) = desc.first().cloned() else { panic!("fixture") };
match map {
Some(map) => desc.set(b"CIDToGIDMap".to_vec(), map),
None => {
desc.remove(b"CIDToGIDMap");
}
}
f.set(b"DescendantFonts".to_vec(), Object::Array(vec![Object::Dict(desc)]));
f
};
let by_reference = with_map(Some(Object::Ref(doc.add(Object::name("Identity")))));
assert_eq!(recovered_thai_map(&doc, &by_reference), recovered_thai_map(&doc, &font), "a map by reference");
assert_eq!(recovered_thai_map(&doc, &with_map(None)), recovered_thai_map(&doc, &font), "a missing map");
// Any other map is refused: one that moves glyphs, and another name.
let moved = Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), &[0, 1, 0, 0]))));
assert_eq!(recovered_thai_map(&doc, &with_map(Some(moved))), None, "a map that moves glyphs");
assert_eq!(recovered_thai_map(&doc, &with_map(Some(Object::name("Foo")))), None, "another name");
// 259–261 are ฮ, ฯ and ะ, between the last anchor (อ, 258) and the first mark: a font that
// put Mai Han-akat one glyph earlier passes every anchor and only this check sees it.
let mut shifted = thai_ordered_program();
+62
View File
@@ -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");
}