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 overrides;
79
80use std::collections::BTreeMap;
81
82use crate::error::Error;
83use crate::write::id::IdSource;
84
85/// How the glyphs of a font were renumbered by subsetting.
86///
87/// Old glyph ID to new. Everything PDF-side that named a glyph — `/W`,
88/// `/ToUnicode`, and the char codes of an Identity-H content stream — has to
89/// be looked up through this.
90#[derive(Debug, Clone, Default, PartialEq, Eq)]
91pub struct GidMap {
92 map: BTreeMap<u16, u16>,
93}
94
95impl GidMap {
96 /// Build a map from pairs of (old, new).
97 #[must_use]
98 pub fn from_pairs(pairs: impl IntoIterator<Item = (u16, u16)>) -> Self {
99 Self {
100 map: pairs.into_iter().collect(),
101 }
102 }
103
104 /// The new glyph ID for an old one.
105 #[must_use]
106 pub fn get(&self, old: u16) -> Option<u16> {
107 self.map.get(&old).copied()
108 }
109
110 /// Every (old, new) pair, ascending by old ID.
111 pub fn pairs(&self) -> impl Iterator<Item = (u16, u16)> + '_ {
112 self.map.iter().map(|(a, b)| (*a, *b))
113 }
114
115 /// How many glyphs survived.
116 #[must_use]
117 pub fn len(&self) -> usize {
118 self.map.len()
119 }
120
121 /// Whether nothing survived.
122 #[must_use]
123 pub fn is_empty(&self) -> bool {
124 self.map.is_empty()
125 }
126}
127
128/// A subset font program and the renumbering it performed.
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub struct Subsetted {
131 /// The font program.
132 pub bytes: Vec<u8>,
133 /// Old glyph ID to new.
134 pub gid_map: GidMap,
135}
136
137/// Subset a font program to `gids`.
138///
139/// The returned program contains those glyphs and nothing else, renumbered
140/// into a contiguous space starting at 0 with `.notdef` first. Everything
141/// PDF-side that named a glyph must be looked up through
142/// [`Subsetted::gid_map`].
143///
144/// `.notdef` (glyph 0) is always included whether or not it was asked for,
145/// because a font without it is malformed.
146///
147/// # Errors
148///
149/// [`Error::Subset`] when the program cannot be parsed or subsetted — a
150/// format the subsetter does not handle (CFF2 without variable-font support),
151/// a truncated table directory, or a glyph ID past the end of the font.
152///
153/// ```
154/// # fn main() -> Result<(), pdfrum_edit::Error> {
155/// # let font_bytes = include_bytes!("../../tests/files/tiny.ttf");
156/// let subset = pdfrum_edit::subset(font_bytes, &[3, 7])?;
157/// // Glyph 0 is always kept, so the map holds three entries.
158/// assert_eq!(subset.gid_map.get(0), Some(0));
159/// assert!(subset.bytes.len() < font_bytes.len());
160/// # Ok(())
161/// # }
162/// ```
163pub fn subset(font_bytes: &[u8], gids: &[u16]) -> Result<Subsetted, Error> {
164 // `.notdef` is not optional: a font without glyph 0 is malformed, and the
165 // subsetter's own output always starts with it.
166 let mut wanted: Vec<u16> = gids.to_vec();
167 wanted.push(0);
168 wanted.sort_unstable();
169 wanted.dedup();
170
171 let remapper = subsetter::GlyphRemapper::new_from_glyphs_sorted(&wanted);
172 let bytes =
173 subsetter::subset(font_bytes, 0, &remapper).map_err(|e| Error::Subset(e.to_string()))?;
174
175 let gid_map = GidMap::from_pairs(
176 wanted
177 .iter()
178 .filter_map(|old| remapper.get(*old).map(|new| (*old, new))),
179 );
180 Ok(Subsetted { bytes, gid_map })
181}
182
183/// The six-letter tag an embedded subset's name carries.
184///
185/// Uppercase ASCII, drawn from the save's own [`IdSource`] so a fixed save is
186/// byte-reproducible.
187#[must_use]
188pub(crate) fn subset_tag(source: IdSource) -> [u8; 6] {
189 let mut out = [b'A'; 6];
190 for (i, slot) in out.iter_mut().enumerate() {
191 *slot = b'A' + (source.tag_byte(i as u64) % 26);
192 }
193 out
194}
195
196/// `ABCDEF+Original`, with any existing tag stripped first.
197#[must_use]
198pub(crate) fn subset_name(base: &[u8], tag: [u8; 6]) -> Vec<u8> {
199 let mut out = Vec::with_capacity(base.len() + 7);
200 out.extend_from_slice(&tag);
201 out.push(b'+');
202 out.extend_from_slice(strip_subset_prefix(base));
203 out
204}
205
206/// A font name with its subset tag removed, if it has one.
207///
208/// A tag is exactly six uppercase letters followed by `+`, and the name must
209/// be longer than that — a name that is *only* a tag has nothing to strip.
210#[must_use]
211pub(crate) fn strip_subset_prefix(name: &[u8]) -> &[u8] {
212 if name.len() <= 7 {
213 return name;
214 }
215 if name.get(6) != Some(&b'+') {
216 return name;
217 }
218 if !name
219 .get(..6)
220 .is_some_and(|p| p.iter().all(u8::is_ascii_uppercase))
221 {
222 return name;
223 }
224 name.get(7..).unwrap_or(name)
225}
226
227/// Whether a font program is OpenType with CFF outlines — an `OTTO` tag on
228/// the *original* bytes.
229///
230/// It decides two things at once: the descriptor writes `/FontFile3` rather
231/// than `/FontFile2`, and the descendant font is a `/CIDFontType0`.
232#[must_use]
233pub(crate) fn is_opentype_cff(bytes: &[u8]) -> bool {
234 bytes.get(..4) == Some(b"OTTO")
235}
236
237#[cfg(test)]
238mod tests {
239 use super::{GidMap, is_opentype_cff, strip_subset_prefix, subset, subset_name, subset_tag};
240 use crate::write::id::IdSource;
241
242 const TINY: &[u8] = include_bytes!("../../tests/files/tiny.ttf");
243
244 #[test]
245 fn subsetting_keeps_the_asked_for_glyphs_and_notdef() {
246 let out = subset(TINY, &[1]).expect("subsets");
247 // `.notdef` is always present, whether or not it was asked for.
248 assert_eq!(out.gid_map.get(0), Some(0));
249 assert!(out.gid_map.get(1).is_some());
250 assert_eq!(out.gid_map.len(), 2);
251 }
252
253 // The whole reason this crate re-keys anything: glyph IDs move.
254 #[test]
255 fn glyph_ids_are_renumbered_into_a_contiguous_space() {
256 let out = subset(TINY, &[0, 1, 2]).expect("subsets");
257 let new: Vec<u16> = out.gid_map.pairs().map(|(_, n)| n).collect();
258 assert_eq!(new, vec![0, 1, 2], "contiguous from zero");
259 }
260
261 #[test]
262 fn a_subset_is_smaller_than_the_original() {
263 let out = subset(TINY, &[1]).expect("subsets");
264 assert!(
265 out.bytes.len() <= TINY.len(),
266 "{} vs {}",
267 out.bytes.len(),
268 TINY.len()
269 );
270 }
271
272 #[test]
273 fn junk_is_refused_rather_than_panicking() {
274 assert!(subset(b"not a font at all", &[1]).is_err());
275 assert!(subset(&[], &[]).is_err());
276 }
277
278 // ReplaceExistingPrefix (:514-551): one tag, never two.
279 #[test]
280 fn an_existing_prefix_is_replaced_not_stacked() {
281 let name = subset_name(b"AAAAAA+Arimo-Regular", *b"XXXXXX");
282 assert_eq!(name, b"XXXXXX+Arimo-Regular");
283 assert_eq!(name.iter().filter(|b| **b == b'+').take(2).count(), 1);
284 }
285
286 #[test]
287 fn a_name_with_no_prefix_gains_one() {
288 assert_eq!(
289 subset_name(b"Arimo-Regular", *b"ABCDEF"),
290 b"ABCDEF+Arimo-Regular"
291 );
292 }
293
294 // The prefix test is exact: six *uppercase* letters and a plus.
295 #[test]
296 fn only_a_real_prefix_is_stripped() {
297 assert_eq!(strip_subset_prefix(b"ABCDEF+Name"), b"Name");
298 // Lowercase is not a tag.
299 assert_eq!(strip_subset_prefix(b"abcdef+Name"), b"abcdef+Name");
300 // Five letters is not a tag.
301 assert_eq!(strip_subset_prefix(b"ABCDE+Name"), b"ABCDE+Name");
302 // Digits are not letters.
303 assert_eq!(strip_subset_prefix(b"ABC123+Name"), b"ABC123+Name");
304 // No plus at all.
305 assert_eq!(strip_subset_prefix(b"ABCDEFName"), b"ABCDEFName");
306 // A name that is only a tag has nothing after it to keep.
307 assert_eq!(strip_subset_prefix(b"ABCDEF+"), b"ABCDEF+");
308 assert_eq!(strip_subset_prefix(b""), b"");
309 }
310
311 #[test]
312 fn a_tag_is_six_uppercase_letters() {
313 let tag = subset_tag(IdSource::Fixed([3u8; 16]));
314 assert_eq!(tag.len(), 6);
315 assert!(tag.iter().all(u8::is_ascii_uppercase), "{tag:?}");
316 }
317
318 // Determinism: a fixed source gives a reproducible tag.
319 #[test]
320 fn a_fixed_source_gives_the_same_tag_every_time() {
321 let seed = IdSource::Fixed([9u8; 16]);
322 assert_eq!(subset_tag(seed), subset_tag(seed));
323 assert_ne!(subset_tag(seed), subset_tag(IdSource::Fixed([8u8; 16])));
324 }
325
326 // The four-byte tag test on the *original* bytes, which decides both
327 // `/FontFile3` and `/CIDFontType0`.
328 #[test]
329 fn opentype_cff_is_an_otto_tag() {
330 assert!(is_opentype_cff(b"OTTO\x00\x01"));
331 assert!(!is_opentype_cff(b"\x00\x01\x00\x00"));
332 assert!(!is_opentype_cff(b"true"));
333 assert!(!is_opentype_cff(b"OTT"));
334 assert!(!is_opentype_cff(b""));
335 }
336
337 #[test]
338 fn an_empty_map_reports_itself_empty() {
339 let map = GidMap::default();
340 assert!(map.is_empty());
341 assert_eq!(map.get(0), None);
342 }
343}