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.