mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 13:01:29 +00:00
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:
10 files changed
+1087
-64
No files matched your search
+161
-2
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
)));
|
||||
|
||||
@@ -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
@@ -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
@@ -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));
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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