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>
This commit is contained in:
@@ -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 }
|
||||
|
||||
64
crates/sylpheed-formats/src/font.rs
Normal file
64
crates/sylpheed-formats/src/font.rs
Normal file
@@ -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<FontInfo> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
187
crates/sylpheed-formats/src/ixud.rs
Normal file
187
crates/sylpheed-formats/src/ixud.rs
Normal file
@@ -0,0 +1,187 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<PakDetail>,
|
||||
/// 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<ImageRgba>,
|
||||
},
|
||||
/// 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<u8>,
|
||||
}
|
||||
|
||||
/// The parsed IDXD detail behind one entry, shown in the master-detail pane.
|
||||
@@ -221,6 +253,9 @@ pub struct PakView {
|
||||
pub rows: Vec<PakRow>,
|
||||
pub selected: Option<usize>,
|
||||
pub error: Option<String>,
|
||||
/// 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<u8>) -> Result<Vec<PakRow>, 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<u8>) -> Result<Vec<PakRow>, 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<u8>) -> Result<Vec<PakRow>, String> {
|
||||
format,
|
||||
identity,
|
||||
detail,
|
||||
content,
|
||||
});
|
||||
}
|
||||
Err(err) => rows.push(PakRow {
|
||||
@@ -765,12 +808,123 @@ fn build_pak_rows(index: &[u8], data: Vec<u8>) -> Result<Vec<PakRow>, 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"<?xml") || vfs::identify_format(payload) == vfs::FileFormat::Text {
|
||||
let (mut text, encoding) = vfs::decode_text(payload);
|
||||
if text.len() > 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<ImageRgba> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`.
|
||||
|
||||
Reference in New Issue
Block a user