labels: shared platform (vocab/text/jar) + dbp refactor

Establishes the shared infrastructure layer for label parsers so that
Java-touching parsers (dbp, deluxe) don't reimplement jar walking and
all parsers route language/purpose/qualifier classification through
one source of truth instead of N hand-rolls.

New modules:

  vocab.rs       expanded from 27 -> ~370 lines
                 + lang(text) -> Option<&'static str>      (English/multi-word
                                                            -> ISO 639-2; ~45
                                                            languages, compound
                                                            phrases like
                                                            'Brazilian Portuguese'
                                                            and 'Castilian Spanish')
                 + purpose(text) -> LabelPurpose            (Commentary,
                                                            Descriptive, Score,
                                                            Ime; word-boundary
                                                            matched)
                 + qualifier(text) -> LabelQualifier        (SDH, Forced,
                                                            DescriptiveService)
                 + has_word internal primitive — enforces word-boundary
                   matching so 'Commenter' no longer matches 'commentary' and
                   'engineering' no longer matches 'english'. Existing parsers
                   used .contains() and got lucky on the corpus; vocab now
                   guarantees the boundary in one place. 20+ unit tests.

  text.rs        NEW (~85 lines)
                 + extract_ascii_strings(data, min_len) — promoted from two
                   near-duplicate copies (pixelogic min=4, dbp min=5);
                   threshold passed in. 7 unit tests including
                   trailing-without-terminator + high-bit-byte handling.

  jar.rs         NEW (~120 lines)
                 + for_each_jar(reader, udf, fn)  — walk every top-level
                                                    .jar under /BDMV/JAR/,
                                                    yield to callback.
                 + has_path_prefix(archive, prefix) — cheap 'is this MY
                                                      framework's jar?' check
                                                      via central-dir filenames.
                 + for_each_class(archive, fn)    — parse every .class entry
                                                    through class_reader,
                                                    yield (name, &ClassFile).
                 + try_each_class(archive, fn)    — same with early-return on
                                                    first Some(R) match.

Refactored:

  dbp.rs         v2 on the new platform:
                 - dropped extract_printable raw byte scan
                 - dropped its own English -> ISO 639-2 map
                 - dropped its own parse_attributes hand-roll
                 + iterates CpInfo::Utf8 via class_reader (structurally clean,
                   no false-positive risk from method bytecode bytes)
                 + routes language/purpose/qualifier through vocab
                 All 7 prior dbp tests still pass; +2 new ones cover
                 vocab routing.

dead-code allows on text.rs (extract_ascii_strings) and jar.rs
(try_each_class) come off when pixelogic and deluxe land — they're
staged for next steps.

Precommit green (cargo +1.86 fmt + clippy + test).
This commit is contained in:
MattJackson
2026-05-10 15:16:25 -07:00
parent 99236ffd55
commit 4ec75a03f2
5 changed files with 707 additions and 208 deletions
+91 -199
View File
@@ -22,25 +22,29 @@
//! what precedes it. `Subtitle0` is the disable-subtitles menu //! what precedes it. `Subtitle0` is the disable-subtitles menu
//! button and is skipped (not a real subtitle stream). //! button and is skipped (not a real subtitle stream).
//! //!
//! Per `(internal)/memory/feedback_label_data_rules.md`: this //! ## Implementation
//! parser knows its own format, so we map human-readable language //!
//! names ("English", "Spanish", "Canadian French", ...) to ISO 639-2 //! v2 (2026-05-10): rewritten on top of [`super::class_reader`] —
//! codes locally. The full disc-authored display string is preserved //! iterates `CpInfo::Utf8` constant-pool entries instead of raw byte
//! in the label `name` field — consumers display it raw without //! scanning each class file. Equivalent label coverage (the literal
//! freemkv guessing further structure. //! `TextField,...` strings live in the CP as Utf8 entries), but
//! structurally cleaner: no false-positive risk from method bytecode
//! or attribute names happening to contain `TextField,`. Language /
//! purpose / qualifier classification moved to [`super::vocab`] so all
//! Java-parser families share one source of truth.
use super::{LabelPurpose, LabelQualifier, StreamLabel, StreamLabelType}; use super::class_reader::CpInfo;
use super::{StreamLabel, StreamLabelType, jar, vocab};
use crate::sector::SectorReader; use crate::sector::SectorReader;
use crate::udf::UdfFs; use crate::udf::UdfFs;
use std::collections::BTreeMap;
/// dbp detect can't peek inside a jar without a SectorReader (the /// dbp detect can't peek inside a jar without a SectorReader (the
/// trait function only takes `&UdfFs`), so we trigger on the cheap /// trait function only takes `&UdfFs`), so we trigger on the cheap
/// signal "any top-level .jar in /BDMV/JAR/." That fires on every /// signal "any top-level .jar in /BDMV/JAR/." That fires on every
/// BD-J disc, but parse() does the real `com/dbp/` check and /// BD-J disc, but parse() does the real `com/dbp/` check and
/// returns None on a mismatch — so this parser only ever consumes /// returns None on a mismatch — so this parser only ever consumes
/// time on discs that fell through every earlier parser. The /// time on discs that fell through every earlier parser.
/// parse-side mismatch is bounded (read one .jar, list central
/// directory, walk class strings).
pub fn detect(udf: &UdfFs) -> bool { pub fn detect(udf: &UdfFs) -> bool {
let Some(jar_dir) = udf.find_dir("/BDMV/JAR") else { let Some(jar_dir) = udf.find_dir("/BDMV/JAR") else {
return false; return false;
@@ -52,73 +56,35 @@ pub fn detect(udf: &UdfFs) -> bool {
} }
pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<StreamLabel>> { pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
let jar_dir = udf.find_dir("/BDMV/JAR")?; jar::for_each_jar(reader, udf, |_entry_name, archive| {
for entry in &jar_dir.entries { if !jar::has_path_prefix(archive, "com/dbp/") {
if entry.is_dir { return None;
continue;
} }
if !entry.name.to_lowercase().ends_with(".jar") { let labels = scan_jar(archive);
continue; if labels.is_empty() {
None
} else {
Some(labels)
} }
let path = format!("/BDMV/JAR/{}", entry.name); })
let Ok(bytes) = udf.read_file(reader, &path) else {
continue;
};
let cursor = std::io::Cursor::new(&bytes);
let Ok(mut archive) = zip::ZipArchive::new(cursor) else {
continue;
};
if !archive_has_dbp(&mut archive) {
continue;
}
let labels = scan_jar(&mut archive);
if !labels.is_empty() {
return Some(labels);
}
}
None
} }
fn archive_has_dbp<R: std::io::Read + std::io::Seek>(archive: &mut zip::ZipArchive<R>) -> bool { fn scan_jar(archive: &mut jar::Jar) -> Vec<StreamLabel> {
for i in 0..archive.len() {
if let Ok(f) = archive.by_index(i) {
if f.name().starts_with("com/dbp/") {
return true;
}
}
}
false
}
fn scan_jar<R: std::io::Read + std::io::Seek>(
archive: &mut zip::ZipArchive<R>,
) -> Vec<StreamLabel> {
use std::collections::BTreeMap;
// BTreeMap so we keep the highest-numbered (last-written) label // BTreeMap so we keep the highest-numbered (last-written) label
// for each stream slot deterministic across runs. Entries are // for each stream slot deterministic across runs. The same
// collected from string-pool fragments scattered across hundreds // TextField,Audio1,... string can appear in multiple classes
// of obfuscated .class files; the same TextField,Audio1,... // (button-state variants, localization fallbacks). Last write
// string can appear in multiple classes (button-state variants, // wins — they should all agree, but the structure is defensive.
// localization fallbacks). Last write wins — they should all
// agree on the label text, but the structure is defensive.
let mut audios: BTreeMap<u16, String> = BTreeMap::new(); let mut audios: BTreeMap<u16, String> = BTreeMap::new();
let mut subs: BTreeMap<u16, String> = BTreeMap::new(); let mut subs: BTreeMap<u16, String> = BTreeMap::new();
for i in 0..archive.len() { jar::for_each_class(archive, |_class_name, class| {
let Ok(mut f) = archive.by_index(i) else { for (_idx, cp) in class.constant_pool.iter() {
continue; if let CpInfo::Utf8(s) = cp {
}; collect_textfield(s, &mut audios, &mut subs);
if !f.name().ends_with(".class") { }
continue;
} }
let mut buf = Vec::new(); });
if std::io::Read::read_to_end(&mut f, &mut buf).is_err() {
continue;
}
for s in extract_printable(&buf) {
collect_textfield(&s, &mut audios, &mut subs);
}
}
let mut out = Vec::new(); let mut out = Vec::new();
for (num, label) in audios { for (num, label) in audios {
@@ -132,8 +98,8 @@ fn scan_jar<R: std::io::Read + std::io::Seek>(
fn collect_textfield( fn collect_textfield(
s: &str, s: &str,
audios: &mut std::collections::BTreeMap<u16, String>, audios: &mut BTreeMap<u16, String>,
subs: &mut std::collections::BTreeMap<u16, String>, subs: &mut BTreeMap<u16, String>,
) { ) {
// Anchor on "TextField," — the prefix character before it varies // Anchor on "TextField," — the prefix character before it varies
// (string-pool ordering inside compiled Java) and is irrelevant. // (string-pool ordering inside compiled Java) and is irrelevant.
@@ -163,7 +129,9 @@ fn collect_textfield(
} }
fn make_label(num: u16, label: String, stream_type: StreamLabelType) -> StreamLabel { fn make_label(num: u16, label: String, stream_type: StreamLabelType) -> StreamLabel {
let (language, qualifier, purpose) = parse_attributes(&label); let language = vocab::lang(&label).unwrap_or_default().to_string();
let qualifier = vocab::qualifier(&label);
let purpose = vocab::purpose(&label);
StreamLabel { StreamLabel {
stream_number: num, stream_number: num,
stream_type, stream_type,
@@ -176,111 +144,15 @@ fn make_label(num: u16, label: String, stream_type: StreamLabelType) -> StreamLa
} }
} }
fn parse_attributes(label: &str) -> (String, LabelQualifier, LabelPurpose) {
let lower = label.to_lowercase();
let language = detect_language(&lower);
let qualifier = if lower.contains("sdh") {
LabelQualifier::Sdh
} else if lower.contains("descriptive service") || lower.contains(" rnib") {
LabelQualifier::DescriptiveService
} else if lower.contains("forced") {
LabelQualifier::Forced
} else {
LabelQualifier::None
};
let purpose = if lower.contains("commentary") {
LabelPurpose::Commentary
} else if lower.contains("descriptive") || lower.contains("audio description") {
LabelPurpose::Descriptive
} else {
LabelPurpose::Normal
};
(language, qualifier, purpose)
}
/// Map English-language label tokens to ISO 639-2 codes. Keep this
/// list conservative — only common tokens we've actually observed
/// or that have a canonical mapping. Returns "" when the token
/// isn't recognized; the consumer falls back to fill_defaults reading
/// MPLS spec language codes.
fn detect_language(lower: &str) -> String {
// Compound tokens first (multi-word language names).
for (needle, code) in [
("brazilian portuguese", "por"),
("euro portuguese", "por"),
("castilian spanish", "spa"),
("latin american spanish", "spa"),
("canadian french", "fra"),
("parisian french", "fra"),
("australian english", "eng"),
("austrailian english", "eng"), // disc-corpus typo, keep matching
] {
if lower.contains(needle) {
return code.to_string();
}
}
// Then bare tokens. Order matters where one is prefix of another.
for (needle, code) in [
("english", "eng"),
("spanish", "spa"),
("french", "fra"),
("german", "deu"),
("italian", "ita"),
("japanese", "jpn"),
("chinese", "zho"),
("portuguese", "por"),
("polish", "pol"),
("czech", "ces"),
("hungarian", "hun"),
("dutch", "nld"),
("korean", "kor"),
("arabic", "ara"),
("hindi", "hin"),
("turkish", "tur"),
("thai", "tha"),
("swedish", "swe"),
("norwegian", "nor"),
("danish", "dan"),
("finnish", "fin"),
("hebrew", "heb"),
("russian", "rus"),
] {
if lower.split_whitespace().next() == Some(needle)
|| lower.split_whitespace().any(|w| w == needle)
{
return code.to_string();
}
}
String::new()
}
fn extract_printable(data: &[u8]) -> Vec<String> {
let mut out = Vec::new();
let mut current = String::new();
for &b in data {
if (0x20..0x7f).contains(&b) {
current.push(b as char);
} else {
if current.len() >= 5 {
out.push(current.clone());
}
current.clear();
}
}
if current.len() >= 5 {
out.push(current);
}
out
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::super::{LabelPurpose, LabelQualifier};
use super::*; use super::*;
#[test] #[test]
fn collect_extracts_audio_and_subtitle_indices() { fn collect_extracts_audio_and_subtitle_indices() {
let mut audios = std::collections::BTreeMap::new(); let mut audios = BTreeMap::new();
let mut subs = std::collections::BTreeMap::new(); let mut subs = BTreeMap::new();
let lines = [ let lines = [
"LTextField,Audio1,English Dolby Atmos,Fontstrip_Composite,296,763,275,25,left", "LTextField,Audio1,English Dolby Atmos,Fontstrip_Composite,296,763,275,25,left",
"RTextField,Audio2,English Descriptive Audio,Fontstrip_Composite,296,803,275,25,left", "RTextField,Audio2,English Descriptive Audio,Fontstrip_Composite,296,803,275,25,left",
@@ -304,8 +176,8 @@ mod tests {
#[test] #[test]
fn collect_ignores_non_textfield_strings() { fn collect_ignores_non_textfield_strings() {
let mut audios = std::collections::BTreeMap::new(); let mut audios = BTreeMap::new();
let mut subs = std::collections::BTreeMap::new(); let mut subs = BTreeMap::new();
for s in [ for s in [
"GraphicButton,SU_Audio", "GraphicButton,SU_Audio",
"AudioMenu", "AudioMenu",
@@ -319,47 +191,67 @@ mod tests {
} }
#[test] #[test]
fn parse_attributes_recognizes_sdh() { fn make_label_routes_via_vocab() {
let (lang, qual, purp) = parse_attributes("English SDH"); let l = make_label(1, "English SDH".to_string(), StreamLabelType::Subtitle);
assert_eq!(lang, "eng"); assert_eq!(l.language, "eng");
assert_eq!(qual, LabelQualifier::Sdh); assert_eq!(l.qualifier, LabelQualifier::Sdh);
assert_eq!(purp, LabelPurpose::Normal); assert_eq!(l.purpose, LabelPurpose::Normal);
} }
#[test] #[test]
fn parse_attributes_recognizes_descriptive_audio() { fn make_label_descriptive_audio() {
let (lang, qual, purp) = parse_attributes("English Descriptive Audio"); let l = make_label(
assert_eq!(lang, "eng"); 2,
assert_eq!(qual, LabelQualifier::None); "English Descriptive Audio".to_string(),
assert_eq!(purp, LabelPurpose::Descriptive); StreamLabelType::Audio,
);
assert_eq!(l.language, "eng");
assert_eq!(l.purpose, LabelPurpose::Descriptive);
} }
#[test] #[test]
fn parse_attributes_recognizes_commentary() { fn make_label_commentary() {
let (lang, qual, purp) = parse_attributes("English Director's Commentary"); let l = make_label(
assert_eq!(lang, "eng"); 3,
assert_eq!(qual, LabelQualifier::None); "English Director's Commentary".to_string(),
assert_eq!(purp, LabelPurpose::Commentary); StreamLabelType::Audio,
);
assert_eq!(l.language, "eng");
assert_eq!(l.purpose, LabelPurpose::Commentary);
} }
#[test] #[test]
fn parse_attributes_recognizes_compound_languages() { fn make_label_compound_languages() {
assert_eq!(parse_attributes("Brazilian Portuguese 5.1").0, "por"); assert_eq!(
assert_eq!(parse_attributes("Castilian Spanish").0, "spa"); make_label(1, "Brazilian Portuguese 5.1".into(), StreamLabelType::Audio).language,
assert_eq!(parse_attributes("Canadian French Dolby Digital").0, "fra"); "por"
assert_eq!(parse_attributes("Latin American Spanish").0, "spa"); );
assert_eq!(
make_label(1, "Castilian Spanish".into(), StreamLabelType::Audio).language,
"spa"
);
assert_eq!(
make_label(
1,
"Canadian French Dolby Digital".into(),
StreamLabelType::Audio
)
.language,
"fra"
);
} }
#[test] #[test]
fn parse_attributes_returns_empty_for_unknown_language() { fn make_label_unknown_language_is_empty() {
// Don't guess. Per the rules-of-engagement. // vocab::lang returns None — make_label converts to "".
assert_eq!(parse_attributes("Klingon Dolby Atmos").0, ""); let l = make_label(1, "Klingon Dolby Atmos".into(), StreamLabelType::Audio);
assert_eq!(l.language, "");
} }
#[test] #[test]
fn parse_attributes_recognizes_rnib_descriptive_service() { fn make_label_rnib_descriptive_service() {
let (lang, qual, _) = parse_attributes("English RNIB"); let l = make_label(1, "English RNIB".into(), StreamLabelType::Subtitle);
assert_eq!(lang, "eng"); assert_eq!(l.language, "eng");
assert_eq!(qual, LabelQualifier::DescriptiveService); assert_eq!(l.qualifier, LabelQualifier::DescriptiveService);
} }
} }
+136
View File
@@ -0,0 +1,136 @@
//! BD-J jar utilities — common scaffolding for parsers that read
//! `/BDMV/JAR/*.jar`.
//!
//! Composes with [`class_reader`](super::class_reader) for structured
//! `.class` access. Used by `dbp` (string-pool scan via constant pool)
//! and `deluxe` (bytecode pattern matching) — those parsers express
//! "open every top-level jar, look at every .class inside" without
//! repeating the zip-archive boilerplate.
// `try_each_class` is staged for `labels::deluxe`, which needs the
// early-return form to short-circuit class iteration on a match.
// dead-code allow comes off when deluxe lands.
#![allow(dead_code)]
use super::class_reader::ClassFile;
use crate::sector::SectorReader;
use crate::udf::UdfFs;
use std::io::Cursor;
use zip::ZipArchive;
/// In-memory zip archive: backed by a `Vec<u8>` read from UDF. Owns
/// the buffer; callers pass it to [`has_path_prefix`], [`for_each_class`],
/// etc.
pub type Jar = ZipArchive<Cursor<Vec<u8>>>;
/// Open every top-level `*.jar` entry in `/BDMV/JAR/` and yield each
/// `(entry_name, Jar)` to `f`. Returns the first `Some(R)` the callback
/// produces, or `None` if every jar was visited without a hit.
///
/// "Top-level" means entries directly under `/BDMV/JAR/`, not nested
/// under a subdir. (Pixelogic, Criterion, Paramount, etc. put their
/// data files inside `/BDMV/JAR/<x>/`; dbp and Deluxe put their jar
/// directly at `/BDMV/JAR/<name>.jar`.)
///
/// Entries that fail to read from UDF or that aren't valid zips are
/// silently skipped — same defensive shape as the existing dbp parser.
pub fn for_each_jar<R, F>(reader: &mut dyn SectorReader, udf: &UdfFs, mut f: F) -> Option<R>
where
F: FnMut(&str, &mut Jar) -> Option<R>,
{
let jar_dir = udf.find_dir("/BDMV/JAR")?;
for entry in &jar_dir.entries {
if entry.is_dir {
continue;
}
if !entry.name.to_lowercase().ends_with(".jar") {
continue;
}
let path = format!("/BDMV/JAR/{}", entry.name);
let Ok(bytes) = udf.read_file(reader, &path) else {
continue;
};
let Ok(mut archive) = ZipArchive::new(Cursor::new(bytes)) else {
continue;
};
if let Some(r) = f(&entry.name, &mut archive) {
return Some(r);
}
}
None
}
/// True if any entry in this jar's central directory starts with
/// `prefix`. Fast — only reads filenames, never extracts bytes.
///
/// Used by parsers as a cheap "is this MY framework's jar?" check
/// (e.g. `has_path_prefix(archive, "com/dbp/")` for dbp,
/// `has_path_prefix(archive, "com/bydeluxe/")` for Deluxe).
pub fn has_path_prefix(archive: &mut Jar, prefix: &str) -> bool {
for i in 0..archive.len() {
if let Ok(f) = archive.by_index(i) {
if f.name().starts_with(prefix) {
return true;
}
}
}
false
}
/// Iterate every `.class` entry in the jar, parse it with
/// [`class_reader`], and call `f` with `(entry_name, &ClassFile)`.
///
/// Entries that fail to read or parse are silently skipped — this is
/// label-extraction code, robustness matters more than completeness.
/// Callers that need to know which classes failed should use the
/// lower-level [`class_reader`] API directly.
pub fn for_each_class<F>(archive: &mut Jar, mut f: F)
where
F: FnMut(&str, &ClassFile),
{
for i in 0..archive.len() {
let Ok(mut entry) = archive.by_index(i) else {
continue;
};
if !entry.name().ends_with(".class") {
continue;
}
let name = entry.name().to_string();
let mut bytes = Vec::with_capacity(entry.size() as usize);
if std::io::Read::read_to_end(&mut entry, &mut bytes).is_err() {
continue;
}
let Ok(class) = ClassFile::parse(&bytes) else {
continue;
};
f(&name, &class);
}
}
/// Like [`for_each_class`] but allows the callback to short-circuit
/// iteration. Returns the first `Some(R)` the callback produces.
pub fn try_each_class<R, F>(archive: &mut Jar, mut f: F) -> Option<R>
where
F: FnMut(&str, &ClassFile) -> Option<R>,
{
for i in 0..archive.len() {
let Ok(mut entry) = archive.by_index(i) else {
continue;
};
if !entry.name().ends_with(".class") {
continue;
}
let name = entry.name().to_string();
let mut bytes = Vec::with_capacity(entry.size() as usize);
if std::io::Read::read_to_end(&mut entry, &mut bytes).is_err() {
continue;
}
let Ok(class) = ClassFile::parse(&bytes) else {
continue;
};
if let Some(r) = f(&name, &class) {
return Some(r);
}
}
None
}
+2
View File
@@ -11,8 +11,10 @@ pub(crate) mod class_reader;
mod criterion; mod criterion;
mod ctrm; mod ctrm;
mod dbp; mod dbp;
pub(crate) mod jar;
mod paramount; mod paramount;
mod pixelogic; mod pixelogic;
pub(crate) mod text;
pub mod vocab; pub mod vocab;
use crate::disc::{DiscTitle, Stream}; use crate::disc::{DiscTitle, Stream};
+94
View File
@@ -0,0 +1,94 @@
//! Text-extraction helpers used by parsers that scan binary blobs for
//! embedded label strings.
//!
//! Promoted from two near-duplicate implementations:
//! - `pixelogic::extract_strings` (`bluray_project.bin`, min_len=4)
//! - `dbp::extract_printable` (`.class` files in jars, min_len=5)
//!
//! Single implementation, threshold passed in. Callers that have a
//! more structured parse path (e.g. `class_reader` for .class) should
//! prefer that — this helper is for genuinely unstructured input.
// Staged for the pixelogic refactor: pixelogic still has its own
// extract_strings copy; this is the shared replacement waiting for
// the refactor. dead-code allow comes off when pixelogic switches.
#![allow(dead_code)]
/// Walk `data`, emit every maximal run of printable-ASCII bytes
/// (`0x20..=0x7E`) whose length is at least `min_len`.
///
/// Non-printable bytes (including `\t`, `\n`, NUL) terminate the
/// current run. Output strings are guaranteed valid UTF-8 (they're
/// pure 7-bit ASCII). Strings shorter than `min_len` are dropped.
pub fn extract_ascii_strings(data: &[u8], min_len: usize) -> Vec<String> {
let mut out = Vec::new();
let mut current = String::new();
for &b in data {
if (0x20..=0x7E).contains(&b) {
current.push(b as char);
} else if current.len() >= min_len {
out.push(std::mem::take(&mut current));
} else {
current.clear();
}
}
if current.len() >= min_len {
out.push(current);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extracts_simple_runs() {
let got = extract_ascii_strings(b"hello\0world\0", 3);
assert_eq!(got, vec!["hello", "world"]);
}
#[test]
fn applies_minimum_length() {
let got = extract_ascii_strings(b"hi\0ok\0longer\0", 5);
assert_eq!(got, vec!["longer"]);
}
#[test]
fn treats_tab_and_newline_as_separators() {
// \t (0x09) and \n (0x0A) are below 0x20, so they break runs.
let got = extract_ascii_strings(b"alpha\tbeta\ngamma", 3);
assert_eq!(got, vec!["alpha", "beta", "gamma"]);
}
#[test]
fn handles_trailing_run_without_terminator() {
// Run at the very end of the buffer should still be emitted.
let got = extract_ascii_strings(b"prefix\0tail", 3);
assert_eq!(got, vec!["prefix", "tail"]);
}
#[test]
fn rejects_high_bit_bytes() {
// 0x80+ is non-printable per this helper's definition.
let mut buf = b"good".to_vec();
buf.push(0xC3);
buf.push(0xA9);
buf.extend_from_slice(b"more");
let got = extract_ascii_strings(&buf, 3);
assert_eq!(got, vec!["good", "more"]);
}
#[test]
fn empty_input_returns_empty() {
let got = extract_ascii_strings(&[], 1);
assert!(got.is_empty());
}
#[test]
fn min_len_zero_emits_singletons() {
// Pathological but well-defined.
let got = extract_ascii_strings(b"a\0b", 0);
assert_eq!(got, vec!["a", "b"]);
}
}
+384 -9
View File
@@ -1,19 +1,40 @@
//! Shared label vocabulary — values we are 100% confident about. //! Shared label vocabulary — canonical mappings used by ≥1 label parser.
//! //!
//! Labels come from BD-J authoring tool files (bluray_project.bin, //! Labels come from BD-J authoring tool files (bluray_project.bin,
//! playlists.xml, menu_base.prop, etc.) — NOT from BD spec fields. //! playlists.xml, menu_base.prop, .class string pools, etc.) — NOT
//! This is NOT for MPLS/CLPI/STN data. Those follow the BD spec directly. //! from BD spec fields. This module is the central, regression-tested
//! source of truth for:
//! //!
//! Rules: //! - Codec brand name aliases (`MLP` → `TrueHD`).
//! - Only map values we are 100% certain about (published codec names). //! - English / multi-word language name → ISO 639-2 code.
//! - Unknown codes (csp, eda, cf, etc.) pass through raw from disc. //! - English text → [`LabelPurpose`] (Commentary / Descriptive / etc.).
//! - The app/CLI handles display text, not the lib. //! - English text → [`LabelQualifier`] (SDH / Forced / Descriptive Service).
//!
//! Rules of engagement (carried over from
//! `(internal)/memory/feedback_label_data_rules.md`):
//!
//! 1. Only map values we are 100% certain about — published codec
//! names, well-known ISO 639-2 mappings, vendor-documented purpose
//! keywords.
//! 2. Unknown codes / unrecognized phrases pass through raw or return
//! `None`. We never guess.
//! 3. Matching is case-insensitive and word-boundary-aware where
//! relevant (so "Commenter" doesn't match "commentary"). Anchoring
//! on whole tokens is the responsibility of this module — callers
//! pass raw text, we handle it.
//!
//! This module is NOT for BD spec STN codec IDs; those decode in
//! `mpls.rs` separately.
use super::{LabelPurpose, LabelQualifier};
// ── Codec aliases ────────────────────────────────────────────────────────────
/// Map a codec identifier found in label data to its display name. /// Map a codec identifier found in label data to its display name.
/// ///
/// These are well-known codec identifiers used across multiple BD-J /// These are well-known codec identifiers used across multiple BD-J
/// authoring tools. NOT BD spec STN codec IDs — those are decoded /// authoring tools. Unknown codes pass through unchanged so callers
/// separately in mpls.rs. /// can still surface vendor-specific tokens we haven't catalogued.
pub fn codec(code: &str) -> &str { pub fn codec(code: &str) -> &str {
match code { match code {
"MLP" => "TrueHD", "MLP" => "TrueHD",
@@ -25,3 +46,357 @@ pub fn codec(code: &str) -> &str {
_ => code, _ => code,
} }
} }
// ── Language: English / multi-word names → ISO 639-2 ─────────────────────────
/// Map a free-form language label fragment to an ISO 639-2 code.
///
/// Handles both bare English names ("English", "Spanish") and the
/// multi-word vendor variants we've seen in the corpus ("Brazilian
/// Portuguese", "Castilian Spanish", "Canadian French"). Match is
/// case-insensitive; longer compound phrases win over their bare
/// counterparts (so "Brazilian Portuguese" → `por`, not consumed by
/// the bare "Portuguese" entry).
///
/// Returns `None` for unrecognized input — callers decide whether to
/// fall back to MPLS spec codes, pass through raw, or drop the stream.
/// Never guesses.
pub fn lang(text: &str) -> Option<&'static str> {
let lower = text.to_lowercase();
// Multi-word compounds first — longest-match wins.
for (needle, code) in COMPOUND_LANGS {
if lower.contains(needle) {
return Some(code);
}
}
// Bare names: word-boundary match (avoid "english" inside "englishman"
// or any other accidental substring).
for (needle, code) in BARE_LANGS {
if has_word(&lower, needle) {
return Some(code);
}
}
None
}
const COMPOUND_LANGS: &[(&str, &str)] = &[
("brazilian portuguese", "por"),
("euro portuguese", "por"),
("european portuguese", "por"),
("castilian spanish", "spa"),
("latin american spanish", "spa"),
("latin spanish", "spa"),
("canadian french", "fra"),
("parisian french", "fra"),
("australian english", "eng"),
("austrailian english", "eng"), // disc-corpus typo, keep matching
("british english", "eng"),
("simplified chinese", "zho"),
("traditional chinese", "zho"),
("mandarin chinese", "zho"),
("cantonese chinese", "zho"),
];
const BARE_LANGS: &[(&str, &str)] = &[
("english", "eng"),
("spanish", "spa"),
("french", "fra"),
("german", "deu"),
("italian", "ita"),
("japanese", "jpn"),
("chinese", "zho"),
("mandarin", "zho"),
("cantonese", "zho"),
("portuguese", "por"),
("polish", "pol"),
("czech", "ces"),
("hungarian", "hun"),
("dutch", "nld"),
("korean", "kor"),
("arabic", "ara"),
("hindi", "hin"),
("turkish", "tur"),
("thai", "tha"),
("swedish", "swe"),
("norwegian", "nor"),
("danish", "dan"),
("finnish", "fin"),
("hebrew", "heb"),
("russian", "rus"),
("greek", "ell"),
("vietnamese", "vie"),
("indonesian", "ind"),
("malay", "msa"),
("ukrainian", "ukr"),
("romanian", "ron"),
("bulgarian", "bul"),
("croatian", "hrv"),
("serbian", "srp"),
("slovak", "slk"),
("slovenian", "slv"),
("estonian", "est"),
("latvian", "lav"),
("lithuanian", "lit"),
("icelandic", "isl"),
("basque", "eus"),
("catalan", "cat"),
("galician", "glg"),
];
// ── Purpose ──────────────────────────────────────────────────────────────────
/// Classify a free-form English label string into a [`LabelPurpose`].
///
/// Recognized keywords (case-insensitive, word-boundary matched):
/// - "commentary", "director's commentary" → `Commentary`
/// - "descriptive", "description", "audio description", "described" → `Descriptive`
/// - "score", "music only" → `Score`
/// - "ime" (alternate music for closing themes etc.) → `Ime`
/// - anything else → `Normal`
///
/// Word-boundary matching means "Commentary track" matches but
/// "Commenter Pro audio" does not.
pub fn purpose(text: &str) -> LabelPurpose {
let lower = text.to_lowercase();
// Multi-word compounds first — they're more specific.
if lower.contains("audio description") || lower.contains("descriptive service") {
return LabelPurpose::Descriptive;
}
if lower.contains("music only") {
return LabelPurpose::Score;
}
if has_word(&lower, "commentary") {
return LabelPurpose::Commentary;
}
if has_word(&lower, "descriptive")
|| has_word(&lower, "description")
|| has_word(&lower, "described")
{
return LabelPurpose::Descriptive;
}
if has_word(&lower, "score") {
return LabelPurpose::Score;
}
if has_word(&lower, "ime") {
return LabelPurpose::Ime;
}
LabelPurpose::Normal
}
// ── Qualifier ────────────────────────────────────────────────────────────────
/// Classify a free-form English label string into a [`LabelQualifier`].
///
/// Recognized keywords (case-insensitive, word-boundary matched):
/// - "sdh", "captions" → `Sdh`
/// - "forced", "forced narrative" → `Forced`
/// - "rnib", "descriptive service" → `DescriptiveService`
/// - anything else → `None`
///
/// SDH (Subtitles for the Deaf and Hard of hearing) wins over Forced
/// when both keywords are present, because an SDH track is its own
/// stream regardless of whether the player flags it as "forced".
pub fn qualifier(text: &str) -> LabelQualifier {
let lower = text.to_lowercase();
if has_word(&lower, "sdh") || has_word(&lower, "captions") {
return LabelQualifier::Sdh;
}
if lower.contains("descriptive service") || has_word(&lower, "rnib") {
return LabelQualifier::DescriptiveService;
}
if has_word(&lower, "forced") {
return LabelQualifier::Forced;
}
LabelQualifier::None
}
// ── Internal: word-boundary matching ────────────────────────────────────────
/// True if `needle` appears in `haystack` surrounded by non-alphanumeric
/// boundaries (or string ends). `haystack` is assumed lowercase already.
///
/// This is the load-bearing primitive for `lang` / `purpose` /
/// `qualifier`: bare-token matchers MUST use it, otherwise we match
/// "english" inside "englishman" and "sdh" inside "lambdash". The
/// existing parsers used `.contains()` and got lucky on the corpus;
/// vocab guarantees the boundary.
fn has_word(haystack: &str, needle: &str) -> bool {
if needle.is_empty() {
return false;
}
let bytes = haystack.as_bytes();
let nb = needle.as_bytes();
let mut i = 0;
while i + nb.len() <= bytes.len() {
if &bytes[i..i + nb.len()] == nb {
let before = if i == 0 { None } else { Some(bytes[i - 1]) };
let after = bytes.get(i + nb.len()).copied();
let bound = |c: Option<u8>| match c {
None => true,
Some(b) => !b.is_ascii_alphanumeric(),
};
if bound(before) && bound(after) {
return true;
}
}
i += 1;
}
false
}
// ── Tests ────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn codec_known_aliases() {
assert_eq!(codec("MLP"), "TrueHD");
assert_eq!(codec("AC3"), "Dolby Digital");
assert_eq!(codec("AC"), "Dolby Digital");
assert_eq!(codec("DDL"), "Dolby Digital Plus");
assert_eq!(codec("atmos"), "Dolby Atmos");
}
#[test]
fn codec_unknown_passes_through() {
assert_eq!(codec("FX9"), "FX9");
assert_eq!(codec(""), "");
}
#[test]
fn lang_bare_names() {
assert_eq!(lang("English"), Some("eng"));
assert_eq!(lang("english"), Some("eng"));
assert_eq!(lang("Spanish 5.1 Dolby Digital"), Some("spa"));
assert_eq!(lang("japanese"), Some("jpn"));
assert_eq!(lang("Italian"), Some("ita"));
}
#[test]
fn lang_compounds_win_over_bare() {
// Brazilian Portuguese should map to por via the compound rule,
// not be intercepted by bare "portuguese" (also por, but the
// matcher must walk compounds first to be correct in principle).
assert_eq!(lang("Brazilian Portuguese 5.1"), Some("por"));
assert_eq!(lang("Castilian Spanish"), Some("spa"));
assert_eq!(lang("Canadian French Dolby Digital"), Some("fra"));
assert_eq!(lang("Latin American Spanish"), Some("spa"));
}
#[test]
fn lang_unknown_returns_none() {
assert_eq!(lang("Klingon Dolby Atmos"), None);
assert_eq!(lang(""), None);
assert_eq!(lang("eng"), None); // 3-letter codes are not English names
}
#[test]
fn lang_word_boundary_avoids_substring_false_positive() {
// No false positive — "engineering" must NOT match "english".
assert_eq!(lang("Audio Engineering Demo"), None);
}
#[test]
fn purpose_recognizes_commentary() {
assert_eq!(purpose("English Commentary"), LabelPurpose::Commentary);
assert_eq!(purpose("Director's Commentary"), LabelPurpose::Commentary);
}
#[test]
fn purpose_recognizes_descriptive() {
assert_eq!(
purpose("English Descriptive Audio"),
LabelPurpose::Descriptive
);
assert_eq!(purpose("Audio Description"), LabelPurpose::Descriptive);
assert_eq!(purpose("Described Video"), LabelPurpose::Descriptive);
}
#[test]
fn purpose_descriptive_service_routes_to_descriptive() {
// "Descriptive Service" is qualifier territory but the purpose
// implication is Descriptive — vocab::purpose treats it as such.
assert_eq!(
purpose("English Descriptive Service"),
LabelPurpose::Descriptive
);
}
#[test]
fn purpose_word_boundary_avoids_commenter_false_positive() {
// "Commenter Pro audio" does NOT match "commentary" — the
// existing dbp/ctrm hand-rolls would have. Vocab is stricter.
assert_eq!(purpose("Commenter Pro audio track"), LabelPurpose::Normal);
}
#[test]
fn purpose_recognizes_score() {
assert_eq!(purpose("Music Only"), LabelPurpose::Score);
assert_eq!(purpose("Isolated Score"), LabelPurpose::Score);
}
#[test]
fn purpose_unknown_is_normal() {
assert_eq!(purpose("English Dolby Atmos"), LabelPurpose::Normal);
assert_eq!(purpose(""), LabelPurpose::Normal);
}
#[test]
fn qualifier_recognizes_sdh() {
assert_eq!(qualifier("English SDH"), LabelQualifier::Sdh);
assert_eq!(qualifier("English Captions"), LabelQualifier::Sdh);
}
#[test]
fn qualifier_recognizes_forced() {
assert_eq!(qualifier("English Forced"), LabelQualifier::Forced);
assert_eq!(qualifier("Forced Narrative"), LabelQualifier::Forced);
}
#[test]
fn qualifier_recognizes_descriptive_service() {
assert_eq!(
qualifier("English RNIB"),
LabelQualifier::DescriptiveService
);
assert_eq!(
qualifier("English Descriptive Service"),
LabelQualifier::DescriptiveService
);
}
#[test]
fn qualifier_sdh_wins_over_forced_when_both_present() {
// SDH track is its own stream regardless of forced flag.
assert_eq!(qualifier("English Forced SDH"), LabelQualifier::Sdh);
}
#[test]
fn qualifier_unknown_is_none() {
assert_eq!(qualifier("English"), LabelQualifier::None);
assert_eq!(qualifier(""), LabelQualifier::None);
}
#[test]
fn has_word_basic() {
assert!(has_word("english forced", "english"));
assert!(has_word("english forced", "forced"));
assert!(has_word("english", "english"));
}
#[test]
fn has_word_rejects_substring() {
assert!(!has_word("engineering", "english"));
assert!(!has_word("englishman", "english"));
assert!(!has_word("aenglish", "english"));
}
#[test]
fn has_word_punctuation_boundary() {
// "(SDH)" is a valid boundary — parentheses count as non-alphanum.
assert!(has_word("english (sdh)", "sdh"));
assert!(has_word("commentary,extra,info", "commentary"));
}
}