Skip to main content

oxideav_ttf/
collection.rs

1//! TrueType Collection (`.ttc` / `.ttcf`) header parser.
2//!
3//! A TTC file packs several sfnt-flavoured fonts into one file with a
4//! shared "TTC header" up front. The header announces the table:
5//!
6//! ```text
7//! TTCHeader {
8//!     u32 ttcTag;        // 'ttcf' (0x74746366)
9//!     u16 majorVersion;  // 1 or 2
10//!     u16 minorVersion;  // 0
11//!     u32 numFonts;
12//!     u32 offsetTable[numFonts]; // each points at a per-subfont sfnt header
13//!     // version 2 only:
14//!     // u32 dsigTag, dsigLength, dsigOffset
15//! }
16//! ```
17//!
18//! Each `offsetTable[i]` is the file-relative byte offset of the i-th
19//! subfont's sfnt directory (the same `0x00010000` / `OTTO` magic + 12 byte
20//! sfnt header that `parser.rs` parses). To consume a TTC, the caller picks
21//! a subfont index and then runs the existing sfnt parsing path against
22//! `&bytes[offset..]`.
23//!
24//! Spec references:
25//! - Microsoft OpenType 1.9 §"Font Collections" / TTC header layout.
26//! - Apple TrueType Reference Manual / "The Font File", "TrueType
27//!   Collections".
28//!
29//! We accept versions 1.0 AND 2.0; the version-2-only DSIG (digital
30//! signature) trailer is ignored — we never validate signatures.
31
32use crate::parser::{read_u16, read_u32};
33use crate::Error;
34
35/// Magic four-byte tag that identifies the TTC container.
36pub const TTC_MAGIC: u32 = 0x7474_6366; // 'ttcf' (big-endian)
37
38/// Maximum subfont count we will accept. Real-world TTCs contain tens of
39/// subfonts (Noto Sans CJK ships 7); the cap exists purely to bound how
40/// much we read on malformed input.
41const MAX_SUBFONTS: u32 = 1024;
42
43/// Parsed TTC header. Carries the per-subfont byte offsets so the caller
44/// can construct a `Font<'_>` over `&bytes[offset..]`.
45#[derive(Debug, Clone)]
46pub struct CollectionHeader {
47    /// `(major, minor)` from the TTC header. Always `(1, 0)` or `(2, 0)`
48    /// in real-world fonts; we don't enforce minor==0 strictly.
49    pub version: (u16, u16),
50    /// Per-subfont byte offsets within the parent file.
51    pub offsets: Vec<u32>,
52}
53
54impl CollectionHeader {
55    /// Try to parse a TTC header at the start of `bytes`. Returns
56    /// `Error::BadMagic` if the leading 4 bytes are not `'ttcf'` —
57    /// callers can use that to differentiate between a TTC and a plain
58    /// sfnt without an explicit container probe.
59    pub fn parse(bytes: &[u8]) -> Result<Self, Error> {
60        if bytes.len() < 12 {
61            return Err(Error::UnexpectedEof);
62        }
63        let tag = read_u32(bytes, 0)?;
64        if tag != TTC_MAGIC {
65            return Err(Error::BadMagic);
66        }
67        let major = read_u16(bytes, 4)?;
68        let minor = read_u16(bytes, 6)?;
69        if major != 1 && major != 2 {
70            return Err(Error::BadHeader);
71        }
72        let num_fonts = read_u32(bytes, 8)?;
73        if num_fonts == 0 || num_fonts > MAX_SUBFONTS {
74            return Err(Error::BadHeader);
75        }
76        let table_end = 12usize
77            .checked_add(num_fonts as usize * 4)
78            .ok_or(Error::BadHeader)?;
79        if bytes.len() < table_end {
80            return Err(Error::UnexpectedEof);
81        }
82        let mut offsets = Vec::with_capacity(num_fonts as usize);
83        for i in 0..num_fonts as usize {
84            let off = read_u32(bytes, 12 + i * 4)?;
85            // The offset must point into the buffer with at least 12 bytes
86            // (the sfnt header) accessible, otherwise the subfont parse
87            // would fail in a hard-to-diagnose way.
88            if (off as usize)
89                .checked_add(12)
90                .map(|end| end > bytes.len())
91                .unwrap_or(true)
92            {
93                return Err(Error::BadOffset);
94            }
95            offsets.push(off);
96        }
97        // version 2 trailer (DSIG) is left untouched.
98        Ok(Self {
99            version: (major, minor),
100            offsets,
101        })
102    }
103
104    /// Number of subfonts in this collection.
105    pub fn num_fonts(&self) -> u32 {
106        self.offsets.len() as u32
107    }
108
109    /// File-relative byte offset of subfont `index`. Returns `None` if
110    /// `index` is out of range.
111    pub fn font_offset(&self, index: u32) -> Option<u32> {
112        self.offsets.get(index as usize).copied()
113    }
114}
115
116/// `true` if `bytes` starts with the TTC magic (`ttcf`).
117pub fn is_collection(bytes: &[u8]) -> bool {
118    if bytes.len() < 4 {
119        return false;
120    }
121    read_u32(bytes, 0).map(|t| t == TTC_MAGIC).unwrap_or(false)
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    /// Hand-built minimal TTC header: version 1, two subfonts, with
129    /// pretend per-subfont offsets that point at fake but in-range data.
130    fn synth_ttc_header() -> Vec<u8> {
131        let mut bytes = vec![0u8; 256];
132        // ttcTag
133        bytes[0..4].copy_from_slice(&TTC_MAGIC.to_be_bytes());
134        // version 1.0
135        bytes[4..6].copy_from_slice(&1u16.to_be_bytes());
136        bytes[6..8].copy_from_slice(&0u16.to_be_bytes());
137        // numFonts = 2
138        bytes[8..12].copy_from_slice(&2u32.to_be_bytes());
139        // offsetTable[0] = 100, offsetTable[1] = 200
140        bytes[12..16].copy_from_slice(&100u32.to_be_bytes());
141        bytes[16..20].copy_from_slice(&200u32.to_be_bytes());
142        bytes
143    }
144
145    #[test]
146    fn parses_minimal_v1_collection() {
147        let bytes = synth_ttc_header();
148        let hdr = CollectionHeader::parse(&bytes).expect("parse");
149        assert_eq!(hdr.version, (1, 0));
150        assert_eq!(hdr.num_fonts(), 2);
151        assert_eq!(hdr.font_offset(0), Some(100));
152        assert_eq!(hdr.font_offset(1), Some(200));
153        assert_eq!(hdr.font_offset(2), None);
154    }
155
156    #[test]
157    fn rejects_non_ttc_magic() {
158        let mut bytes = synth_ttc_header();
159        bytes[0..4].copy_from_slice(&0x00010000u32.to_be_bytes());
160        assert!(matches!(
161            CollectionHeader::parse(&bytes),
162            Err(Error::BadMagic)
163        ));
164    }
165
166    #[test]
167    fn rejects_offset_past_eof() {
168        let mut bytes = synth_ttc_header();
169        // Drop the buffer so offset 200 lands past end (need 12 bytes
170        // accessible at the offset).
171        bytes.truncate(150);
172        // First subfont (offset 100) needs 12 bytes — fits in 150-byte
173        // buffer (100..=112). Second (200) doesn't — should fail.
174        assert!(matches!(
175            CollectionHeader::parse(&bytes),
176            Err(Error::BadOffset)
177        ));
178    }
179
180    #[test]
181    fn rejects_zero_subfonts() {
182        let mut bytes = synth_ttc_header();
183        bytes[8..12].copy_from_slice(&0u32.to_be_bytes());
184        assert!(matches!(
185            CollectionHeader::parse(&bytes),
186            Err(Error::BadHeader)
187        ));
188    }
189
190    #[test]
191    fn is_collection_distinguishes() {
192        assert!(is_collection(&TTC_MAGIC.to_be_bytes()));
193        assert!(!is_collection(&0x00010000u32.to_be_bytes()));
194        assert!(!is_collection(&0x4F54544Fu32.to_be_bytes())); // OTTO
195        assert!(!is_collection(&[]));
196    }
197}