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 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}