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