Skip to main content

write_fonts/
font_builder.rs

1//!  A builder for top-level font objects
2
3use std::borrow::Cow;
4use std::collections::BTreeMap;
5
6use read_fonts::{FontRef, TableProvider};
7use types::{Tag, TT_SFNT_VERSION};
8
9#[cfg(feature = "tables")]
10use crate::error::BuilderError;
11use crate::search_range::SearchRange;
12
13include!("../generated/generated_font.rs");
14
15const TABLE_RECORD_LEN: usize = 16;
16const CFF: Tag = Tag::new(b"CFF ");
17const CFF2: Tag = Tag::new(b"CFF2");
18
19/// Build a font from some set of tables.
20#[derive(Debug, Clone, Default)]
21pub struct FontBuilder<'a> {
22    tables: BTreeMap<Tag, Cow<'a, [u8]>>,
23    checksums: BTreeMap<Tag, u32>,
24}
25
26impl TableDirectory {
27    pub fn from_table_records(table_records: Vec<TableRecord>) -> TableDirectory {
28        assert!(table_records.len() <= u16::MAX as usize);
29        // See https://learn.microsoft.com/en-us/typography/opentype/spec/otff#table-directory
30        let computed = SearchRange::compute(table_records.len(), TABLE_RECORD_LEN);
31
32        let is_cff = table_records
33            .iter()
34            .any(|rec| [CFF, CFF2].contains(&rec.tag));
35        let sfnt = if is_cff {
36            CFF_SFNT_VERSION
37        } else {
38            TT_SFNT_VERSION
39        };
40
41        TableDirectory::new(
42            sfnt,
43            computed.search_range,
44            computed.entry_selector,
45            computed.range_shift,
46            table_records,
47        )
48    }
49}
50
51// https://learn.microsoft.com/en-us/typography/opentype/spec/recom#optimized-table-ordering
52const RECOMMENDED_TABLE_ORDER_TTF: [Tag; 19] = [
53    Tag::new(b"head"),
54    Tag::new(b"hhea"),
55    Tag::new(b"maxp"),
56    Tag::new(b"OS/2"),
57    Tag::new(b"hmtx"),
58    Tag::new(b"LTSH"),
59    Tag::new(b"VDMX"),
60    Tag::new(b"hdmx"),
61    Tag::new(b"cmap"),
62    Tag::new(b"fpgm"),
63    Tag::new(b"prep"),
64    Tag::new(b"cvt "),
65    Tag::new(b"loca"),
66    Tag::new(b"glyf"),
67    Tag::new(b"kern"),
68    Tag::new(b"name"),
69    Tag::new(b"post"),
70    Tag::new(b"gasp"),
71    Tag::new(b"PCLT"),
72];
73
74const RECOMMENDED_TABLE_ORDER_CFF: [Tag; 8] = [
75    Tag::new(b"head"),
76    Tag::new(b"hhea"),
77    Tag::new(b"maxp"),
78    Tag::new(b"OS/2"),
79    Tag::new(b"name"),
80    Tag::new(b"cmap"),
81    Tag::new(b"post"),
82    Tag::new(b"CFF "),
83];
84
85impl<'a> FontBuilder<'a> {
86    /// Create a new builder to compile a binary font
87    pub fn new() -> Self {
88        Self::default()
89    }
90
91    /// Add a table to the builder.
92    ///
93    /// The table can be any top-level table defined in this crate. This function
94    /// will attempt to compile the table and then add it to the builder if
95    /// successful, returning an error otherwise.
96    #[cfg(feature = "tables")]
97    pub fn add_table<T>(&mut self, table: &T) -> Result<&mut Self, BuilderError>
98    where
99        T: FontWrite + Validate + TopLevelTable,
100    {
101        let tag = T::TAG;
102        let bytes = crate::dump_table(table).map_err(|inner| BuilderError { inner, tag })?;
103        Ok(self.add_raw(tag, bytes))
104    }
105
106    /// A builder method to add raw data for the provided tag
107    pub fn add_raw(&mut self, tag: Tag, data: impl Into<Cow<'a, [u8]>>) -> &mut Self {
108        self.tables.insert(tag, data.into());
109        self.checksums.remove(&tag);
110        self
111    }
112
113    /// Add raw table data with its precomputed OpenType checksum.
114    ///
115    /// The checksum must include the table's implicit zero padding to a multiple of four bytes.
116    /// Supplying it avoids rescanning table data that was copied unchanged from another font.
117    pub fn add_raw_with_checksum(
118        &mut self,
119        tag: Tag,
120        data: impl Into<Cow<'a, [u8]>>,
121        checksum: u32,
122    ) -> &mut Self {
123        self.tables.insert(tag, data.into());
124        self.checksums.insert(tag, checksum);
125        self
126    }
127
128    /// Copy each table from the source font if it does not already exist
129    pub fn copy_missing_tables(&mut self, font: FontRef<'a>) -> &mut Self {
130        for record in font.table_directory().table_records() {
131            let tag = record.tag();
132            if !self.tables.contains_key(&tag) {
133                if let Some(data) = font.data_for_tag(tag) {
134                    self.add_raw(tag, data);
135                } else {
136                    log::warn!("data for '{tag}' is malformed");
137                }
138            }
139        }
140        self
141    }
142
143    /// Returns `true` if the builder contains a table with this tag.
144    pub fn contains(&self, tag: Tag) -> bool {
145        self.tables.contains_key(&tag)
146    }
147
148    /// Returns the builder's table tags in the order recommended by the OpenType spec.
149    ///
150    /// Table tags not in the recommended order are sorted lexicographically, and 'DSIG'
151    /// is always sorted last.
152    /// The presence of the 'CFF ' table determines which of the two recommended orders is used.
153    /// This matches fontTools' `sortedTagList` function.
154    ///
155    /// See:
156    /// <https://learn.microsoft.com/en-us/typography/opentype/spec/recom#optimized-table-ordering>
157    /// <https://github.com/fonttools/fonttools/blob/8d6b2f8f87637fcad8dae498d32eae738cd951bf/Lib/fontTools/ttLib/ttFont.py#L1096-L1117>
158    pub fn ordered_tags(&self) -> Vec<Tag> {
159        let recommended_order: &[Tag] = if self.contains(Tag::new(b"CFF ")) {
160            &RECOMMENDED_TABLE_ORDER_CFF
161        } else {
162            &RECOMMENDED_TABLE_ORDER_TTF
163        };
164        // Sort tags into three groups:
165        //   Group 0: tags that are in the recommended order, sorted accordingly.
166        //   Group 1: tags not in the recommended order, sorted alphabetically.
167        //   Group 2: 'DSIG' is always sorted last, matching fontTools' behavior.
168        let mut ordered_tags: Vec<Tag> = self.tables.keys().copied().collect();
169        let dsig = Tag::new(b"DSIG");
170        ordered_tags.sort_unstable_by_key(|rtag| {
171            let tag = *rtag;
172            if tag == dsig {
173                (2, 0, tag)
174            } else if let Some(idx) = recommended_order.iter().position(|t| t == rtag) {
175                (0, idx, tag)
176            } else {
177                (1, 0, tag)
178            }
179        });
180
181        ordered_tags
182    }
183
184    /// Assemble all the tables into a binary font file with a [Table Directory].
185    ///
186    /// [Table Directory]: https://learn.microsoft.com/en-us/typography/opentype/spec/otff#table-directory
187    /// [Calculating Checksums]: https://learn.microsoft.com/en-us/typography/opentype/spec/otff#calculating-checksums
188    pub fn build(&mut self) -> Vec<u8> {
189        // See: https://learn.microsoft.com/en-us/typography/opentype/spec/head
190        const HEAD_CHECKSUM_START: usize = 8;
191        const HEAD_CHECKSUM_END: usize = 12;
192
193        let header_len = std::mem::size_of::<u32>() // sfnt
194            + std::mem::size_of::<u16>() * 4 // num_tables to range_shift
195            + self.tables.len() * TABLE_RECORD_LEN;
196
197        // note this is the order of the tables themselves, not the records in the table directory
198        // which are sorted by tag so they can be binary searched
199        let table_order = self.ordered_tags();
200
201        let mut position = header_len as u32;
202        let mut checksums = Vec::with_capacity(self.tables.len() + 1);
203        let head_tag = Tag::new(b"head");
204
205        let mut table_records = Vec::with_capacity(self.tables.len());
206        for tag in table_order.iter() {
207            // safe to unwrap as ordered_tags() guarantees that all keys exist
208            let data = self.tables.get_mut(tag).unwrap();
209            let offset = position;
210            let length = data.len() as u32;
211            position += length;
212            if *tag == head_tag && data.len() >= HEAD_CHECKSUM_END {
213                // The head table checksum is computed with the checksum field set to 0.
214                // Equivalent to Python's `data[:HEAD_CHECKSUM_START] + b"\0\0\0\0" + data[HEAD_CHECKSUM_END:]`
215                //
216                // Only do this if there is enough data in the head table to write the bytes.
217                let head = data.to_mut();
218                head[HEAD_CHECKSUM_START..HEAD_CHECKSUM_END].copy_from_slice(&[0, 0, 0, 0]);
219            }
220            // The head table is mutated above, so any supplied checksum for it is stale.
221            let padding = round4(data.len()) - data.len();
222            let checksum = if *tag == head_tag {
223                read_fonts::tables::compute_checksum(data)
224            } else {
225                self.checksums
226                    .get(tag)
227                    .copied()
228                    .unwrap_or_else(|| read_fonts::tables::compute_checksum(data))
229            };
230            checksums.push(checksum);
231            position += padding as u32;
232            table_records.push(TableRecord::new(*tag, checksum, offset, length));
233        }
234        table_records.sort_unstable_by_key(|record| record.tag);
235
236        let directory = TableDirectory::from_table_records(table_records);
237
238        let mut writer = TableWriter::default();
239        directory.write_into(&mut writer);
240        let mut data = writer.into_data().bytes;
241        data.reserve_exact((position as usize).saturating_sub(data.len()));
242        let expected_data_len = data.capacity();
243        checksums.push(read_fonts::tables::compute_checksum(&data));
244
245        // Summing all the individual table checksums, including the table directory's,
246        // gives the checksum for the entire font.
247        // The checksum_adjustment is computed as 0xB1B0AFBA - checksum, modulo 2^32.
248        // https://learn.microsoft.com/en-us/typography/opentype/spec/otff#calculating-checksums
249        let checksum = checksums.into_iter().fold(0u32, u32::wrapping_add);
250        let checksum_adjustment = 0xB1B0_AFBAu32.wrapping_sub(checksum);
251
252        for tag in table_order {
253            let table = self.tables.remove(&tag).unwrap();
254            self.checksums.remove(&tag);
255            if tag == head_tag && table.len() >= HEAD_CHECKSUM_END {
256                // store the checksum_adjustment in the head table
257                data.extend_from_slice(&table[..HEAD_CHECKSUM_START]);
258                data.extend_from_slice(&checksum_adjustment.to_be_bytes());
259                data.extend_from_slice(&table[HEAD_CHECKSUM_END..]);
260            } else {
261                data.extend_from_slice(&table);
262            }
263            let rem = round4(table.len()) - table.len();
264            let padding = [0u8; 4];
265            data.extend_from_slice(&padding[..rem]);
266        }
267        if expected_data_len != data.len() {
268            log::warn!(
269                "suboptimal: allocated {expected_data_len} bytes but needed {actual_len}",
270                actual_len = data.len()
271            );
272        }
273        data
274    }
275}
276
277/// <https://github.com/google/woff2/blob/a0d0ed7da27b708c0a4e96ad7a998bddc933c06e/src/round.h#L19>
278fn round4(sz: usize) -> usize {
279    (sz + 3) & !3
280}
281
282#[cfg(test)]
283fn checksum_and_padding(table: &[u8]) -> (u32, u32) {
284    let checksum = read_fonts::tables::compute_checksum(table);
285    let padding = round4(table.len()) - table.len();
286    (checksum, padding as u32)
287}
288
289impl TTCHeader {
290    #[allow(dead_code)] // compute_version is required by codegen even though sometimes unused
291    fn compute_version(&self) -> MajorMinor {
292        panic!("TTCHeader writing not supported (yet)")
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use super::{RECOMMENDED_TABLE_ORDER_CFF, RECOMMENDED_TABLE_ORDER_TTF};
299    use font_types::Tag;
300    use read_fonts::FontRef;
301
302    use crate::{font_builder::checksum_and_padding, FontBuilder};
303    use rand::seq::SliceRandom;
304    use rstest::rstest;
305
306    #[test]
307    fn sets_binary_search_assists() {
308        // Based on Roboto's num tables
309        let data = b"doesn't matter".to_vec();
310        let mut builder = FontBuilder::default();
311        (0..0x16u32).for_each(|i| {
312            builder.add_raw(Tag::from_be_bytes(i.to_ne_bytes()), &data);
313        });
314        let bytes = builder.build();
315        let font = FontRef::new(&bytes).unwrap();
316        let td = font.table_directory();
317        assert_eq!(
318            (256, 4, 96),
319            (td.search_range(), td.entry_selector(), td.range_shift())
320        );
321    }
322
323    #[test]
324    fn survives_no_tables() {
325        FontBuilder::default().build();
326    }
327
328    #[test]
329    fn pad4() {
330        for i in 0..10 {
331            let pad = checksum_and_padding(&vec![0; i]).1;
332            assert!(pad < 4);
333            assert!((i + pad as usize) % 4 == 0, "pad {i} +{pad} bytes");
334        }
335    }
336
337    #[test]
338    fn uses_and_invalidates_precomputed_checksums() {
339        let tag = Tag::new(b"TEST");
340        let mut builder = FontBuilder::default();
341        builder.add_raw_with_checksum(tag, [1, 2, 3].as_slice(), 0xDEAD_BEEF);
342        let bytes = builder.build();
343        let font = FontRef::new(&bytes).unwrap();
344        let record = font
345            .table_directory()
346            .table_records()
347            .iter()
348            .find(|record| record.tag() == tag)
349            .unwrap();
350        assert_eq!(record.checksum(), 0xDEAD_BEEF);
351
352        builder.add_raw_with_checksum(tag, [0; 4].as_slice(), 0xDEAD_BEEF);
353        builder.add_raw(tag, [1, 2, 3].as_slice());
354        let bytes = builder.build();
355        let font = FontRef::new(&bytes).unwrap();
356        let record = font
357            .table_directory()
358            .table_records()
359            .iter()
360            .find(|record| record.tag() == tag)
361            .unwrap();
362        assert_eq!(record.checksum(), 0x0102_0300);
363    }
364
365    #[test]
366    fn validate_font_checksum() {
367        use rand::RngExt;
368
369        // Add a dummy 'head' plus a couple of made-up tables containing random bytes
370        // and verify that the total font checksum is always equal to the special
371        // constant 0xB1B0AFBA, which should be the case if the FontBuilder computed
372        // the head.checksum_adjustment correctly.
373        let head_size = 54;
374        let mut rng = rand::rng();
375        let mut builder = FontBuilder::default();
376        for tag in [Tag::new(b"head"), Tag::new(b"FOO "), Tag::new(b"BAR ")] {
377            let data: Vec<u8> = (0..=head_size).map(|_| rng.r#random()).collect();
378            builder.add_raw(tag, data);
379        }
380        let font_data = builder.build();
381        assert_eq!(read_fonts::tables::compute_checksum(&font_data), 0xB1B0AFBA);
382    }
383
384    #[test]
385    fn minimum_head_size_for_checksum_rewrite() {
386        let mut builder = FontBuilder::default();
387        builder.add_raw(
388            Tag::new(b"head"),
389            vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
390        );
391
392        let font_data = builder.build();
393        let font = FontRef::new(&font_data).unwrap();
394        let head = font.table_data(Tag::new(b"head")).unwrap();
395
396        assert_eq!(
397            head.as_bytes(),
398            &vec![0, 1, 2, 3, 4, 5, 6, 7, 65, 61, 62, 10]
399        );
400    }
401
402    #[test]
403    fn doesnt_overflow_head() {
404        let mut builder = FontBuilder::default();
405        builder.add_raw(Tag::new(b"head"), vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
406
407        let font_data = builder.build();
408        let font = FontRef::new(&font_data).unwrap();
409        let head = font.table_data(Tag::new(b"head")).unwrap();
410
411        assert_eq!(head.as_bytes(), &vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
412    }
413
414    #[rstest]
415    #[case::ttf(&RECOMMENDED_TABLE_ORDER_TTF)]
416    #[case::cff(&RECOMMENDED_TABLE_ORDER_CFF)]
417    fn recommended_table_order(#[case] recommended_order: &[Tag]) {
418        let dsig = Tag::new(b"DSIG");
419        let mut builder = FontBuilder::default();
420        builder.add_raw(dsig, vec![0]);
421        let mut tags = recommended_order.to_vec();
422        tags.shuffle(&mut rand::rng());
423        for tag in tags {
424            builder.add_raw(tag, vec![0]);
425        }
426        builder.add_raw(Tag::new(b"ZZZZ"), vec![0]);
427        builder.add_raw(Tag::new(b"AAAA"), vec![0]);
428
429        // recommended order first, then sorted additional tags, and last DSIG
430        let mut expected = recommended_order.to_vec();
431        expected.push(Tag::new(b"AAAA"));
432        expected.push(Tag::new(b"ZZZZ"));
433        expected.push(dsig);
434
435        assert_eq!(builder.ordered_tags(), expected);
436    }
437}