Skip to main content

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}