Skip to main content

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}