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:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/target
|
||||
Cargo.lock
|
||||
12
Cargo.toml
Normal file
12
Cargo.toml
Normal 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
42
README.md
Normal 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
248
src/lib.rs
Normal 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
60
src/mac_roman.rs
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user