Skip to main content

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