From e17bc0216d1e8e65941de377565e296331b07fe1 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sat, 11 Jul 2026 15:19:31 +0200 Subject: [PATCH] feat(viewer): present subtitles, fonts, PNG + text in the PAK browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). - 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) --- Cargo.lock | 11 +- crates/sylpheed-formats/Cargo.toml | 1 + crates/sylpheed-formats/src/font.rs | 64 ++++ crates/sylpheed-formats/src/ixud.rs | 187 ++++++++++++ crates/sylpheed-formats/src/lib.rs | 8 + .../sylpheed-formats/tests/pak_idxd_disc.rs | 39 +++ crates/sylpheed-viewer/Cargo.toml | 4 + crates/sylpheed-viewer/src/iso_loader.rs | 180 ++++++++++++ crates/sylpheed-viewer/src/ui.rs | 274 +++++++++++++++--- 9 files changed, 721 insertions(+), 47 deletions(-) create mode 100644 crates/sylpheed-formats/src/font.rs create mode 100644 crates/sylpheed-formats/src/ixud.rs diff --git a/Cargo.lock b/Cargo.lock index c125e70..a24c9b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3760,7 +3760,7 @@ version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" dependencies = [ - "ttf-parser", + "ttf-parser 0.25.1", ] [[package]] @@ -4592,6 +4592,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tracing", + "ttf-parser 0.24.1", "xdvdfs", ] @@ -4599,9 +4600,11 @@ dependencies = [ name = "sylpheed-viewer" version = "0.1.0" dependencies = [ + "ab_glyph", "bevy", "bevy_egui", "futures", + "image", "rfd", "rodio", "sylpheed-formats", @@ -5007,6 +5010,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "ttf-parser" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be21190ff5d38e8b4a2d3b6a3ae57f612cc39c96e83cedeaf7abc338a8bac4a" + [[package]] name = "ttf-parser" version = "0.25.1" diff --git a/crates/sylpheed-formats/Cargo.toml b/crates/sylpheed-formats/Cargo.toml index a94fd8e..b1456b7 100644 --- a/crates/sylpheed-formats/Cargo.toml +++ b/crates/sylpheed-formats/Cargo.toml @@ -10,6 +10,7 @@ authors.workspace = true xdvdfs = { workspace = true } binrw = { workspace = true } flate2 = "1" # zlib/DEFLATE for IPFB "Z1" entries (miniz_oxide backend, WASM-safe) +ttf-parser = { version = "0.24", default-features = false, features = ["std", "opentype-layout"] } # font metadata (OTF/TTF/ttcf), WASM-safe tokio = { workspace = true } futures = { workspace = true } thiserror = { workspace = true } diff --git a/crates/sylpheed-formats/src/font.rs b/crates/sylpheed-formats/src/font.rs new file mode 100644 index 0000000..7bcd91f --- /dev/null +++ b/crates/sylpheed-formats/src/font.rs @@ -0,0 +1,64 @@ +//! Font metadata for the embedded UI fonts. +//! +//! The packs carry sfnt fonts (`\x00\x01\x00\x00` TrueType, `OTTO` OpenType/CFF) +//! and TrueType Collections (`ttcf`). We only need light metadata to *present* +//! them — family name, face count, glyph count — which `ttf-parser` extracts +//! safely (malformed input yields `None`, never a panic). The raw bytes are +//! handed to egui separately for a live sample. + +use ttf_parser::{fonts_in_collection, name_id, Face}; + +/// Light font metadata for the viewer. +#[derive(Debug, Clone, PartialEq)] +pub struct FontInfo { + /// "TrueType", "OpenType (CFF)", or "TrueType Collection". + pub kind: &'static str, + /// Family / full name (best-effort; empty if the name table is unreadable). + pub family: String, + /// Number of faces (1 for a plain sfnt, N for a `ttcf` collection). + pub faces: u32, + /// Glyph count of the first face. + pub glyphs: u16, +} + +/// Whether `bytes` starts with a recognized font signature. +pub fn is_font(bytes: &[u8]) -> bool { + matches!( + bytes.get(0..4), + Some(b"\x00\x01\x00\x00") | Some(b"OTTO") | Some(b"true") | Some(b"ttcf") + ) +} + +/// Extract light metadata, or `None` if the font can't be parsed. +pub fn parse_info(bytes: &[u8]) -> Option { + let is_collection = bytes.get(0..4) == Some(b"ttcf"); + let faces = fonts_in_collection(bytes).unwrap_or(1).max(1); + + // Face 0 gives the representative family + glyph count. + let face = Face::parse(bytes, 0).ok()?; + let glyphs = face.number_of_glyphs(); + + let kind = if is_collection { + "TrueType Collection" + } else if bytes.get(0..4) == Some(b"OTTO") { + "OpenType (CFF)" + } else { + "TrueType" + }; + + // Prefer the typographic/full family name; fall back to any readable name. + let family = face + .names() + .into_iter() + .filter(|n| n.name_id == name_id::FULL_NAME || n.name_id == name_id::FAMILY) + .find_map(|n| n.to_string()) + .or_else(|| face.names().into_iter().find_map(|n| n.to_string())) + .unwrap_or_default(); + + Some(FontInfo { + kind, + family, + faces, + glyphs, + }) +} diff --git a/crates/sylpheed-formats/src/ixud.rs b/crates/sylpheed-formats/src/ixud.rs new file mode 100644 index 0000000..8564b40 --- /dev/null +++ b/crates/sylpheed-formats/src/ixud.rs @@ -0,0 +1,187 @@ +//! IXUD localized-string / subtitle table reader. +//! +//! `IXUD` entries in the language packs (`dat/movie/.pak`, the +//! `GP_MAIN_GAME_` 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, + pub text: String, +} + +/// A decoded subtitle track. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct Subtitle { + pub cues: Vec, +} + +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 { + 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 { + 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)> { + let one = |p: &str| -> Option { + 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 { + 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()); + } +} diff --git a/crates/sylpheed-formats/src/lib.rs b/crates/sylpheed-formats/src/lib.rs index cddb4f0..b33e135 100644 --- a/crates/sylpheed-formats/src/lib.rs +++ b/crates/sylpheed-formats/src/lib.rs @@ -25,6 +25,12 @@ pub mod pak; // IDXD reflective object (ship / weapon / effect / menu definitions) reader pub mod idxd; +// IXUD localized string / subtitle table reader +pub mod ixud; + +// Embedded font (OTF / TTF / ttcf) metadata +pub mod font; + // XISO reading is not available in-browser #[cfg(not(target_arch = "wasm32"))] pub mod xiso; @@ -36,7 +42,9 @@ pub mod mesh; pub mod audio; /// Re-export the most commonly used types at the crate root. +pub use font::FontInfo; pub use idxd::{IdxdError, IdxdObject}; +pub use ixud::{Cue, Subtitle}; pub use pak::{PakArchive, PakEntry, PakError}; pub use texture::{X360Texture, X360TextureFormat}; pub use vfs::{GameAssets, VfsError}; diff --git a/crates/sylpheed-formats/tests/pak_idxd_disc.rs b/crates/sylpheed-formats/tests/pak_idxd_disc.rs index 743b862..10da278 100644 --- a/crates/sylpheed-formats/tests/pak_idxd_disc.rs +++ b/crates/sylpheed-formats/tests/pak_idxd_disc.rs @@ -124,6 +124,45 @@ fn name_lookup_resolves_weapon_tbl() { assert!(obj.count > 0); } +/// The English movie subtitle track (an `IXUD` entry) decodes to timed cues. +#[test] +fn eng_movie_subtitle_track() { + skip_without_disc!(root); + let arc = PakArchive::open(root.join("dat/movie/eng.pak")).unwrap(); + // S04A's track — the longest inline English one (starts "Calm down!"). + let bytes = arc.read_by_hash(0x73d2_2a3b).expect("entry present").unwrap(); + let sub = sylpheed_formats::ixud::parse(&bytes).expect("IXUD parses as subtitle"); + assert!(sub.cues.len() > 50, "cues={}", sub.cues.len()); + assert!(!sub.is_reference_only()); + assert_eq!(sub.cues[0].text, "Calm down!"); + assert!((sub.cues[0].start - 9.4).abs() < 0.01); + assert_eq!(sub.cues[0].end, Some(11.0)); +} + +/// The subtitle tracks are genuinely localized: the same entry hash differs +/// between the English and German packs. +#[test] +fn subtitles_are_localized() { + skip_without_disc!(root); + let eng = PakArchive::open(root.join("dat/movie/eng.pak")).unwrap(); + let deu = PakArchive::open(root.join("dat/movie/deu.pak")).unwrap(); + let e = eng.read_by_hash(0x73d2_2a3b).unwrap().unwrap(); + let d = deu.read_by_hash(0x73d2_2a3b).unwrap().unwrap(); + assert_ne!(e, d, "eng/deu subtitle payloads should differ"); +} + +/// The English movie pack's embedded subtitle font parses to sane metadata. +#[test] +fn eng_movie_font_metadata() { + skip_without_disc!(root); + let arc = PakArchive::open(root.join("dat/movie/eng.pak")).unwrap(); + let bytes = arc.read_by_hash(0x5cd0_fca6).expect("entry present").unwrap(); + assert!(sylpheed_formats::font::is_font(&bytes)); + let info = sylpheed_formats::font::parse_info(&bytes).expect("font parses"); + assert!(info.glyphs > 0, "glyphs={}", info.glyphs); + assert!(!info.family.is_empty(), "family should be readable"); +} + /// The Acheron backdrop is a TXCM world cubemap: 6 faces, and the cube path's /// face 0 matches the (validated) 2D `from_xpr2` face-0 decode. #[test] diff --git a/crates/sylpheed-viewer/Cargo.toml b/crates/sylpheed-viewer/Cargo.toml index d93db7b..455938d 100644 --- a/crates/sylpheed-viewer/Cargo.toml +++ b/crates/sylpheed-viewer/Cargo.toml @@ -52,3 +52,7 @@ tracing-subscriber = { workspace = true } rfd = { workspace = true } # Audio for the WMV video player (pulls cpal → alsa on Linux). rodio = { workspace = true } +# PNG decode for pak image entries (pure Rust; Bevy already pulls it in). +image = { version = "0.25", default-features = false, features = ["png"] } +# Off-thread rasterization of embedded-font samples (avoids egui's global fonts). +ab_glyph = "0.2" diff --git a/crates/sylpheed-viewer/src/iso_loader.rs b/crates/sylpheed-viewer/src/iso_loader.rs index 2125230..21544f1 100644 --- a/crates/sylpheed-viewer/src/iso_loader.rs +++ b/crates/sylpheed-viewer/src/iso_loader.rs @@ -132,6 +132,38 @@ pub struct PakRow { pub identity: String, /// Parsed IDXD detail for the property table, when the entry is an object. pub detail: Option, + /// Decoded presentable payload for the detail pane (subtitle / font / image + /// / text). `None` for IDXD (uses `detail`) and un-presentable formats. + pub content: PakContent, +} + +/// Presentable, off-thread-decoded content behind a pack entry, shown in the +/// browser's detail pane without touching the Bevy texture pipeline. +#[derive(Clone, Default)] +pub enum PakContent { + #[default] + None, + /// IXUD subtitle / localized-string track. + Subtitle(sylpheed_formats::ixud::Subtitle), + /// Embedded font: metadata + a pre-rasterized sample line (RGBA8), if the + /// font could be rendered off-thread (`None` for collections / CFF that our + /// rasterizer declines — metadata still shows). + Font { + info: sylpheed_formats::FontInfo, + sample: Option, + }, + /// Decoded PNG image (RGBA8), shown via an egui texture. + Png(ImageRgba), + /// Plain-text / XML payload, with its encoding label. + Text { text: String, encoding: String }, +} + +/// A plain RGBA8 image handed to the UI for an egui texture. +#[derive(Clone)] +pub struct ImageRgba { + pub width: u32, + pub height: u32, + pub rgba: Vec, } /// The parsed IDXD detail behind one entry, shown in the master-detail pane. @@ -221,6 +253,9 @@ pub struct PakView { pub rows: Vec, pub selected: Option, pub error: Option, + /// egui-side cache: (entry hash, texture) for the shown image — a PNG entry + /// or a rasterized font sample. + pub img_tex: Option<(u32, egui::TextureHandle)>, } /// Total decompressed bytes we're willing to decode when labelling a pack's @@ -720,6 +755,7 @@ fn build_pak_rows(index: &[u8], data: Vec) -> Result, String> { format: "(not decoded)".into(), identity: String::new(), detail: None, + content: PakContent::None, }); continue; } @@ -749,6 +785,12 @@ fn build_pak_rows(index: &[u8], data: Vec) -> Result, String> { } else { (String::new(), None) }; + // Presentable content for non-IDXD entries (subtitle/font/png/text). + let content = if detail.is_some() { + PakContent::None + } else { + classify_content(&payload) + }; rows.push(PakRow { hash: e.name_hash, comp_size: e.comp_size, @@ -756,6 +798,7 @@ fn build_pak_rows(index: &[u8], data: Vec) -> Result, String> { format, identity, detail, + content, }); } Err(err) => rows.push(PakRow { @@ -765,12 +808,123 @@ fn build_pak_rows(index: &[u8], data: Vec) -> Result, String> { format: "(read error)".into(), identity: err.to_string(), detail: None, + content: PakContent::None, }), } } Ok(rows) } +/// Cap on decoded text we retain per pack entry (xml/text preview). +#[cfg(not(target_arch = "wasm32"))] +const PAK_TEXT_CAP: usize = 512 * 1024; + +/// Classify a (non-IDXD) decompressed entry into presentable content: subtitle +/// track, embedded font, PNG image, or plain text/XML. Runs off-thread. +#[cfg(not(target_arch = "wasm32"))] +fn classify_content(payload: &[u8]) -> PakContent { + use sylpheed_formats::{font, ixud, vfs}; + + if ixud::is_ixud(payload) { + if let Some(sub) = ixud::parse(payload) { + return PakContent::Subtitle(sub); + } + } + if font::is_font(payload) { + if let Some(info) = font::parse_info(payload) { + // Rasterize a sample line here (off-thread) for single-face fonts — + // never touch egui's global font system on the main thread. + let sample = if info.faces <= 1 && info.kind != "TrueType Collection" { + render_font_sample(payload, "The quick brown fox — 0123456789 !?&", 34.0) + } else { + None + }; + return PakContent::Font { info, sample }; + } + } + if payload.starts_with(&[0x89, 0x50, 0x4E, 0x47]) { + if let Ok(img) = image::load_from_memory_with_format(payload, image::ImageFormat::Png) { + let rgba = img.to_rgba8(); + let (width, height) = rgba.dimensions(); + return PakContent::Png(ImageRgba { + width, + height, + rgba: rgba.into_raw(), + }); + } + } + if payload.starts_with(b" PAK_TEXT_CAP { + let end = text + .char_indices() + .take_while(|(i, _)| *i < PAK_TEXT_CAP) + .last() + .map(|(i, c)| i + c.len_utf8()) + .unwrap_or(0); + text.truncate(end); + } + return PakContent::Text { + text, + encoding: encoding.to_string(), + }; + } + PakContent::None +} + +/// Rasterize a sample line with the embedded font into a white-on-transparent +/// RGBA image. Fully off-thread and panic-free: `ab_glyph` returns `None` for +/// unusable fonts/glyphs rather than unwrapping (unlike egui's global fonts). +#[cfg(not(target_arch = "wasm32"))] +fn render_font_sample(bytes: &[u8], text: &str, px: f32) -> Option { + use ab_glyph::{point, Font, FontRef, ScaleFont}; + + let font = FontRef::try_from_slice(bytes).ok()?; + let scaled = font.as_scaled(px); + let (ascent, descent) = (scaled.ascent(), scaled.descent()); + let pad = 2.0; + + // Lay glyphs left-to-right on a single baseline. + let mut caret = pad; + let mut glyphs = Vec::new(); + for ch in text.chars() { + let id = font.glyph_id(ch); + glyphs.push(id.with_scale_and_position(px, point(caret, pad + ascent))); + caret += scaled.h_advance(id); + } + let width = (caret + pad).ceil().max(1.0) as u32; + let height = (pad * 2.0 + ascent - descent).ceil().max(1.0) as u32; + if width > 8192 || height > 512 { + return None; // sanity bound + } + + let mut rgba = vec![0u8; (width as usize) * (height as usize) * 4]; + let mut drew_any = false; + for g in glyphs { + if let Some(outline) = font.outline_glyph(g) { + let bb = outline.px_bounds(); + outline.draw(|gx, gy, cov| { + let x = bb.min.x as i32 + gx as i32; + let y = bb.min.y as i32 + gy as i32; + if x >= 0 && y >= 0 && (x as u32) < width && (y as u32) < height { + let i = ((y as u32 * width + x as u32) * 4) as usize; + let a = (cov * 255.0) as u8; + rgba[i] = 255; + rgba[i + 1] = 255; + rgba[i + 2] = 255; + rgba[i + 3] = rgba[i + 3].max(a); + drew_any = true; + } + }); + } + } + drew_any.then_some(ImageRgba { + width, + height, + rgba, + }) +} + // ── Video preparation (loader thread) ───────────────────────────────────────── /// Make a filesystem-safe stem for temp files from a leaf name. @@ -1358,6 +1512,7 @@ fn apply_pak( rows: std::mem::take(&mut pending.rows), selected: None, error: pending.error.take(), + ..Default::default() }; } @@ -1628,3 +1783,28 @@ fn advance_video_playback( } } } + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod font_sample_tests { + use super::*; + use std::path::Path; + + /// The real English movie font must rasterize a sample off-thread without + /// panicking (regression for the egui `set_fonts` crash). + #[test] + fn real_font_rasterizes() { + let pak = Path::new("/tmp/sylph_extract/dat/movie/eng.pak"); + if !pak.exists() { + eprintln!("SKIP: extract not present"); + return; + } + let arc = sylpheed_formats::PakArchive::open(pak).unwrap(); + let bytes = arc.read_by_hash(0x5cd0_fca6).unwrap().unwrap(); + assert!(sylpheed_formats::font::is_font(&bytes)); + let img = render_font_sample(&bytes, "The quick brown fox 0123", 34.0) + .expect("real TrueType font should rasterize a sample"); + assert!(img.width > 0 && img.height > 0); + assert_eq!(img.rgba.len(), (img.width * img.height * 4) as usize); + assert!(img.rgba.iter().skip(3).step_by(4).any(|&a| a > 0), "some ink"); + } +} diff --git a/crates/sylpheed-viewer/src/ui.rs b/crates/sylpheed-viewer/src/ui.rs index 445b612..9f1a73a 100644 --- a/crates/sylpheed-viewer/src/ui.rs +++ b/crates/sylpheed-viewer/src/ui.rs @@ -10,8 +10,8 @@ use bevy::prelude::*; use bevy_egui::{egui, EguiContexts}; use crate::iso_loader::{ - FileInfo, FileSelected, IsoState, PakView, RequestOpenDir, RequestOpenIso, SkyboxPreview, - TextPreview, TexturePreview, VideoPreview, IsoLoaderSystemSet, + FileInfo, FileSelected, ImageRgba, IsoState, PakContent, PakView, RequestOpenDir, + RequestOpenIso, SkyboxPreview, TextPreview, TexturePreview, VideoPreview, IsoLoaderSystemSet, }; use crate::ViewerState; @@ -401,7 +401,8 @@ fn draw_viewer_ui( // ── Data pack (IPFB / IDXD) browser ─────────────────────────────────────────── /// Master-detail view for an open `.pak`: entry list on the left, the selected -/// entry's IDXD schema + property table on the right. +/// entry's content on the right (IDXD table, subtitle cues, font sample, image, +/// or text — whatever `classify_content` decoded off-thread). fn draw_pak_browser(ui: &mut egui::Ui, pak: &mut PakView) { ui.horizontal(|ui| { ui.heading(&pak.name); @@ -415,11 +416,15 @@ fn draw_pak_browser(ui: &mut egui::Ui, pak: &mut PakView) { } ui.separator(); - // Clone rows so the two column closures can read them while we record the - // click into a local (avoids borrowing `pak` mutably inside the closures). - let rows = pak.rows.clone(); - let selected = pak.selected; - let mut new_selection = None; + // Destructure so the columns can read `rows` while mutating the selection + + // the egui-side caches — without cloning the (now heavy) rows each frame. + let PakView { + rows, + selected, + img_tex, + .. + } = pak; + let mut new_selection = *selected; ui.columns(2, |cols| { // Left — entry list. @@ -429,11 +434,13 @@ fn draw_pak_browser(ui: &mut egui::Ui, pak: &mut PakView) { for (i, row) in rows.iter().enumerate() { let text = egui::RichText::new(format!( "{:08x} {:<7} {}", - row.hash, row.format, row.identity + row.hash, + row.format, + row_kind(row) )) .monospace(); if ui - .add(egui::SelectableLabel::new(selected == Some(i), text)) + .add(egui::SelectableLabel::new(*selected == Some(i), text)) .clicked() { new_selection = Some(i); @@ -441,57 +448,232 @@ fn draw_pak_browser(ui: &mut egui::Ui, pak: &mut PakView) { } }); - // Right — detail of the selected entry. + // Right — detail of the selected entry, dispatched on decoded content. egui::ScrollArea::vertical() .id_salt("pak_detail") .show(&mut cols[1], |ui| match selected.and_then(|i| rows.get(i)) { None => { ui.label("Select an entry to inspect."); } - Some(row) => match &row.detail { - Some(d) => { - let title = if row.identity.is_empty() { - row.format.as_str() - } else { - row.identity.as_str() - }; - ui.heading(title); - ui.label(format!("schema 0x{:08x} count {}", d.schema_hash, d.count)); - ui.label(format!("{} bytes hash {:08x}", row.size, row.hash)); - ui.separator(); - if d.fields.is_empty() { - ui.label("(no explicit-value fields)"); - } else { - egui::Grid::new("pak_fields") - .striped(true) - .num_columns(2) - .show(ui, |ui| { - for (k, v) in &d.fields { - ui.label(k); - ui.monospace(v); - ui.end_row(); - } - }); - } + Some(row) => match &row.content { + PakContent::Subtitle(sub) => draw_subtitle_detail(ui, row, sub), + PakContent::Font { info, sample } => { + draw_font_detail(ui, row, info, sample.as_ref(), img_tex) } - None => { - ui.heading(&row.format); - ui.label(format!("{} bytes hash {:08x}", row.size, row.hash)); - if !row.identity.is_empty() { - ui.label(&row.identity); - } - ui.separator(); - ui.label("Not an IDXD object — no field table."); + PakContent::Png(img) => draw_png_detail(ui, row, img, img_tex), + PakContent::Text { text, encoding } => { + draw_text_detail(ui, row, text, encoding) } + PakContent::None => draw_idxd_detail(ui, row), }, }); }); - if new_selection.is_some() { - pak.selected = new_selection; + *selected = new_selection; +} + +/// Short kind hint for the entry list (subtitle/font/image/text), falling back +/// to the IDXD identity. +fn row_kind(row: &crate::iso_loader::PakRow) -> String { + match &row.content { + PakContent::Subtitle(s) => format!("subtitle · {} cues", s.cues.len()), + PakContent::Font { info, .. } => { + if info.family.is_empty() { + "font".into() + } else { + info.family.clone() + } + } + PakContent::Png(img) => format!("PNG {}×{}", img.width, img.height), + PakContent::Text { .. } => "text".into(), + PakContent::None => row.identity.clone(), } } +/// `mm:ss.SS` timestamp for subtitle cues. +fn fmt_ts(secs: f32) -> String { + let s = secs.max(0.0); + format!("{:02}:{:05.2}", (s / 60.0) as u32, s % 60.0) +} + +/// IDXD object detail (or bare format/size for other un-presentable entries). +fn draw_idxd_detail(ui: &mut egui::Ui, row: &crate::iso_loader::PakRow) { + match &row.detail { + Some(d) => { + let title = if row.identity.is_empty() { + row.format.as_str() + } else { + row.identity.as_str() + }; + ui.heading(title); + ui.label(format!("schema 0x{:08x} count {}", d.schema_hash, d.count)); + ui.label(format!("{} bytes hash {:08x}", row.size, row.hash)); + ui.separator(); + if d.fields.is_empty() { + ui.label("(no explicit-value fields)"); + } else { + egui::Grid::new("pak_fields") + .striped(true) + .num_columns(2) + .show(ui, |ui| { + for (k, v) in &d.fields { + ui.label(k); + ui.monospace(v); + ui.end_row(); + } + }); + } + } + None => { + ui.heading(&row.format); + ui.label(format!("{} bytes hash {:08x}", row.size, row.hash)); + ui.separator(); + ui.label("No decoded preview for this format yet."); + } + } +} + +/// IXUD subtitle track: a timecode + text cue table. +fn draw_subtitle_detail( + ui: &mut egui::Ui, + row: &crate::iso_loader::PakRow, + sub: &sylpheed_formats::ixud::Subtitle, +) { + ui.heading("Subtitle track"); + let note = if sub.is_reference_only() { + " · reference-only (MSG_* keys)" + } else { + "" + }; + ui.label(format!( + "IXUD · {} cues · hash {:08x}{note}", + sub.cues.len(), + row.hash + )); + ui.separator(); + if sub.cues.is_empty() { + ui.label("(empty — no cues)"); + return; + } + egui::Grid::new("subtitle_cues") + .striped(true) + .num_columns(2) + .show(ui, |ui| { + ui.strong("Time"); + ui.strong("Text"); + ui.end_row(); + for c in &sub.cues { + let t = match c.end { + Some(e) => format!("{}–{}", fmt_ts(c.start), fmt_ts(e)), + None => fmt_ts(c.start), + }; + ui.monospace(t); + ui.label(&c.text); + ui.end_row(); + } + }); +} + +/// Cache an `ImageRgba` as an egui texture (keyed by entry hash) and draw it, +/// scaled to fit the available width (capped by `max_scale`). Shared by the PNG +/// and font-sample views. Never touches egui's global font system. +fn show_cached_image( + ui: &mut egui::Ui, + hash: u32, + img: &ImageRgba, + cache: &mut Option<(u32, egui::TextureHandle)>, + max_scale: f32, +) { + if cache.as_ref().map(|(h, _)| *h) != Some(hash) { + let color = egui::ColorImage::from_rgba_unmultiplied( + [img.width as usize, img.height as usize], + &img.rgba, + ); + let tex = ui.ctx().load_texture( + format!("pak_img_{hash:08x}"), + color, + egui::TextureOptions::LINEAR, + ); + *cache = Some((hash, tex)); + } + if let Some((_, tex)) = cache.as_ref() { + let scale = (ui.available_width() / img.width.max(1) as f32).min(max_scale); + let (dw, dh) = (img.width as f32 * scale, img.height as f32 * scale); + ui.add(egui::Image::new(egui::load::SizedTexture::new( + tex.id(), + [dw, dh], + ))); + } +} + +/// Embedded font: metadata + a pre-rasterized sample line (rendered off-thread +/// with `ab_glyph`, shown as an image — no global egui font install). +fn draw_font_detail( + ui: &mut egui::Ui, + row: &crate::iso_loader::PakRow, + info: &sylpheed_formats::FontInfo, + sample: Option<&ImageRgba>, + img_tex: &mut Option<(u32, egui::TextureHandle)>, +) { + ui.heading(if info.family.is_empty() { + "Font" + } else { + &info.family + }); + ui.label(format!( + "{} · {} face(s) · {} glyphs · hash {:08x}", + info.kind, info.faces, info.glyphs, row.hash + )); + ui.separator(); + match sample { + Some(img) => { + ui.label("Sample:"); + show_cached_image(ui, row.hash, img, img_tex, 1.0); + } + None => { + ui.label("(no sample — font collection or unsupported outlines)"); + } + } +} + +/// Decoded PNG image, shown via a cached egui texture. +fn draw_png_detail( + ui: &mut egui::Ui, + row: &crate::iso_loader::PakRow, + img: &ImageRgba, + img_tex: &mut Option<(u32, egui::TextureHandle)>, +) { + ui.heading(format!("PNG image {}×{}", img.width, img.height)); + ui.label(format!("hash {:08x}", row.hash)); + ui.separator(); + show_cached_image(ui, row.hash, img, img_tex, 2.0); +} + +/// Plain-text / XML entry in a read-only monospace box. +fn draw_text_detail( + ui: &mut egui::Ui, + row: &crate::iso_loader::PakRow, + text: &str, + encoding: &str, +) { + ui.horizontal(|ui| { + ui.heading(&row.format); + ui.separator(); + ui.label(encoding); + ui.separator(); + ui.label(format!("{} bytes", row.size)); + }); + ui.separator(); + let mut buf = text; + ui.add( + egui::TextEdit::multiline(&mut buf) + .font(egui::TextStyle::Monospace) + .code_editor() + .desired_width(f32::INFINITY) + .interactive(false), + ); +} + // ── Video player (WMV cutscenes) ────────────────────────────────────────────── /// Format seconds as `mm:ss`.