Files
Syplheed-Reborn/crates/sylpheed-formats/src/ixud.rs
MechaCat02 e17bc0216d feat(viewer): present subtitles, fonts, PNG + text in the PAK browser
Decode and present four more PAK item types in the pack browser's detail
pane. All content is decoded off-thread in build_pak_rows and attached to
each PakRow, then rendered with egui's own texture facilities — the Bevy
texture pipeline and egui's global font system are untouched.

formats crate (Bevy-free, reusable by CLI):
- ixud: parse IXUD localized-string tables as subtitle cue lists (UTF-16BE
  pool, SUBTITLE header + (text, timecode) pairs -> Vec<Cue{start,end,text}>).
- font: OTF/TTF/ttcf metadata (family / faces / glyphs) via ttf-parser.

viewer:
- PakRow gains `content: PakContent` (Subtitle | Font | Png | Text | None);
  classify_content fills it for IXUD / fonts / PNG / xml+text within the
  existing decode budget. Font samples are pre-rasterized off-thread with
  ab_glyph into an image (ImageRgba) — ab_glyph returns Option/Result at every
  step, so a bad font yields no sample instead of panicking (the earlier
  egui set_fonts approach crashed the app on atlas rebuild).
- draw_pak_browser dispatches on content: subtitle cue table, font metadata +
  sample image, PNG image, scrollable text; else the existing IDXD detail.
  Borrow-split PakView instead of cloning the (now heavy) rows each frame;
  one hash-keyed egui texture cache serves both PNG and font-sample images.

Subtitle<->movie auto-matching is deferred: movie filenames don't hash to the
IXUD keys and no cross-reference table was found (the link is an internal
MSG_DEMO id), so the movie player gets no subtitle track yet.

Tests: ixud unit tests; disc tests for the real English subtitle track
(cues + timecodes), eng/deu localization, font metadata, and off-thread
rasterization of the real font.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 15:19:31 +02:00

188 lines
5.9 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! IXUD localized-string / subtitle table reader.
//!
//! `IXUD` entries in the language packs (`dat/movie/<lang>.pak`, the
//! `GP_MAIN_GAME_<lang>` tables, …) hold localized UTF-16**BE** strings. The
//! movie language packs use them as **subtitle cue lists**: a `SUBTITLE` header
//! followed by alternating `(text, timecode)` tokens, e.g.
//!
//! ```text
//! SUBTITLE
//! "Calm down!" 00:09.40-00:11.00
//! "Why are you taking Margras away?" 00:18.50-00:19.70
//! ```
//!
//! Reference-style tracks store a message key (`MSG_DEMO_240`) plus a single
//! start timecode instead of inline text; the actual string then lives in a
//! separate global table (not resolved here).
//!
//! ## Binary layout (as far as needed)
//! ```text
//! 0x00 4 Magic "IXUD"
//! 0x04 4 version (1)
//! 0x08 4 schema/type hash (constant 0x6CC83E70)
//! .. .. record directory { key_hash u32, offset u32, len u32 } × n
//! .. .. UTF-16BE string pool, NUL-terminated entries
//! ```
//! We don't need the directory to *present* the track — decoding the pool and
//! pairing tokens after the `SUBTITLE` header is enough and robust.
/// Magic at the start of every IXUD entry.
pub const IXUD_MAGIC: [u8; 4] = *b"IXUD";
/// One subtitle cue: a start time (seconds), optional end time, and the text
/// (or a `MSG_*` reference key for reference-style tracks).
#[derive(Debug, Clone, PartialEq)]
pub struct Cue {
pub start: f32,
pub end: Option<f32>,
pub text: String,
}
/// A decoded subtitle track.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Subtitle {
pub cues: Vec<Cue>,
}
impl Subtitle {
/// True when the track carries no inline text — every cue is a `MSG_*`
/// reference whose string lives in a separate global table.
pub fn is_reference_only(&self) -> bool {
!self.cues.is_empty() && self.cues.iter().all(|c| c.text.starts_with("MSG_"))
}
}
/// Whether `bytes` is an IXUD entry.
pub fn is_ixud(bytes: &[u8]) -> bool {
bytes.len() >= 4 && bytes[0..4] == IXUD_MAGIC
}
/// Parse an IXUD entry as a subtitle track. Returns `None` if not IXUD.
/// Malformed pools yield as many well-formed cues as can be recovered.
pub fn parse(bytes: &[u8]) -> Option<Subtitle> {
if !is_ixud(bytes) {
return None;
}
// Decode the whole payload as UTF-16BE and split into NUL-terminated,
// printable runs. The binary header/directory decodes to control/CJK-range
// junk that we skip by seeking to the `SUBTITLE` marker.
let tokens = utf16be_tokens(bytes);
let start = tokens
.iter()
.position(|t| t.ends_with("SUBTITLE"))
.map(|i| i + 1)
.unwrap_or(0);
let rest = &tokens[start..];
// Pair (text, timecode). A token that parses as a timecode closes the cue
// opened by the preceding text token; unpaired tokens are skipped so a
// single glitch doesn't desync the rest.
let mut cues = Vec::new();
let mut i = 0;
while i + 1 < rest.len() {
if let Some((s, e)) = parse_timing(&rest[i + 1]) {
cues.push(Cue {
start: s,
end: e,
text: rest[i].clone(),
});
i += 2;
} else {
i += 1;
}
}
Some(Subtitle { cues })
}
/// Split the payload (interpreted as UTF-16BE) into printable, NUL-delimited
/// tokens. Control chars terminate the current token.
fn utf16be_tokens(bytes: &[u8]) -> Vec<String> {
let mut tokens = Vec::new();
let mut cur = String::new();
for pair in bytes.chunks_exact(2) {
let u = u16::from_be_bytes([pair[0], pair[1]]);
match char::from_u32(u as u32) {
Some(c) if !c.is_control() => cur.push(c),
_ => {
if !cur.is_empty() {
tokens.push(std::mem::take(&mut cur));
}
}
}
}
if !cur.is_empty() {
tokens.push(cur);
}
tokens
}
/// Parse `MM:SS.ss` (`" 9:04.40"` style, minutes may be blank/space-padded) or
/// a `start-end` range into seconds.
fn parse_timing(s: &str) -> Option<(f32, Option<f32>)> {
let one = |p: &str| -> Option<f32> {
let (mm, ss) = p.trim().split_once(':')?;
let m: f32 = if mm.trim().is_empty() {
0.0
} else {
mm.trim().parse().ok()?
};
let sec: f32 = ss.trim().parse().ok()?;
Some(m * 60.0 + sec)
};
match s.split_once('-') {
Some((a, b)) => Some((one(a)?, Some(one(b)?))),
None => Some((one(s)?, None)),
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Build a synthetic IXUD payload: an 8-byte header stand-in + UTF-16BE
/// NUL-terminated tokens.
fn synth(tokens: &[&str]) -> Vec<u8> {
let mut b = IXUD_MAGIC.to_vec();
b.extend_from_slice(&[0, 0, 0, 1, 0x6C, 0xC8, 0x3E, 0x70]); // version + schema
for t in tokens {
for u in t.encode_utf16() {
b.extend_from_slice(&u.to_be_bytes());
}
b.extend_from_slice(&[0, 0]); // NUL
}
b
}
#[test]
fn inline_subtitle_track() {
let b = synth(&[
"SUBTITLE",
"Calm down!",
"00:09.40-00:11.00",
"Let go of me!",
"00:13.10-00:14.50",
]);
let sub = parse(&b).unwrap();
assert_eq!(sub.cues.len(), 2);
assert_eq!(sub.cues[0].text, "Calm down!");
assert_eq!(sub.cues[0].start, 9.4);
assert_eq!(sub.cues[0].end, Some(11.0));
assert!(!sub.is_reference_only());
}
#[test]
fn reference_track() {
let b = synth(&["SUBTITLE", "MSG_DEMO_240", "00:01.00", "MSG_DEMO_241", "00:03.20"]);
let sub = parse(&b).unwrap();
assert_eq!(sub.cues.len(), 2);
assert_eq!(sub.cues[0].end, None);
assert!(sub.is_reference_only());
}
#[test]
fn rejects_non_ixud() {
assert!(parse(b"IDXD\0\0\0\0").is_none());
}
}