commit 4f1439af65ab322577fba6a0857e0216714a27f5 Author: Claude Fable 5 Date: Sun Jul 19 11:37:24 2026 -0500 apple-encodings: initial commit (renamed from mac-encodings) Bidirectional, emulator-grade conversion between classic Mac OS text encodings and Unicode. Renamed at extraction-to-shared-crate time (2026-07-19): consumers will be fsinspect, adx, and ad-decoder via git dependencies on code.movq.us. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..96ef6c0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..2ffb7e0 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "apple-encodings" +version = "0.1.0" +edition = "2021" +description = "Bidirectional, emulator-grade conversion between classic Mac OS text encodings and Unicode" +license = "MIT" + +[dependencies] +thiserror = "2" + +[profile.release] +lto = true diff --git a/README.md b/README.md new file mode 100644 index 0000000..f05463f --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +# apple-encodings + +Bidirectional, emulator-grade conversion between classic Mac OS text encodings +and Unicode — without linking ICU. The crate owns the canonical Apple tables, so +it stays small, self-contained, and cross-compiles cleanly. + +```rust +use apple_encodings::{AppleEncoding, MacRomanRevision}; + +let enc = AppleEncoding::default(); // Mac OS Roman, post-8.5 +assert_eq!(enc.decode(b"Caf\x8e"), "Café"); +assert_eq!(enc.encode("Café").unwrap(), b"Caf\x8e"); + +// Pick by the Finder Info `fdScript` byte: +let enc = AppleEncoding::from_script_code(apple_encodings::SCRIPT_ROMAN).unwrap(); + +// Revision matters for exactly one byte (0xDB): +let classic = AppleEncoding::MacRoman(MacRomanRevision::Classic); +assert_eq!(classic.decode(&[0xDB]), "¤"); // pre-8.5 currency sign, not € +``` + +## Status + +- **Mac OS Roman** — implemented, both pre- and post-8.5 revisions, decode + encode. +- **Regional single-byte** (Cyrillic, Greek, Turkish, …) and **CJK double-byte** + (Japanese, Big5, GB, Korean) — planned, to be codegen'd from the Unicode + Consortium `VENDORS/APPLE/*.TXT` tables. The double-byte tables will be + feature-gated. + +## Scope + +Text-encoding conversion only. Unicode normalization concerns (e.g. HFS+'s +NFD-ish decomposition) are deliberately **out of scope** and left to consumers. + +## Consumers + +Designed to be shared by `ad-decoder`/`adx` and `fsinspect` via path (and later +git) dependency. + +## License + +MIT diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..b847452 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,248 @@ +//! Bidirectional conversion between classic Mac OS text encodings and Unicode. +//! +//! Classic Mac OS named files, volumes, and Finder comments in one of a family +//! of script encodings — Mac OS Roman and its regional/CJK relatives — selected +//! by a *script code*. This crate converts those bytes to and from Unicode with +//! emulator-grade fidelity: it owns the canonical tables rather than linking ICU, +//! so it stays small, self-contained, and cross-compiles cleanly. +//! +//! Today it implements **Mac OS Roman** (both the pre- and post-8.5 revisions). +//! The [`AppleEncoding`] enum and [`AppleEncoding::from_script_code`] are the +//! growth points for the remaining single-byte scripts and the (table-heavy, +//! codegen'd) double-byte CJK encodings. +//! +//! ``` +//! use apple_encodings::AppleEncoding; +//! +//! let enc = AppleEncoding::default(); // Mac OS Roman, post-8.5 +//! assert_eq!(enc.decode(b"Caf\x8e"), "Café"); +//! assert_eq!(enc.encode("Café").unwrap(), b"Caf\x8e"); +//! ``` +#![forbid(unsafe_code)] +#![warn(missing_docs)] + +use std::fmt; + +mod mac_roman; + +pub use mac_roman::MacRomanRevision; + +/// Mac script code for the Roman script system (`smRoman`). +/// +/// This is the value carried in the Finder Info `fdScript` byte; pass it to +/// [`AppleEncoding::from_script_code`] to pick the matching encoding. +pub const SCRIPT_ROMAN: u8 = 0; + +/// A classic Mac OS text encoding. +/// +/// Non-exhaustive: more script systems will be added without it being a breaking +/// change, so external matches must include a wildcard arm. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum AppleEncoding { + /// Mac OS Roman, at the given table revision. + MacRoman(MacRomanRevision), +} + +impl Default for AppleEncoding { + /// Mac OS Roman at the modern (post-8.5) revision — the common default. + fn default() -> Self { + Self::MacRoman(MacRomanRevision::Modern) + } +} + +impl AppleEncoding { + /// Select an encoding from a Mac `fdScript` script code, or `None` if this + /// crate does not yet implement it. + /// + /// Roman resolves to the modern revision; callers wanting pre-8.5 fidelity + /// construct [`AppleEncoding::MacRoman`] with [`MacRomanRevision::Classic`]. + #[must_use] + pub fn from_script_code(code: u8) -> Option { + match code { + SCRIPT_ROMAN => Some(Self::MacRoman(MacRomanRevision::default())), + _ => None, + } + } + + /// Decode every byte of `bytes` to Unicode, faithfully (control bytes and a + /// trailing `NUL` included). Use [`decode_cstr`](Self::decode_cstr) for + /// `NUL`-terminated fixed-width fields. + #[must_use] + pub fn decode(self, bytes: &[u8]) -> String { + match self { + Self::MacRoman(revision) => decode_single_byte(bytes, &mac_roman::high_table(revision)), + } + } + + /// Decode up to (but not including) the first `NUL` byte — the convention for + /// classic fixed-width name fields. + #[must_use] + pub fn decode_cstr(self, bytes: &[u8]) -> String { + let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len()); + self.decode(&bytes[..end]) + } + + /// Encode `text` back to this encoding's bytes. + /// + /// # Errors + /// + /// Returns [`EncodeError::Unmappable`] for the first character with no + /// representation in this encoding (e.g. a Euro sign under + /// [`MacRomanRevision::Classic`]). + pub fn encode(self, text: &str) -> Result, EncodeError> { + match self { + Self::MacRoman(revision) => { + encode_single_byte(text, &mac_roman::high_table(revision), self) + } + } + } +} + +impl fmt::Display for AppleEncoding { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MacRoman(MacRomanRevision::Modern) => f.write_str("Mac OS Roman (post-8.5)"), + Self::MacRoman(MacRomanRevision::Classic) => f.write_str("Mac OS Roman (pre-8.5)"), + } + } +} + +/// A character could not be encoded into the target Mac encoding. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum EncodeError { + /// `ch` has no representation in `encoding`. + #[error("character {ch:?} has no mapping in {encoding}")] + Unmappable { + /// The offending character. + ch: char, + /// The encoding that lacks a mapping for it. + encoding: AppleEncoding, + }, +} + +/// Decode a single-byte encoding: ASCII passes through, high bytes index `high`. +fn decode_single_byte(bytes: &[u8], high: &[char; 128]) -> String { + bytes + .iter() + .map(|&b| { + if b < 0x80 { + b as char + } else { + high[(b - 0x80) as usize] + } + }) + .collect() +} + +/// Encode to a single-byte encoding. ASCII maps to itself; other characters are +/// looked up in `high` (a bijection over `0x80..=0xFF`), erroring if absent. +fn encode_single_byte( + text: &str, + high: &[char; 128], + encoding: AppleEncoding, +) -> Result, EncodeError> { + text.chars() + .map(|ch| { + if (ch as u32) < 0x80 { + Ok(ch as u8) + } else { + high.iter() + .position(|&c| c == ch) + .map(|i| 0x80 + i as u8) + .ok_or(EncodeError::Unmappable { ch, encoding }) + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn roman() -> AppleEncoding { + AppleEncoding::default() + } + + #[test] + fn ascii_passthrough() { + assert_eq!(roman().decode(b"Macintosh HD"), "Macintosh HD"); + assert_eq!(roman().encode("Macintosh HD").unwrap(), b"Macintosh HD"); + } + + #[test] + fn known_high_mappings() { + // Same golden cases the fsinspect table was validated against. + assert_eq!(roman().decode(&[0x80]), "Ä"); + assert_eq!(roman().decode(&[0x8E]), "é"); + assert_eq!(roman().decode(&[0xA9]), "©"); + assert_eq!(roman().decode(&[0xAA]), "™"); + assert_eq!(roman().decode(&[0xF0]), "\u{F8FF}"); // Apple logo (PUA) + } + + #[test] + fn euro_revision_split() { + let modern = AppleEncoding::MacRoman(MacRomanRevision::Modern); + let classic = AppleEncoding::MacRoman(MacRomanRevision::Classic); + assert_eq!(modern.decode(&[0xDB]), "€"); + assert_eq!(classic.decode(&[0xDB]), "¤"); + // Euro round-trips only under the modern table. + assert_eq!(modern.encode("€").unwrap(), vec![0xDB]); + assert_eq!(classic.encode("¤").unwrap(), vec![0xDB]); + assert!(matches!( + classic.encode("€"), + Err(EncodeError::Unmappable { ch: '€', .. }) + )); + } + + #[test] + fn decode_cstr_stops_at_nul() { + assert_eq!(roman().decode_cstr(b"Test\x00garbage"), "Test"); + assert_eq!( + roman().decode(b"Test\x00garbage").len(), + "Test garbage".len() + ); + } + + #[test] + fn high_bytes_round_trip_both_revisions() { + for revision in [MacRomanRevision::Modern, MacRomanRevision::Classic] { + let enc = AppleEncoding::MacRoman(revision); + for byte in 0x80u8..=0xFF { + let decoded = enc.decode(&[byte]); + assert_eq!( + enc.encode(&decoded).unwrap(), + vec![byte], + "byte {byte:#04x} failed to round-trip under {enc}" + ); + } + } + } + + #[test] + fn high_table_is_a_bijection() { + // No duplicate glyphs, else encode would be ambiguous. + let table = mac_roman::high_table(MacRomanRevision::Modern); + for i in 0..table.len() { + for j in (i + 1)..table.len() { + assert_ne!(table[i], table[j], "duplicate glyph at {i} and {j}"); + } + } + } + + #[test] + fn unmappable_character_errors() { + // A CJK character has no place in Mac OS Roman. + let err = roman().encode("空").unwrap_err(); + assert!(matches!(err, EncodeError::Unmappable { ch: '空', .. })); + } + + #[test] + fn from_script_code_roman_only() { + assert_eq!( + AppleEncoding::from_script_code(SCRIPT_ROMAN), + Some(AppleEncoding::MacRoman(MacRomanRevision::Modern)) + ); + assert_eq!(AppleEncoding::from_script_code(2), None); // smJapanese, not yet + } +} diff --git a/src/mac_roman.rs b/src/mac_roman.rs new file mode 100644 index 0000000..e1219eb --- /dev/null +++ b/src/mac_roman.rs @@ -0,0 +1,60 @@ +//! Mac OS Roman: the high half of the table and revision handling. +//! +//! The lower 128 byte values are identical to ASCII; only `0x80..=0xFF` carry +//! Mac-specific glyphs. The table below is the **post-Mac OS 8.5** revision +//! (byte `0xDB` is the Euro sign); [`MacRomanRevision::Classic`] swaps that one +//! slot back to the pre-8.5 currency sign. See `docs` in the consuming projects +//! for why revision pinning is per-encoding. +//! +//! Source: the Unicode Consortium `VENDORS/APPLE/ROMAN.TXT` mapping. + +/// Unicode scalars for Mac OS Roman bytes `0x80..=0xFF` (post-8.5 revision). +/// +/// Index `i` corresponds to byte value `0x80 + i` — e.g. index 0 is `0x80` → 'Ä'. +pub(crate) const MAC_ROMAN_HIGH: [char; 128] = [ + 'Ä', 'Å', 'Ç', 'É', 'Ñ', 'Ö', 'Ü', 'á', // 0x80-0x87 + 'à', 'â', 'ä', 'ã', 'å', 'ç', 'é', 'è', // 0x88-0x8F + 'ê', 'ë', 'í', 'ì', 'î', 'ï', 'ñ', 'ó', // 0x90-0x97 + 'ò', 'ô', 'ö', 'õ', 'ú', 'ù', 'û', 'ü', // 0x98-0x9F + '†', '°', '¢', '£', '§', '•', '¶', 'ß', // 0xA0-0xA7 + '®', '©', '™', '´', '¨', '≠', 'Æ', 'Ø', // 0xA8-0xAF + '∞', '±', '≤', '≥', '¥', 'µ', '∂', '∑', // 0xB0-0xB7 + '∏', 'π', '∫', 'ª', 'º', 'Ω', 'æ', 'ø', // 0xB8-0xBF + '¿', '¡', '¬', '√', 'ƒ', '≈', '∆', '«', // 0xC0-0xC7 + '»', '…', '\u{00A0}', 'À', 'Ã', 'Õ', 'Œ', 'œ', // 0xC8-0xCF + '–', '—', '\u{201C}', '\u{201D}', '\u{2018}', '\u{2019}', '÷', '◊', // 0xD0-0xD7 + 'ÿ', 'Ÿ', '⁄', '€', '‹', '›', '\u{FB01}', '\u{FB02}', // 0xD8-0xDF (0xDB € post-8.5) + '‡', '·', '‚', '„', '‰', 'Â', 'Ê', 'Á', // 0xE0-0xE7 + 'Ë', 'È', 'Í', 'Î', 'Ï', 'Ì', 'Ó', 'Ô', // 0xE8-0xEF + '\u{F8FF}', 'Ò', 'Ú', 'Û', 'Ù', 'ı', 'ˆ', '˜', // 0xF0-0xF7 (0xF0 = Apple logo, PUA) + '¯', '˘', '˙', '˚', '¸', '˝', '˛', 'ˇ', // 0xF8-0xFF +]; + +/// Index of byte `0xDB`, the only slot that differs across the 8.5 revision. +const EURO_SLOT: usize = (0xDB - 0x80) as usize; + +/// The pre-8.5 glyph at `0xDB`: CURRENCY SIGN. +const CLASSIC_CURRENCY: char = '\u{00A4}'; + +/// Which revision of Mac OS Roman to use. +/// +/// The Mac OS 8.5 Euro update changed exactly one byte (`0xDB`). Defaults to +/// [`Modern`](MacRomanRevision::Modern); choose [`Classic`](MacRomanRevision::Classic) +/// for pre-8.5 / emulator fidelity. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum MacRomanRevision { + /// Mac OS 8.5 and later: `0xDB` is EURO SIGN (`U+20AC`). + #[default] + Modern, + /// Before Mac OS 8.5: `0xDB` is CURRENCY SIGN (`U+00A4`). + Classic, +} + +/// The effective high-half table for `revision` (a cheap 512-byte stack copy). +pub(crate) fn high_table(revision: MacRomanRevision) -> [char; 128] { + let mut table = MAC_ROMAN_HIGH; + if revision == MacRomanRevision::Classic { + table[EURO_SLOT] = CLASSIC_CURRENCY; + } + table +}