Skip to content

Commit 5fe5083

Browse files
committed
fix(text): embed the offered fonts so the render matches the picker
The compositor looked every caption and annotation family up among the machine's installed fonts, while the picker offered 17 Google families the app never shipped. On a machine without them every choice drew Segoe UI. Ship Inter, Lora, Oswald, Caveat and IBM Plex Mono (Regular + Bold, SIL OFL, unmodified upstream files) in public/fonts, register them privately on all three backends via OPENSCREEN_FONTS_DIR, offer only those in the picker, and read any other stored family as Inter.
1 parent e2ee540 commit 5fe5083

35 files changed

Lines changed: 1163 additions & 36 deletions

‎THIRD-PARTY-NOTICES.md‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,30 @@ distributed by their own registries, not redistributed inside our binaries.
123123
so nothing of PipeWire's ships inside our installers beyond the compiled
124124
result of its headers (inline functions and struct layouts).
125125

126+
## Fonts — Inter, Lora, Oswald, Caveat, IBM Plex Mono
127+
128+
- **Components**: the Regular and Bold `.ttf` of each family, under
129+
`resources/fonts/`.
130+
- **Used by**: the native compositor, which draws captions and annotations with
131+
these files and nothing else (it registers them privately at start and never
132+
reads the fonts installed on the machine), and the editor's font picker.
133+
- **License**: **SIL Open Font License 1.1** — <https://openfontlicense.org>.
134+
Each family's copyright notice and licence text ship beside its files as
135+
`<Family>-OFL.txt`, as the licence requires.
136+
- **Unmodified**: every file is redistributed exactly as upstream publishes it,
137+
which is also why the Reserved Font Names "Lora" and "Plex" are kept.
138+
- **Upstream**:
139+
- Inter — Copyright 2016 The Inter Project Authors. `extras/ttf/` of the v4.1
140+
release, <https://github.com/rsms/inter/releases/tag/v4.1>.
141+
- Lora 3.021 — Copyright 2011 The Lora Project Authors. `fonts/ttf/` of
142+
<https://github.com/cyrealtype/Lora-Cyrillic> at `2d53b449b6`.
143+
- Oswald 4.103 — Copyright 2016 The Oswald Project Authors. `fonts/ttf/` of
144+
<https://github.com/googlefonts/OswaldFont> at `89795261ac`.
145+
- Caveat 2.000 — Copyright 2014 The Caveat Project Authors. `fonts/ttf/` of
146+
<https://github.com/googlefonts/caveat> at `59745e818e`.
147+
- IBM Plex Mono 2.3 — Copyright © 2017 IBM Corp. `ofl/ibmplexmono/` of
148+
<https://github.com/google/fonts> at `23e54b51dd`.
149+
126150
## OpenScreen native helpers
127151

128152
`wgc-capture` (Windows Graphics Capture), the ScreenCaptureKit helper (macOS),

‎crates/compositor/src/lib.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ pub mod scene;
5050
// indépendant du choix de packaging d'ONNX Runtime.
5151
pub mod segmentation;
5252
pub mod text_anim;
53+
pub mod text_fonts;
5354
pub mod text_plate;
5455
pub(crate) mod timeline_walk;
5556

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
//! Les polices du texte (sous-titres et annotations) : celles que l'app embarque, et elles seules.
2+
//!
3+
//! Les trois backends cherchaient la famille demandée parmi les polices INSTALLÉES sur la
4+
//! machine, alors que l'app proposait des familles qu'elle n'installait nulle part. Sur une
5+
//! machine qui ne les avait pas, chaque choix retombait sur la même police de repli, et changer
6+
//! de police ne changeait rien à l'écran.
7+
//!
8+
//! Les fichiers vivent dans `public/fonts`. L'app pose `OPENSCREEN_FONTS_DIR` sur leur dossier
9+
//! avant de charger l'addon (`compositorViewService.ts`, même contrat que `ORT_DYLIB_PATH`), et
10+
//! chaque backend les enregistre pour ce processus seulement, sans rien installer : collection
11+
//! privée pour DirectWrite, portée processus pour CoreText, base `fontdb` pour cosmic-text.
12+
//! Sur macOS et Linux, une police installée qui porte le même nom peut passer devant la nôtre :
13+
//! c'est alors la même famille dans une autre version, jamais un repli.
14+
//!
15+
//! Sans la variable (POC, addon chargé hors de l'app), la liste est vide et le texte se dessine
16+
//! avec les polices du système, comme avant.
17+
18+
use std::path::{Path, PathBuf};
19+
20+
/// Posée par l'app : le dossier des polices embarquées.
21+
pub const FONTS_DIR_ENV: &str = "OPENSCREEN_FONTS_DIR";
22+
23+
/// Les fichiers de police embarqués, ou aucun si l'app n'a pas indiqué leur dossier.
24+
pub fn embedded_font_files() -> Vec<PathBuf> {
25+
std::env::var_os(FONTS_DIR_ENV)
26+
.map(|dir| font_files_in(Path::new(&dir)))
27+
.unwrap_or_default()
28+
}
29+
30+
/// Les `.ttf` et `.otf` de `dir`, triés. Le dossier porte aussi les licences OFL, qui ne sont
31+
/// pas des polices. Un dossier illisible donne une liste vide : mieux vaut les polices du
32+
/// système que pas de texte du tout.
33+
pub fn font_files_in(dir: &Path) -> Vec<PathBuf> {
34+
let mut files: Vec<PathBuf> = std::fs::read_dir(dir)
35+
.into_iter()
36+
.flatten()
37+
.flatten()
38+
.map(|entry| entry.path())
39+
.filter(|path| {
40+
let ext = path.extension().and_then(|ext| ext.to_str()).unwrap_or_default();
41+
ext.eq_ignore_ascii_case("ttf") || ext.eq_ignore_ascii_case("otf")
42+
})
43+
.collect();
44+
files.sort();
45+
files
46+
}
47+
48+
/// `public/fonts` du dépôt, pour les tests des trois backends.
49+
#[cfg(test)]
50+
pub(crate) fn repo_fonts_dir() -> PathBuf {
51+
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../public/fonts")
52+
}
53+
54+
/// Les familles embarquées, telles que l'app les stocke. Miroir de `TEXT_FONT_FAMILIES`
55+
/// (`src/lib/textFonts.ts`), dont le test vérifie que chaque fichier porte bien ce nom.
56+
#[cfg(test)]
57+
pub(crate) const SHIPPED_FAMILIES: [&str; 5] =
58+
["Inter", "Lora", "Oswald", "Caveat", "IBM Plex Mono"];
59+
60+
/// Chaque famille se voit : deux familles se confondent quand elles dessinent le même bloc,
61+
/// même largeur ET même hauteur à 2 % près, et un repli commun les confondrait toutes. La
62+
/// largeur seule ne suffit pas : Inter et Lora ne diffèrent que d'1 % sur « Hamburgefonstiv ».
63+
#[cfg(test)]
64+
pub(crate) fn assert_distinct_blocks(blocks: &[(&str, f32, f32)]) {
65+
let close = |a: f32, b: f32| (a - b).abs() <= a.min(b) * 0.02;
66+
for (i, (a, wa, ha)) in blocks.iter().enumerate() {
67+
for (b, wb, hb) in &blocks[i + 1..] {
68+
assert!(
69+
!(close(*wa, *wb) && close(*ha, *hb)),
70+
"{a} et {b} dessinent le même bloc : {blocks:?}"
71+
);
72+
}
73+
}
74+
}
75+
76+
#[cfg(test)]
77+
mod tests {
78+
use super::*;
79+
80+
#[test]
81+
fn only_the_font_files_of_the_directory_are_registered() {
82+
let dir = repo_fonts_dir();
83+
let files = font_files_in(&dir);
84+
// Régulier et gras de chaque famille : les deux graisses qu'un contrôle peut produire.
85+
assert_eq!(files.len(), SHIPPED_FAMILIES.len() * 2, "{files:?}");
86+
assert!(files.iter().all(|f| f.extension().is_some_and(|e| e == "ttf")));
87+
// Les licences sont bien là, et bien écartées.
88+
let licences = std::fs::read_dir(&dir)
89+
.unwrap()
90+
.flatten()
91+
.filter(|e| e.path().extension().is_some_and(|x| x == "txt"))
92+
.count();
93+
assert_eq!(licences, SHIPPED_FAMILIES.len());
94+
}
95+
96+
#[test]
97+
fn a_missing_directory_means_system_fonts_not_a_failure() {
98+
assert!(font_files_in(Path::new("/definitely/not/a/fonts/dir")).is_empty());
99+
}
100+
}

‎crates/compositor/src/text_linux.rs‎

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,10 +111,23 @@ pub struct TextRasterizer {
111111
swash_cache: RefCell<SwashCache>,
112112
}
113113

114+
/// Ajoute `files` (`crate::text_fonts`) a la base `fontdb` de ce rasteriseur, sans rien
115+
/// installer sur la machine. Un fichier illisible ne coute que sa police : le texte retombe
116+
/// sur celles du systeme.
117+
fn load_fonts(font_system: &mut FontSystem, files: &[std::path::PathBuf]) {
118+
for path in files {
119+
if let Err(e) = font_system.db_mut().load_font_file(path) {
120+
eprintln!("[text] police non chargee ({e}) : {}", path.display());
121+
}
122+
}
123+
}
124+
114125
impl TextRasterizer {
115126
pub fn new() -> Result<TextRasterizer> {
127+
let mut font_system = FontSystem::new();
128+
load_fonts(&mut font_system, &crate::text_fonts::embedded_font_files());
116129
Ok(TextRasterizer {
117-
font_system: RefCell::new(FontSystem::new()),
130+
font_system: RefCell::new(font_system),
118131
swash_cache: RefCell::new(SwashCache::new()),
119132
})
120133
}
@@ -436,6 +449,49 @@ mod tests {
436449
.collect()
437450
}
438451

452+
/// Chaque famille embarquee est celle que cosmic-text dessine, dans les deux graisses
453+
/// qu'un controle peut produire, et deux familles ne se confondent jamais a l'ecran.
454+
#[test]
455+
fn every_shipped_family_draws_from_its_own_files() {
456+
use cosmic_text::fontdb::{Family, Query, Stretch, Style, Weight};
457+
let mut font_system = FontSystem::new();
458+
let files = crate::text_fonts::font_files_in(&crate::text_fonts::repo_fonts_dir());
459+
load_fonts(&mut font_system, &files);
460+
for family in crate::text_fonts::SHIPPED_FAMILIES {
461+
for weight in [Weight::NORMAL, Weight::BOLD] {
462+
let query = Query {
463+
families: &[Family::Name(family)],
464+
weight,
465+
stretch: Stretch::Normal,
466+
style: Style::Normal,
467+
};
468+
let id = font_system
469+
.db()
470+
.query(&query)
471+
.unwrap_or_else(|| panic!("{family} introuvable"));
472+
// Un vrai fichier de cette graisse, pas le regulier epaissi ou non.
473+
assert_eq!(font_system.db().face(id).unwrap().weight, weight, "{family}");
474+
}
475+
}
476+
477+
let raster = TextRasterizer {
478+
font_system: RefCell::new(font_system),
479+
swash_cache: RefCell::new(SwashCache::new()),
480+
};
481+
let mut blocks = Vec::new();
482+
for family in crate::text_fonts::SHIPPED_FAMILIES {
483+
let mut s = spec("Hamburgefonstiv", "left");
484+
s.font_family = family.to_owned();
485+
s.font_size_px = 100.0;
486+
s.box_px = [1600, 200];
487+
let atlas = raster.build_atlas(&s).expect("atlas").pixels;
488+
let (cols, rows) = (ink_cols(&atlas, 1600), ink_rows(&atlas, 1600, 0, 1600));
489+
let (w, h) = (cols[cols.len() - 1] - cols[0] + 1, rows[rows.len() - 1] - rows[0] + 1);
490+
blocks.push((family, w as f32, h as f32));
491+
}
492+
crate::text_fonts::assert_distinct_blocks(&blocks);
493+
}
494+
439495
#[test]
440496
fn the_plate_hugs_the_text_instead_of_filling_the_box() {
441497
// LE bug des sous-titres sous Linux. La plaque etait le quad de la boite

‎crates/compositor/src/text_macos.rs‎

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
use crate::d3d::Gpu;
3535
use anyhow::{anyhow, bail, Result};
3636
use std::ffi::c_void;
37+
use std::path::PathBuf;
3738

3839
/// Spécification d'un texte à rastériser. Mêmes champs que `text_windows::TextSpec`
3940
/// — le moteur macOS les consomme via `cache_key` pour déterminer si une re-rastérisation
@@ -141,6 +142,8 @@ const K_CT_PARAGRAPH_STYLE_SPECIFIER_ALIGNMENT: u32 = 0;
141142
/// `CTFontSymbolicTraits` : italique / gras.
142143
const K_CT_FONT_TRAIT_ITALIC: u32 = 1 << 0;
143144
const K_CT_FONT_TRAIT_BOLD: u32 = 1 << 1;
145+
/// `kCTFontManagerScopeProcess` : la police n'existe que pour ce processus, rien n'est installé.
146+
const K_CT_FONT_MANAGER_SCOPE_PROCESS: u32 = 1;
144147

145148
#[repr(C)]
146149
#[derive(Clone, Copy)]
@@ -174,6 +177,12 @@ extern "C" {
174177
str_: CFTypeRef,
175178
attributes: CFTypeRef,
176179
) -> CFTypeRef;
180+
fn CFURLCreateFromFileSystemRepresentation(
181+
alloc: CFTypeRef,
182+
buffer: *const u8,
183+
buf_len: CFIndex,
184+
is_directory: u8,
185+
) -> CFTypeRef;
177186
static kCFTypeDictionaryKeyCallBacks: c_void;
178187
static kCFTypeDictionaryValueCallBacks: c_void;
179188
}
@@ -231,6 +240,11 @@ extern "C" {
231240
frame_attributes: CFTypeRef,
232241
) -> CFTypeRef;
233242
fn CTFrameDraw(frame: CFTypeRef, context: CFTypeRef);
243+
fn CTFontManagerRegisterFontsForURL(
244+
font_url: CFTypeRef,
245+
scope: u32,
246+
error: *mut CFTypeRef,
247+
) -> bool;
234248

235249
static kCTFontAttributeName: CFTypeRef;
236250
static kCTForegroundColorAttributeName: CFTypeRef;
@@ -406,13 +420,46 @@ unsafe fn cf_string(s: &str) -> Option<CFOwned> {
406420
))
407421
}
408422

423+
/// Rend `files` (`crate::text_fonts`) visibles de CoreText pour CE processus seulement :
424+
/// `CTFontCreateWithName` les trouve ensuite par leur nom de famille, sans que rien ne soit
425+
/// installé sur la machine.
426+
///
427+
/// Un fichier refusé ne coûte que sa police : le texte retombe sur celles du système. Un
428+
/// fichier déjà enregistré est refusé aussi, et reste utilisable.
429+
unsafe fn register_fonts(files: &[PathBuf]) {
430+
use std::os::unix::ffi::OsStrExt;
431+
for path in files {
432+
let bytes = path.as_os_str().as_bytes();
433+
let Some(url) = CFOwned::new(CFURLCreateFromFileSystemRepresentation(
434+
std::ptr::null(),
435+
bytes.as_ptr(),
436+
bytes.len() as CFIndex,
437+
0,
438+
)) else {
439+
continue;
440+
};
441+
let registered = CTFontManagerRegisterFontsForURL(
442+
url.get(),
443+
K_CT_FONT_MANAGER_SCOPE_PROCESS,
444+
std::ptr::null_mut(),
445+
);
446+
if !registered {
447+
eprintln!("[text] police non enregistrée (ou déjà) : {}", path.display());
448+
}
449+
}
450+
}
451+
409452
/// Rastériseur de texte macOS. Pas d'état persistant : CoreText et CoreGraphics sont
410453
/// prêts dès le link des frameworks (côté Windows, `TextRasterizer::new` alloue les
411454
/// factories DirectWrite/Direct2D — d'où le `Result` conservé pour la symétrie).
412455
pub struct TextRasterizer;
413456

414457
impl TextRasterizer {
415458
pub fn new() -> Result<TextRasterizer> {
459+
// Une fois par processus : l'enregistrement vaut pour tout CoreText, alors que chaque
460+
// compositeur (preview, export) crée son rastériseur.
461+
static REGISTER: std::sync::Once = std::sync::Once::new();
462+
REGISTER.call_once(|| unsafe { register_fonts(&crate::text_fonts::embedded_font_files()) });
416463
Ok(TextRasterizer)
417464
}
418465

@@ -739,6 +786,80 @@ mod tests {
739786
(x0, y0, x1, y1)
740787
}
741788

789+
#[link(name = "CoreText", kind = "framework")]
790+
extern "C" {
791+
fn CTFontCopyFamilyName(font: CFTypeRef) -> CFTypeRef;
792+
fn CTFontGetSymbolicTraits(font: CFTypeRef) -> u32;
793+
}
794+
795+
#[link(name = "CoreFoundation", kind = "framework")]
796+
extern "C" {
797+
fn CFStringGetCString(
798+
string: CFTypeRef,
799+
buffer: *mut std::ffi::c_char,
800+
buffer_size: CFIndex,
801+
encoding: u32,
802+
) -> u8;
803+
}
804+
805+
/// Le nom de famille que CoreText a réellement résolu pour `font`.
806+
unsafe fn family_of(font: CFTypeRef) -> String {
807+
let name = CFOwned::new(CTFontCopyFamilyName(font)).expect("CTFontCopyFamilyName");
808+
let mut buf = [0 as std::ffi::c_char; 256];
809+
let ok = CFStringGetCString(
810+
name.get(),
811+
buf.as_mut_ptr(),
812+
buf.len() as CFIndex,
813+
K_CF_STRING_ENCODING_UTF8,
814+
);
815+
assert_ne!(ok, 0, "CFStringGetCString");
816+
std::ffi::CStr::from_ptr(buf.as_ptr()).to_string_lossy().into_owned()
817+
}
818+
819+
/// Chaque famille embarquée est celle que CoreText dessine. `CTFontCreateWithName` ne
820+
/// renvoie jamais NULL pour un nom inconnu : il retombe en silence sur une autre police,
821+
/// donc seul le nom de famille RÉSOLU dit si la bonne a été prise.
822+
#[test]
823+
fn every_shipped_family_draws_from_its_own_files() {
824+
unsafe {
825+
register_fonts(&crate::text_fonts::font_files_in(&crate::text_fonts::repo_fonts_dir()));
826+
for family in crate::text_fonts::SHIPPED_FAMILIES {
827+
let name = cf_string(family).unwrap();
828+
let font = CFOwned::new(CTFontCreateWithName(name.get(), 40.0, std::ptr::null()))
829+
.unwrap();
830+
assert_eq!(family_of(font.get()), family);
831+
let traits = CTFontGetSymbolicTraits(font.get());
832+
assert_eq!(traits & K_CT_FONT_TRAIT_BOLD, 0, "{family} : le régulier sort gras");
833+
// Le réglage Gras prend un vrai fichier gras de la MÊME famille.
834+
let bold = CFOwned::new(CTFontCreateCopyWithSymbolicTraits(
835+
font.get(),
836+
0.0,
837+
std::ptr::null(),
838+
K_CT_FONT_TRAIT_BOLD,
839+
K_CT_FONT_TRAIT_BOLD | K_CT_FONT_TRAIT_ITALIC,
840+
))
841+
.unwrap_or_else(|| panic!("{family} : pas de gras"));
842+
assert_eq!(family_of(bold.get()), family);
843+
assert_ne!(CTFontGetSymbolicTraits(bold.get()) & K_CT_FONT_TRAIT_BOLD, 0);
844+
}
845+
}
846+
847+
// Et chaque famille se voit, à l'encre dessinée.
848+
let mut blocks = Vec::new();
849+
for family in crate::text_fonts::SHIPPED_FAMILIES {
850+
let mut s = spec("Hamburgefonstiv");
851+
s.font_family = family.into();
852+
s.font_size_px = 100.0;
853+
s.box_px = [1600, 200];
854+
let Some((px, w, h)) = raster_bgra(&s) else {
855+
return;
856+
};
857+
let (x0, y0, x1, y1) = ink_bounds(&px, w, h);
858+
blocks.push((family, (x1 - x0 + 1) as f32, (y1 - y0 + 1) as f32));
859+
}
860+
crate::text_fonts::assert_distinct_blocks(&blocks);
861+
}
862+
742863
/// Le texte n'est pas retourné. Le test regarde où est l'encre plutôt que de faire
743864
/// confiance au sens du CTM : c'est la seule façon de distinguer « bien orienté » de
744865
/// « retourné », et le retournement était un vrai bug de ce fichier.

0 commit comments

Comments
 (0)