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:
MechaCat02
2026-07-11 15:19:31 +02:00
parent 91bd2543f4
commit e17bc0216d
9 changed files with 721 additions and 47 deletions

View File

@@ -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`.