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:
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user