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.
This commit is contained in:
Claude Fable 5
2026-07-19 11:37:24 -05:00
commit 4f1439af65
5 changed files with 364 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/target
Cargo.lock

12
Cargo.toml Normal file
View File

@@ -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

42
README.md Normal file
View File

@@ -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

248
src/lib.rs Normal file
View File

@@ -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<Self> {
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<Vec<u8>, 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<Vec<u8>, 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
}
}

60
src/mac_roman.rs Normal file
View File

@@ -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
}