write_fonts/
font_builder.rs

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