oxideav_ttf/tables/meta.rs
1//! `meta` — Metadata table.
2//!
3//! Spec: ISO/IEC 14496-22:2019 §5.7.6 ("meta – Metadata table"). The
4//! metadata table is the OpenType-level grab-bag for font-wide
5//! key/value pairs whose keys are four-character ASCII tags and whose
6//! values may be either UTF-8 text or opaque binary bytes. Two tags
7//! are registered today — `'dlng'` (design languages) and `'slng'`
8//! (supported languages) — and the spec reserves `'appl'` / `'bild'`
9//! for Apple use. Any other tag is treated as either a vendor-private
10//! key (uppercase + digits, per §5.7.6.2) or an as-yet-unregistered
11//! public key whose semantics the caller is expected to interpret.
12//!
13//! ## On-disk layout (§5.7.6.1)
14//!
15//! ```text
16//! uint32 version // set to 1
17//! uint32 flags // currently unused; set to 0
18//! uint32 reserved // not used; set to 0 — see NOTE in §5.7.6.1
19//! // ("originally documented in the Apple
20//! // TrueType spec as a data offset")
21//! uint32 dataMapsCount
22//! DataMap dataMaps[dataMapsCount]
23//! Tag tag
24//! Offset32 dataOffset // from the start of the 'meta' table
25//! uint32 dataLength
26//! ```
27//!
28//! The data payload referenced by each `DataMap.dataOffset` lives
29//! later in the same table. The spec is explicit that "the data is
30//! not required to be padded to any byte boundary" so the parser
31//! treats each payload as an opaque byte slice and lets the caller
32//! decide whether to validate UTF-8.
33//!
34//! ## Header invariants (§5.7.6.1)
35//!
36//! The parser enforces:
37//!
38//! - `version == 1` per the spec's "set to 1" mandate (a future
39//! header revision is permitted via the tag registry but a new
40//! `version` would imply a structural break, so rejection here is
41//! defensive);
42//! - `flags == 0` and the reserved field is read but not validated
43//! (its prose says "currently unused" and "not used"; a font that
44//! misuses it does not break our parse);
45//! - every `DataMap.dataOffset + dataLength` slice fits inside the
46//! on-wire `meta` byte range (out-of-range entries are rejected
47//! as `BadStructure`);
48//! - the `tag` field passes the §5.7.6.2 tag character class
49//! (letters / digits / trailing spaces only — letters must be the
50//! first character of the tag).
51//!
52//! ## Tag class (§5.7.6.2)
53//!
54//! "Metadata tags shall begin with a letter (0x41 to 0x5A, 0x61 to
55//! 0x7A) and must use only letters, digits (0x30 to 0x39) or space
56//! (0x20). Space characters must only occur as trailing characters
57//! in tags that have fewer than four letters or digits."
58//!
59//! The [`is_valid_meta_tag`] helper applies that grammar; the parser
60//! invokes it on every `DataMap.tag` and rejects a malformed entry
61//! with `BadStructure`. Vendor-private tags (uppercase-letter-led,
62//! all-uppercase + digits per §5.7.6.2 paragraph 4) pass the same
63//! grammar so the parser does not need a second pass.
64//!
65//! ## Registered tags (§5.7.6.2)
66//!
67//! Two registered tags are defined as of the 2019 edition:
68//!
69//! - `'dlng'` — *Design languages*. UTF-8 text restricted to Basic
70//! Latin (ASCII) characters. Comma-separated ScriptLangTags
71//! identifying the languages or scripts the font was primarily
72//! designed for. Only one record is meaningful; subsequent
73//! records are ignored per the §5.7.6.1 closing paragraph ("If
74//! only one record or value is permitted for a tag, then any
75//! instances after the first may be ignored.").
76//! - `'slng'` — *Supported languages*. Same encoding as `'dlng'`;
77//! declares the languages or scripts the font can render
78//! adequately.
79//!
80//! Two reserved tags (`'appl'` and `'bild'`) carry Apple-private
81//! semantics. The parser surfaces them unchanged.
82//!
83//! ## ScriptLangTag values (§5.7.6.3)
84//!
85//! The `dlng` / `slng` payloads are ASCII strings of the form
86//! `[language "-"] script ["-" region] *("-" variant) *("-"
87//! extension) ["-" privateuse]`. Multiple values are separated by
88//! commas (with optional trailing spaces). The [`script_lang_tags`]
89//! helper splits a `dlng` / `slng` payload into the individual
90//! [`ScriptLangTag`] values, trimming whitespace and discarding
91//! empty fragments per the §5.7.6.3 rule "any ScriptLangTag value
92//! not conforming to these specifications is ignored."
93//!
94//! The split only validates the *grammar* of the value; deeper
95//! validation (IANA Language Subtag Registry, ISO 15924 script
96//! subtags, BCP 47 region forms) is deliberately left to the
97//! caller — those registries change on a cadence independent of
98//! the on-wire format and pulling them into the parser would
99//! couple it to a moving target.
100
101use crate::parser::{read_u32, read_u8};
102use crate::Error;
103
104/// On-wire version of the metadata table per §5.7.6.1. The spec
105/// fixes the field at 1; any other value is rejected.
106pub const META_VERSION_1: u32 = 1;
107
108/// Length in bytes of the fixed `meta` header (§5.7.6.1 "Metadata
109/// header"). 4 × `uint32` fields.
110// internal — exposed for tests/fuzz; not part of the stable API
111#[doc(hidden)]
112pub const META_HEADER_LEN: usize = 16;
113
114/// Length in bytes of one `DataMap` record (§5.7.6.1). `Tag` +
115/// `Offset32` + `uint32` = 12 bytes.
116// internal — exposed for tests/fuzz; not part of the stable API
117#[doc(hidden)]
118pub const META_DATA_MAP_LEN: usize = 12;
119
120/// Four-byte ASCII tag identifying this table in the sfnt directory.
121pub const META_TABLE_TAG: [u8; 4] = *b"meta";
122
123/// Sanity cap on the per-table `dataMapsCount`. The on-wire field is
124/// a `uint32` so the spec ceiling is 2³². A real-world font carries
125/// a handful (typically 1–4); the cap here matches the directory
126/// cap on sfnt-level tables (1024) so a malformed `meta` cannot
127/// allocate an arbitrarily large vector.
128const MAX_DATA_MAPS: u32 = 1024;
129
130/// Registered tag `'dlng'` per §5.7.6.2 — design-language list.
131pub const META_TAG_DLNG: [u8; 4] = *b"dlng";
132
133/// Registered tag `'slng'` per §5.7.6.2 — supported-language list.
134pub const META_TAG_SLNG: [u8; 4] = *b"slng";
135
136/// Reserved tag `'appl'` per §5.7.6.2 — used by Apple.
137pub const META_TAG_APPL: [u8; 4] = *b"appl";
138
139/// Reserved tag `'bild'` per §5.7.6.2 — used by Apple.
140pub const META_TAG_BILD: [u8; 4] = *b"bild";
141
142/// One `DataMap` record from the `meta` table (§5.7.6.1).
143///
144/// The `tag` field has already been validated against the §5.7.6.2
145/// character class at parse time. The `payload` slice points into
146/// the on-wire `meta` table bytes — its length matches the on-wire
147/// `dataLength` field exactly (no padding is implied by the spec).
148#[derive(Debug, Clone, Copy)]
149pub struct MetaRecord<'a> {
150 /// The four-byte ASCII tag identifying the category of the
151 /// payload. See §5.7.6.2 for the registered + reserved tags.
152 pub tag: [u8; 4],
153 /// Raw bytes of the payload. The spec leaves the encoding of
154 /// the payload to the tag definition; `'dlng'` and `'slng'`
155 /// are ASCII text, vendor-private tags are opaque, others are
156 /// defined by their per-tag registration.
157 pub payload: &'a [u8],
158}
159
160impl<'a> MetaRecord<'a> {
161 /// Interpret the payload as a UTF-8 string. Returns `None` when
162 /// the bytes are not valid UTF-8. The registered text tags
163 /// (`'dlng'`, `'slng'`) are restricted to ASCII per §5.7.6.2 so
164 /// this is the convenience accessor for those.
165 pub fn payload_as_str(&self) -> Option<&'a str> {
166 std::str::from_utf8(self.payload).ok()
167 }
168}
169
170/// Parsed `meta` table — the 16-byte header plus the borrowed
171/// `DataMap` records. The data payloads themselves are kept as
172/// borrows into the on-wire bytes so the parser does not copy any
173/// of the (potentially large) payload data.
174#[derive(Debug, Clone)]
175// internal — exposed for tests/fuzz; not part of the stable API
176#[doc(hidden)]
177pub struct MetaTable<'a> {
178 version: u32,
179 flags: u32,
180 reserved: u32,
181 records: Vec<MetaRecord<'a>>,
182}
183
184impl<'a> MetaTable<'a> {
185 /// Decode the `meta` table from the on-wire byte slice. The
186 /// returned [`MetaTable`] borrows from `bytes` so its lifetime
187 /// is bounded by the caller's slice.
188 pub fn parse(bytes: &'a [u8]) -> Result<Self, Error> {
189 if bytes.len() < META_HEADER_LEN {
190 return Err(Error::UnexpectedEof);
191 }
192 let version = read_u32(bytes, 0)?;
193 if version != META_VERSION_1 {
194 return Err(Error::BadStructure("meta: version != 1"));
195 }
196 let flags = read_u32(bytes, 4)?;
197 if flags != 0 {
198 return Err(Error::BadStructure("meta: flags != 0"));
199 }
200 let reserved = read_u32(bytes, 8)?;
201 // §5.7.6.1 NOTE: the reserved field "was originally
202 // documented in Apple TrueType specification as a data
203 // offset. This was redundant…" — we read the value so a
204 // caller can introspect a non-zero reserved field but do
205 // not gate parsing on it.
206 let count = read_u32(bytes, 12)?;
207 if count > MAX_DATA_MAPS {
208 return Err(Error::BadStructure("meta: dataMapsCount cap"));
209 }
210 let count_usize = count as usize;
211 let body_end = META_HEADER_LEN
212 .checked_add(
213 count_usize
214 .checked_mul(META_DATA_MAP_LEN)
215 .ok_or(Error::BadStructure("meta: dataMaps overflow"))?,
216 )
217 .ok_or(Error::BadStructure("meta: dataMaps overflow"))?;
218 if bytes.len() < body_end {
219 return Err(Error::UnexpectedEof);
220 }
221 let total_len = bytes.len();
222 let mut records: Vec<MetaRecord<'a>> = Vec::with_capacity(count_usize);
223 for i in 0..count_usize {
224 let off = META_HEADER_LEN + i * META_DATA_MAP_LEN;
225 let tag = [
226 read_u8(bytes, off)?,
227 read_u8(bytes, off + 1)?,
228 read_u8(bytes, off + 2)?,
229 read_u8(bytes, off + 3)?,
230 ];
231 if !is_valid_meta_tag(&tag) {
232 return Err(Error::BadStructure("meta: tag not §5.7.6.2-conformant"));
233 }
234 let data_offset = read_u32(bytes, off + 4)? as usize;
235 let data_length = read_u32(bytes, off + 8)? as usize;
236 // The §5.7.6.1 DataMap record names dataOffset as
237 // "Offset in bytes from the beginning of the metadata
238 // table" — i.e. relative to `bytes`, not to the
239 // dataMaps array.
240 let data_end = data_offset
241 .checked_add(data_length)
242 .ok_or(Error::BadStructure(
243 "meta: dataOffset + dataLength overflow",
244 ))?;
245 if data_end > total_len {
246 return Err(Error::BadStructure(
247 "meta: DataMap payload past end of table",
248 ));
249 }
250 let payload = &bytes[data_offset..data_end];
251 records.push(MetaRecord { tag, payload });
252 }
253 Ok(Self {
254 version,
255 flags,
256 reserved,
257 records,
258 })
259 }
260
261 /// `version` field from the header (always 1 per §5.7.6.1).
262 pub fn version(&self) -> u32 {
263 self.version
264 }
265
266 /// `flags` field from the header (always 0 per §5.7.6.1).
267 pub fn flags(&self) -> u32 {
268 self.flags
269 }
270
271 /// `reserved` field from the header. §5.7.6.1 says "not used;
272 /// set to 0" but legacy Apple TrueType fonts may carry a
273 /// non-zero value here per the NOTE; we surface the raw value
274 /// rather than discard it.
275 pub fn reserved(&self) -> u32 {
276 self.reserved
277 }
278
279 /// Borrow the full DataMap record array. Records appear in
280 /// the order they sit on disk; §5.7.6 does not impose a sort
281 /// order.
282 pub fn records(&self) -> &[MetaRecord<'a>] {
283 &self.records
284 }
285
286 /// Return the first `MetaRecord` whose tag equals `tag`.
287 /// §5.7.6.1 closing paragraph notes that "If only one record
288 /// or value is permitted for a tag, then any instances after
289 /// the first may be ignored" — the registered `'dlng'` and
290 /// `'slng'` tags both fall into that single-record category,
291 /// so this accessor returns the first match.
292 pub fn record(&self, tag: &[u8; 4]) -> Option<MetaRecord<'a>> {
293 self.records.iter().copied().find(|r| &r.tag == tag)
294 }
295
296 /// Convenience: return the `'dlng'` (design languages) payload
297 /// as a UTF-8 string, if present and well-formed.
298 pub fn design_languages(&self) -> Option<&'a str> {
299 self.record(&META_TAG_DLNG)?.payload_as_str()
300 }
301
302 /// Convenience: return the `'slng'` (supported languages)
303 /// payload as a UTF-8 string, if present and well-formed.
304 pub fn supported_languages(&self) -> Option<&'a str> {
305 self.record(&META_TAG_SLNG)?.payload_as_str()
306 }
307}
308
309/// `[language "-"] script ["-" region] *("-" variant) *("-"
310/// extension) ["-" privateuse]` per §5.7.6.3, kept as the raw
311/// ASCII slice. The splitter ([`script_lang_tags`]) only enforces
312/// the surface grammar (non-empty, ASCII, hyphen-separated
313/// subtags); deeper validation against the IANA Language Subtag
314/// Registry and ISO 15924 is left to the caller.
315#[derive(Debug, Clone, Copy, PartialEq, Eq)]
316pub struct ScriptLangTag<'a> {
317 /// Raw ASCII bytes of the tag, hyphens included, trimmed of
318 /// surrounding whitespace.
319 pub raw: &'a str,
320}
321
322impl<'a> ScriptLangTag<'a> {
323 /// Subtags split on the `-` separator. Per §5.7.6.3 the script
324 /// subtag is mandatory; the parser does not assume a position,
325 /// so this is the raw split.
326 pub fn subtags(&self) -> impl Iterator<Item = &'a str> {
327 self.raw.split('-')
328 }
329
330 /// Number of subtags in the tag (hyphen-separated).
331 pub fn subtag_count(&self) -> usize {
332 self.subtags().count()
333 }
334}
335
336/// Split a `'dlng'` / `'slng'` payload into [`ScriptLangTag`]
337/// values per §5.7.6.3 ("a series of comma-separated
338/// ScriptLangTags … Spaces may follow the comma delimiters and
339/// are ignored.").
340///
341/// Returns an empty iterator for a non-UTF-8 payload. Per the
342/// §5.7.6.3 directive "Any ScriptLangTag value not conforming to
343/// these specifications is ignored", individual fragments that
344/// are empty or contain non-ASCII bytes are skipped silently;
345/// well-formed fragments are returned in document order.
346pub fn script_lang_tags(payload: &str) -> impl Iterator<Item = ScriptLangTag<'_>> {
347 payload.split(',').filter_map(|raw| {
348 let trimmed = raw.trim();
349 if trimmed.is_empty() {
350 return None;
351 }
352 if !trimmed.is_ascii() {
353 return None;
354 }
355 // Hyphen at either end or a doubled hyphen would produce
356 // an empty subtag — both invalid per §5.7.6.3's BNF.
357 if trimmed.starts_with('-') || trimmed.ends_with('-') || trimmed.contains("--") {
358 return None;
359 }
360 Some(ScriptLangTag { raw: trimmed })
361 })
362}
363
364/// §5.7.6.2 tag-character class:
365///
366/// > Metadata tags shall begin with a letter (0x41 to 0x5A, 0x61 to
367/// > 0x7A) and must use only letters, digits (0x30 to 0x39) or space
368/// > (0x20). Space characters must only occur as trailing characters
369/// > in tags that have fewer than four letters or digits.
370pub fn is_valid_meta_tag(tag: &[u8; 4]) -> bool {
371 if !is_meta_tag_letter(tag[0]) {
372 return false;
373 }
374 let mut seen_space = false;
375 for &b in tag {
376 if b == b' ' {
377 seen_space = true;
378 continue;
379 }
380 // Once we have seen a space, the rest must also be spaces
381 // ("only occur as trailing characters").
382 if seen_space {
383 return false;
384 }
385 if !(is_meta_tag_letter(b) || b.is_ascii_digit()) {
386 return false;
387 }
388 }
389 true
390}
391
392#[inline]
393fn is_meta_tag_letter(b: u8) -> bool {
394 matches!(b, 0x41..=0x5A | 0x61..=0x7A)
395}
396
397#[cfg(test)]
398mod tests {
399 use super::*;
400
401 /// Build a synthetic `meta` table whose layout matches §5.7.6.1
402 /// exactly: header (16 B), DataMap array (12 B / entry),
403 /// then the data payloads packed in record order.
404 fn build(records: &[(&[u8; 4], &[u8])]) -> Vec<u8> {
405 let mut b = Vec::new();
406 b.extend_from_slice(&META_VERSION_1.to_be_bytes());
407 b.extend_from_slice(&0u32.to_be_bytes()); // flags
408 b.extend_from_slice(&0u32.to_be_bytes()); // reserved
409 b.extend_from_slice(&(records.len() as u32).to_be_bytes());
410 // Pre-compute data offsets: each payload sits after the
411 // DataMap array.
412 let payload_base = META_HEADER_LEN + records.len() * META_DATA_MAP_LEN;
413 let mut cur = payload_base;
414 for (tag, payload) in records {
415 b.extend_from_slice(*tag);
416 b.extend_from_slice(&(cur as u32).to_be_bytes());
417 b.extend_from_slice(&(payload.len() as u32).to_be_bytes());
418 cur += payload.len();
419 }
420 for (_, payload) in records {
421 b.extend_from_slice(payload);
422 }
423 b
424 }
425
426 #[test]
427 fn parses_minimal_empty_table() {
428 // §5.7.6.1 permits dataMapsCount = 0 implicitly: the
429 // table is just its 16-byte header.
430 let bytes = build(&[]);
431 assert_eq!(bytes.len(), META_HEADER_LEN);
432 let meta = MetaTable::parse(&bytes).expect("parse");
433 assert_eq!(meta.version(), META_VERSION_1);
434 assert_eq!(meta.flags(), 0);
435 assert_eq!(meta.reserved(), 0);
436 assert_eq!(meta.records().len(), 0);
437 assert!(meta.design_languages().is_none());
438 assert!(meta.supported_languages().is_none());
439 }
440
441 #[test]
442 fn parses_dlng_and_slng_records() {
443 // §5.7.6.2 worked example: dlng = "Latn" (designed for
444 // Latin script), slng = "Latn, Cyrl, Grek".
445 let dlng = b"Latn";
446 let slng = b"Latn, Cyrl, Grek";
447 let bytes = build(&[(b"dlng", dlng), (b"slng", slng)]);
448 let meta = MetaTable::parse(&bytes).expect("parse");
449 assert_eq!(meta.records().len(), 2);
450 assert_eq!(meta.design_languages(), Some("Latn"));
451 assert_eq!(meta.supported_languages(), Some("Latn, Cyrl, Grek"));
452 // Tag lookup matches both registered tags.
453 assert!(meta.record(&META_TAG_DLNG).is_some());
454 assert!(meta.record(&META_TAG_SLNG).is_some());
455 assert!(meta.record(&META_TAG_APPL).is_none());
456 }
457
458 #[test]
459 fn reserved_tags_appl_and_bild_pass_the_tag_grammar() {
460 // §5.7.6.2 lists 'appl' and 'bild' as reserved — both must
461 // pass the tag character class.
462 assert!(is_valid_meta_tag(&META_TAG_APPL));
463 assert!(is_valid_meta_tag(&META_TAG_BILD));
464 assert!(is_valid_meta_tag(&META_TAG_DLNG));
465 assert!(is_valid_meta_tag(&META_TAG_SLNG));
466 }
467
468 #[test]
469 fn rejects_short_header() {
470 let b = vec![0u8; META_HEADER_LEN - 1];
471 assert!(matches!(MetaTable::parse(&b), Err(Error::UnexpectedEof)));
472 }
473
474 #[test]
475 fn rejects_wrong_version() {
476 let mut b = build(&[]);
477 b[0..4].copy_from_slice(&2u32.to_be_bytes());
478 assert!(matches!(MetaTable::parse(&b), Err(Error::BadStructure(_))));
479 }
480
481 #[test]
482 fn rejects_nonzero_flags() {
483 let mut b = build(&[]);
484 b[4..8].copy_from_slice(&1u32.to_be_bytes());
485 assert!(matches!(MetaTable::parse(&b), Err(Error::BadStructure(_))));
486 }
487
488 #[test]
489 fn tolerates_nonzero_reserved_field() {
490 // §5.7.6.1 NOTE: the reserved field was historically used
491 // by Apple as a data offset. We surface a non-zero value
492 // through `reserved()` rather than reject it.
493 let mut b = build(&[]);
494 b[8..12].copy_from_slice(&42u32.to_be_bytes());
495 let meta = MetaTable::parse(&b).expect("parse");
496 assert_eq!(meta.reserved(), 42);
497 }
498
499 #[test]
500 fn rejects_truncated_data_maps_array() {
501 let mut b = build(&[(b"dlng", b"Latn")]);
502 // Claim 2 records but only ship the bytes for 1.
503 b[12..16].copy_from_slice(&2u32.to_be_bytes());
504 assert!(matches!(MetaTable::parse(&b), Err(Error::UnexpectedEof)));
505 }
506
507 #[test]
508 fn rejects_data_payload_past_table_end() {
509 let mut b = build(&[(b"dlng", b"Latn")]);
510 // First DataMap record sits at byte 16; dataOffset is at
511 // byte 20, dataLength at byte 24.
512 let map_off = META_HEADER_LEN;
513 let bogus_offset = (b.len() + 10) as u32;
514 b[map_off + 4..map_off + 8].copy_from_slice(&bogus_offset.to_be_bytes());
515 assert!(matches!(MetaTable::parse(&b), Err(Error::BadStructure(_))));
516 }
517
518 #[test]
519 fn rejects_data_offset_plus_length_overflow() {
520 let mut b = build(&[(b"dlng", b"Latn")]);
521 let map_off = META_HEADER_LEN;
522 b[map_off + 4..map_off + 8].copy_from_slice(&u32::MAX.to_be_bytes());
523 b[map_off + 8..map_off + 12].copy_from_slice(&u32::MAX.to_be_bytes());
524 assert!(matches!(MetaTable::parse(&b), Err(Error::BadStructure(_))));
525 }
526
527 #[test]
528 fn rejects_data_maps_count_cap() {
529 // 4-byte header reads fine, dataMapsCount > MAX_DATA_MAPS.
530 let mut b = vec![0u8; META_HEADER_LEN];
531 b[0..4].copy_from_slice(&META_VERSION_1.to_be_bytes());
532 b[12..16].copy_from_slice(&(MAX_DATA_MAPS + 1).to_be_bytes());
533 assert!(matches!(MetaTable::parse(&b), Err(Error::BadStructure(_))));
534 }
535
536 #[test]
537 fn rejects_tag_starting_with_digit() {
538 // §5.7.6.2: "tags shall begin with a letter".
539 let b = build(&[(b"1lng", b"Latn")]);
540 assert!(matches!(MetaTable::parse(&b), Err(Error::BadStructure(_))));
541 }
542
543 #[test]
544 fn rejects_tag_with_inner_space() {
545 // §5.7.6.2: spaces must only be trailing.
546 let b = build(&[(b"d ng", b"Latn")]);
547 assert!(matches!(MetaTable::parse(&b), Err(Error::BadStructure(_))));
548 }
549
550 #[test]
551 fn rejects_tag_with_non_alphanumeric() {
552 let b = build(&[(b"dl-g", b"Latn")]);
553 assert!(matches!(MetaTable::parse(&b), Err(Error::BadStructure(_))));
554 }
555
556 #[test]
557 fn accepts_short_tag_padded_with_trailing_space() {
558 // §5.7.6.2: "tags that have fewer than four letters or
559 // digits" carry trailing spaces. The whole-spec-defined
560 // short tag we care about is 'CFF ' style for sfnt tables;
561 // for the meta tag registry no current short tag is
562 // defined, but the grammar allows it.
563 assert!(is_valid_meta_tag(b"ab "));
564 assert!(is_valid_meta_tag(b"a "));
565 }
566
567 #[test]
568 fn rejects_all_space_tag() {
569 // First byte must be a letter, not a space.
570 assert!(!is_valid_meta_tag(b" "));
571 }
572
573 #[test]
574 fn parses_vendor_private_tag() {
575 // §5.7.6.2 paragraph 4: vendor-private tags use uppercase
576 // letters + digits. Our parser does not distinguish
577 // private from registered tags — both flow through.
578 let b = build(&[(b"XYZ9", b"private blob")]);
579 let meta = MetaTable::parse(&b).expect("parse");
580 let rec = meta.record(b"XYZ9").expect("vendor tag visible");
581 assert_eq!(rec.payload, b"private blob");
582 // payload_as_str round-trips the bytes when they are valid
583 // UTF-8.
584 assert_eq!(rec.payload_as_str(), Some("private blob"));
585 }
586
587 #[test]
588 fn payload_as_str_returns_none_for_non_utf8_bytes() {
589 // §5.7.6.2 permits binary-typed payloads (e.g. for
590 // unregistered tags). `payload_as_str` is the convenience
591 // accessor for the text branch — it must reject binary.
592 let bytes = build(&[(b"BINS", &[0xFF, 0xFE, 0xFD])]);
593 let meta = MetaTable::parse(&bytes).expect("parse");
594 let rec = meta.record(b"BINS").expect("record");
595 assert!(rec.payload_as_str().is_none());
596 }
597
598 #[test]
599 fn script_lang_tag_splitter_handles_single_value() {
600 let tags: Vec<_> = script_lang_tags("Latn").map(|t| t.raw).collect();
601 assert_eq!(tags, vec!["Latn"]);
602 }
603
604 #[test]
605 fn script_lang_tag_splitter_handles_multiple_values() {
606 // §5.7.6.3 worked example pattern: comma-separated with
607 // optional trailing space.
608 let tags: Vec<_> = script_lang_tags("Latn, Cyrl, Grek")
609 .map(|t| t.raw)
610 .collect();
611 assert_eq!(tags, vec!["Latn", "Cyrl", "Grek"]);
612 }
613
614 #[test]
615 fn script_lang_tag_splitter_handles_extended_subtags() {
616 // §5.7.6.3 example: 'sr-Cyrl', 'en-Dsrt', 'Hant-HK'.
617 let tags: Vec<_> = script_lang_tags("sr-Cyrl, en-Dsrt, Hant-HK")
618 .map(|t| (t.raw, t.subtag_count()))
619 .collect();
620 assert_eq!(tags, vec![("sr-Cyrl", 2), ("en-Dsrt", 2), ("Hant-HK", 2)]);
621 }
622
623 #[test]
624 fn script_lang_tag_splitter_discards_empty_fragments() {
625 // §5.7.6.3: "Any ScriptLangTag value not conforming to
626 // these specifications is ignored."
627 let tags: Vec<_> = script_lang_tags("Latn, , Cyrl, ").map(|t| t.raw).collect();
628 assert_eq!(tags, vec!["Latn", "Cyrl"]);
629 }
630
631 #[test]
632 fn script_lang_tag_splitter_rejects_leading_or_trailing_hyphen() {
633 // §5.7.6.3 BNF: every subtag is a non-empty token between
634 // hyphens; a leading / trailing / doubled hyphen would
635 // produce an empty subtag and the value is rejected.
636 let tags: Vec<_> = script_lang_tags("-Latn, Cyrl-, ja--Jpan, ok-Latn")
637 .map(|t| t.raw)
638 .collect();
639 assert_eq!(tags, vec!["ok-Latn"]);
640 }
641
642 #[test]
643 fn script_lang_tag_splitter_rejects_non_ascii_fragment() {
644 let tags: Vec<_> = script_lang_tags("Latn, Lаtn") // second has a Cyrillic 'а'
645 .map(|t| t.raw)
646 .collect();
647 assert_eq!(tags, vec!["Latn"]);
648 }
649
650 #[test]
651 fn shared_data_payload_between_two_records_round_trips() {
652 // Two DataMap records pointing at the same payload bytes
653 // is permitted by §5.7.6.1 — it just means two tags share
654 // a value. Build by hand to confirm aliasing works.
655 let mut b = Vec::new();
656 b.extend_from_slice(&META_VERSION_1.to_be_bytes());
657 b.extend_from_slice(&0u32.to_be_bytes());
658 b.extend_from_slice(&0u32.to_be_bytes());
659 b.extend_from_slice(&2u32.to_be_bytes()); // dataMapsCount
660 let payload_off = META_HEADER_LEN + 2 * META_DATA_MAP_LEN;
661 let payload = b"shared";
662 // dlng -> shared
663 b.extend_from_slice(b"dlng");
664 b.extend_from_slice(&(payload_off as u32).to_be_bytes());
665 b.extend_from_slice(&(payload.len() as u32).to_be_bytes());
666 // slng -> shared (same offset)
667 b.extend_from_slice(b"slng");
668 b.extend_from_slice(&(payload_off as u32).to_be_bytes());
669 b.extend_from_slice(&(payload.len() as u32).to_be_bytes());
670 b.extend_from_slice(payload);
671 let meta = MetaTable::parse(&b).expect("parse");
672 assert_eq!(meta.design_languages(), Some("shared"));
673 assert_eq!(meta.supported_languages(), Some("shared"));
674 }
675
676 #[test]
677 fn records_accessor_preserves_document_order() {
678 // §5.7.6 does not require records to be sorted; the parser
679 // must surface them in on-wire order.
680 let b = build(&[
681 (b"slng", b"Latn, Cyrl"),
682 (b"dlng", b"Latn"),
683 (b"XYZ1", b"blob"),
684 ]);
685 let meta = MetaTable::parse(&b).expect("parse");
686 let tags: Vec<_> = meta.records().iter().map(|r| r.tag).collect();
687 assert_eq!(tags, vec![*b"slng", *b"dlng", *b"XYZ1"]);
688 }
689}