From 648c35e92ae07b3174ca99581da5e59e115bd0be Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Tue, 27 Oct 2026 23:08:43 +0100 Subject: [PATCH] fix(app): the subtitle preview is drawn in the chosen font MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The preview drew every font in the app's own Inter, so picking one changed nothing on screen. On Linux it now asks fontconfig for the face libass would use — the family with the weight and slant wanted, or fontconfig's fallback when it is not installed — loads it on a thread of its own and draws the sample in it. A bold or italic the face lacks is made up, as libass does. Windows and macOS have no fc-match and keep the app's font, as the hint there still says. The CJK fallback and the subtitle face are now both kept, so installing one no longer drops the other. --- crates/pstr-app/src/theme.rs | 229 +++++++++++++++++++++++++++-- crates/pstr-app/src/ui/settings.rs | 43 ++++-- crates/pstr-core/src/prefs.rs | 3 + crates/pstr-player/src/player.rs | 2 +- docs/USER_GUIDE.md | 5 +- 5 files changed, 255 insertions(+), 27 deletions(-) diff --git a/crates/pstr-app/src/theme.rs b/crates/pstr-app/src/theme.rs index 8478125..5d09fe6 100644 --- a/crates/pstr-app/src/theme.rs +++ b/crates/pstr-app/src/theme.rs @@ -186,9 +186,48 @@ pub fn semibold() -> egui::FontFamily { egui::FontFamily::Name("semibold".into()) } +/// The family the subtitle preview is drawn in: the face the chosen subtitle +/// font resolves to, ahead of the app's own fonts, which stand in until it has +/// loaded and for any glyph it lacks. +pub fn subtitle_family() -> egui::FontFamily { + egui::FontFamily::Name("subtitle".into()) +} + +/// The system fonts loaded so far. Each install builds the definitions from +/// all of them, so loading one does not drop the other. +#[derive(Default)] +struct SystemFonts { + cjk: Option>, + subtitle: Option<(SubtitleRequest, Arc, SubtitleFace)>, + /// The subtitle face last asked for, loading or loaded. + wanted: Option, +} + +static SYSTEM_FONTS: parking_lot::Mutex = parking_lot::Mutex::new(SystemFonts { + cjk: None, + subtitle: None, + wanted: None, +}); + +#[derive(Debug, Clone, PartialEq, Eq)] +struct SubtitleRequest { + family: String, + bold: bool, + italic: bool, +} + +/// What the loaded subtitle face draws by itself. A style it lacks is one +/// libass makes up, and so must the preview. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SubtitleFace { + pub bold: bool, + pub italic: bool, +} + /// Inter, the icon font, and egui's own fonts behind them for the emoji and -/// symbols Inter does not carry. `cjk` goes last, when there is one. -fn font_definitions(cjk: Option) -> egui::FontDefinitions { +/// symbols Inter does not carry. The CJK fallback goes last, when there is +/// one. +fn font_definitions(system: &SystemFonts) -> egui::FontDefinitions { use egui::FontFamily::{Monospace, Proportional}; let mut fonts = egui::FontDefinitions::default(); @@ -207,21 +246,29 @@ fn font_definitions(cjk: Option) -> egui::FontDefinitions { // Right behind Inter. The icons are in the private use area, which the // bundled Inter no longer maps, so neither font shadows the other. egui_phosphor::add_to_fonts(&mut fonts, egui_phosphor::Variant::Regular); - if let Some(cjk) = cjk { + if let Some(cjk) = &system.cjk { let name = "system-cjk-fallback".to_owned(); - fonts.font_data.insert(name.clone(), Arc::new(cjk)); + fonts.font_data.insert(name.clone(), cjk.clone()); for family in [Proportional, Monospace] { fonts.families.entry(family).or_default().push(name.clone()); } } - let fallbacks = fonts.families[&Proportional] + let fallbacks: Vec = fonts.families[&Proportional] .iter() .filter(|name| *name != "inter") - .cloned(); + .cloned() + .collect(); let semibold_chain = std::iter::once("inter-semibold".to_owned()) .chain(fallbacks) .collect(); fonts.families.insert(semibold(), semibold_chain); + let mut subtitle_chain = fonts.families[&Proportional].clone(); + if let Some((_, face, _)) = &system.subtitle { + let name = "system-subtitle".to_owned(); + fonts.font_data.insert(name.clone(), face.clone()); + subtitle_chain.insert(0, name); + } + fonts.families.insert(subtitle_family(), subtitle_chain); fonts } @@ -234,7 +281,7 @@ fn font_definitions(cjk: Option) -> egui::FontDefinitions { /// anyway. egui deliberately does not bundle the multi-megabyte CJK families, /// and native desktops already provide one. pub fn install_fonts(ctx: &egui::Context) { - ctx.set_fonts(font_definitions(None)); + ctx.set_fonts(font_definitions(&SYSTEM_FONTS.lock())); let ctx = ctx.clone(); let spawned = std::thread::Builder::new() .name("fonts".into()) @@ -251,20 +298,80 @@ fn install_cjk_fallback(ctx: &egui::Context) { let Ok(bytes) = std::fs::read(&path) else { return; }; - let Some(data) = cjk_font_data(bytes, index) else { + let Some(data) = system_font_data(bytes, index) else { tracing::debug!("CJK fallback {}#{index} does not parse", path.display()); return; }; - ctx.set_fonts(font_definitions(Some(data))); + let mut system = SYSTEM_FONTS.lock(); + system.cjk = Some(Arc::new(data)); + ctx.set_fonts(font_definitions(&system)); ctx.request_repaint(); } -/// The fallback font, if egui can read it. +/// The face the subtitle preview should be drawn in for this font, bold and +/// italic, once [`subtitle_family`] holds it. +/// +/// `None` while it loads, and for good where the system cannot say which file +/// a family is — everywhere but Linux. The first call for a font starts the +/// load on a thread of its own, since `fc-match` and reading the file are too +/// slow for a frame, and asks for a repaint when it is in. +pub fn subtitle_face( + ctx: &egui::Context, + family: &str, + bold: bool, + italic: bool, +) -> Option { + let request = SubtitleRequest { + family: family.to_owned(), + bold, + italic, + }; + let mut system = SYSTEM_FONTS.lock(); + if let Some((loaded, _, face)) = &system.subtitle + && *loaded == request + { + return Some(*face); + } + if system.wanted.as_ref() != Some(&request) { + system.wanted = Some(request.clone()); + let ctx = ctx.clone(); + let spawned = std::thread::Builder::new() + .name("subtitle-font".into()) + .spawn(move || install_subtitle_face(&ctx, request)); + if let Err(error) = spawned { + tracing::debug!("subtitle font thread: {error}"); + } + } + None +} + +fn install_subtitle_face(ctx: &egui::Context, request: SubtitleRequest) { + let Some((path, index, face)) = system_subtitle_font(&request) else { + return; + }; + let Ok(bytes) = std::fs::read(&path) else { + return; + }; + let Some(data) = system_font_data(bytes, index) else { + tracing::debug!("subtitle font {}#{index} does not parse", path.display()); + return; + }; + let mut system = SYSTEM_FONTS.lock(); + // Another font was picked while this one loaded. + if system.wanted.as_ref() != Some(&request) { + return; + } + system.subtitle = Some((request, Arc::new(data), face)); + ctx.set_fonts(font_definitions(&system)); + ctx.request_repaint(); +} + +/// A system font, if egui can read it. /// /// egui panics on a font it cannot parse, and this one comes from whatever the /// system has installed. So it is parsed here first, the same way egui does, /// and a font that fails is left out rather than taking the app down. -fn cjk_font_data(bytes: Vec, index: u32) -> Option { +fn system_font_data(bytes: Vec, index: u32) -> Option { skrifa::FontRef::from_index(&bytes, index).ok()?; let mut data = egui::FontData::from_owned(bytes); data.index = index; @@ -299,6 +406,68 @@ fn system_cjk_font() -> Option<(std::path::PathBuf, u32)> { path.is_file().then_some((path, index)) } +/// The file fontconfig picks for a subtitle font, as libass asks for it: the +/// family with the weight and slant wanted, falling back as libass would to +/// whatever fontconfig likes best when the family is not installed. +#[cfg(target_os = "linux")] +fn system_subtitle_font( + request: &SubtitleRequest, +) -> Option<(std::path::PathBuf, u32, SubtitleFace)> { + let output = std::process::Command::new("fc-match") + .args([ + "-f", + "%{file}\\n%{index}\\n%{weight}\\n%{slant}", + &fontconfig_pattern(request), + ]) + .output() + .ok()?; + let text = std::str::from_utf8(&output.stdout).ok()?; + let mut lines = text.lines(); + let path = std::path::PathBuf::from(lines.next()?); + let mut number = || lines.next().and_then(|value| value.parse::().ok()); + let index = number().unwrap_or(0); + let (weight, slant) = (number().unwrap_or(0), number().unwrap_or(0)); + // A variable font's named instance is drawn by egui as the default one, + // which is upright and regular, whatever fontconfig says it matched. + let instance = index != fontconfig_face(index); + let face = SubtitleFace { + bold: !instance && weight >= FC_WEIGHT_BOLD, + italic: !instance && slant > 0, + }; + path.is_file() + .then_some((path, fontconfig_face(index), face)) +} + +#[cfg(any(target_os = "linux", test))] +const FC_WEIGHT_BOLD: u32 = 200; + +/// A fontconfig pattern for the request. A family name is free text, and `-`, +/// `:`, `,` and `\\` mean something in a pattern, so those are escaped. +#[cfg(any(target_os = "linux", test))] +fn fontconfig_pattern(request: &SubtitleRequest) -> String { + let mut pattern = String::new(); + for character in request.family.chars() { + if matches!(character, '-' | ':' | ',' | '\\') { + pattern.push('\\'); + } + pattern.push(character); + } + if request.bold { + pattern.push_str(&format!(":weight={FC_WEIGHT_BOLD}")); + } + if request.italic { + pattern.push_str(":slant=100"); + } + pattern +} + +/// Elsewhere there is no asking which file a family is, and the preview +/// stays in the app's own font. +#[cfg(not(target_os = "linux"))] +fn system_subtitle_font(_: &SubtitleRequest) -> Option<(std::path::PathBuf, u32, SubtitleFace)> { + None +} + #[cfg(target_os = "windows")] fn system_cjk_font() -> Option<(std::path::PathBuf, u32)> { let path = std::path::PathBuf::from(r"C:\Windows\Fonts\YuGothR.ttc"); @@ -960,10 +1129,40 @@ mod tests { } #[test] - fn a_fallback_font_egui_cannot_read_is_left_out() { - assert!(cjk_font_data(INTER.to_vec(), 0).is_some()); - assert!(cjk_font_data(INTER.to_vec(), 4).is_none()); - assert!(cjk_font_data(b"not a font".to_vec(), 0).is_none()); + fn a_system_font_egui_cannot_read_is_left_out() { + assert!(system_font_data(INTER.to_vec(), 0).is_some()); + assert!(system_font_data(INTER.to_vec(), 4).is_none()); + assert!(system_font_data(b"not a font".to_vec(), 0).is_none()); + } + + #[test] + fn a_subtitle_font_is_asked_of_fontconfig_with_its_style_and_escaped() { + let request = |family: &str, bold, italic| SubtitleRequest { + family: family.to_owned(), + bold, + italic, + }; + assert_eq!( + fontconfig_pattern(&request("Noto Sans", false, false)), + "Noto Sans" + ); + assert_eq!( + fontconfig_pattern(&request("Noto Sans", true, true)), + "Noto Sans:weight=200:slant=100" + ); + assert_eq!( + fontconfig_pattern(&request("A-B:C,D\\E", false, true)), + "A\\-B\\:C\\,D\\\\E:slant=100" + ); + } + + #[test] + fn the_subtitle_preview_family_exists_before_any_font_has_loaded() { + let fonts = font_definitions(&SystemFonts::default()); + assert_eq!( + fonts.families[&subtitle_family()], + fonts.families[&egui::FontFamily::Proportional] + ); } #[test] diff --git a/crates/pstr-app/src/ui/settings.rs b/crates/pstr-app/src/ui/settings.rs index 64f92dd..e7e5ca3 100644 --- a/crates/pstr-app/src/ui/settings.rs +++ b/crates/pstr-app/src/ui/settings.rs @@ -463,7 +463,11 @@ fn subtitle_form(ui: &mut egui::Ui, style: &SubtitleStyle, actions: &mut Vec) { /// A sample line, half on a bright sky and half on night, drawn in the /// chosen style — so whether it reads on both is visible before a film is. /// -/// An approximation: the app's own font rather than the chosen one, egui's -/// slant for italic, and no blur. Proportions follow mpv's, where sizes are -/// in pixels of a 38-pixel font. +/// An approximation. The font is the face fontconfig picks for the chosen one, +/// as libass's is, but only on Linux; elsewhere, and while it loads, it is the +/// app's own. A bold or italic the face lacks is made up, as libass does, and +/// there is no blur. Proportions follow mpv's, where sizes are in pixels of a +/// 38-pixel font. fn subtitle_preview(ui: &mut egui::Ui, style: &SubtitleStyle) { const HEIGHT: f32 = 132.0; /// mpv's `sub-font-size`, the unit every other size is relative to. @@ -694,17 +700,27 @@ fn subtitle_preview(ui: &mut egui::Ui, style: &SubtitleStyle) { let font_px = 22.0 * style.scale as f32; let unit = font_px / MPV_FONT; - let family = if style.bold { - theme::semibold() - } else { - egui::FontFamily::Proportional + let face = theme::subtitle_face( + ui.ctx(), + style.font.as_deref().unwrap_or(SubtitleStyle::DEFAULT_FONT), + style.bold, + style.italic, + ); + let (family, made_up_bold, made_up_italic) = match face { + Some(face) => ( + theme::subtitle_family(), + style.bold && !face.bold, + style.italic && !face.italic, + ), + None if style.bold => (theme::semibold(), false, style.italic), + None => (egui::FontFamily::Proportional, false, style.italic), }; let job = egui::text::LayoutJob::single_section( "Can you read this on both sides?".to_owned(), egui::TextFormat { font_id: egui::FontId::new(font_px, family), color: egui::Color32::PLACEHOLDER, - italics: style.italic, + italics: made_up_italic, ..Default::default() }, ); @@ -763,6 +779,15 @@ fn subtitle_preview(ui: &mut egui::Ui, style: &SubtitleStyle) { } } } + if made_up_bold { + // libass thickens a face with no bold by a stroke of its own colour. + let thicken = (font_px / 40.0).max(0.5); + painter.galley_with_override_text_color( + at + egui::vec2(thicken, 0.0), + galley.clone(), + colour(style.colour), + ); + } painter.galley_with_override_text_color(at, galley, colour(style.colour)); } diff --git a/crates/pstr-core/src/prefs.rs b/crates/pstr-core/src/prefs.rs index 78b20ca..9a3fd34 100644 --- a/crates/pstr-core/src/prefs.rs +++ b/crates/pstr-core/src/prefs.rs @@ -108,6 +108,9 @@ pub struct SubtitleStyle { } impl SubtitleStyle { + /// The family a style with no font of its own is set in: mpv's default, + /// which fontconfig resolves to the system's sans-serif face. + pub const DEFAULT_FONT: &str = "sans-serif"; pub const MIN_SCALE: f64 = 0.5; pub const MAX_SCALE: f64 = 2.5; pub const MAX_OUTLINE: f64 = 10.0; diff --git a/crates/pstr-player/src/player.rs b/crates/pstr-player/src/player.rs index 990fef1..6af596e 100644 --- a/crates/pstr-player/src/player.rs +++ b/crates/pstr-player/src/player.rs @@ -639,7 +639,7 @@ pub fn subtitle_options(style: &SubtitleStyle) -> Vec<(&'static str, String)> { style .font .clone() - .unwrap_or_else(|| "sans-serif".to_owned()), + .unwrap_or_else(|| SubtitleStyle::DEFAULT_FONT.to_owned()), ), ("sub-bold", flag(style.bold)), ("sub-italic", flag(style.italic)), diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index fb24f91..2ac3a54 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -196,8 +196,9 @@ How subtitles look. A sample line at the top shows the style on a bright and a d a video that is playing changes at once. - **Size** and **Position**: how big the line is and how far up the picture it sits. -- **Font**, **Bold** and **Italic**: any font installed on this computer. The sample line uses the - app's own font; the video uses the one you chose. +- **Font**, **Bold** and **Italic**: any font installed on this computer. On Linux the sample + line shows the font too; on Windows and macOS it is drawn in the app's own font, and only the + video uses the one you chose. - **Text colour**, and a **Background** of **Outline** or **Box**, each with its own colour. A colour's opacity sets how much of the picture shows through. Choose **Box** if text is hard to read on bright scenes.