pdfrum_edit/font/mod.rs
1//! Font subsetting (ISO 32000-1 §9.9), and where the renumbering it forces
2//! is absorbed.
3//!
4//! This is the stage [`crate::SaveOptions::subset_new_fonts`] names. It runs
5//! over the objects a save is writing as *new*, produces replacement objects
6//! for the font ones among them, and never touches the document: the
7//! writer's new-object loop consults the map per object.
8//!
9//! # One fact about the subsetter decides the shape
10//!
11//! `HarfBuzz`, which PDFium uses, has a `RETAIN_GIDS` mode: glyph IDs survive
12//! subsetting unchanged, so `/W`, the encoding CMap and `/ToUnicode` all stay
13//! valid without being touched. The `subsetter` crate has no such mode — it
14//! **always** produces a contiguous glyph space starting at 0 with `.notdef`
15//! first, and it removes `cmap` unconditionally ("CID fonts in PDF define
16//! their own cmaps").
17//!
18//! The renumbering has to be absorbed somewhere. `/CIDToGIDMap` is that
19//! somewhere: ISO 32000-1 §9.7.4.2 already defines a per-CID glyph index for
20//! a `CIDFontType2`, so writing one that sends each CID to its *new* glyph
21//! leaves everything else that named a glyph alone. In particular:
22//!
23//! - the **character codes on the page do not change**, so no content stream
24//! is regenerated and none of [`crate::regenerate`]'s losses are incurred;
25//! - **`/W` is carried through untouched**, still keyed by CID, exactly as
26//! the C++ leaves it. Its `CreateWidthsArray` rebuild is a *pruning* of
27//! widths for glyphs the file no longer draws, which no correct reader can
28//! observe.
29//! - **`/ToUnicode` is carried through untouched** for the same reason, so
30//! text extraction over a subsetted save is unchanged (round-trip
31//! obligation R15).
32//!
33//! The alternative — re-keying `/W`, `/ToUnicode` and the content streams —
34//! is strictly worse: it makes subsetting depend on an emitter that drops
35//! character spacing, shadings, text clips and soft masks
36//! ([`crate::content`]'s loss list), so a page would come back visibly
37//! changed to save bytes no reader can see.
38//!
39//! # What is subsetted, and what is left alone
40//!
41//! A candidate is a `/Type0` font, new in this save, whose descendant is a
42//! `CIDFontType2` with a `/FontFile2`, reached by a show operator on a page.
43//! Everything else is skipped:
44//!
45//! - **Type 1 (`/FontFile`)** — as in the C++, which notes `HarfBuzz` cannot
46//! subset one either.
47//! - **A simple TrueType font**, even with `/FontFile2`. It maps codes to
48//! glyphs *through the program's own `cmap`*, which the subsetter removes,
49//! so a subsetted simple font would render nothing. The C++ subsets these;
50//! this is a narrowing, and the widest one here.
51//! - **`OpenType`-CFF (`OTTO`)**. Its descendant is a `CIDFontType0`, where
52//! the CID *is* the glyph index and `/CIDToGIDMap` is never consulted, so
53//! the renumbering would have nowhere to go but the content streams. The
54//! C++ subsets these and switches `/Subtype` to `/CIDFontType0` with
55//! `/FontFile3`; ours declines. The
56//! `OTTO` test that drives the switch ([`is_opentype_cff`]) stays, because
57//! it is what recognises the case to decline.
58//!
59//! A candidate whose subset would not be *smaller* is also left alone: four
60//! rewritten objects and a new table are not worth paying for a program that
61//! did not shrink.
62//!
63//! # Subset names
64//!
65//! An embedded subset is named `ABCDEF+Original`: six uppercase letters, a
66//! plus, then the base name. An existing prefix is stripped before a new one
67//! is added, so a font that has been subsetted twice still carries exactly
68//! one tag.
69
70// Where the shape comes from: `CPDF_Creator::WriteNewObjs` (`:203-226`)
71// consults `CPDF_FontSubsetter::GenerateObjectOverrides` the same way. The
72// R15 obligation above is what `fpdf_save_embeddertest.cpp:362-383` asserts
73// of the C++, and the `CIDFontType0` fact — CID *is* the glyph index, so
74// `/CIDToGIDMap` is never consulted — is `cpdf_cidfont.cpp:508-518`.
75
76pub(crate) mod collect;
77pub(crate) mod embed;
78pub(crate) mod glyph;
79pub(crate) mod instance;
80pub(crate) mod instance_name;
81pub(crate) mod overrides;
82
83use std::collections::BTreeMap;
84
85use crate::error::Error;
86use crate::write::id::IdSource;
87
88/// How the glyphs of a font were renumbered by subsetting.
89///
90/// Old glyph ID to new. Everything PDF-side that named a glyph — `/W`,
91/// `/ToUnicode`, and the char codes of an Identity-H content stream — has to
92/// be looked up through this.
93#[derive(Debug, Clone, Default, PartialEq, Eq)]
94pub struct GidMap {
95 map: BTreeMap<u16, u16>,
96}
97
98impl GidMap {
99 /// Build a map from pairs of (old, new).
100 #[must_use]
101 pub fn from_pairs(pairs: impl IntoIterator<Item = (u16, u16)>) -> Self {
102 Self {
103 map: pairs.into_iter().collect(),
104 }
105 }
106
107 /// The new glyph ID for an old one.
108 #[must_use]
109 pub fn get(&self, old: u16) -> Option<u16> {
110 self.map.get(&old).copied()
111 }
112
113 /// Every (old, new) pair, ascending by old ID.
114 pub fn pairs(&self) -> impl Iterator<Item = (u16, u16)> + '_ {
115 self.map.iter().map(|(a, b)| (*a, *b))
116 }
117
118 /// How many glyphs survived.
119 #[must_use]
120 pub fn len(&self) -> usize {
121 self.map.len()
122 }
123
124 /// Whether nothing survived.
125 #[must_use]
126 pub fn is_empty(&self) -> bool {
127 self.map.is_empty()
128 }
129}
130
131/// A subset font program and the renumbering it performed.
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub struct Subsetted {
134 /// The font program.
135 pub bytes: Vec<u8>,
136 /// Old glyph ID to new.
137 pub gid_map: GidMap,
138}
139
140/// Subset a font program to `gids`.
141///
142/// The returned program contains those glyphs and nothing else, renumbered
143/// into a contiguous space starting at 0 with `.notdef` first. Everything
144/// PDF-side that named a glyph must be looked up through
145/// [`Subsetted::gid_map`].
146///
147/// `.notdef` (glyph 0) is always included whether or not it was asked for,
148/// because a font without it is malformed.
149///
150/// # Errors
151///
152/// [`Error::Subset`] when the program cannot be parsed or subsetted — a
153/// format the subsetter does not handle (CFF2 without variable-font support),
154/// a truncated table directory, or a glyph ID past the end of the font.
155///
156/// ```
157/// # fn main() -> Result<(), pdfrum_edit::Error> {
158/// # let font_bytes = include_bytes!("../../tests/files/tiny.ttf");
159/// let subset = pdfrum_edit::subset(font_bytes, &[3, 7])?;
160/// // Glyph 0 is always kept, so the map holds three entries.
161/// assert_eq!(subset.gid_map.get(0), Some(0));
162/// assert!(subset.bytes.len() < font_bytes.len());
163/// # Ok(())
164/// # }
165/// ```
166pub fn subset(font_bytes: &[u8], gids: &[u16]) -> Result<Subsetted, Error> {
167 // `.notdef` is not optional: a font without glyph 0 is malformed, and the
168 // subsetter's own output always starts with it.
169 let mut wanted: Vec<u16> = gids.to_vec();
170 wanted.push(0);
171 wanted.sort_unstable();
172 wanted.dedup();
173
174 let remapper = subsetter::GlyphRemapper::new_from_glyphs_sorted(&wanted);
175 let bytes =
176 subsetter::subset(font_bytes, 0, &remapper).map_err(|e| Error::Subset(e.to_string()))?;
177
178 let gid_map = GidMap::from_pairs(
179 wanted
180 .iter()
181 .filter_map(|old| remapper.get(*old).map(|new| (*old, new))),
182 );
183 Ok(Subsetted { bytes, gid_map })
184}
185
186/// The six-letter tag an embedded subset's name carries.
187///
188/// Uppercase ASCII, drawn from the save's own [`IdSource`] so a fixed save is
189/// byte-reproducible.
190#[must_use]
191pub(crate) fn subset_tag(source: IdSource) -> [u8; 6] {
192 let mut out = [b'A'; 6];
193 for (i, slot) in out.iter_mut().enumerate() {
194 *slot = b'A' + (source.tag_byte(i as u64) % 26);
195 }
196 out
197}
198
199/// `ABCDEF+Original`, with any existing tag stripped first.
200#[must_use]
201pub(crate) fn subset_name(base: &[u8], tag: [u8; 6]) -> Vec<u8> {
202 let mut out = Vec::with_capacity(base.len() + 7);
203 out.extend_from_slice(&tag);
204 out.push(b'+');
205 out.extend_from_slice(strip_subset_prefix(base));
206 out
207}
208
209/// A font name with its subset tag removed, if it has one.
210///
211/// A tag is exactly six uppercase letters followed by `+`, and the name must
212/// be longer than that — a name that is *only* a tag has nothing to strip.
213#[must_use]
214pub(crate) fn strip_subset_prefix(name: &[u8]) -> &[u8] {
215 if name.len() <= 7 {
216 return name;
217 }
218 if name.get(6) != Some(&b'+') {
219 return name;
220 }
221 if !name
222 .get(..6)
223 .is_some_and(|p| p.iter().all(u8::is_ascii_uppercase))
224 {
225 return name;
226 }
227 name.get(7..).unwrap_or(name)
228}
229
230/// Whether a font program is OpenType with CFF outlines — an `OTTO` tag on
231/// the *original* bytes.
232///
233/// It decides two things at once: the descriptor writes `/FontFile3` rather
234/// than `/FontFile2`, and the descendant font is a `/CIDFontType0`.
235#[must_use]
236pub(crate) fn is_opentype_cff(bytes: &[u8]) -> bool {
237 bytes.get(..4) == Some(b"OTTO")
238}
239
240#[cfg(test)]
241mod tests {
242 use super::{GidMap, is_opentype_cff, strip_subset_prefix, subset, subset_name, subset_tag};
243 use crate::write::id::IdSource;
244
245 const TINY: &[u8] = include_bytes!("../../tests/files/tiny.ttf");
246
247 #[test]
248 fn subsetting_keeps_the_asked_for_glyphs_and_notdef() {
249 let out = subset(TINY, &[1]).expect("subsets");
250 // `.notdef` is always present, whether or not it was asked for.
251 assert_eq!(out.gid_map.get(0), Some(0));
252 assert!(out.gid_map.get(1).is_some());
253 assert_eq!(out.gid_map.len(), 2);
254 }
255
256 // The whole reason this crate re-keys anything: glyph IDs move.
257 #[test]
258 fn glyph_ids_are_renumbered_into_a_contiguous_space() {
259 let out = subset(TINY, &[0, 1, 2]).expect("subsets");
260 let new: Vec<u16> = out.gid_map.pairs().map(|(_, n)| n).collect();
261 assert_eq!(new, vec![0, 1, 2], "contiguous from zero");
262 }
263
264 #[test]
265 fn a_subset_is_smaller_than_the_original() {
266 let out = subset(TINY, &[1]).expect("subsets");
267 assert!(
268 out.bytes.len() <= TINY.len(),
269 "{} vs {}",
270 out.bytes.len(),
271 TINY.len()
272 );
273 }
274
275 #[test]
276 fn junk_is_refused_rather_than_panicking() {
277 assert!(subset(b"not a font at all", &[1]).is_err());
278 assert!(subset(&[], &[]).is_err());
279 }
280
281 // ReplaceExistingPrefix (:514-551): one tag, never two.
282 #[test]
283 fn an_existing_prefix_is_replaced_not_stacked() {
284 let name = subset_name(b"AAAAAA+Arimo-Regular", *b"XXXXXX");
285 assert_eq!(name, b"XXXXXX+Arimo-Regular");
286 assert_eq!(name.iter().filter(|b| **b == b'+').take(2).count(), 1);
287 }
288
289 #[test]
290 fn a_name_with_no_prefix_gains_one() {
291 assert_eq!(
292 subset_name(b"Arimo-Regular", *b"ABCDEF"),
293 b"ABCDEF+Arimo-Regular"
294 );
295 }
296
297 // The prefix test is exact: six *uppercase* letters and a plus.
298 #[test]
299 fn only_a_real_prefix_is_stripped() {
300 assert_eq!(strip_subset_prefix(b"ABCDEF+Name"), b"Name");
301 // Lowercase is not a tag.
302 assert_eq!(strip_subset_prefix(b"abcdef+Name"), b"abcdef+Name");
303 // Five letters is not a tag.
304 assert_eq!(strip_subset_prefix(b"ABCDE+Name"), b"ABCDE+Name");
305 // Digits are not letters.
306 assert_eq!(strip_subset_prefix(b"ABC123+Name"), b"ABC123+Name");
307 // No plus at all.
308 assert_eq!(strip_subset_prefix(b"ABCDEFName"), b"ABCDEFName");
309 // A name that is only a tag has nothing after it to keep.
310 assert_eq!(strip_subset_prefix(b"ABCDEF+"), b"ABCDEF+");
311 assert_eq!(strip_subset_prefix(b""), b"");
312 }
313
314 #[test]
315 fn a_tag_is_six_uppercase_letters() {
316 let tag = subset_tag(IdSource::Fixed([3u8; 16]));
317 assert_eq!(tag.len(), 6);
318 assert!(tag.iter().all(u8::is_ascii_uppercase), "{tag:?}");
319 }
320
321 // Determinism: a fixed source gives a reproducible tag.
322 #[test]
323 fn a_fixed_source_gives_the_same_tag_every_time() {
324 let seed = IdSource::Fixed([9u8; 16]);
325 assert_eq!(subset_tag(seed), subset_tag(seed));
326 assert_ne!(subset_tag(seed), subset_tag(IdSource::Fixed([8u8; 16])));
327 }
328
329 // The four-byte tag test on the *original* bytes, which decides both
330 // `/FontFile3` and `/CIDFontType0`.
331 #[test]
332 fn opentype_cff_is_an_otto_tag() {
333 assert!(is_opentype_cff(b"OTTO\x00\x01"));
334 assert!(!is_opentype_cff(b"\x00\x01\x00\x00"));
335 assert!(!is_opentype_cff(b"true"));
336 assert!(!is_opentype_cff(b"OTT"));
337 assert!(!is_opentype_cff(b""));
338 }
339
340 #[test]
341 fn an_empty_map_reports_itself_empty() {
342 let map = GidMap::default();
343 assert!(map.is_empty());
344 assert_eq!(map.get(0), None);
345 }
346}