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}