M7: a font's metrics can be read within a caller's decode allowance

Metrics::from_dict decodes up to four streams of a font (its Encoding and
ToUnicode CMaps, its program, its /CIDToGIDMap), each within a limit of
its own but afresh on every call, so a caller reading many fonts that
name one large stream decodes it once per font with no total.

Metrics::from_dict_within charges each of those decodes to the caller's
DecodeBudget, and past_allowance says when one was left unread because
the allowance was spent, so a caller can refuse rather than work from
guessed widths, code lengths or meanings. A stream past its own limit is
left unread as before and doesn't count as past the allowance.
from_dict reads with no total, as before.

embedded_sfnt_within, which the editor's font listing shares one
allowance across, read the metrics that adapt a program's cmap outside
that allowance: their ToUnicode and the program again. It now charges
them, skips a composite font before reading its metrics (it never used
them), and adapts no copy from metrics read past the allowance.

Part of #5755.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
This commit is contained in:
Grzegorz Martens
2026-11-04 21:50:33 +01:00
co-authored by Claude Opus 5.5
parent 7d17aea92c
commit 9bc24aadff
2 changed files with 205 additions and 22 deletions
+71 -18
View File
@@ -14,7 +14,7 @@
use std::borrow::Cow;
use std::collections::{BinaryHeap, HashMap, HashSet};
use pdfcraft_cos::{DecodeBudget, Dict, Document, Object};
use pdfcraft_cos::{DecodeBudget, Dict, Document, Object, Stream};
/// How the bytes of a string map to codes.
#[derive(Clone, Debug, PartialEq)]
@@ -67,6 +67,10 @@ 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`) 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) is a guess.
pub past_allowance: bool,
}
fn nums(doc: &Document, o: Option<&Object>) -> Vec<f64> {
@@ -92,14 +96,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());
@@ -120,8 +127,13 @@ pub fn embedded_sfnt_within(doc: &Document, font: &Dict, budget: &mut DecodeBudg
// format-4/12 cmaps. Combine the PDF's Unicode meanings with the source glyph IDs;
// only this in-memory copy gets a new cmap, never the working PDF or a font asset.
let Ok(cmap) = face.cmap() else { continue };
let metrics = Metrics::from_dict(doc, font);
if metrics.composite {
// A composite font's codes aren't these, so its metrics aren't read (Metrics::composite
// is this test). Meanings read past the allowance would be guesses: no copy is adapted.
if font.name(b"Subtype") == Some(b"Type0") {
continue;
}
let metrics = Metrics::from_dict_within(doc, font, budget);
if metrics.past_allowance {
continue;
}
let mut mappings = std::collections::BTreeMap::new();
@@ -147,7 +159,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 {
@@ -156,13 +168,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) = 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) = 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
@@ -181,7 +193,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 };
@@ -189,7 +201,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) = 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 {
@@ -287,6 +299,33 @@ 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.
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> {
budget: &'a mut DecodeBudget,
refused: bool,
}
impl<'a> Reads<'a> {
fn new(budget: &'a mut DecodeBudget) -> Self {
Self { budget, refused: false }
}
/// `stream` decoded within `max` and the allowance; `None` when it can't be, noting when the
/// allowance is why. A stream past `max` is unread as before allowances, not refused.
fn decode(&mut self, stream: &Stream, max: usize) -> Option<Vec<u8>> {
match self.budget.decode_within(stream, max) {
Ok(data) => Some(data),
Err(_) => {
self.refused |= self.budget.remaining() == 0;
None
}
}
}
}
/// Code → Unicode of a font. Explicit mappings and sequential ranges are kept as declared, so a range
/// costs one record however many codes it covers; where two overlap, the one declared later wins.
@@ -628,11 +667,24 @@ 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), 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(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())
@@ -649,17 +701,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)
@@ -680,7 +733,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();
@@ -691,7 +744,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) = 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() {
@@ -1229,7 +1282,7 @@ pub fn glyph_unicode(name: &str) -> Option<char> {
/// 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));
@@ -1293,7 +1346,7 @@ 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)) => {
if let Ok(data) = s.decoded_within(MAX_CMAP_BYTES) {
if let Some(data) = reads.decode(s, MAX_CMAP_BYTES) {
parse_to_unicode(&data, &mut out);
}
}
+134 -4
View File
@@ -154,9 +154,10 @@ fn a_font_without_widths_measures_with_its_embedded_program() {
assert!(m.width(u32::from('i')) < 0.4, "not Courier's 0.6");
}
#[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();
@@ -170,6 +171,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));
@@ -190,6 +198,128 @@ 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, no copy is
/// adapted from guessed meanings.
#[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 = 2 * 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's metrics aren't read at all: 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]);
}
/// A stream past its own 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);
}
/// 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);
}
fn metrics_with_flags(flags: Option<Object>) -> Metrics {
let mut doc = Document::new_empty();
let mut descriptor = Dict::new();
@@ -565,7 +695,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(&mut DecodeBudget::new(usize::MAX, 0))).text(u32::MAX).as_deref(), Some("B"));
}
#[test]