diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 394012f2..148f352a 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -12,6 +12,9 @@ iconography, images or other Adobe assets; open licences only; every asset attri | `assets/fonts/Inter-Regular.ttf` | The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) | | `assets/fonts/Inter-Medium.ttf` | The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) | | `assets/fonts/Inter-SemiBold.ttf` | The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) | +| `assets/fonts/Inter-Bold.ttf` | The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) | +| `assets/fonts/Inter-Italic.ttf` | The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) | +| `assets/fonts/NotoSerif-Regular.ttf` | The Noto Project Authors | https://github.com/notofonts/latin-greek-cyrillic | OFL-1.1 (`assets/fonts/OFL-NotoSerif.txt`) | | `assets/fonts/JetBrainsMono-Regular.ttf` | The JetBrains Mono Project Authors | https://github.com/JetBrains/JetBrainsMono | OFL-1.1 (`assets/fonts/OFL-JetBrainsMono.txt`) | | `docs/images/filmcraft-hero.png` | FilmCraft contributors | Original work: FilmCraft screenshot; frames from public-domain films (Night of the Living Dead 1968, Carnival of Souls 1962), CC0 Chopin (Musopen) | MIT OR Apache-2.0; film frames public domain | | `docs/images/filmcraft-color.png` | FilmCraft contributors | Original work: FilmCraft screenshot; frames from Charade (1963, public domain) | MIT OR Apache-2.0; film frames public domain | diff --git a/Cargo.lock b/Cargo.lock index c551bf47..86481ba9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1360,9 +1360,9 @@ version = "0.1.0" dependencies = [ "filmcraft-color", "filmcraft-project", + "filmcraft-text", "filmcraft-time", "proptest", - "skrifa", "thiserror 2.0.21", ] @@ -1645,6 +1645,7 @@ dependencies = [ "filmcraft-geom", "filmcraft-media", "filmcraft-project", + "filmcraft-text", "filmcraft-time", "image", "rayon", @@ -1659,6 +1660,17 @@ dependencies = [ "png", ] +[[package]] +name = "filmcraft-text" +version = "0.1.0" +dependencies = [ + "filmcraft-color", + "harfrust", + "skrifa", + "unicode-bidi", + "unicode-linebreak", +] + [[package]] name = "filmcraft-time" version = "0.1.0" @@ -1684,6 +1696,7 @@ dependencies = [ "filmcraft-media", "filmcraft-project", "filmcraft-render", + "filmcraft-text", "filmcraft-time", "log", "rayon", @@ -4669,6 +4682,12 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-general-category" version = "1.1.0" @@ -4681,6 +4700,12 @@ version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + [[package]] name = "unicode-segmentation" version = "1.13.3" diff --git a/Cargo.toml b/Cargo.toml index a718dffd..f96a87db 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ filmcraft-codecs = { path = "crates/codecs" } filmcraft-project = { path = "crates/project" } filmcraft-edit = { path = "crates/edit" } filmcraft-interchange = { path = "crates/interchange" } +filmcraft-text = { path = "crates/text" } filmcraft-captions = { path = "crates/captions" } filmcraft-render = { path = "crates/render" } filmcraft-gpu = { path = "crates/gpu" } diff --git a/assets/fonts/Inter-Bold.ttf b/assets/fonts/Inter-Bold.ttf new file mode 100644 index 00000000..9fb9b751 Binary files /dev/null and b/assets/fonts/Inter-Bold.ttf differ diff --git a/assets/fonts/Inter-Bold.ttf.attribution b/assets/fonts/Inter-Bold.ttf.attribution new file mode 100644 index 00000000..6f2ca0b1 --- /dev/null +++ b/assets/fonts/Inter-Bold.ttf.attribution @@ -0,0 +1,8 @@ +asset: Inter-Bold.ttf +title: Inter Bold (text engine / graphics) +author: The Inter Project Authors (Rasmus Andersson et al.) +source: https://github.com/rsms/inter (release v4.1, extras/ttf) +license: OFL-1.1 +license-file: assets/fonts/OFL-Inter.txt +added: 2026-10-01 by Claude (agent) for Brandon Thomas +notes: Unmodified font file, bundled via include_bytes! in the text engine (crates/text/src/fonts.rs) for graphics text, titles and burn-ins. diff --git a/assets/fonts/Inter-Italic.ttf b/assets/fonts/Inter-Italic.ttf new file mode 100644 index 00000000..e7ed211d Binary files /dev/null and b/assets/fonts/Inter-Italic.ttf differ diff --git a/assets/fonts/Inter-Italic.ttf.attribution b/assets/fonts/Inter-Italic.ttf.attribution new file mode 100644 index 00000000..e346bc98 --- /dev/null +++ b/assets/fonts/Inter-Italic.ttf.attribution @@ -0,0 +1,8 @@ +asset: Inter-Italic.ttf +title: Inter Italic (text engine / graphics) +author: The Inter Project Authors (Rasmus Andersson et al.) +source: https://github.com/rsms/inter (release v4.1, extras/ttf) +license: OFL-1.1 +license-file: assets/fonts/OFL-Inter.txt +added: 2026-10-01 by Claude (agent) for Brandon Thomas +notes: Unmodified font file, bundled via include_bytes! in the text engine (crates/text/src/fonts.rs) for graphics text, titles and burn-ins. diff --git a/assets/fonts/Inter-Medium.ttf.attribution b/assets/fonts/Inter-Medium.ttf.attribution index 20dc0cf4..707fc56c 100644 --- a/assets/fonts/Inter-Medium.ttf.attribution +++ b/assets/fonts/Inter-Medium.ttf.attribution @@ -5,4 +5,4 @@ source: https://github.com/rsms/inter license: OFL-1.1 license-file: assets/fonts/OFL-Inter.txt added: 2026-09-30 by Claude (agent) for Brandon Thomas -notes: Unmodified font file, embedded in the UI via include_bytes! (crates/ui-egui/src/theme.rs). +notes: Unmodified font file, embedded via include_bytes! in the text engine (crates/text/src/fonts.rs), which also supplies it to the UI (crates/ui-egui/src/theme.rs). diff --git a/assets/fonts/Inter-Regular.ttf.attribution b/assets/fonts/Inter-Regular.ttf.attribution index c4f496fb..052b0113 100644 --- a/assets/fonts/Inter-Regular.ttf.attribution +++ b/assets/fonts/Inter-Regular.ttf.attribution @@ -5,4 +5,4 @@ source: https://github.com/rsms/inter license: OFL-1.1 license-file: assets/fonts/OFL-Inter.txt added: 2026-09-30 by Claude (agent) for Brandon Thomas -notes: Unmodified font file, embedded in the UI via include_bytes! (crates/ui-egui/src/theme.rs). +notes: Unmodified font file, embedded via include_bytes! in the text engine (crates/text/src/fonts.rs), which also supplies it to the UI (crates/ui-egui/src/theme.rs). diff --git a/assets/fonts/Inter-SemiBold.ttf.attribution b/assets/fonts/Inter-SemiBold.ttf.attribution index 771822f0..e010cad7 100644 --- a/assets/fonts/Inter-SemiBold.ttf.attribution +++ b/assets/fonts/Inter-SemiBold.ttf.attribution @@ -5,4 +5,4 @@ source: https://github.com/rsms/inter license: OFL-1.1 license-file: assets/fonts/OFL-Inter.txt added: 2026-09-30 by Claude (agent) for Brandon Thomas -notes: Unmodified font file, embedded via include_bytes! in the UI (crates/ui-egui/src/theme.rs) and as the caption burn-in font (crates/captions/src/font.rs). +notes: Unmodified font file, embedded via include_bytes! in the text engine (crates/text/src/fonts.rs; caption burn-in, graphics), which also supplies it to the UI (crates/ui-egui/src/theme.rs). diff --git a/assets/fonts/JetBrainsMono-Regular.ttf.attribution b/assets/fonts/JetBrainsMono-Regular.ttf.attribution index ab720f6c..f185af97 100644 --- a/assets/fonts/JetBrainsMono-Regular.ttf.attribution +++ b/assets/fonts/JetBrainsMono-Regular.ttf.attribution @@ -5,4 +5,4 @@ source: https://github.com/JetBrains/JetBrainsMono license: OFL-1.1 license-file: assets/fonts/OFL-JetBrainsMono.txt added: 2026-09-30 by Claude (agent) for Brandon Thomas -notes: Unmodified font file, embedded in the UI via include_bytes! (crates/ui-egui/src/theme.rs). +notes: Unmodified font file, embedded via include_bytes! in the text engine (crates/text/src/fonts.rs), which also supplies it to the UI (crates/ui-egui/src/theme.rs). diff --git a/assets/fonts/NotoSerif-Regular.ttf b/assets/fonts/NotoSerif-Regular.ttf new file mode 100644 index 00000000..9fe4b298 Binary files /dev/null and b/assets/fonts/NotoSerif-Regular.ttf differ diff --git a/assets/fonts/NotoSerif-Regular.ttf.attribution b/assets/fonts/NotoSerif-Regular.ttf.attribution new file mode 100644 index 00000000..8d09443b --- /dev/null +++ b/assets/fonts/NotoSerif-Regular.ttf.attribution @@ -0,0 +1,8 @@ +asset: NotoSerif-Regular.ttf +title: Noto Serif Regular (serif face of the text engine / graphics) +author: The Noto Project Authors +source: https://github.com/notofonts/notofonts.github.io (fonts/NotoSerif/unhinted/ttf) +license: OFL-1.1 +license-file: assets/fonts/OFL-NotoSerif.txt +added: 2026-10-01 by Claude (agent) for Brandon Thomas +notes: Unmodified font file, bundled via include_bytes! in the text engine (crates/text/src/fonts.rs) for graphics text, titles and burn-ins. diff --git a/assets/fonts/OFL-NotoSerif.txt b/assets/fonts/OFL-NotoSerif.txt new file mode 100644 index 00000000..6843f318 --- /dev/null +++ b/assets/fonts/OFL-NotoSerif.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/crates/captions/Cargo.toml b/crates/captions/Cargo.toml index 55ce943c..dad44780 100644 --- a/crates/captions/Cargo.toml +++ b/crates/captions/Cargo.toml @@ -13,9 +13,8 @@ workspace = true filmcraft-time = { workspace = true } filmcraft-color = { workspace = true } filmcraft-project = { workspace = true } +filmcraft-text = { workspace = true } thiserror = { workspace = true } -# Font tables, glyph outlines and metrics (MIT OR Apache-2.0). Rasterisation is our own. -skrifa = { version = "0.44", default-features = false, features = ["std"] } [dev-dependencies] proptest = { workspace = true } diff --git a/crates/captions/README.md b/crates/captions/README.md index 919e057c..b988d65f 100644 --- a/crates/captions/README.md +++ b/crates/captions/README.md @@ -1,7 +1,7 @@ # filmcraft-captions Caption files and caption burn-in for FilmCraft (layer L2: depends on `filmcraft-time`, -`filmcraft-color`, `filmcraft-project` and `skrifa`). +`filmcraft-color`, `filmcraft-project` and `filmcraft-text`). The caption *model* (caption tracks in a sequence, captions, track style) lives in `filmcraft-project` (`caption.rs`) because it is part of the `.fcproj` document. This crate reads @@ -40,16 +40,13 @@ rasterises it to a small premultiplied linear-light RGBA overlay. `filmcraft-ren overlays of visible caption tracks over the finished frame (Program monitor and export burn-in). Text is set in **Inter SemiBold** (`assets/fonts/Inter-SemiBold.ttf`, SIL OFL 1.1, attributed in -`ATTRIBUTION.md`). `skrifa` (MIT OR Apache-2.0) provides glyph outlines and metrics; the -rasteriser (`raster.rs`, non-zero winding, 5 sub-scanlines with exact horizontal coverage) is our -own. Glyph masks are cached per character, size and quarter-pixel offset. There is no shaping or -kerning yet; that arrives with the M10.1 text engine. +`ATTRIBUTION.md`) by the `filmcraft-text` engine: shaped with kerning and ligatures, bidi-ordered, +and drawn from its sub-pixel positioned glyph cache. ## Tests - Unit tests per module (timestamps, decoding, sloppy SRT, WebVTT blocks and voices, CEA-608 - tables, PAC round trips, known SCC pop-on and roll-up streams, rasteriser coverage, glyphs, - layout). + tables, PAC round trips, known SCC pop-on and roll-up streams, layout). - `tests/roundtrip.rs`: property tests — SRT (also with BOM + CRLF), WebVTT (ids, speakers, settings, STYLE blocks) and SCC (drop-frame and non-drop, gaps and back-to-back captions) round-trip exactly; readers never panic on random bytes; SRT → VTT → SCC keeps text and frames. diff --git a/crates/captions/src/burn.rs b/crates/captions/src/burn.rs index 8fb7d75c..5c43e525 100644 --- a/crates/captions/src/burn.rs +++ b/crates/captions/src/burn.rs @@ -10,7 +10,7 @@ use filmcraft_color::srgb_to_linear; use filmcraft_project::{Caption, CaptionAlign, CaptionAnchor, CaptionStyle, CaptionTrack, Sequence}; use filmcraft_time::Tick; -use crate::font; +use filmcraft_text::{ParagraphStyle, TextStyle, layout, render}; /// A rendered caption: premultiplied linear-light RGBA f32 pixels at `(x, y)` in the frame. #[derive(Clone, Debug, PartialEq)] @@ -49,6 +49,15 @@ impl Overlay { } } +/// The caption face: Inter SemiBold at `px`. +fn caption_style(px: f32) -> TextStyle { + TextStyle { style: "SemiBold".into(), size: px, ..Default::default() } +} + +fn measure(text: &str, px: f32) -> f32 { + filmcraft_text::measure(text, &caption_style(px)) +} + fn lin(c: [u8; 4]) -> [f32; 4] { let a = c[3] as f32 / 255.0; [srgb_to_linear(c[0] as f32 / 255.0) * a, srgb_to_linear(c[1] as f32 / 255.0) * a, srgb_to_linear(c[2] as f32 / 255.0) * a, a] @@ -60,7 +69,7 @@ fn wrap(line: &str, px: f32, max_w: f32) -> Vec { let mut cur = String::new(); for word in line.split(' ') { let cand = if cur.is_empty() { word.to_string() } else { format!("{cur} {word}") }; - if !cur.is_empty() && font::measure(&cand, px) > max_w { + if !cur.is_empty() && measure(&cand, px) > max_w { out.push(std::mem::take(&mut cur)); cur = word.to_string(); } else { @@ -115,7 +124,8 @@ pub fn render_caption(c: &Caption, style: &CaptionStyle, w: usize, h: usize) -> if lines.is_empty() { return None; } - let (asc, desc) = font::metrics(px); + let vm = filmcraft_text::fonts::face(filmcraft_text::resolve("Inter", "SemiBold").face).metrics(px); + let (asc, desc) = (vm.ascent, vm.descent); let lh = px * style.line_spacing.max(0.8); let pad_x = (px * 0.3).round(); let block_h = lh * lines.len() as f32; @@ -131,7 +141,7 @@ pub fn render_caption(c: &Caption, style: &CaptionStyle, w: usize, h: usize) -> .max(0.0) .round(); let side = w as f32 * 0.05; - let widths: Vec = lines.iter().map(|l| font::measure(l, px)).collect(); + let widths: Vec = lines.iter().map(|l| measure(l, px)).collect(); let xs: Vec = widths .iter() .map(|&lw| match align { @@ -166,28 +176,10 @@ pub fn render_caption(c: &Caption, style: &CaptionStyle, w: usize, h: usize) -> } } let baseline = lt + (lh - (asc + desc)) / 2.0 + asc; - let mut pen = xs[i]; - for ch in line.chars() { - if let Some(g) = font::glyph(ch, px, pen.fract()) { - let gx = pen.floor() as i32 + g.left - ox; - let gy = baseline.round() as i32 + g.top - oy; - for y in 0..g.mask.h { - let ty = gy + y as i32; - if ty < 0 || ty >= oh as i32 { - continue; - } - for x in 0..g.mask.w { - let tx = gx + x as i32; - if tx < 0 || tx >= ow as i32 { - continue; - } - let j = ty as usize * ow + tx as usize; - cover[j] = (cover[j] + g.mask.get(x, y)).min(1.0); - } - } - } - pen += font::advance(ch, px); - } + let l = layout(line, &caption_style(px), &ParagraphStyle::default()); + let mut m = filmcraft_text::Mask { w: ow, h: oh, a: std::mem::take(&mut cover) }; + render::draw(&l, &render::at(xs[i], baseline.round()), &mut m, (ox, oy)); + cover = m.a; } let stroke = if outline > 0.0 { dilate(&cover, ow, oh, outline) } else { Vec::new() }; let (tc, bc, oc) = (lin(style.color), lin(style.background_color), lin(style.outline_color)); diff --git a/crates/captions/src/font.rs b/crates/captions/src/font.rs deleted file mode 100644 index 2aa1cf84..00000000 --- a/crates/captions/src/font.rs +++ /dev/null @@ -1,137 +0,0 @@ -//! The caption font: Inter SemiBold (SIL OFL 1.1, `assets/fonts/Inter-SemiBold.ttf`, attributed in -//! ATTRIBUTION.md), read with `skrifa` and rasterised by [`crate::raster`]. Glyph masks are -//! cached by (char, size, sub-pixel offset). - -use std::collections::HashMap; -use std::sync::{Arc, Mutex, OnceLock}; - -use skrifa::instance::{LocationRef, Size}; -use skrifa::outline::{DrawSettings, OutlinePen}; -use skrifa::{FontRef, MetadataProvider}; - -use crate::raster::{Mask, Path, fill}; - -static INTER_SEMIBOLD: &[u8] = include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf"); - -fn font() -> Option> { - FontRef::new(INTER_SEMIBOLD).ok() -} - -/// Vertical metrics at a pixel size: (ascent, descent), both positive. -pub fn metrics(px: f32) -> (f32, f32) { - match font() { - Some(f) => { - let m = f.metrics(Size::new(px), LocationRef::default()); - (m.ascent, -m.descent) - } - None => (px * 0.8, px * 0.2), - } -} - -/// Horizontal advance of a character at a pixel size. -pub fn advance(c: char, px: f32) -> f32 { - let Some(f) = font() else { return px * 0.5 }; - let gid = f.charmap().map(c).or_else(|| f.charmap().map('?')); - gid.and_then(|g| f.glyph_metrics(Size::new(px), LocationRef::default()).advance_width(g)).unwrap_or(px * 0.5) -} - -/// Width of a single line of text. -pub fn measure(text: &str, px: f32) -> f32 { - text.chars().map(|c| advance(c, px)).sum() -} - -struct Pen { - p: Path, -} - -impl OutlinePen for Pen { - fn move_to(&mut self, x: f32, y: f32) { - self.p.move_to(x, -y); - } - fn line_to(&mut self, x: f32, y: f32) { - self.p.line_to(x, -y); - } - fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) { - self.p.quad_to(cx0, -cy0, x, -y); - } - fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) { - self.p.cubic_to(cx0, -cy0, cx1, -cy1, x, -y); - } - fn close(&mut self) { - self.p.close(); - } -} - -/// A rasterised glyph: coverage mask placed with its top-left at `(left, top)` relative to the -/// pen position on the baseline. -#[derive(Debug)] -pub struct Glyph { - pub mask: Mask, - pub left: i32, - pub top: i32, -} - -type Key = (char, u32, u8); - -fn cache() -> &'static Mutex>>> { - static C: OnceLock>>>> = OnceLock::new(); - C.get_or_init(Default::default) -} - -/// Rasterise `c` at `px` with the pen at fractional x offset `frac` (0..1, quantised to ¼ px). -pub fn glyph(c: char, px: f32, frac: f32) -> Option> { - let size_q = (px * 4.0).round().max(1.0) as u32; - let frac_q = ((frac.rem_euclid(1.0) * 4.0).round() as u8) % 4; - let key = (c, size_q, frac_q); - if let Some(g) = cache().lock().unwrap_or_else(|e| e.into_inner()).get(&key) { - return g.clone(); - } - let g = render_glyph(c, size_q as f32 / 4.0, frac_q as f32 / 4.0).map(Arc::new); - let mut m = cache().lock().unwrap_or_else(|e| e.into_inner()); - if m.len() > 8192 { - m.clear(); - } - m.insert(key, g.clone()); - g -} - -fn render_glyph(c: char, px: f32, frac: f32) -> Option { - if c.is_whitespace() { - return None; - } - let f = font()?; - let gid = f.charmap().map(c).or_else(|| f.charmap().map('?'))?; - let outline = f.outline_glyphs().get(gid)?; - let mut pen = Pen { p: Path::default() }; - outline.draw(DrawSettings::unhinted(Size::new(px), LocationRef::default()), &mut pen).ok()?; - pen.p.close(); - let (x0, y0, x1, y1) = pen.p.bounds()?; - let left = (x0 + frac).floor() as i32; - let top = y0.floor() as i32; - let w = ((x1 + frac).ceil() as i32 - left).max(1) as usize; - let h = (y1.ceil() as i32 - top).max(1) as usize; - let mask = fill(&pen.p, w, h, frac - left as f32, -top as f32); - Some(Glyph { mask, left, top }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn inter_loads_and_draws() { - let (a, d) = metrics(100.0); - assert!(a > 80.0 && a < 110.0, "ascent {a}"); - assert!(d > 10.0 && d < 40.0, "descent {d}"); - assert!(advance('W', 100.0) > advance('i', 100.0)); - let g = glyph('H', 40.0, 0.0).unwrap(); - // the H has two stems: the middle row has ink on both sides and a gap between them - let y = g.mask.h / 4; - let row: Vec = (0..g.mask.w).map(|x| g.mask.get(x, y)).collect(); - assert!(row[1] > 0.9 && row[g.mask.w - 2] > 0.9, "{row:?}"); - assert!(row[g.mask.w / 2] < 0.1, "{row:?}"); - assert!(g.top < 0, "glyph rises above the baseline"); - assert!(glyph(' ', 40.0, 0.0).is_none()); - assert!(measure("Hello", 30.0) > 50.0); - } -} diff --git a/crates/captions/src/lib.rs b/crates/captions/src/lib.rs index 4c8b24b7..d10c98d1 100644 --- a/crates/captions/src/lib.rs +++ b/crates/captions/src/lib.rs @@ -10,13 +10,11 @@ //! (drop-frame or not) convert exactly; [`Document::snap_to_frames`] snaps to a sequence's frames. //! - **Model:** [`track_from_document`] / [`document_from_track`] convert to and from the project's //! [`CaptionTrack`]. -//! - **Burn-in:** [`burn`] lays a caption out with the track style and rasterises it with the -//! bundled Inter font (outlines via `skrifa`, coverage rasteriser in [`raster`]). +//! - **Burn-in:** [`burn`] lays a caption out with the track style and draws it with the +//! `filmcraft-text` engine (Inter SemiBold, shaped and kerned). pub mod burn; pub mod cea608; -pub mod font; -pub mod raster; pub mod scc; pub mod srt; pub mod vtt; diff --git a/crates/captions/src/raster.rs b/crates/captions/src/raster.rs deleted file mode 100644 index ff8dcc38..00000000 --- a/crates/captions/src/raster.rs +++ /dev/null @@ -1,228 +0,0 @@ -//! A small anti-aliased path rasteriser for glyph outlines. -//! -//! Curves are flattened to line segments; each pixel row is sampled at [`SUB`] sub-scanlines, and -//! on each sub-scanline the non-zero winding spans between edge crossings are accumulated with -//! exact horizontal coverage at the span ends. Original implementation for FilmCraft. - -/// Vertical sub-samples per pixel row. -pub const SUB: usize = 5; - -/// A flattened path: line segments `(x0, y0, x1, y1)` in pixel space, y down. -#[derive(Clone, Debug, Default)] -pub struct Path { - pub segs: Vec<[f32; 4]>, - start: (f32, f32), - cur: (f32, f32), - open: bool, -} - -impl Path { - pub fn move_to(&mut self, x: f32, y: f32) { - self.close(); - self.start = (x, y); - self.cur = (x, y); - self.open = true; - } - pub fn line_to(&mut self, x: f32, y: f32) { - if (x, y) != self.cur { - self.segs.push([self.cur.0, self.cur.1, x, y]); - } - self.cur = (x, y); - } - pub fn quad_to(&mut self, cx: f32, cy: f32, x: f32, y: f32) { - let (x0, y0) = self.cur; - let dd = ((x0 - 2.0 * cx + x).abs() + (y0 - 2.0 * cy + y).abs()).max(0.01); - let n = ((dd * 2.0).sqrt().ceil() as usize).clamp(1, 32); - for i in 1..=n { - let t = i as f32 / n as f32; - let u = 1.0 - t; - self.line_to(u * u * x0 + 2.0 * u * t * cx + t * t * x, u * u * y0 + 2.0 * u * t * cy + t * t * y); - } - } - pub fn cubic_to(&mut self, c1x: f32, c1y: f32, c2x: f32, c2y: f32, x: f32, y: f32) { - let (x0, y0) = self.cur; - let dd = (x0 - 2.0 * c1x + c2x).abs() + (y0 - 2.0 * c1y + c2y).abs() + (c1x - 2.0 * c2x + x).abs() + (c1y - 2.0 * c2y + y).abs(); - let n = (((dd.max(0.01)) * 3.0).sqrt().ceil() as usize).clamp(1, 48); - for i in 1..=n { - let t = i as f32 / n as f32; - let u = 1.0 - t; - let (a, b, c, d) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t); - self.line_to(a * x0 + b * c1x + c * c2x + d * x, a * y0 + b * c1y + c * c2y + d * y); - } - } - pub fn close(&mut self) { - if self.open && self.cur != self.start { - self.segs.push([self.cur.0, self.cur.1, self.start.0, self.start.1]); - } - self.cur = self.start; - self.open = false; - } - /// Bounding box `(x0, y0, x1, y1)` of all segments. - pub fn bounds(&self) -> Option<(f32, f32, f32, f32)> { - let mut it = self.segs.iter(); - let f = it.next()?; - let mut b = (f[0].min(f[2]), f[1].min(f[3]), f[0].max(f[2]), f[1].max(f[3])); - for s in it { - b = (b.0.min(s[0]).min(s[2]), b.1.min(s[1]).min(s[3]), b.2.max(s[0]).max(s[2]), b.3.max(s[1]).max(s[3])); - } - Some(b) - } -} - -/// Coverage mask (0..1), row-major. -#[derive(Clone, Debug, PartialEq)] -pub struct Mask { - pub w: usize, - pub h: usize, - pub a: Vec, -} - -impl Mask { - pub fn new(w: usize, h: usize) -> Self { - Self { w, h, a: vec![0.0; w * h] } - } - pub fn get(&self, x: usize, y: usize) -> f32 { - self.a[y * self.w + x] - } -} - -/// Fill `path` (non-zero winding) into a `w`×`h` mask, offsetting the path by `(dx, dy)`. -pub fn fill(path: &Path, w: usize, h: usize, dx: f32, dy: f32) -> Mask { - let mut m = Mask::new(w, h); - if w == 0 || h == 0 { - return m; - } - // edges: (ytop, ybot, x at ytop, dx/dy, winding) - let mut edges: Vec<(f32, f32, f32, f32, i32)> = path - .segs - .iter() - .filter(|s| s[1] != s[3]) - .map(|s| { - let (x0, y0, x1, y1) = (s[0] + dx, s[1] + dy, s[2] + dx, s[3] + dy); - let (dir, xa, ya, xb, yb) = if y0 < y1 { (1, x0, y0, x1, y1) } else { (-1, x1, y1, x0, y0) }; - (ya, yb, xa, (xb - xa) / (yb - ya), dir) - }) - .collect(); - edges.sort_by(|a, b| a.0.total_cmp(&b.0)); - let mut xs: Vec<(f32, i32)> = Vec::new(); - let mut row = vec![0.0f32; w + 1]; - let weight = 1.0 / SUB as f32; - for y in 0..h { - row.iter_mut().for_each(|v| *v = 0.0); - let mut any = false; - for k in 0..SUB { - let sy = y as f32 + (k as f32 + 0.5) / SUB as f32; - xs.clear(); - for e in &edges { - if e.0 > sy { - break; // sorted by top - } - if sy < e.1 { - xs.push((e.2 + (sy - e.0) * e.3, e.4)); - } - } - if xs.len() < 2 { - continue; - } - xs.sort_by(|a, b| a.0.total_cmp(&b.0)); - let mut wind = 0; - let mut span_start = 0.0f32; - for &(x, d) in &xs { - let before = wind; - wind += d; - if before == 0 && wind != 0 { - span_start = x; - } else if before != 0 && wind == 0 { - add_span(&mut row, span_start, x, weight, w); - any = true; - } - } - } - if any { - let out = &mut m.a[y * w..(y + 1) * w]; - for (o, v) in out.iter_mut().zip(&row) { - *o = v.min(1.0); - } - } - } - m -} - -/// Add coverage `weight` over `[x0, x1)` with fractional ends. -fn add_span(row: &mut [f32], x0: f32, x1: f32, weight: f32, w: usize) { - let x0 = x0.clamp(0.0, w as f32); - let x1 = x1.clamp(0.0, w as f32); - if x1 <= x0 { - return; - } - let i0 = x0.floor() as usize; - let i1 = x1.floor() as usize; - if i0 == i1 { - row[i0] += (x1 - x0) * weight; - return; - } - row[i0] += (i0 as f32 + 1.0 - x0) * weight; - for v in &mut row[i0 + 1..i1] { - *v += weight; - } - if i1 < w { - row[i1] += (x1 - i1 as f32) * weight; - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn rect(x0: f32, y0: f32, x1: f32, y1: f32) -> Path { - let mut p = Path::default(); - p.move_to(x0, y0); - p.line_to(x1, y0); - p.line_to(x1, y1); - p.line_to(x0, y1); - p.close(); - p - } - - #[test] - fn rectangle_coverage() { - let m = fill(&rect(1.5, 2.0, 4.0, 5.0), 6, 6, 0.0, 0.0); - assert!((m.get(1, 3) - 0.5).abs() < 1e-5); - assert!((m.get(2, 3) - 1.0).abs() < 1e-5); - assert_eq!(m.get(4, 3), 0.0); - assert_eq!(m.get(2, 1), 0.0); - assert_eq!(m.get(2, 5), 0.0); - let total: f32 = m.a.iter().sum(); - assert!((total - 2.5 * 3.0).abs() < 1e-3, "{total}"); - } - - #[test] - fn hole_with_opposite_winding() { - let mut p = rect(0.0, 0.0, 10.0, 10.0); - // inner square wound the other way - p.move_to(3.0, 3.0); - p.line_to(3.0, 7.0); - p.line_to(7.0, 7.0); - p.line_to(7.0, 3.0); - p.close(); - let m = fill(&p, 10, 10, 0.0, 0.0); - assert_eq!(m.get(5, 5), 0.0); - assert!((m.get(1, 5) - 1.0).abs() < 1e-5); - } - - #[test] - fn circle_area() { - let mut p = Path::default(); - let (cx, cy, r) = (10.0f32, 10.0f32, 6.0f32); - p.move_to(cx + r, cy); - for i in 1..=64 { - let a = i as f32 / 64.0 * std::f32::consts::TAU; - p.line_to(cx + r * a.cos(), cy + r * a.sin()); - } - p.close(); - let m = fill(&p, 20, 20, 0.0, 0.0); - let total: f32 = m.a.iter().sum(); - let want = std::f32::consts::PI * r * r; - assert!((total - want).abs() / want < 0.02, "{total} vs {want}"); - } -} diff --git a/crates/golden/goldens/text_burnin.png b/crates/golden/goldens/text_burnin.png index e447d3dd..105c26ab 100644 Binary files a/crates/golden/goldens/text_burnin.png and b/crates/golden/goldens/text_burnin.png differ diff --git a/crates/golden/tests/golden.rs b/crates/golden/tests/golden.rs index ddc3da30..01747e31 100644 --- a/crates/golden/tests/golden.rs +++ b/crates/golden/tests/golden.rs @@ -223,7 +223,7 @@ fn transition(effect: &str, frame: i64) -> Scene { b.at(frame) } -/// Timecode and Clip Name burn-ins (FilmCraft's built-in bitmap text). +/// Timecode and Clip Name burn-ins (text engine: JetBrains Mono / Inter). fn text_burnin() -> Scene { let mut b = Builder::new(1); let bg = b.demo(DemoScene::CityNight); diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index 7c9b9c9e..315bd48c 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -18,6 +18,7 @@ filmcraft-media = { workspace = true } filmcraft-project = { workspace = true } filmcraft-audio-dsp = { workspace = true } filmcraft-captions = { workspace = true } +filmcraft-text = { workspace = true } rayon = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/render/src/effects.rs b/crates/render/src/effects.rs index 2136fda7..fe40bbd4 100644 --- a/crates/render/src/effects.rs +++ b/crates/render/src/effects.rs @@ -716,40 +716,16 @@ pub fn apply(img: &mut Image, e: &EffectInstance, cx: &FxCtx) { }); } "timecode" | "clip_name" => { - let text = if e.effect == "timecode" { - cx.timecode.to_string() - } else { - cx.clip_name.chars().filter(|c| c.is_ascii_digit() || *c == ':' || *c == '-').collect() - }; + let (text, family) = if e.effect == "timecode" { (cx.timecode.to_string(), "JetBrains Mono") } else { (cx.clip_name.to_string(), "Inter") }; let pos = point(e, "position", cx, img); - let size = (f(e, "size", cx) / 100.0 * img.h as f32 * 0.5).max(6.0) as i32; - burn_text(img, &text, pos, size); + let px = (f(e, "size", cx) / 100.0 * img.h as f32 * 0.5).max(6.0); + let box_alpha = if e.effect == "timecode" { (f(e, "opacity", cx) / 100.0).clamp(0.0, 1.0) * 0.8 } else { 0.8 }; + crate::graphics::burn_text(img, &text, family, pos, px, box_alpha); } _ => {} } } -fn burn_text(img: &mut Image, text: &str, pos: Vec2, size: i32) { - let tw = filmcraft_media::digits::text_width(size, text); - let (w, h) = (img.w, img.h); - let mut buf = vec![0u8; w * h * 4]; - let x = pos.x as i32 - tw / 2; - let y = pos.y as i32 - size / 2; - filmcraft_media::digits::fill_rect(&mut buf, w, h, x - size / 4, y - size / 4, tw + size / 2, size + size / 2, [0, 0, 0, 200]); - filmcraft_media::digits::draw_text(&mut buf, w, h, x, y, size, text, [255, 255, 255, 255]); - img.px.par_chunks_mut(w * 4).zip(buf.par_chunks(w * 4)).for_each(|(d, s)| { - for (d, s) in d.chunks_exact_mut(4).zip(s.chunks_exact(4)) { - let a = s[3] as f32 / 255.0; - if a > 0.0 { - for k in 0..3 { - d[k] = srgb_to_linear(s[k] as f32 / 255.0) * a + d[k] * (1.0 - a); - } - d[3] = a + d[3] * (1.0 - a); - } - } - }); -} - /// Fill an opaque generated colour over the image at `op` opacity (Generate category). fn fill_over(img: &mut Image, op: f32, f: impl Fn(f32, f32) -> [f32; 3] + Sync) { let w = img.w; diff --git a/crates/render/src/graphics.rs b/crates/render/src/graphics.rs new file mode 100644 index 00000000..3664aa01 --- /dev/null +++ b/crates/render/src/graphics.rs @@ -0,0 +1,72 @@ +//! Text and vector graphics drawn with `filmcraft-text`: the Timecode / Clip Name burn-ins and +//! (see below) graphic clips' text and shape layers. + +use filmcraft_color::srgb_to_linear; +use filmcraft_geom::Vec2; +use filmcraft_text::{Align, Mask, ParagraphStyle, TextStyle, layout, render}; +use rayon::prelude::*; + +use crate::image::Image; + +/// sRGB-encoded straight colour (0..1) → premultiplied linear. +pub fn premul_linear(c: [f32; 4]) -> [f32; 4] { + let a = c[3].clamp(0.0, 1.0); + [srgb_to_linear(c[0]) * a, srgb_to_linear(c[1]) * a, srgb_to_linear(c[2]) * a, a] +} + +/// Composite `color` (premultiplied linear) through coverage `m` placed at (`x0`, `y0`), scaled by +/// `opacity`, over `img`. +pub fn paint_mask(img: &mut Image, m: &Mask, x0: i32, y0: i32, color: [f32; 4], opacity: f32) { + if m.w == 0 || m.h == 0 || color[3] <= 0.0 || opacity <= 0.0 { + return; + } + let w = img.w; + let ys = y0.max(0) as usize..((y0 + m.h as i32).min(img.h as i32)).max(0) as usize; + if ys.is_empty() { + return; + } + let xs = x0.max(0)..(x0 + m.w as i32).min(w as i32); + if xs.is_empty() { + return; + } + let rows = &mut img.px[ys.start * w * 4..ys.end * w * 4]; + rows.par_chunks_mut(w * 4).enumerate().for_each(|(ry, row)| { + let my = (ys.start + ry) as i32 - y0; + let mrow = &m.a[my as usize * m.w..(my as usize + 1) * m.w]; + for x in xs.clone() { + let cov = mrow[(x - x0) as usize] * opacity; + if cov <= 0.0 { + continue; + } + let p = &mut row[x as usize * 4..x as usize * 4 + 4]; + let k = 1.0 - color[3] * cov; + for c in 0..4 { + p[c] = color[c] * cov + p[c] * k; + } + } + }); +} + +/// Draw `text` centred on `pos` (layer pixels) at `px` pixels in white over a black box of +/// `box_alpha` (the Timecode and Clip Name effects). +pub fn burn_text(img: &mut Image, text: &str, family: &str, pos: Vec2, px: f32, box_alpha: f32) { + if text.is_empty() || pos.x.is_nan() { + return; + } + let st = TextStyle { family: family.into(), style: "Regular".into(), size: px, ..Default::default() }; + let l = layout(text, &st, &ParagraphStyle { align: Align::Center, ..Default::default() }); + let vm = filmcraft_text::fonts::face(filmcraft_text::resolve(family, "Regular").face).metrics(px); + // centre the cap height on pos + let (bx, by) = (pos.x as f32, pos.y as f32 + vm.cap_height / 2.0); + let pad = px * 0.25; + let b = l.bounds; + let boxp = filmcraft_text::Path::rect(bx + b[0] - pad, by - vm.cap_height - pad, bx + b[2] + pad, by + vm.descent * 0.6 + pad); + if let Some(bb) = boxp.bounds() { + let (x0, y0) = (bb.0.floor() as i32, bb.1.floor() as i32); + let m = + filmcraft_text::raster::fill(&boxp, (bb.2.ceil() as i32 - x0).max(1) as usize, (bb.3.ceil() as i32 - y0).max(1) as usize, -x0 as f32, -y0 as f32); + paint_mask(img, &m, x0, y0, [0.0, 0.0, 0.0, 1.0], box_alpha); + } + let (m, x0, y0) = render::rasterize(&l, &render::at(bx, by.round())); + paint_mask(img, &m, x0, y0, [1.0, 1.0, 1.0, 1.0], 1.0); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index a723b6bc..1cb40933 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -11,6 +11,7 @@ pub mod audio; pub mod audio_fx; pub mod blend; pub mod effects; +pub mod graphics; pub mod image; pub mod plan; pub mod preview; diff --git a/crates/text/Cargo.toml b/crates/text/Cargo.toml new file mode 100644 index 00000000..d9f7f8e6 --- /dev/null +++ b/crates/text/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "filmcraft-text" +description = "FilmCraft text engine: font database (bundled + system), shaping, bidi, line breaking, paragraph layout and anti-aliased glyph/path rasterisation" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[lints] +workspace = true + +[dependencies] +filmcraft-color = { workspace = true } +# Font tables, glyph outlines and metrics (MIT OR Apache-2.0). Rasterisation is our own. +skrifa = { version = "0.44", default-features = false, features = ["std"] } +# OpenType shaping (HarfBuzz port on read-fonts, MIT); shares font parsing with skrifa. +harfrust = { version = "0.12", default-features = false, features = ["std"] } +# Unicode Bidirectional Algorithm (MIT OR Apache-2.0). +unicode-bidi = "0.3.18" +# UAX #14 line breaking (Apache-2.0). +unicode-linebreak = "0.1.5" diff --git a/crates/text/README.md b/crates/text/README.md new file mode 100644 index 00000000..b55c0dab --- /dev/null +++ b/crates/text/README.md @@ -0,0 +1,30 @@ +# filmcraft-text + +FilmCraft's text engine (layer L1: depends on `filmcraft-color`, `skrifa`, `harfrust`, +`unicode-bidi` and `unicode-linebreak`). It draws graphic-clip text and shapes, caption burn-in +and the Timecode / Clip Name effects. Pure Rust; it builds for `wasm32-unknown-unknown`. + +| Module | What | +|---|---| +| `fonts` | Font database. Bundled faces (always present, also on the web): Inter Regular / Medium / SemiBold / Bold / Italic, JetBrains Mono Regular, Noto Serif Regular — all SIL OFL 1.1, unmodified, in `assets/fonts/` with `.attribution` sidecars. More faces come from `FontSource`s: `DirectorySource::system()` scans the platform font folders on first need (only the `name` and `OS/2` tables are read, via `sfnt`); on wasm the scan finds nothing and hosts can call `add_font_data`. `resolve(family, style)` picks a face, synthesises bold/italic when the family lacks them, and falls back to Inter (flagged `missing`). `fallback_for` finds a face for characters the chosen face lacks. | +| `layout` | Paragraph layout: per-character font fallback → bidi levels (UAX #9) → items (runs of one face, direction and size) shaped with harfrust (OpenType kerning, ligatures, marks, complex scripts) → tracking → line breaking at UAX #14 opportunities (area text) → visual reordering per line → alignment (left / centre / right / justify), leading (added to the natural line height), baseline shift, all caps / synthesised small caps (78 %), underline. Every character boundary gets a caret stop (ligatures are split evenly), so editors can place carets, hit-test and draw selections. Layouts are cached by text + style (512 entries, LRU). | +| `raster` | Our anti-aliased rasteriser (non-zero winding, 5 sub-scanlines, exact horizontal coverage, active-edge list), contour paths with transforms, rounded rectangles, ellipses, polygons and outline emboldening. Coverage is linear, so compositing in linear light is correct. | +| `render` | Glyph outlines (cached per face + glyph) and glyph masks (cached per size in ⅛ px, ¼-px horizontal phase, synthetic style). Axis-aligned uniform transforms use the mask cache with sub-pixel x positioning; rotated / skewed / non-uniform transforms fill the transformed outlines of the whole run in one pass. Synthetic bold grows outlines by 2.5 % of the size; synthetic italic slants by 12°. | +| `mask` | Outer / centre / inner strokes from an exact Euclidean distance transform (Felzenszwalb–Huttenlocher) with sub-pixel refinement, a three-pass box blur (≈ Gaussian) for shadows, offsets. | + +## Coordinates + +Pixels, y down. Point text: the origin is the alignment point on the first baseline (left edge, +centre or right edge). Area text (`ParagraphStyle::width`): the origin is the box's top-left. + +## Performance + +A three-line, centred, 96 px bold title at 1080p (`perf::three_line_title_is_fast`), release build +on an M-series Mac: about 1.6 ms cold (shaping, outline extraction, glyph rasterisation) and +0.15 ms warm (layout cache + glyph cache). The test fails if the warm path exceeds 40 ms. + +## Not yet + +Per-character styling within one text layer (the whole layer has one style), vertical text, OpenType +feature toggles beyond kerning/ligatures, variable-font axes, hinting, colour (emoji) glyphs, and +hyphenation. Words longer than an area-text line overflow instead of breaking mid-word. diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs new file mode 100644 index 00000000..290a27e1 --- /dev/null +++ b/crates/text/src/fonts.rs @@ -0,0 +1,475 @@ +//! The font database: bundled fonts (always available, also on the web) plus fonts discovered by +//! [`FontSource`]s (system font folders on native platforms). +//! +//! Faces are addressed by [`FaceId`] (an index that never changes for the life of the process). +//! [`resolve`] maps a family + style name (as stored in projects) to a face and the synthetic +//! bold/italic needed when the family lacks that style. Unknown families fall back to Inter. + +use std::path::PathBuf; +use std::sync::{Arc, OnceLock, RwLock}; + +use skrifa::instance::{LocationRef, Size}; +use skrifa::{FontRef, MetadataProvider}; + +use crate::sfnt::{FaceNames, read_faces, read_faces_bytes}; + +/// Bundled fonts (SIL OFL 1.1; see `assets/fonts/*.attribution` and ATTRIBUTION.md). +pub static INTER_REGULAR: &[u8] = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); +pub static INTER_MEDIUM: &[u8] = include_bytes!("../../../assets/fonts/Inter-Medium.ttf"); +pub static INTER_SEMIBOLD: &[u8] = include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf"); +pub static INTER_BOLD: &[u8] = include_bytes!("../../../assets/fonts/Inter-Bold.ttf"); +pub static INTER_ITALIC: &[u8] = include_bytes!("../../../assets/fonts/Inter-Italic.ttf"); +pub static JETBRAINS_MONO_REGULAR: &[u8] = include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf"); +pub static NOTO_SERIF_REGULAR: &[u8] = include_bytes!("../../../assets/fonts/NotoSerif-Regular.ttf"); + +/// The default family for new text. +pub const DEFAULT_FAMILY: &str = "Inter"; + +/// Index of a face in the database. +pub type FaceId = usize; + +/// Where a face's bytes come from. +#[derive(Clone, Debug)] +pub enum FaceData { + Static(&'static [u8]), + Shared(Arc>), + /// A file read on first use. + File(PathBuf), +} + +/// Description of a face offered by a [`FontSource`]. +#[derive(Clone, Debug)] +pub struct FaceInfo { + pub family: String, + pub style: String, + pub weight: u16, + pub italic: bool, + pub index: u32, + pub data: FaceData, + /// "bundled", "system", … + pub origin: &'static str, +} + +/// Something that can list fonts (system folders, a web font picker, a project's font folder…). +pub trait FontSource: Send + Sync { + fn faces(&self) -> Vec; +} + +/// Scans font folders on disk (reading only name tables). Native only; on the web there are no +/// folders and the scan finds nothing. +pub struct DirectorySource { + pub dirs: Vec, +} + +impl DirectorySource { + /// The platform's standard font folders. + pub fn system() -> Self { + let mut dirs: Vec = Vec::new(); + let home = std::env::var_os("HOME").map(PathBuf::from); + if cfg!(target_os = "macos") { + dirs.extend(["/System/Library/Fonts", "/Library/Fonts"].map(PathBuf::from)); + if let Some(h) = &home { + dirs.push(h.join("Library/Fonts")); + } + } else if cfg!(windows) { + let win = std::env::var_os("WINDIR").map(PathBuf::from).unwrap_or_else(|| PathBuf::from("C:\\Windows")); + dirs.push(win.join("Fonts")); + if let Some(l) = std::env::var_os("LOCALAPPDATA") { + dirs.push(PathBuf::from(l).join("Microsoft\\Windows\\Fonts")); + } + } else if cfg!(unix) && !cfg!(target_arch = "wasm32") { + dirs.extend(["/usr/share/fonts", "/usr/local/share/fonts"].map(PathBuf::from)); + if let Some(h) = &home { + dirs.push(h.join(".local/share/fonts")); + dirs.push(h.join(".fonts")); + } + } + Self { dirs } + } +} + +fn walk(dir: &std::path::Path, depth: u32, out: &mut Vec) { + let Ok(rd) = std::fs::read_dir(dir) else { return }; + for e in rd.flatten() { + let p = e.path(); + if p.is_dir() { + if depth < 4 { + walk(&p, depth + 1, out); + } + } else if p.extension().and_then(|x| x.to_str()).is_some_and(|x| matches!(x.to_ascii_lowercase().as_str(), "ttf" | "otf" | "ttc" | "otc")) { + out.push(p); + } + } +} + +impl FontSource for DirectorySource { + fn faces(&self) -> Vec { + let mut files = Vec::new(); + for d in &self.dirs { + walk(d, 0, &mut files); + } + files.sort(); + let mut out = Vec::new(); + for f in files { + let Ok(mut file) = std::fs::File::open(&f) else { continue }; + for n in read_faces(&mut file) { + out.push(info_from(n, FaceData::File(f.clone()), "system")); + } + } + out + } +} + +fn info_from(n: FaceNames, data: FaceData, origin: &'static str) -> FaceInfo { + FaceInfo { family: n.family, style: n.style, weight: n.weight, italic: n.italic, index: n.index, data, origin } +} + +/// A loaded (or loadable) face. +pub struct Face { + pub id: FaceId, + pub info: FaceInfo, + bytes: OnceLock>, + shaper: OnceLock, +} + +#[derive(Clone)] +enum FaceBytes { + Static(&'static [u8]), + Shared(Arc>), +} + +impl FaceBytes { + fn get(&self) -> &[u8] { + match self { + FaceBytes::Static(b) => b, + FaceBytes::Shared(b) => b, + } + } +} + +/// Vertical metrics at a pixel size (all positive distances, y down from the baseline for the +/// underline position). +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct VMetrics { + pub ascent: f32, + pub descent: f32, + pub line_gap: f32, + pub cap_height: f32, + pub x_height: f32, + pub underline_pos: f32, + pub underline_thickness: f32, +} + +impl Face { + fn bytes(&self) -> Option<&[u8]> { + self.bytes + .get_or_init(|| match &self.info.data { + FaceData::Static(b) => Some(FaceBytes::Static(b)), + FaceData::Shared(b) => Some(FaceBytes::Shared(b.clone())), + FaceData::File(p) => std::fs::read(p).ok().map(|v| FaceBytes::Shared(Arc::new(v))), + }) + .as_ref() + .map(FaceBytes::get) + } + /// The parsed font (None if the file is missing or unreadable). + pub fn font(&self) -> Option> { + FontRef::from_index(self.bytes()?, self.info.index).ok() + } + pub(crate) fn shaper_data(&self) -> Option<&harfrust::ShaperData> { + let f = self.font()?; + Some(self.shaper.get_or_init(|| harfrust::ShaperData::new(&f))) + } + pub fn units_per_em(&self) -> f32 { + self.font().map(|f| f.metrics(Size::unscaled(), LocationRef::default()).units_per_em as f32).filter(|u| *u > 0.0).unwrap_or(1000.0) + } + /// Glyph for a character (None when the face does not cover it). + pub fn glyph(&self, c: char) -> Option { + self.font()?.charmap().map(c).map(|g| g.to_u32()).filter(|g| *g != 0) + } + pub fn has_char(&self, c: char) -> bool { + self.glyph(c).is_some() + } + pub fn metrics(&self, px: f32) -> VMetrics { + let Some(f) = self.font() else { + return VMetrics { + ascent: px * 0.8, + descent: px * 0.2, + cap_height: px * 0.7, + x_height: px * 0.5, + underline_pos: px * 0.1, + underline_thickness: px * 0.06, + ..Default::default() + }; + }; + let m = f.metrics(Size::new(px), LocationRef::default()); + VMetrics { + ascent: m.ascent, + descent: -m.descent, + line_gap: m.leading, + cap_height: m.cap_height.unwrap_or(m.ascent * 0.7), + x_height: m.x_height.unwrap_or(m.ascent * 0.5), + underline_pos: m.underline.map_or(px * 0.1, |u| -u.offset), + underline_thickness: m.underline.map_or(px * 0.06, |u| u.thickness.max(px * 0.03)), + } + } + /// Whether the face's GSUB offers an OpenType feature (e.g. `smcp`). + pub fn has_feature(&self, tag: &[u8; 4]) -> bool { + use skrifa::raw::TableProvider; + let Some(f) = self.font() else { return false }; + let Ok(gsub) = f.gsub() else { return false }; + let Ok(list) = gsub.feature_list() else { return false }; + list.feature_records().iter().any(|r| r.feature_tag().to_be_bytes() == *tag) + } +} + +struct Db { + faces: Vec>, + scanned: bool, +} + +fn db() -> &'static RwLock { + static DB: OnceLock> = OnceLock::new(); + DB.get_or_init(|| { + let mut db = Db { faces: Vec::new(), scanned: false }; + for b in [INTER_REGULAR, INTER_MEDIUM, INTER_SEMIBOLD, INTER_BOLD, INTER_ITALIC, JETBRAINS_MONO_REGULAR, NOTO_SERIF_REGULAR] { + for n in read_faces_bytes(b) { + push(&mut db, info_from(n, FaceData::Static(b), "bundled")); + } + } + RwLock::new(db) + }) +} + +fn push(db: &mut Db, info: FaceInfo) -> FaceId { + let id = db.faces.len(); + db.faces.push(Arc::new(Face { id, info, bytes: OnceLock::new(), shaper: OnceLock::new() })); + id +} + +/// Add faces from a source (skipping family+style pairs already present). Returns how many were +/// added. +pub fn add_source(src: &dyn FontSource) -> usize { + let faces = src.faces(); + let mut d = db().write().unwrap_or_else(|e| e.into_inner()); + let mut n = 0; + for f in faces { + let dup = d.faces.iter().any(|g| g.info.family.eq_ignore_ascii_case(&f.family) && g.info.style.eq_ignore_ascii_case(&f.style)); + if !dup { + push(&mut d, f); + n += 1; + } + } + n +} + +/// Register a font from memory (e.g. a font file the user picked on the web). Returns its faces. +pub fn add_font_data(data: Vec) -> Vec { + let data = Arc::new(data); + let names = read_faces_bytes(&data); + let mut d = db().write().unwrap_or_else(|e| e.into_inner()); + names.into_iter().map(|n| push(&mut d, info_from(n, FaceData::Shared(data.clone()), "user"))).collect() +} + +/// Scan the system font folders once (native only; a no-op on wasm). Returns the number of faces +/// added by this call. +pub fn scan_system() -> usize { + { + let mut d = db().write().unwrap_or_else(|e| e.into_inner()); + if d.scanned { + return 0; + } + d.scanned = true; + } + if cfg!(target_arch = "wasm32") { + return 0; + } + add_source(&DirectorySource::system()) +} + +pub fn system_scanned() -> bool { + db().read().unwrap_or_else(|e| e.into_inner()).scanned +} + +/// A face by id (panics never: unknown ids give the default face). +pub fn face(id: FaceId) -> Arc { + let d = db().read().unwrap_or_else(|e| e.into_inner()); + d.faces.get(id).or_else(|| d.faces.first()).cloned().expect("bundled fonts present") +} + +/// All faces (bundled first). +pub fn all_faces() -> Vec> { + db().read().unwrap_or_else(|e| e.into_inner()).faces.clone() +} + +/// Families with their style names, sorted by family; styles in weight order. +pub fn families() -> Vec<(String, Vec)> { + let mut m: std::collections::BTreeMap> = Default::default(); + for f in all_faces() { + let v = m.entry(f.info.family.clone()).or_default(); + if !v.iter().any(|(_, _, s)| s == &f.info.style) { + v.push((f.info.italic, f.info.weight, f.info.style.clone())); + } + } + m.into_iter() + .map(|(k, mut v)| { + v.sort(); + (k, v.into_iter().map(|(_, _, s)| s).collect()) + }) + .collect() +} + +/// The face chosen for a family/style request, plus synthetic emboldening / slant when the family +/// lacks a real bold / italic. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct Resolved { + pub face: FaceId, + pub synth_bold: bool, + pub synth_italic: bool, + /// The requested family was not found (Premiere shows such fonts as missing). + pub missing: bool, +} + +fn style_wants(style: &str) -> (u16, bool) { + let s = style.to_ascii_lowercase().replace([' ', '-'], ""); + let italic = s.contains("italic") || s.contains("oblique"); + let w = if s.contains("thin") || s.contains("hairline") { + 100 + } else if s.contains("extralight") || s.contains("ultralight") { + 200 + } else if s.contains("light") { + 300 + } else if s.contains("medium") { + 500 + } else if s.contains("semibold") || s.contains("demibold") { + 600 + } else if s.contains("extrabold") || s.contains("ultrabold") { + 800 + } else if s.contains("black") || s.contains("heavy") { + 900 + } else if s.contains("bold") { + 700 + } else { + 400 + }; + (w, italic) +} + +/// Resolve a family + style name. Family matching is case-insensitive; a style that the family +/// lacks picks the nearest weight and synthesises bold (≥ 600 requested, ≤ 500 found) or italic. +pub fn resolve(family: &str, style: &str) -> Resolved { + if let Some(r) = resolve_in(family, style) { + return r; + } + if !system_scanned() && !family.is_empty() && !family.eq_ignore_ascii_case(DEFAULT_FAMILY) { + scan_system(); + if let Some(r) = resolve_in(family, style) { + return r; + } + } + let mut r = resolve_in(DEFAULT_FAMILY, style).expect("Inter is bundled"); + r.missing = !family.is_empty() && !family.eq_ignore_ascii_case(DEFAULT_FAMILY); + r +} + +fn resolve_in(family: &str, style: &str) -> Option { + let faces = all_faces(); + let fam: Vec<&Arc> = faces.iter().filter(|f| f.info.family.eq_ignore_ascii_case(family)).collect(); + if fam.is_empty() { + return None; + } + if let Some(f) = fam.iter().find(|f| f.info.style.eq_ignore_ascii_case(style)) { + return Some(Resolved { face: f.id, synth_bold: false, synth_italic: false, missing: false }); + } + let (w, it) = style_wants(style); + let best = fam + .iter() + .min_by_key(|f| { + let italic_pen = if f.info.italic == it { 0 } else { 1000 }; + italic_pen + (f.info.weight as i32 - w as i32).unsigned_abs() + }) + .expect("non-empty"); + Some(Resolved { face: best.id, synth_bold: w >= 600 && best.info.weight <= 500, synth_italic: it && !best.info.italic, missing: false }) +} + +/// A face that covers `c`, preferring `prefer`; bundled and already-registered faces are searched. +pub fn fallback_for(c: char, prefer: FaceId) -> FaceId { + let p = face(prefer); + if p.has_char(c) || c.is_control() { + return prefer; + } + let faces = all_faces(); + // prefer a face of the same italic-ness that is bundled (cheap), then anything loaded already + for f in faces.iter().filter(|f| f.info.origin == "bundled") { + if f.has_char(c) { + return f.id; + } + } + for f in faces.iter().filter(|f| f.info.origin != "bundled" && f.bytes.get().is_some()) { + if f.has_char(c) { + return f.id; + } + } + prefer +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bundled_families_and_resolution() { + let fams = families(); + let inter = fams.iter().find(|(f, _)| f == "Inter").expect("Inter"); + for s in ["Regular", "Medium", "SemiBold", "Bold", "Italic"] { + assert!(inter.1.iter().any(|x| x == s), "{s} in {:?}", inter.1); + } + assert!(fams.iter().any(|(f, _)| f == "Noto Serif")); + assert!(fams.iter().any(|(f, _)| f == "JetBrains Mono")); + let b = resolve("inter", "bold"); + assert_eq!(face(b.face).info.style, "Bold"); + assert!(!b.synth_bold); + // Noto Serif has only Regular: Bold Italic is synthesised + let s = resolve("Noto Serif", "Bold Italic"); + assert_eq!(face(s.face).info.family, "Noto Serif"); + assert!(s.synth_bold && s.synth_italic); + // Inter Bold Italic: nearest is Italic (400) → synth bold + let bi = resolve("Inter", "Bold Italic"); + assert!(face(bi.face).info.italic && bi.synth_bold && !bi.synth_italic); + } + + #[test] + fn missing_family_falls_back_to_inter() { + let r = resolve("No Such Font Family 123", "Regular"); + assert_eq!(face(r.face).info.family, "Inter"); + assert!(r.missing); + } + + /// Native: the system scan reads name tables only and resolves families installed there. + #[test] + fn system_scan_finds_fonts() { + let t = std::time::Instant::now(); + scan_system(); // (another test may have triggered the scan already) + let n = all_faces().iter().filter(|f| f.info.origin == "system").count(); + eprintln!("system fonts: {n} faces in {:?}", t.elapsed()); + assert_eq!(scan_system(), 0, "scans once"); + if cfg!(target_os = "macos") { + assert!(n > 20, "{n}"); + let r = resolve("Helvetica", "Bold"); + assert_eq!(face(r.face).info.family, "Helvetica"); + let l = crate::layout("Helvetica", &crate::TextStyle { family: "Helvetica".into(), size: 30.0, ..Default::default() }, &Default::default()); + assert!(!l.missing_font && l.glyphs.iter().all(|g| g.id != 0)); + } + } + + #[test] + fn metrics_and_coverage() { + let f = face(resolve("Inter", "Regular").face); + let m = f.metrics(100.0); + assert!(m.ascent > 80.0 && m.ascent < 110.0, "{m:?}"); + assert!(m.descent > 10.0 && m.descent < 40.0); + assert!(m.cap_height > 60.0 && m.cap_height < 80.0); + assert!(f.has_char('A') && f.has_char('Ж')); + assert!(!f.has_char('\u{5d0}'), "Inter has no Hebrew"); + assert!(f.has_feature(b"liga") || f.has_feature(b"calt")); + } +} diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs new file mode 100644 index 00000000..59c4941d --- /dev/null +++ b/crates/text/src/layout.rs @@ -0,0 +1,723 @@ +//! Paragraph layout: font fallback, bidi (UAX #9), shaping (OpenType via harfrust: kerning, +//! ligatures, mark positioning, complex scripts), line breaking (UAX #14), alignment, leading, +//! tracking, baseline shift, all caps / small caps, underline. Results are cached. +//! +//! Coordinates are pixels, y down. For **point text** (`ParagraphStyle::width == None`) the +//! origin is the alignment point on the first baseline: x = 0 is the left edge (left / justify), +//! the centre (centre) or the right edge (right). For **area text** (`width == Some(w)`) the +//! origin is the top-left of the text box and lines wrap at `w`. + +use std::collections::HashMap; +use std::hash::{Hash, Hasher}; +use std::ops::Range; +use std::sync::{Arc, Mutex, OnceLock}; + +use harfrust::{Direction, Feature, Tag, UnicodeBuffer}; +use unicode_bidi::{BidiInfo, Level}; + +use crate::fonts::{self, FaceId, Resolved}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub enum Caps { + #[default] + Normal, + All, + Small, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub enum Align { + #[default] + Left, + Center, + Right, + Justify, +} + +/// Character formatting. +#[derive(Clone, Debug, PartialEq)] +pub struct TextStyle { + pub family: String, + pub style: String, + /// Font size in pixels. + pub size: f32, + /// Tracking in 1/1000 em (added after every cluster). + pub tracking: f32, + /// Metric kerning (`kern`). + pub kerning: bool, + /// Standard ligatures (`liga`, `clig`). + pub ligatures: bool, + /// Baseline shift in pixels (positive = up). + pub baseline_shift: f32, + pub faux_bold: bool, + pub faux_italic: bool, + pub caps: Caps, + pub underline: bool, +} + +impl Default for TextStyle { + fn default() -> Self { + Self { + family: fonts::DEFAULT_FAMILY.into(), + style: "Regular".into(), + size: 100.0, + tracking: 0.0, + kerning: true, + ligatures: true, + baseline_shift: 0.0, + faux_bold: false, + faux_italic: false, + caps: Caps::Normal, + underline: false, + } + } +} + +/// Paragraph formatting. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ParagraphStyle { + pub align: Align, + /// Extra line spacing in pixels added to the font's natural line height (Premiere's Leading). + pub leading: f32, + /// Wrap width (area text); None = point text. + pub width: Option, + /// Base direction: None = from the first strong character. + pub rtl: Option, +} + +fn hf(h: &mut impl Hasher, v: f32) { + v.to_bits().hash(h); +} + +impl Hash for TextStyle { + fn hash(&self, h: &mut H) { + self.family.hash(h); + self.style.hash(h); + hf(h, self.size); + hf(h, self.tracking); + hf(h, self.baseline_shift); + (self.kerning, self.ligatures, self.faux_bold, self.faux_italic, self.caps, self.underline).hash(h); + } +} + +impl Hash for ParagraphStyle { + fn hash(&self, h: &mut H) { + self.align.hash(h); + hf(h, self.leading); + self.width.map(f32::to_bits).hash(h); + self.rtl.hash(h); + } +} + +/// A positioned glyph. `(x, y)` is the glyph origin on its baseline. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Glyph { + pub face: FaceId, + pub id: u32, + pub x: f32, + pub y: f32, + pub size: f32, + /// Byte offset of the cluster in the source text. + pub cluster: usize, + pub synth_bold: bool, + pub synth_italic: bool, +} + +/// One laid-out line. +#[derive(Clone, Debug, PartialEq)] +pub struct Line { + /// Byte range in the source text (without the terminating newline). + pub range: Range, + pub baseline: f32, + /// Left edge and width of the line's content (trailing spaces excluded). + pub x: f32, + pub width: f32, + pub ascent: f32, + pub descent: f32, + pub glyphs: Range, + /// Caret stops `(byte offset, x)` for every character boundary in the line, by byte offset. + pub carets: Vec<(usize, f32)>, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Layout { + pub glyphs: Vec, + pub lines: Vec, + /// Underline rectangles `[x0, y0, x1, y1]`. + pub underlines: Vec<[f32; 4]>, + /// Logical bounds `[x0, y0, x1, y1]` (line boxes; area text spans the box width). + pub bounds: [f32; 4], + /// The face requested was missing (substituted). + pub missing_font: bool, + pub text_len: usize, +} + +impl Layout { + /// Line index for a caret at byte `pos`. + pub fn line_of(&self, pos: usize) -> usize { + let mut li = 0; + for (i, l) in self.lines.iter().enumerate() { + if l.range.start <= pos { + li = i; + } + } + li + } + /// Caret position `(x, baseline, line)` for byte offset `pos`. + pub fn caret(&self, pos: usize) -> (f32, f32, usize) { + let li = self.line_of(pos); + let Some(l) = self.lines.get(li) else { return (0.0, 0.0, 0) }; + let x = l.carets.iter().min_by_key(|(b, _)| b.abs_diff(pos)).map_or(l.x, |c| c.1); + (x, l.baseline, li) + } + /// Nearest caret byte offset to point `(x, y)`. + pub fn hit(&self, x: f32, y: f32) -> usize { + let Some(l) = self.lines.iter().min_by(|a, b| line_dist(a, y).total_cmp(&line_dist(b, y))) else { + return 0; + }; + l.carets.iter().min_by(|a, b| (a.1 - x).abs().total_cmp(&(b.1 - x).abs())).map_or(l.range.start, |c| c.0) + } + /// Selection highlight rectangles `[x0, y0, x1, y1]` for the byte range `a..b`. + pub fn selection_rects(&self, a: usize, b: usize) -> Vec<[f32; 4]> { + let (a, b) = (a.min(b), a.max(b)); + let mut out = Vec::new(); + for l in &self.lines { + let s = a.max(l.range.start); + let e = b.min(l.range.end); + if s > e || (s == e && !(a < l.range.start && b > l.range.end)) { + continue; + } + let xs: Vec = l.carets.iter().filter(|(p, _)| *p >= s && *p <= e).map(|c| c.1).collect(); + if xs.len() < 2 && b <= l.range.end { + continue; + } + let x0 = xs.iter().copied().fold(f32::MAX, f32::min); + let mut x1 = xs.iter().copied().fold(f32::MIN, f32::max); + if b > l.range.end { + x1 += l.ascent * 0.25; // selected newline + } + out.push([x0.min(x1), l.baseline - l.ascent, x1, l.baseline + l.descent]); + } + out + } +} + +fn line_dist(l: &Line, y: f32) -> f32 { + let (t, b) = (l.baseline - l.ascent, l.baseline + l.descent); + if y < t { + t - y + } else if y > b { + y - b + } else { + 0.0 + } +} + +struct ShapedGlyph { + face: FaceId, + id: u32, + cluster: usize, + adv: f32, + dx: f32, + dy: f32, + size: f32, +} + +struct Item { + range: Range, + rtl: bool, + glyphs: Vec, +} + +fn upper_single(c: char) -> char { + let mut u = c.to_uppercase(); + match (u.next(), u.next()) { + (Some(x), None) => x, + _ => c, + } +} + +const SMALL_CAPS_SCALE: f32 = 0.78; + +fn shape_item(text: &str, chars: &[(usize, char, char)], rtl: bool, face: FaceId, size: f32, style: &TextStyle) -> Vec { + let f = fonts::face(face); + let (Some(font), Some(data)) = (f.font(), f.shaper_data()) else { return Vec::new() }; + let shaper = data.shaper(&font).build(); + let mut buf = UnicodeBuffer::new(); + for &(b, _, sc) in chars { + buf.add(if sc == '\t' { ' ' } else { sc }, b as u32); + } + buf.set_direction(if rtl { Direction::RightToLeft } else { Direction::LeftToRight }); + buf.guess_segment_properties(); + let mut feats = Vec::new(); + if !style.kerning { + feats.push(Feature::new(Tag::new(b"kern"), 0, ..)); + } + if !style.ligatures { + feats.push(Feature::new(Tag::new(b"liga"), 0, ..)); + feats.push(Feature::new(Tag::new(b"clig"), 0, ..)); + } + let out = shaper.shape(buf, harfrust::ShapeOptions::new().features(&feats)); + let k = size / f.units_per_em(); + let _ = text; + out.glyph_infos() + .iter() + .zip(out.glyph_positions()) + .map(|(i, p)| ShapedGlyph { + face, + id: i.glyph_id, + cluster: i.cluster as usize, + adv: p.x_advance as f32 * k, + dx: p.x_offset as f32 * k, + dy: p.y_offset as f32 * k, + size, + }) + .collect() +} + +struct ParaLine { + range: Range, + glyphs: Vec, + carets: Vec<(usize, f32)>, + content: f32, + left_trim: f32, + ascent: f32, + descent: f32, +} + +/// Lay out one paragraph (no newlines) whose text starts at byte `base` of the whole string. +fn paragraph(text: &str, base: usize, style: &TextStyle, para: &ParagraphStyle, primary: Resolved) -> Vec { + let pm = fonts::face(primary.face).metrics(style.size); + if text.is_empty() { + return vec![ParaLine { + range: base..base, + glyphs: vec![], + carets: vec![(base, 0.0)], + content: 0.0, + left_trim: 0.0, + ascent: pm.ascent, + descent: pm.descent, + }]; + } + let default_level = para.rtl.map(|r| if r { Level::rtl() } else { Level::ltr() }); + let bidi = BidiInfo::new(text, default_level); + let pinfo = &bidi.paragraphs[0]; + let base_rtl = pinfo.level.is_rtl(); + // per char: (byte, char, shaped char), face, level, small + let chars: Vec<(usize, char, char, FaceId, bool, bool)> = text + .char_indices() + .map(|(b, c)| { + let (sc, small) = match style.caps { + Caps::Normal => (c, false), + Caps::All => (upper_single(c), false), + Caps::Small if c.is_lowercase() => (upper_single(c), true), + Caps::Small => (c, false), + }; + let face = if sc.is_whitespace() { primary.face } else { fonts::fallback_for(sc, primary.face) }; + (b, c, sc, face, bidi.levels[b].is_rtl(), small) + }) + .collect(); + // items: runs of equal (face, rtl, small) + let mut items: Vec = Vec::new(); + let mut i = 0; + while i < chars.len() { + let (face, rtl, small) = (chars[i].3, chars[i].4, chars[i].5); + let mut j = i + 1; + while j < chars.len() && chars[j].3 == face && chars[j].4 == rtl && chars[j].5 == small { + j += 1; + } + let sub: Vec<(usize, char, char)> = chars[i..j].iter().map(|c| (c.0, c.1, c.2)).collect(); + let size = if small { style.size * SMALL_CAPS_SCALE } else { style.size }; + let mut glyphs = shape_item(text, &sub, rtl, face, size, style); + // tracking after each cluster + if style.tracking != 0.0 { + let t = style.tracking * style.size / 1000.0; + for k in 0..glyphs.len() { + if k + 1 == glyphs.len() || glyphs[k + 1].cluster != glyphs[k].cluster { + glyphs[k].adv += t; + } + } + } + let end = if j < chars.len() { chars[j].0 } else { text.len() }; + items.push(Item { range: chars[i].0..end, rtl, glyphs }); + i = j; + } + // advance per char byte (cluster start) + let mut char_adv = vec![0.0f32; text.len() + 1]; + for it in &items { + for g in &it.glyphs { + char_adv[g.cluster.min(text.len())] += g.adv; + } + } + let range_w = |r: Range| -> f32 { char_adv[r].iter().sum() }; + let trailing_ws = |r: &Range| -> (usize, f32) { + let mut end = r.end; + let mut w = 0.0; + for (b, c) in text[r.clone()].char_indices().rev() { + if !c.is_whitespace() { + break; + } + end = r.start + b; + w += char_adv[r.start + b]; + } + (end, w) + }; + // line breaking + let mut ranges: Vec> = Vec::new(); + match para.width { + None => ranges.push(0..text.len()), + Some(maxw) => { + let mut start = 0usize; + let mut last_ok: Option = None; + for (p, _) in unicode_linebreak::linebreaks(text) { + loop { + let r = start..p; + let (ce, _) = trailing_ws(&r); + let w = range_w(start..ce); + if w <= maxw || last_ok.is_none() { + last_ok = Some(p); + break; + } + let b = last_ok.take().expect("checked"); + ranges.push(start..b); + start = b; + } + } + ranges.push(start..text.len()); + ranges.retain(|r| !r.is_empty()); + if ranges.is_empty() { + ranges.push(0..text.len()); + } + } + } + let nlines = ranges.len(); + let mut out = Vec::new(); + for (li, r) in ranges.into_iter().enumerate() { + let (ce, tw) = trailing_ws(&r); + let content = range_w(r.start..ce); + // justification + let mut space_extra = 0.0; + if para.align == Align::Justify + && li + 1 < nlines + && let Some(maxw) = para.width + { + let spaces = text[r.start..ce].chars().filter(|c| *c == ' ').count(); + if spaces > 0 { + space_extra = ((maxw - content) / spaces as f32).max(0.0); + } + } + let (levels, runs) = bidi.visual_runs(pinfo, r.clone()); + let mut pen = 0.0f32; + let mut glyphs = Vec::new(); + let mut extents: HashMap = HashMap::new(); + for run in runs { + let rtl = levels[run.start].is_rtl(); + let mut idx: Vec = (0..items.len()).filter(|&k| items[k].range.start < run.end && items[k].range.end > run.start).collect(); + if rtl { + idx.reverse(); + } + for k in idx { + for g in items[k].glyphs.iter().filter(|g| run.contains(&g.cluster)) { + let x0 = pen; + glyphs.push(Glyph { + face: g.face, + id: g.id, + x: pen + g.dx, + y: -g.dy - style.baseline_shift, + size: g.size, + cluster: base + g.cluster, + synth_bold: primary.synth_bold || style.faux_bold, + synth_italic: primary.synth_italic || style.faux_italic, + }); + pen += g.adv; + if space_extra > 0.0 && text[g.cluster..].starts_with(' ') && g.cluster < ce { + pen += space_extra; + } + let e = extents.entry(g.cluster).or_insert((x0, pen, items[k].rtl)); + e.0 = e.0.min(x0); + e.1 = e.1.max(pen); + } + } + } + // caret stops for each char boundary in the line + let mut carets = Vec::new(); + let mut cluster_starts: Vec = extents.keys().copied().collect(); + cluster_starts.sort_unstable(); + let char_bytes: Vec = text[r.clone()].char_indices().map(|(b, _)| r.start + b).collect(); + for (ci, &cs) in cluster_starts.iter().enumerate() { + let (x0, x1, rtl) = extents[&cs]; + let next = cluster_starts.get(ci + 1).copied().unwrap_or(r.end); + let members: Vec = char_bytes.iter().copied().filter(|b| *b >= cs && *b < next).collect(); + let n = members.len().max(1) as f32; + for (mi, b) in members.iter().enumerate() { + let f = mi as f32 / n; + let x = if rtl { x1 - (x1 - x0) * f } else { x0 + (x1 - x0) * f }; + carets.push((base + b, x)); + } + } + // end of line caret + let end_x = match chars.iter().rev().find(|c| c.0 < r.end && c.0 >= r.start) { + Some(c) => { + let cs = cluster_starts.iter().rev().find(|s| **s <= c.0).copied(); + match cs.and_then(|s| extents.get(&s)) { + Some(&(x0, x1, rtl)) => { + if rtl { + x0 + } else { + x1 + } + } + None => pen, + } + } + None => 0.0, + }; + carets.push((base + r.end, end_x)); + carets.sort_by_key(|c| c.0); + carets.dedup_by_key(|c| c.0); + let mut asc = pm.ascent; + let mut desc = pm.descent; + for g in &glyphs { + if g.face != primary.face { + let m = fonts::face(g.face).metrics(g.size); + asc = asc.max(m.ascent); + desc = desc.max(m.descent); + } + } + let content_w = if space_extra > 0.0 { para.width.unwrap_or(content) } else { content }; + out.push(ParaLine { + range: base + r.start..base + r.end, + glyphs, + carets, + content: content_w, + left_trim: if base_rtl { tw } else { 0.0 }, + ascent: asc, + descent: desc, + }); + } + out +} + +/// Lay out `text` (paragraphs separated by `\n`). +pub fn layout_uncached(text: &str, style: &TextStyle, para: &ParagraphStyle) -> Layout { + let primary = fonts::resolve(&style.family, &style.style); + let pm = fonts::face(primary.face).metrics(style.size); + let line_h = (pm.ascent + pm.descent + pm.line_gap).max(style.size * 0.5) + para.leading; + let mut lay = Layout { missing_font: primary.missing, text_len: text.len(), ..Default::default() }; + let mut base = 0usize; + let mut plines = Vec::new(); + for p in text.split('\n') { + let p_clean = p.strip_suffix('\r').unwrap_or(p); + plines.extend(paragraph(p_clean, base, style, para, primary)); + base += p.len() + 1; + } + let first_baseline = if para.width.is_some() { pm.ascent } else { 0.0 }; + for (i, pl) in plines.into_iter().enumerate() { + let baseline = first_baseline + line_h * i as f32; + let shift = match (para.width, para.align) { + (None, Align::Left | Align::Justify) => 0.0, + (None, Align::Center) => -pl.content / 2.0, + (None, Align::Right) => -pl.content, + (Some(_), Align::Left | Align::Justify) => 0.0, + (Some(w), Align::Center) => (w - pl.content) / 2.0, + (Some(w), Align::Right) => w - pl.content, + } - pl.left_trim; + let g0 = lay.glyphs.len(); + lay.glyphs.extend(pl.glyphs.into_iter().map(|mut g| { + g.x += shift; + g.y += baseline; + g + })); + let x = shift + pl.left_trim; + if style.underline && pl.content > 0.0 { + lay.underlines.push([x, baseline + pm.underline_pos, x + pl.content, baseline + pm.underline_pos + pm.underline_thickness]); + } + lay.lines.push(Line { + range: pl.range, + baseline, + x, + width: pl.content, + ascent: pl.ascent, + descent: pl.descent, + glyphs: g0..lay.glyphs.len(), + carets: pl.carets.into_iter().map(|(b, cx)| (b, cx + shift)).collect(), + }); + } + let (mut x0, mut x1) = (f32::MAX, f32::MIN); + for l in &lay.lines { + x0 = x0.min(l.x); + x1 = x1.max(l.x + l.width); + } + if let Some(w) = para.width { + x0 = 0.0; + x1 = x1.max(w); + } + let first = &lay.lines[0]; + let last = lay.lines.last().expect("at least one line"); + lay.bounds = [x0, first.baseline - first.ascent, x1.max(x0), last.baseline + last.descent]; + lay +} + +fn cache() -> &'static Mutex)>> { + static C: OnceLock)>>> = OnceLock::new(); + C.get_or_init(Default::default) +} + +/// Lay out `text` (cached by text and styles). +pub fn layout(text: &str, style: &TextStyle, para: &ParagraphStyle) -> Arc { + let mut h = std::collections::hash_map::DefaultHasher::new(); + text.hash(&mut h); + style.hash(&mut h); + para.hash(&mut h); + let key = h.finish(); + static CLOCK: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let now = CLOCK.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + if let Some(e) = cache().lock().unwrap_or_else(|e| e.into_inner()).get_mut(&key) { + e.0 = now; + return e.1.clone(); + } + let l = Arc::new(layout_uncached(text, style, para)); + let mut c = cache().lock().unwrap_or_else(|e| e.into_inner()); + if c.len() >= 512 { + // evict the oldest quarter + let mut ages: Vec = c.values().map(|v| v.0).collect(); + ages.sort_unstable(); + let cut = ages[ages.len() / 4]; + c.retain(|_, v| v.0 > cut); + } + c.insert(key, (now, l.clone())); + l +} + +/// Width of a single line of `text` (no wrapping). +pub fn measure(text: &str, style: &TextStyle) -> f32 { + let l = layout(text, style, &ParagraphStyle::default()); + l.lines.iter().map(|l| l.width).fold(0.0, f32::max) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn st(size: f32) -> TextStyle { + TextStyle { size, ..Default::default() } + } + + #[test] + fn kerning_and_ligatures_change_advances() { + let kern = measure("AVAVAV", &st(100.0)); + let nokern = measure("AVAVAV", &TextStyle { kerning: false, ..st(100.0) }); + assert!(kern < nokern - 5.0, "kerning tightens AV: {kern} vs {nokern}"); + // tracking adds 1/1000 em per cluster + let tracked = measure("AVAVAV", &TextStyle { tracking: 100.0, ..st(100.0) }); + assert!((tracked - kern - 6.0 * 10.0).abs() < 0.5, "{tracked} {kern}"); + } + + #[test] + fn point_text_alignment() { + let l = layout("Hello", &st(50.0), &ParagraphStyle { align: Align::Center, ..Default::default() }); + let line = &l.lines[0]; + assert!((line.x + line.width / 2.0).abs() < 0.01); + let r = layout("Hello", &st(50.0), &ParagraphStyle { align: Align::Right, ..Default::default() }); + assert!((r.lines[0].x + r.lines[0].width).abs() < 0.01); + assert_eq!(l.glyphs.len(), 5); + assert!(l.bounds[1] < -30.0 && l.bounds[3] > 5.0, "{:?}", l.bounds); + } + + #[test] + fn wraps_area_text_and_justifies() { + let p = ParagraphStyle { width: Some(300.0), align: Align::Justify, ..Default::default() }; + let l = layout("the quick brown fox jumps over the lazy dog again and again", &st(40.0), &p); + assert!(l.lines.len() >= 3, "{}", l.lines.len()); + for line in &l.lines[..l.lines.len() - 1] { + assert!((line.width - 300.0).abs() < 0.5, "justified to the box: {}", line.width); + } + // the lines tile the text + assert_eq!(l.lines[0].range.start, 0); + for w in l.lines.windows(2) { + assert_eq!(w[0].range.end, w[1].range.start); + assert!(w[1].baseline > w[0].baseline); + } + let left = layout("the quick brown fox jumps over the lazy dog", &st(40.0), &ParagraphStyle { width: Some(300.0), ..Default::default() }); + assert!(left.lines.iter().all(|l| l.width <= 300.0 + 0.01)); + } + + #[test] + fn newlines_make_paragraphs_and_leading_adds() { + let l = layout("one\ntwo\n\nfour", &st(30.0), &ParagraphStyle::default()); + assert_eq!(l.lines.len(), 4); + assert_eq!(l.lines[1].range, 4..7); + assert_eq!(l.lines[2].range, 8..8); + let gap = l.lines[1].baseline - l.lines[0].baseline; + let l2 = layout("one\ntwo", &st(30.0), &ParagraphStyle { leading: 10.0, ..Default::default() }); + assert!((l2.lines[1].baseline - l2.lines[0].baseline - gap - 10.0).abs() < 1e-3); + } + + #[test] + fn carets_and_hit_testing() { + let l = layout("abc\nde", &st(40.0), &ParagraphStyle::default()); + let (x0, _, li0) = l.caret(0); + let (x1, _, _) = l.caret(1); + let (x3, _, _) = l.caret(3); + assert!(x0.abs() < 1e-3 && x1 > x0 && x3 > x1); + assert_eq!(li0, 0); + let (_, b4, li4) = l.caret(4); + assert_eq!(li4, 1); + assert!(b4 > 0.0); + assert_eq!(l.hit(x1 + 1.0, 0.0), 1); + assert_eq!(l.hit(1000.0, b4), 6); + let rects = l.selection_rects(1, 5); + assert_eq!(rects.len(), 2); + } + + #[test] + fn bidi_reorders_rtl_runs() { + // Hebrew letters are laid out right-to-left: the first logical letter is rightmost. + let l = layout("ab \u{5d0}\u{5d1}\u{5d2} cd", &st(40.0), &ParagraphStyle::default()); + let x_of = |byte: usize| l.glyphs.iter().find(|g| g.cluster == byte).map(|g| g.x).unwrap(); + let alef = "ab ".len(); + let gimel = alef + 4; + assert!(x_of(alef) > x_of(gimel), "alef right of gimel"); + assert!(x_of(0) < x_of(gimel) && x_of(alef) < x_of("ab \u{5d0}\u{5d1}\u{5d2} ".len())); + } + + #[test] + fn caps_and_small_caps() { + let s = st(50.0); + let up = measure("HELLO", &s); + let all = measure("hello", &TextStyle { caps: Caps::All, ..s.clone() }); + assert!((up - all).abs() < 0.01); + let small = measure("hello", &TextStyle { caps: Caps::Small, ..s.clone() }); + assert!(small < up * 0.9 && small > up * 0.6, "{small} {up}"); + let l = layout("Hi", &TextStyle { baseline_shift: 10.0, underline: true, ..s }, &ParagraphStyle::default()); + assert!((l.glyphs[0].y + 10.0).abs() < 1e-3); + assert_eq!(l.underlines.len(), 1); + } + + #[test] + fn ligature_caret_interpolates() { + // Inter has an "fi"-like ligature only via calt in some versions; use "ffi" which most + // fonts ligate. Whatever shaping does, every char boundary has a caret. + let l = layout("office", &st(40.0), &ParagraphStyle::default()); + let stops: Vec = l.lines[0].carets.iter().map(|c| c.0).collect(); + assert_eq!(stops, vec![0, 1, 2, 3, 4, 5, 6]); + let xs: Vec = l.lines[0].carets.iter().map(|c| c.1).collect(); + assert!(xs.windows(2).all(|w| w[1] >= w[0]), "{xs:?}"); + } + + #[test] + fn fallback_font_for_missing_glyphs() { + // Inter lacks Devanagari/Hebrew; Noto Serif lacks Hebrew too → glyphs still produced (notdef) + let l = layout("A\u{3b1}", &TextStyle { family: "Noto Serif".into(), ..st(40.0) }, &ParagraphStyle::default()); + assert_eq!(l.glyphs.len(), 2); + assert!(l.glyphs.iter().all(|g| g.id != 0)); + } + + #[test] + fn empty_text_has_a_caret() { + let l = layout("", &st(40.0), &ParagraphStyle::default()); + assert_eq!(l.lines.len(), 1); + assert_eq!(l.caret(0).0, 0.0); + assert!(l.bounds[3] > l.bounds[1]); + } +} diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs new file mode 100644 index 00000000..7fffd108 --- /dev/null +++ b/crates/text/src/lib.rs @@ -0,0 +1,52 @@ +//! FilmCraft text engine (layer L1). +//! +//! - [`fonts`]: font database — bundled OFL fonts (Inter, JetBrains Mono, Noto Serif) plus faces +//! discovered by [`fonts::FontSource`]s (system font folders on native platforms; nothing on the +//! web unless the host registers font data), family/style resolution with synthetic bold/italic +//! and per-character fallback. +//! - [`layout`]: shaping (harfrust: kerning, ligatures, complex scripts), bidi, line breaking, +//! paragraph layout, carets and hit testing; cached. +//! - [`raster`]: our own anti-aliased path rasteriser (linear coverage, so compositing in linear +//! light is correct) and vector shapes. +//! - [`render`]: glyph outlines and the glyph-mask cache (sub-pixel positioned), drawing layouts +//! under any affine transform. +//! - [`mask`]: strokes (outer/centre/inner via a Euclidean distance transform), blur, offsets. + +pub mod fonts; +pub mod layout; +pub mod mask; +pub mod raster; +pub mod render; +pub mod sfnt; + +pub use fonts::{FaceId, Resolved, families, resolve}; +pub use layout::{Align, Caps, Glyph, Layout, Line, ParagraphStyle, TextStyle, layout, measure}; +pub use mask::StrokeKind; +pub use raster::{Mask, Path, Xform}; + +#[cfg(test)] +mod perf { + use super::*; + + /// A three-line 1080p title: layout + rasterisation must stay far below a frame (≈ 41 ms at + /// 24 fps). Cold = first time (shaping + glyph rasterisation); warm = cached. + #[test] + fn three_line_title_is_fast() { + let text = "FILMCRAFT PRESENTS\nA Night Drive Through the City\nDirected by Nobody in Particular"; + let st = TextStyle { size: 96.0, style: "Bold".into(), ..Default::default() }; + let ps = ParagraphStyle { align: Align::Center, ..Default::default() }; + let t0 = std::time::Instant::now(); + let l = layout::layout_uncached(text, &st, &ps); + let (m, _, _) = render::rasterize(&l, &render::at(960.0, 400.0)); + let cold = t0.elapsed(); + let _ = layout(text, &st, &ps); + let t1 = std::time::Instant::now(); + let l2 = layout(text, &st, &ps); + let (m2, _, _) = render::rasterize(&l2, &render::at(960.0, 400.0)); + let warm = t1.elapsed(); + assert_eq!(m.a.len(), m2.a.len()); + eprintln!("3-line 1080p title: cold {cold:?}, warm {warm:?} (mask {}x{})", m.w, m.h); + // generous bound for debug builds on busy CI machines + assert!(warm.as_millis() < 40, "warm {warm:?}"); + } +} diff --git a/crates/text/src/mask.rs b/crates/text/src/mask.rs new file mode 100644 index 00000000..33af4e49 --- /dev/null +++ b/crates/text/src/mask.rs @@ -0,0 +1,213 @@ +//! Coverage-mask operations used for text and shape appearance: strokes (outer / centre / inner) +//! from an exact Euclidean distance transform, Gaussian-like blur for shadows, offsets. + +use crate::raster::Mask; + +/// Where a stroke sits relative to the shape's edge (Premiere's stroke types). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub enum StrokeKind { + #[default] + Outer, + Center, + Inner, +} + +const INF: f32 = 1e20; + +/// 1-D squared distance transform (Felzenszwalb & Huttenlocher) of `f` into `d`. +fn edt_1d(f: &[f32], d: &mut [f32], v: &mut [usize], z: &mut [f32]) { + let n = f.len(); + if n == 0 { + return; + } + let mut k = 0usize; + v[0] = 0; + z[0] = -INF; + z[1] = INF; + for q in 1..n { + let fq = f[q] + (q * q) as f32; + let mut s; + loop { + let p = v[k]; + s = (fq - (f[p] + (p * p) as f32)) / (2.0 * (q as f32 - p as f32)); + if s <= z[k] && k > 0 { + k -= 1; + } else { + break; + } + } + if s <= z[k] { + // k == 0: q dominates everything so far + v[0] = q; + z[0] = -INF; + z[1] = INF; + continue; + } + k += 1; + v[k] = q; + z[k] = s; + z[k + 1] = INF; + } + k = 0; + for q in 0..n { + while z[k + 1] < q as f32 { + k += 1; + } + let p = v[k]; + let dq = q as f32 - p as f32; + d[q] = (dq * dq + f[p]).min(INF); + } +} + +/// Euclidean distance (in pixels, between pixel centres) from every pixel to the nearest pixel +/// where `seed` is true. +fn edt(w: usize, h: usize, seed: impl Fn(usize) -> bool) -> Vec { + let mut g = vec![INF; w * h]; + for (i, v) in g.iter_mut().enumerate() { + if seed(i) { + *v = 0.0; + } + } + let n = w.max(h); + let mut f = vec![0.0f32; n]; + let mut d = vec![0.0f32; n]; + let mut v = vec![0usize; n]; + let mut z = vec![0.0f32; n + 1]; + for x in 0..w { + for y in 0..h { + f[y] = g[y * w + x]; + } + edt_1d(&f[..h], &mut d[..h], &mut v[..h], &mut z[..h + 1]); + for y in 0..h { + g[y * w + x] = d[y]; + } + } + for y in 0..h { + f[..w].copy_from_slice(&g[y * w..y * w + w]); + edt_1d(&f[..w], &mut d[..w], &mut v[..w], &mut z[..w + 1]); + for x in 0..w { + g[y * w + x] = d[x].sqrt(); + } + } + g +} + +/// Approximate signed distance to the shape edge: negative inside, positive outside, with +/// sub-pixel refinement from the coverage values. +pub fn signed_distance(m: &Mask) -> Vec { + let (w, h) = (m.w, m.h); + let outside = edt(w, h, |i| m.a[i] >= 0.5); + let inside = edt(w, h, |i| m.a[i] < 0.5); + (0..w * h) + .map(|i| { + let c = m.a[i]; + if c > 0.0 && c < 1.0 { + 0.5 - c + } else if c >= 0.5 { + -(inside[i] - 0.5).max(0.0) + } else { + (outside[i] - 0.5).max(0.0) + } + }) + .collect() +} + +/// Stroke coverage of width `width` pixels around the shape in `m`. +pub fn stroke(m: &Mask, width: f32, kind: StrokeKind) -> Mask { + let sd = signed_distance(m); + let a = sd + .iter() + .zip(&m.a) + .map(|(&d, &c)| { + let v = match kind { + StrokeKind::Outer => (width + 0.5 - d).clamp(0.0, 1.0).max(c), + StrokeKind::Center => (width / 2.0 + 0.5 - d.abs()).clamp(0.0, 1.0), + StrokeKind::Inner => (width + 0.5 + d).clamp(0.0, 1.0).min(c), + }; + if kind == StrokeKind::Outer { v } else { v.min(1.0) } + }) + .collect(); + Mask { w: m.w, h: m.h, a } +} + +fn box_pass(src: &[f32], dst: &mut [f32], n: usize, stride: usize, count: usize, lanes: usize, lane_stride: usize, r: usize) { + let norm = 1.0 / (2 * r + 1) as f32; + for l in 0..lanes { + let base = l * lane_stride; + let at = |i: isize| -> f32 { if i < 0 || i as usize >= count { 0.0 } else { src[base + i as usize * stride] } }; + let mut acc = 0.0f32; + for i in -(r as isize)..=(r as isize) { + acc += at(i); + } + for i in 0..count { + dst[base + i * stride] = acc * norm; + acc += at(i as isize + r as isize + 1) - at(i as isize - r as isize); + } + } + let _ = n; +} + +/// Blur a mask with three box passes per axis (≈ Gaussian of standard deviation `sigma`). +pub fn blur(m: &Mask, sigma: f32) -> Mask { + if sigma < 0.3 || m.w == 0 || m.h == 0 { + return m.clone(); + } + // box radius for 3 passes ≈ sigma * sqrt(3) + let r = ((sigma * sigma + 1.0).sqrt().round() as usize).max(1); + let mut a = m.a.clone(); + let mut b = vec![0.0f32; a.len()]; + for _ in 0..3 { + box_pass(&a, &mut b, 0, 1, m.w, m.h, m.w, r); + std::mem::swap(&mut a, &mut b); + box_pass(&a, &mut b, 0, m.w, m.h, m.w, 1, r); + std::mem::swap(&mut a, &mut b); + } + Mask { w: m.w, h: m.h, a } +} + +/// Shift a mask by whole pixels (content moving out is dropped). +pub fn offset(m: &Mask, dx: i32, dy: i32) -> Mask { + let mut o = Mask::new(m.w, m.h); + o.add(m, dx, dy); + o +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::raster::{Path, fill}; + + #[test] + fn distance_transform_is_euclidean() { + let d = edt(9, 9, |i| i == 4 * 9 + 4); + assert_eq!(d[4 * 9 + 4], 0.0); + assert!((d[4 * 9 + 7] - 3.0).abs() < 1e-5); + assert!((d[0] - (32.0f32).sqrt()).abs() < 1e-4); + let none = edt(3, 3, |_| false); + assert!(none.iter().all(|&v| v > 1e5)); + } + + #[test] + fn strokes_sit_outside_centre_and_inside() { + let m = fill(&Path::rect(10.0, 10.0, 30.0, 30.0), 40, 40, 0.0, 0.0); + let outer = stroke(&m, 4.0, StrokeKind::Outer); + assert!(outer.get(7, 20) > 0.99, "outer covers 3px outside"); + assert!(outer.get(4, 20) < 0.01); + assert!(outer.get(20, 20) > 0.99, "outer includes the fill area"); + let inner = stroke(&m, 4.0, StrokeKind::Inner); + assert!(inner.get(12, 20) > 0.99 && inner.get(7, 20) < 0.01 && inner.get(20, 20) < 0.01); + let centre = stroke(&m, 4.0, StrokeKind::Center); + assert!(centre.get(9, 20) > 0.99 && centre.get(11, 20) > 0.99 && centre.get(20, 20) < 0.01 && centre.get(5, 20) < 0.01); + } + + #[test] + fn blur_keeps_mass() { + let m = fill(&Path::rect(20.0, 20.0, 30.0, 30.0), 50, 50, 0.0, 0.0); + let b = blur(&m, 3.0); + let (s0, s1): (f32, f32) = (m.a.iter().sum(), b.a.iter().sum()); + assert!((s0 - s1).abs() < 0.5, "{s0} {s1}"); + assert!(b.get(18, 25) > 0.05 && b.get(25, 25) < 1.0); + let o = offset(&m, 5, -5); + assert!(o.get(33, 18) > 0.9); + } +} diff --git a/crates/text/src/raster.rs b/crates/text/src/raster.rs new file mode 100644 index 00000000..33bf2f4d --- /dev/null +++ b/crates/text/src/raster.rs @@ -0,0 +1,484 @@ +//! Anti-aliased path rasteriser for glyph outlines and vector shapes. +//! +//! Paths are flattened to closed polylines (contours). [`fill`] samples each pixel row at [`SUB`] +//! sub-scanlines with an active-edge list and accumulates the non-zero winding spans of each +//! sub-scanline with exact horizontal coverage at the span ends. Coverage is linear (fractional +//! area), so compositing it in linear light gives correct anti-aliasing. Original implementation +//! for FilmCraft (it started as the caption rasteriser). + +/// Vertical sub-samples per pixel row. +pub const SUB: usize = 5; + +/// A 2×3 affine transform `[a, b, c, d, e, f]`: (x, y) → (a x + c y + e, b x + d y + f). +pub type Xform = [f32; 6]; + +pub const IDENTITY: Xform = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]; + +#[inline] +pub fn apply(m: &Xform, x: f32, y: f32) -> (f32, f32) { + (m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]) +} + +/// `a ∘ b`: apply `b` first, then `a`. +pub fn compose(a: &Xform, b: &Xform) -> Xform { + [ + a[0] * b[0] + a[2] * b[1], + a[1] * b[0] + a[3] * b[1], + a[0] * b[2] + a[2] * b[3], + a[1] * b[2] + a[3] * b[3], + a[0] * b[4] + a[2] * b[5] + a[4], + a[1] * b[4] + a[3] * b[5] + a[5], + ] +} + +/// Average linear scale of a transform (for flattening tolerances and stroke widths). +pub fn xform_scale(m: &Xform) -> f32 { + (m[0] * m[3] - m[1] * m[2]).abs().sqrt() +} + +/// A flattened path: closed contours of points in pixel space, y down. +#[derive(Clone, Debug, Default)] +pub struct Path { + pub contours: Vec>, + cur: (f32, f32), + open: bool, + /// Flattening tolerance multiplier (curves are split more finely for larger values). + pub detail: f32, +} + +impl Path { + pub fn new() -> Self { + Self { detail: 1.0, ..Default::default() } + } + fn tol(&self) -> f32 { + if self.detail > 0.0 { self.detail } else { 1.0 } + } + pub fn move_to(&mut self, x: f32, y: f32) { + self.close(); + self.contours.push(vec![(x, y)]); + self.cur = (x, y); + self.open = true; + } + pub fn line_to(&mut self, x: f32, y: f32) { + if !self.open { + self.move_to(self.cur.0, self.cur.1); + } + if (x, y) != self.cur { + self.contours.last_mut().expect("open contour").push((x, y)); + } + self.cur = (x, y); + } + pub fn quad_to(&mut self, cx: f32, cy: f32, x: f32, y: f32) { + let (x0, y0) = self.cur; + let dd = ((x0 - 2.0 * cx + x).abs() + (y0 - 2.0 * cy + y).abs()).max(0.01) * self.tol(); + let n = ((dd * 2.0).sqrt().ceil() as usize).clamp(1, 64); + for i in 1..=n { + let t = i as f32 / n as f32; + let u = 1.0 - t; + self.line_to(u * u * x0 + 2.0 * u * t * cx + t * t * x, u * u * y0 + 2.0 * u * t * cy + t * t * y); + } + } + pub fn cubic_to(&mut self, c1x: f32, c1y: f32, c2x: f32, c2y: f32, x: f32, y: f32) { + let (x0, y0) = self.cur; + let dd = + ((x0 - 2.0 * c1x + c2x).abs() + (y0 - 2.0 * c1y + c2y).abs() + (c1x - 2.0 * c2x + x).abs() + (c1y - 2.0 * c2y + y).abs()).max(0.01) * self.tol(); + let n = ((dd * 3.0).sqrt().ceil() as usize).clamp(1, 96); + for i in 1..=n { + let t = i as f32 / n as f32; + let u = 1.0 - t; + let (a, b, c, d) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t); + self.line_to(a * x0 + b * c1x + c * c2x + d * x, a * y0 + b * c1y + c * c2y + d * y); + } + } + /// Close the current contour (contours are always filled as closed polygons). + pub fn close(&mut self) { + if self.open { + if let Some(c) = self.contours.last() + && let Some(&p) = c.first() + { + self.cur = p; + } + if self.contours.last().is_some_and(|c| c.len() < 2) { + self.contours.pop(); + } + } + self.open = false; + } + /// Append all contours of `o`. + pub fn extend(&mut self, o: &Path) { + self.close(); + self.contours.extend(o.contours.iter().cloned()); + } + pub fn is_empty(&self) -> bool { + self.contours.is_empty() + } + pub fn transform(&mut self, m: &Xform) { + for c in &mut self.contours { + for p in c.iter_mut() { + *p = apply(m, p.0, p.1); + } + } + } + pub fn transformed(&self, m: &Xform) -> Path { + let mut p = self.clone(); + p.transform(m); + p + } + /// Bounding box `(x0, y0, x1, y1)` of all points. + pub fn bounds(&self) -> Option<(f32, f32, f32, f32)> { + let mut it = self.contours.iter().flatten(); + let f = it.next()?; + let mut b = (f.0, f.1, f.0, f.1); + for p in it { + b = (b.0.min(p.0), b.1.min(p.1), b.2.max(p.0), b.3.max(p.1)); + } + Some(b) + } + /// Signed area (shoelace, y down: positive = clockwise on screen) of all contours. + pub fn signed_area(&self) -> f32 { + self.contours.iter().map(|c| contour_area(c)).sum() + } + /// Grow (d > 0) or shrink every contour by `d` pixels along its normals (faux bold). Holes + /// shrink because they wind the other way. + pub fn embolden(&mut self, d: f32) { + if d == 0.0 { + return; + } + let sign = if self.signed_area() >= 0.0 { 1.0 } else { -1.0 }; + for c in &mut self.contours { + let n = c.len(); + if n < 3 { + continue; + } + let src = c.clone(); + for i in 0..n { + let p0 = src[(i + n - 1) % n]; + let p1 = src[i]; + let p2 = src[(i + 1) % n]; + let n1 = normal(p0, p1, sign); + let n2 = normal(p1, p2, sign); + let (mx, my) = (n1.0 + n2.0, n1.1 + n2.1); + let ml = (mx * mx + my * my).sqrt(); + if ml < 1e-6 { + c[i] = (p1.0 + n1.0 * d, p1.1 + n1.1 * d); + continue; + } + let (mx, my) = (mx / ml, my / ml); + let cos = (mx * n1.0 + my * n1.1).max(0.35); + c[i] = (p1.0 + mx * d / cos, p1.1 + my * d / cos); + } + } + } + + // ---- shapes ---- + + pub fn rect(x0: f32, y0: f32, x1: f32, y1: f32) -> Path { + let mut p = Path::new(); + p.move_to(x0, y0); + p.line_to(x1, y0); + p.line_to(x1, y1); + p.line_to(x0, y1); + p.close(); + p + } + /// Rectangle with rounded corners of radius `r` (clamped to half the shorter side). + pub fn round_rect(x0: f32, y0: f32, x1: f32, y1: f32, r: f32) -> Path { + let r = r.min((x1 - x0).abs() / 2.0).min((y1 - y0).abs() / 2.0).max(0.0); + if r < 0.01 { + return Path::rect(x0, y0, x1, y1); + } + let k = 0.552_284_8 * r; + let mut p = Path::new(); + p.move_to(x0 + r, y0); + p.line_to(x1 - r, y0); + p.cubic_to(x1 - r + k, y0, x1, y0 + r - k, x1, y0 + r); + p.line_to(x1, y1 - r); + p.cubic_to(x1, y1 - r + k, x1 - r + k, y1, x1 - r, y1); + p.line_to(x0 + r, y1); + p.cubic_to(x0 + r - k, y1, x0, y1 - r + k, x0, y1 - r); + p.line_to(x0, y0 + r); + p.cubic_to(x0, y0 + r - k, x0 + r - k, y0, x0 + r, y0); + p.close(); + p + } + /// Ellipse centred at (cx, cy) with radii (rx, ry), wound clockwise on screen. + pub fn ellipse(cx: f32, cy: f32, rx: f32, ry: f32) -> Path { + let (kx, ky) = (0.552_284_8 * rx, 0.552_284_8 * ry); + let mut p = Path::new(); + p.move_to(cx + rx, cy); + p.cubic_to(cx + rx, cy + ky, cx + kx, cy + ry, cx, cy + ry); + p.cubic_to(cx - kx, cy + ry, cx - rx, cy + ky, cx - rx, cy); + p.cubic_to(cx - rx, cy - ky, cx - kx, cy - ry, cx, cy - ry); + p.cubic_to(cx + kx, cy - ry, cx + rx, cy - ky, cx + rx, cy); + p.close(); + p + } + /// Closed polygon through `pts`. + pub fn polygon(pts: &[(f32, f32)]) -> Path { + let mut p = Path::new(); + if let Some(&(x, y)) = pts.first() { + p.move_to(x, y); + for &(x, y) in &pts[1..] { + p.line_to(x, y); + } + p.close(); + } + p + } +} + +fn contour_area(c: &[(f32, f32)]) -> f32 { + let n = c.len(); + let mut a = 0.0; + for i in 0..n { + let (x0, y0) = c[i]; + let (x1, y1) = c[(i + 1) % n]; + a += x0 * y1 - x1 * y0; + } + a / 2.0 +} + +/// Outward unit normal of edge p0→p1 for an outline of orientation `sign`. +fn normal(p0: (f32, f32), p1: (f32, f32), sign: f32) -> (f32, f32) { + let (dx, dy) = (p1.0 - p0.0, p1.1 - p0.1); + let l = (dx * dx + dy * dy).sqrt(); + if l < 1e-9 { + return (0.0, 0.0); + } + (sign * dy / l, -sign * dx / l) +} + +/// Coverage mask (0..1), row-major. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Mask { + pub w: usize, + pub h: usize, + pub a: Vec, +} + +impl Mask { + pub fn new(w: usize, h: usize) -> Self { + Self { w, h, a: vec![0.0; w * h] } + } + pub fn get(&self, x: usize, y: usize) -> f32 { + self.a[y * self.w + x] + } + /// Add `src` placed at (`ox`, `oy`) (saturating at 1). + pub fn add(&mut self, src: &Mask, ox: i32, oy: i32) { + for y in 0..src.h { + let ty = oy + y as i32; + if ty < 0 || ty >= self.h as i32 { + continue; + } + let x0 = (-ox).max(0) as usize; + let x1 = src.w.min((self.w as i32 - ox).max(0) as usize); + if x0 >= x1 { + continue; + } + let srow = &src.a[y * src.w + x0..y * src.w + x1]; + let d0 = ty as usize * self.w + (ox + x0 as i32) as usize; + for (d, s) in self.a[d0..d0 + (x1 - x0)].iter_mut().zip(srow) { + *d = (*d + s).min(1.0); + } + } + } + /// Per-pixel maximum with `o` (same size). + pub fn max_with(&mut self, o: &Mask) { + for (a, b) in self.a.iter_mut().zip(&o.a) { + *a = a.max(*b); + } + } +} + +/// Fill `path` (non-zero winding) into a `w`×`h` mask, offsetting the path by `(dx, dy)`. +pub fn fill(path: &Path, w: usize, h: usize, dx: f32, dy: f32) -> Mask { + let mut m = Mask::new(w, h); + fill_into(path, &mut m, dx, dy); + m +} + +struct Edge { + ytop: f32, + ybot: f32, + x: f32, + slope: f32, + dir: i32, +} + +/// Fill `path` into `m`, adding coverage (saturating at 1) — the union of what was there and the +/// path for disjoint content. +pub fn fill_into(path: &Path, m: &mut Mask, dx: f32, dy: f32) { + let (w, h) = (m.w, m.h); + if w == 0 || h == 0 { + return; + } + let mut edges: Vec = Vec::new(); + for c in &path.contours { + let n = c.len(); + if n < 2 { + continue; + } + for i in 0..n { + let (x0, y0) = c[i]; + let (x1, y1) = c[(i + 1) % n]; + let (x0, y0, x1, y1) = (x0 + dx, y0 + dy, x1 + dx, y1 + dy); + if y0 == y1 || !(x0.is_finite() && y0.is_finite() && x1.is_finite() && y1.is_finite()) { + continue; + } + let (dir, xa, ya, xb, yb) = if y0 < y1 { (1, x0, y0, x1, y1) } else { (-1, x1, y1, x0, y0) }; + if yb <= 0.0 || ya >= h as f32 { + continue; + } + edges.push(Edge { ytop: ya, ybot: yb, x: xa, slope: (xb - xa) / (yb - ya), dir }); + } + } + if edges.is_empty() { + return; + } + edges.sort_by(|a, b| a.ytop.total_cmp(&b.ytop)); + let ymin = edges[0].ytop.floor().max(0.0) as usize; + let ymax = (edges.iter().map(|e| e.ybot).fold(f32::MIN, f32::max).ceil().max(0.0) as usize).min(h); + let mut next = 0usize; + let mut active: Vec = Vec::new(); + let mut xs: Vec<(f32, i32)> = Vec::new(); + let mut row = vec![0.0f32; w + 1]; + let weight = 1.0 / SUB as f32; + for y in ymin..ymax { + let mut lo = usize::MAX; + let mut hi = 0usize; + for k in 0..SUB { + let sy = y as f32 + (k as f32 + 0.5) / SUB as f32; + while next < edges.len() && edges[next].ytop <= sy { + active.push(next); + next += 1; + } + active.retain(|&i| edges[i].ybot > sy); + xs.clear(); + for &i in &active { + let e = &edges[i]; + if e.ytop <= sy { + xs.push((e.x + (sy - e.ytop) * e.slope, e.dir)); + } + } + if xs.len() < 2 { + continue; + } + xs.sort_by(|a, b| a.0.total_cmp(&b.0)); + let mut wind = 0; + let mut span_start = 0.0f32; + for &(x, d) in &xs { + let before = wind; + wind += d; + if before == 0 && wind != 0 { + span_start = x; + } else if before != 0 + && wind == 0 + && let Some((a, b)) = add_span(&mut row, span_start, x, weight, w) + { + lo = lo.min(a); + hi = hi.max(b); + } + } + } + if lo <= hi && lo < w { + let hi = hi.min(w - 1); + let out = &mut m.a[y * w..(y + 1) * w]; + for x in lo..=hi { + out[x] = (out[x] + row[x]).min(1.0); + row[x] = 0.0; + } + row[w] = 0.0; + } + } +} + +/// Add coverage `weight` over `[x0, x1)` with fractional ends; returns the touched pixel range. +fn add_span(row: &mut [f32], x0: f32, x1: f32, weight: f32, w: usize) -> Option<(usize, usize)> { + let x0 = x0.clamp(0.0, w as f32); + let x1 = x1.clamp(0.0, w as f32); + if x1 <= x0 { + return None; + } + let i0 = x0.floor() as usize; + let i1 = x1.floor() as usize; + if i0 == i1 { + row[i0] += (x1 - x0) * weight; + return Some((i0, i0)); + } + row[i0] += (i0 as f32 + 1.0 - x0) * weight; + for v in &mut row[i0 + 1..i1] { + *v += weight; + } + if i1 < w { + row[i1] += (x1 - i1 as f32) * weight; + } + Some((i0, i1.min(w - 1))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rectangle_coverage() { + let m = fill(&Path::rect(1.5, 2.0, 4.0, 5.0), 6, 6, 0.0, 0.0); + assert!((m.get(1, 3) - 0.5).abs() < 1e-5); + assert!((m.get(2, 3) - 1.0).abs() < 1e-5); + assert_eq!(m.get(4, 3), 0.0); + assert_eq!(m.get(2, 1), 0.0); + assert_eq!(m.get(2, 5), 0.0); + let total: f32 = m.a.iter().sum(); + assert!((total - 2.5 * 3.0).abs() < 1e-3, "{total}"); + } + + #[test] + fn hole_with_opposite_winding() { + let mut p = Path::rect(0.0, 0.0, 10.0, 10.0); + p.move_to(3.0, 3.0); + p.line_to(3.0, 7.0); + p.line_to(7.0, 7.0); + p.line_to(7.0, 3.0); + p.close(); + let m = fill(&p, 10, 10, 0.0, 0.0); + assert_eq!(m.get(5, 5), 0.0); + assert!((m.get(1, 5) - 1.0).abs() < 1e-5); + } + + #[test] + fn circle_area() { + let (cx, cy, r) = (10.0f32, 10.0f32, 6.0f32); + let m = fill(&Path::ellipse(cx, cy, r, r), 20, 20, 0.0, 0.0); + let total: f32 = m.a.iter().sum(); + let want = std::f32::consts::PI * r * r; + assert!((total - want).abs() / want < 0.02, "{total} vs {want}"); + } + + #[test] + fn embolden_grows_outline_and_shrinks_holes() { + let mut p = Path::rect(4.0, 4.0, 14.0, 14.0); + p.embolden(1.0); + let b = p.bounds().unwrap(); + assert!((b.0 - 3.0).abs() < 1e-4 && (b.2 - 15.0).abs() < 1e-4, "{b:?}"); + // reversed winding: still grows (orientation is taken from the whole outline) + let mut q = Path::polygon(&[(4.0, 4.0), (4.0, 14.0), (14.0, 14.0), (14.0, 4.0)]); + q.embolden(1.0); + assert!((q.bounds().unwrap().0 - 3.0).abs() < 1e-4); + } + + #[test] + fn transforms_compose() { + let t = compose(&[2.0, 0.0, 0.0, 2.0, 0.0, 0.0], &[1.0, 0.0, 0.0, 1.0, 3.0, 4.0]); + assert_eq!(apply(&t, 1.0, 1.0), (8.0, 10.0)); + let p = Path::rect(0.0, 0.0, 1.0, 1.0).transformed(&t); + assert_eq!(p.bounds(), Some((6.0, 8.0, 8.0, 10.0))); + assert!((xform_scale(&t) - 2.0).abs() < 1e-6); + } + + #[test] + fn round_rect_area() { + let m = fill(&Path::round_rect(0.0, 0.0, 20.0, 10.0, 3.0), 20, 10, 0.0, 0.0); + let total: f32 = m.a.iter().sum(); + let want = 200.0 - (4.0 - std::f32::consts::PI) * 9.0; + assert!((total - want).abs() < 1.0, "{total} {want}"); + } +} diff --git a/crates/text/src/render.rs b/crates/text/src/render.rs new file mode 100644 index 00000000..07d88930 --- /dev/null +++ b/crates/text/src/render.rs @@ -0,0 +1,302 @@ +//! Drawing laid-out text into coverage masks. +//! +//! Two paths: +//! - **Glyph cache** (axis-aligned uniform scale, the common case for titles): each glyph is +//! rasterised once per (face, glyph, size, ¼-pixel horizontal phase, synthetic style) and the +//! cached masks are added at their pixel positions (sub-pixel positioning in x, whole pixels +//! in y). +//! - **Outlines** (rotation, skew, non-uniform scale): every glyph outline is transformed into +//! device space and the whole run is filled once. +//! +//! Synthetic bold grows outlines by 2.5 % of the size per side; synthetic italic slants by 12°. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex, OnceLock}; + +use skrifa::instance::{LocationRef, Size}; +use skrifa::outline::{DrawSettings, OutlinePen}; +use skrifa::{GlyphId, MetadataProvider}; + +use crate::fonts::{self, FaceId}; +use crate::layout::{Glyph, Layout}; +use crate::raster::{IDENTITY, Mask, Path, Xform, apply, compose, fill, fill_into, xform_scale}; + +/// Slant of synthetic italic (tan 12°). +pub const SYNTH_ITALIC_SLANT: f32 = 0.2126; +/// Synthetic bold outline offset as a fraction of the font size. +pub const SYNTH_BOLD: f32 = 0.025; + +#[derive(Clone, Copy, Debug)] +enum Cmd { + M(f32, f32), + L(f32, f32), + Q(f32, f32, f32, f32), + C(f32, f32, f32, f32, f32, f32), + Z, +} + +struct Rec(Vec); + +impl OutlinePen for Rec { + fn move_to(&mut self, x: f32, y: f32) { + self.0.push(Cmd::M(x, y)); + } + fn line_to(&mut self, x: f32, y: f32) { + self.0.push(Cmd::L(x, y)); + } + fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) { + self.0.push(Cmd::Q(cx0, cy0, x, y)); + } + fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) { + self.0.push(Cmd::C(cx0, cy0, cx1, cy1, x, y)); + } + fn close(&mut self) { + self.0.push(Cmd::Z); + } +} + +/// Outline commands in font units (y up), cached per (face, glyph). +fn outline(face: FaceId, gid: u32) -> Option>> { + static C: OnceLock>>>>> = OnceLock::new(); + let c = C.get_or_init(Default::default); + if let Some(v) = c.lock().unwrap_or_else(|e| e.into_inner()).get(&(face, gid)) { + return v.clone(); + } + let f = fonts::face(face); + let v = f.font().and_then(|font| { + let g = font.outline_glyphs().get(GlyphId::new(gid))?; + let mut rec = Rec(Vec::new()); + g.draw(DrawSettings::unhinted(Size::unscaled(), LocationRef::default()), &mut rec).ok()?; + Some(Arc::new(rec.0)) + }); + let mut m = c.lock().unwrap_or_else(|e| e.into_inner()); + if m.len() > 20_000 { + m.clear(); + } + m.insert((face, gid), v.clone()); + v +} + +/// The glyph's outline as a path: `m` maps glyph-local pixels (origin on the baseline, y down, +/// at the glyph's size) to the output. +pub fn glyph_path(g: &Glyph, m: &Xform) -> Path { + let mut p = Path::new(); + let Some(cmds) = outline(g.face, g.id) else { return p }; + let k = g.size / fonts::face(g.face).units_per_em(); + let slant = if g.synth_italic { SYNTH_ITALIC_SLANT } else { 0.0 }; + // font units (y up) → glyph px (y down), slanted + let local: Xform = [k, 0.0, slant * k, -k, 0.0, 0.0]; + let t = compose(m, &local); + p.detail = xform_scale(m).max(0.05); + let pt = |x: f32, y: f32| apply(&t, x, y); + for c in cmds.iter() { + match *c { + Cmd::M(x, y) => { + let (a, b) = pt(x, y); + p.move_to(a, b) + } + Cmd::L(x, y) => { + let (a, b) = pt(x, y); + p.line_to(a, b) + } + Cmd::Q(cx, cy, x, y) => { + let (c0, c1) = pt(cx, cy); + let (a, b) = pt(x, y); + p.quad_to(c0, c1, a, b) + } + Cmd::C(c0x, c0y, c1x, c1y, x, y) => { + let (p0, p1) = pt(c0x, c0y); + let (q0, q1) = pt(c1x, c1y); + let (a, b) = pt(x, y); + p.cubic_to(p0, p1, q0, q1, a, b) + } + Cmd::Z => p.close(), + } + } + p.close(); + if g.synth_bold { + p.embolden(g.size * SYNTH_BOLD * xform_scale(m)); + } + p +} + +/// A rasterised glyph: coverage placed with its top-left at `(left, top)` relative to the +/// pen's whole-pixel position. +#[derive(Debug)] +pub struct GlyphMask { + pub mask: Mask, + pub left: i32, + pub top: i32, +} + +type GKey = (FaceId, u32, u32, u8, bool, bool); + +fn glyph_cache() -> &'static Mutex>>> { + static C: OnceLock>>>> = OnceLock::new(); + C.get_or_init(Default::default) +} + +/// Rasterise glyph `g` at pixel size `px` with horizontal phase `frac` (0..1, quantised to ¼). +pub fn glyph_mask(g: &Glyph, px: f32, frac: f32) -> Option> { + let size_q = (px * 8.0).round().max(1.0) as u32; + let frac_q = ((frac.rem_euclid(1.0) * 4.0).round() as u8) % 4; + let key = (g.face, g.id, size_q, frac_q, g.synth_bold, g.synth_italic); + if let Some(v) = glyph_cache().lock().unwrap_or_else(|e| e.into_inner()).get(&key) { + return v.clone(); + } + let px = size_q as f32 / 8.0; + let fq = frac_q as f32 / 4.0; + let gg = Glyph { x: 0.0, y: 0.0, size: px, ..*g }; + let path = glyph_path(&gg, &[1.0, 0.0, 0.0, 1.0, fq, 0.0]); + let v = path.bounds().map(|(x0, y0, x1, y1)| { + let left = x0.floor() as i32; + let top = y0.floor() as i32; + let w = (x1.ceil() as i32 - left).max(1) as usize; + let h = (y1.ceil() as i32 - top).max(1) as usize; + Arc::new(GlyphMask { mask: fill(&path, w, h, -left as f32, -top as f32), left, top }) + }); + let mut m = glyph_cache().lock().unwrap_or_else(|e| e.into_inner()); + if m.len() > 16_384 { + m.clear(); + } + m.insert(key, v.clone()); + v +} + +/// Whether `m` is a uniform positive scale plus translation (the glyph-cache path applies). +pub fn is_axis_uniform(m: &Xform) -> bool { + m[1].abs() < 1e-6 && m[2].abs() < 1e-6 && m[0] > 0.0 && (m[0] - m[3]).abs() < 1e-4 * m[0].max(1.0) +} + +/// The whole layout (glyphs + underlines) as one device-space path. +pub fn layout_path(l: &Layout, m: &Xform) -> Path { + let mut p = Path::new(); + for g in &l.glyphs { + let gm = compose(m, &[1.0, 0.0, 0.0, 1.0, g.x, g.y]); + p.extend(&glyph_path(g, &gm)); + } + for u in &l.underlines { + p.extend(&Path::rect(u[0], u[1], u[2], u[3]).transformed(m)); + } + p +} + +/// Device-space bounding box of the layout's ink under `m` (logical bounds padded for overhangs). +pub fn device_bounds(l: &Layout, m: &Xform) -> [f32; 4] { + let b = l.bounds; + let pad = l.glyphs.iter().map(|g| g.size).fold(0.0f32, f32::max) * 0.35; + let pts = [(b[0] - pad, b[1] - pad), (b[2] + pad, b[1] - pad), (b[0] - pad, b[3] + pad), (b[2] + pad, b[3] + pad)]; + let mut o = [f32::MAX, f32::MAX, f32::MIN, f32::MIN]; + for (x, y) in pts { + let (a, c) = apply(m, x, y); + o = [o[0].min(a), o[1].min(c), o[2].max(a), o[3].max(c)]; + } + o +} + +/// Add the coverage of layout `l` under transform `m` into `out` (whose pixel (0,0) is device +/// pixel `origin`). +pub fn draw(l: &Layout, m: &Xform, out: &mut Mask, origin: (i32, i32)) { + let (ox, oy) = origin; + if is_axis_uniform(m) { + let s = m[0]; + for g in &l.glyphs { + let (x, y) = apply(m, g.x, g.y); + let px = g.size * s; + if px < 0.5 { + continue; + } + let xi = x.floor(); + if let Some(gm) = glyph_mask(g, px, x - xi) { + out.add(&gm.mask, xi as i32 + gm.left - ox, y.round() as i32 + gm.top - oy); + } + } + if !l.underlines.is_empty() { + let mut p = Path::new(); + for u in &l.underlines { + p.extend(&Path::rect(u[0], u[1], u[2], u[3]).transformed(m)); + } + fill_into(&p, out, -ox as f32, -oy as f32); + } + } else { + let p = layout_path(l, m); + fill_into(&p, out, -ox as f32, -oy as f32); + } +} + +/// Rasterise a layout into a tight mask; returns `(mask, x, y)` with the mask's device position. +pub fn rasterize(l: &Layout, m: &Xform) -> (Mask, i32, i32) { + let b = device_bounds(l, m); + let (x0, y0) = (b[0].floor() as i32 - 1, b[1].floor() as i32 - 1); + let (w, h) = ((b[2].ceil() as i32 + 1 - x0).max(1) as usize, (b[3].ceil() as i32 + 1 - y0).max(1) as usize); + let mut mask = Mask::new(w, h); + draw(l, m, &mut mask, (x0, y0)); + (mask, x0, y0) +} + +/// Translate-only transform helper. +pub fn at(x: f32, y: f32) -> Xform { + let mut t = IDENTITY; + t[4] = x; + t[5] = y; + t +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::layout::{ParagraphStyle, TextStyle, layout}; + + fn ink(m: &Mask) -> f32 { + m.a.iter().sum() + } + + #[test] + fn h_has_two_stems() { + let l = layout("H", &TextStyle { size: 40.0, ..Default::default() }, &ParagraphStyle::default()); + let g = glyph_mask(&l.glyphs[0], 40.0, 0.0).unwrap(); + let y = g.mask.h / 4; + let row: Vec = (0..g.mask.w).map(|x| g.mask.get(x, y)).collect(); + assert!(row[1] > 0.9 && row[g.mask.w - 2] > 0.9, "{row:?}"); + assert!(row[g.mask.w / 2] < 0.1, "{row:?}"); + assert!(g.top < 0, "rises above the baseline"); + } + + #[test] + fn cached_and_outline_paths_agree() { + let l = layout("Title 42", &TextStyle { size: 48.0, ..Default::default() }, &ParagraphStyle::default()); + let fast = rasterize(&l, &at(20.25, 60.0)); + // a tiny rotation forces the outline path + let a = 1e-4f32; + let slow = rasterize(&l, &[a.cos(), a.sin(), -a.sin(), a.cos(), 20.25, 60.0]); + let (fi, si) = (ink(&fast.0), ink(&slow.0)); + assert!((fi - si).abs() / fi < 0.02, "{fi} vs {si}"); + } + + #[test] + fn synthetic_styles() { + let base = TextStyle { size: 60.0, ..Default::default() }; + let l = layout("Hello", &base, &ParagraphStyle::default()); + let b = layout("Hello", &TextStyle { faux_bold: true, ..base.clone() }, &ParagraphStyle::default()); + assert!(ink(&rasterize(&b, &IDENTITY).0) > ink(&rasterize(&l, &IDENTITY).0) * 1.1); + let i = layout("l", &TextStyle { faux_italic: true, ..base.clone() }, &ParagraphStyle::default()); + let (mi, _, _) = rasterize(&i, &IDENTITY); + // the top of an italic l is further right than its bottom + let row_centre = |y: usize| { + let r: Vec = (0..mi.w).map(|x| mi.get(x, y)).collect(); + let s: f32 = r.iter().sum(); + r.iter().enumerate().map(|(x, v)| x as f32 * v).sum::() / s.max(1e-6) + }; + let rows: Vec = (0..mi.h).filter(|&y| (0..mi.w).any(|x| mi.get(x, y) > 0.5)).collect(); + assert!(row_centre(rows[2]) > row_centre(rows[rows.len() - 3]) + 3.0); + } + + #[test] + fn rotated_text_draws() { + let l = layout("ROT", &TextStyle { size: 40.0, ..Default::default() }, &ParagraphStyle::default()); + let a = 30f32.to_radians(); + let (m, _, _) = rasterize(&l, &[a.cos(), a.sin(), -a.sin(), a.cos(), 0.0, 0.0]); + let (m0, _, _) = rasterize(&l, &IDENTITY); + assert!((ink(&m) - ink(&m0)).abs() / ink(&m0) < 0.03); + } +} diff --git a/crates/text/src/sfnt.rs b/crates/text/src/sfnt.rs new file mode 100644 index 00000000..bb00d515 --- /dev/null +++ b/crates/text/src/sfnt.rs @@ -0,0 +1,140 @@ +//! Minimal SFNT (TrueType / OpenType / collection) header reader for font discovery: reads only +//! the table directory, `name` and `OS/2` of each face, so scanning a system font folder does not +//! load whole files. + +use std::io::{Read, Seek, SeekFrom}; + +/// Names and style attributes of one face in a font file. +#[derive(Clone, Debug, PartialEq)] +pub struct FaceNames { + pub index: u32, + pub family: String, + pub style: String, + pub full_name: String, + pub weight: u16, + pub italic: bool, +} + +fn be16(b: &[u8], o: usize) -> Option { + Some(u16::from_be_bytes([*b.get(o)?, *b.get(o + 1)?])) +} +fn be32(b: &[u8], o: usize) -> Option { + Some(u32::from_be_bytes([*b.get(o)?, *b.get(o + 1)?, *b.get(o + 2)?, *b.get(o + 3)?])) +} + +fn read_at(r: &mut R, off: u64, len: usize) -> Option> { + if len > 4 << 20 { + return None; + } + r.seek(SeekFrom::Start(off)).ok()?; + let mut v = vec![0u8; len]; + r.read_exact(&mut v).ok()?; + Some(v) +} + +/// Read the names of every face in a font file (a single font or a collection). +pub fn read_faces(r: &mut R) -> Vec { + let Some(head) = read_at(r, 0, 12) else { return Vec::new() }; + let offsets: Vec = if &head[0..4] == b"ttcf" { + let n = be32(&head, 8).unwrap_or(0).min(256) as usize; + let Some(t) = read_at(r, 12, n * 4) else { return Vec::new() }; + (0..n).filter_map(|i| be32(&t, i * 4)).collect() + } else { + vec![0] + }; + offsets.iter().enumerate().filter_map(|(i, &o)| read_face(r, o as u64, i as u32)).collect() +} + +/// Names from in-memory font data. +pub fn read_faces_bytes(data: &[u8]) -> Vec { + read_faces(&mut std::io::Cursor::new(data)) +} + +fn read_face(r: &mut R, off: u64, index: u32) -> Option { + let h = read_at(r, off, 12)?; + let tag = &h[0..4]; + if tag != [0, 1, 0, 0] && tag != b"OTTO" && tag != b"true" { + return None; + } + let n = be16(&h, 4)? as usize; + let dir = read_at(r, off + 12, n * 16)?; + let mut name = None; + let mut os2 = None; + for i in 0..n { + let rec = &dir[i * 16..i * 16 + 16]; + let (o, l) = (be32(rec, 8)? as u64, be32(rec, 12)? as usize); + match &rec[0..4] { + b"name" => name = Some((o, l)), + b"OS/2" => os2 = Some((o, l)), + _ => {} + } + } + let (no, nl) = name?; + let nb = read_at(r, no, nl)?; + let get = |id: u16| name_string(&nb, id); + let family = get(16).or_else(|| get(1))?; + let style = get(17).or_else(|| get(2)).unwrap_or_else(|| "Regular".into()); + let full_name = get(4).unwrap_or_else(|| format!("{family} {style}")); + let (mut weight, mut italic) = (400u16, false); + if let Some((oo, ol)) = os2 + && let Some(ob) = read_at(r, oo, ol.min(78)) + { + weight = be16(&ob, 4).unwrap_or(400); + italic = be16(&ob, 62).is_some_and(|fs| fs & 1 != 0); + } + let sl = style.to_ascii_lowercase(); + italic |= sl.contains("italic") || sl.contains("oblique"); + Some(FaceNames { index, family, style, full_name, weight, italic }) +} + +/// A name-table string by id, preferring Windows Unicode English, then any Unicode, then Mac Roman. +fn name_string(b: &[u8], id: u16) -> Option { + let count = be16(b, 2)? as usize; + let storage = be16(b, 4)? as usize; + let mut best: Option<(u8, String)> = None; + for i in 0..count { + let r = 6 + i * 12; + let (plat, enc, lang, nid, len, off) = + (be16(b, r)?, be16(b, r + 2)?, be16(b, r + 4)?, be16(b, r + 6)?, be16(b, r + 8)? as usize, be16(b, r + 10)? as usize); + if nid != id { + continue; + } + let raw = b.get(storage + off..storage + off + len)?; + let (rank, s) = match plat { + 3 if enc == 1 || enc == 10 => { + let u: Vec = raw.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect(); + (if lang == 0x409 { 0 } else { 1 }, String::from_utf16_lossy(&u)) + } + 0 => { + let u: Vec = raw.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect(); + (2, String::from_utf16_lossy(&u)) + } + 1 if enc == 0 => (3, raw.iter().map(|&c| if c < 128 { c as char } else { '?' }).collect()), + _ => continue, + }; + if best.as_ref().is_none_or(|(r0, _)| rank < *r0) { + best = Some((rank, s)); + } + } + best.map(|(_, s)| s).filter(|s| !s.trim().is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reads_bundled_names() { + let f = read_faces_bytes(crate::fonts::INTER_BOLD); + assert_eq!(f.len(), 1); + assert_eq!(f[0].family, "Inter"); + assert_eq!(f[0].style, "Bold"); + assert_eq!(f[0].weight, 700); + assert!(!f[0].italic); + let i = read_faces_bytes(crate::fonts::INTER_ITALIC); + assert!(i[0].italic, "{:?}", i[0]); + let n = read_faces_bytes(crate::fonts::NOTO_SERIF_REGULAR); + assert_eq!(n[0].family, "Noto Serif"); + assert!(read_faces_bytes(b"not a font").is_empty()); + } +} diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index a42a66f5..32f88ec1 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -18,6 +18,7 @@ filmcraft-media = { workspace = true } filmcraft-project = { workspace = true } filmcraft-edit = { workspace = true } filmcraft-render = { workspace = true } +filmcraft-text = { workspace = true } filmcraft-audio-dsp = { workspace = true } filmcraft-engine = { workspace = true } filmcraft-gpu = { workspace = true } diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 5c548ac9..9f938841 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -193,10 +193,10 @@ impl Tokens { /// Install fonts (Inter, Inter SemiBold, JetBrains Mono) and egui visuals. pub fn install(ctx: &egui::Context, t: &Tokens) { let mut fonts = FontDefinitions::default(); - fonts.font_data.insert("inter".into(), Arc::new(FontData::from_static(include_bytes!("../../../assets/fonts/Inter-Regular.ttf")))); - fonts.font_data.insert("inter-medium".into(), Arc::new(FontData::from_static(include_bytes!("../../../assets/fonts/Inter-Medium.ttf")))); - fonts.font_data.insert("inter-semibold".into(), Arc::new(FontData::from_static(include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf")))); - fonts.font_data.insert("jbmono".into(), Arc::new(FontData::from_static(include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf")))); + fonts.font_data.insert("inter".into(), Arc::new(FontData::from_static(filmcraft_text::fonts::INTER_REGULAR))); + fonts.font_data.insert("inter-medium".into(), Arc::new(FontData::from_static(filmcraft_text::fonts::INTER_MEDIUM))); + fonts.font_data.insert("inter-semibold".into(), Arc::new(FontData::from_static(filmcraft_text::fonts::INTER_SEMIBOLD))); + fonts.font_data.insert("jbmono".into(), Arc::new(FontData::from_static(filmcraft_text::fonts::JETBRAINS_MONO_REGULAR))); fonts.families.entry(FontFamily::Proportional).or_default().insert(0, "inter".into()); fonts.families.entry(FontFamily::Monospace).or_default().insert(0, "jbmono".into()); fonts.families.insert(FontFamily::Name("semibold".into()), vec!["inter-semibold".into(), "inter".into()]); diff --git a/docs/architecture.md b/docs/architecture.md index c225ff12..73a01dd1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -24,8 +24,8 @@ Design principles: L5 ui-egui · automation L4 engine L3 render · gpu · export · golden (test-only) - L2 edit · codecs · interchange - L1 frame · media · project · audio-dsp + L2 edit · codecs · interchange · captions + L1 frame · media · project · audio-dsp · text L0 foundation: time · geom · color · bitstream · testkit (dev-dependency only) codecs/containers: isobmff · matroska · h264 · h264enc · hevc · prores · aac ``` @@ -50,6 +50,7 @@ and `filmcraft-cli`. | `media` | L1 | `MediaSource` trait, probing/openers, frame cache, generators, stills, WAV | | `project` | L1 | document model, effect definitions, keyframes | | `audio-dsp` | L1 | loudness metering (BS.1770 / R128) and audio effects; no dependencies | +| `text` | L1 | text engine: font database (bundled OFL fonts + system fonts), shaping (harfrust), bidi, line breaking, paragraph layout, glyph/path rasteriser, strokes ([crates/text/README.md](../crates/text/README.md)) | | `edit` | L2 | pure edit algebra (insert, overwrite, razor, ripple, roll, slip, slide, rate stretch…) | | `codecs` | L2 | container + codec hub: MP4/MOV and MKV sources, GOP-aware seeking, decoder registry, audio decoding | | `interchange` | L2 | EDL, FCP7 XML, FCPXML and OTIO import/export (no file I/O) | @@ -257,7 +258,7 @@ Protocol reference: [control-protocol.md](control-protocol.md). Agent guide: [ag ## 8. Not built yet The layer table reserves names for crates that don't exist yet: `riff`, `mjpeg`, `dnx`, -`keyframe`, `effects`, `text`, `audio`, `captions`, `scopes`, `playback`, `format` and `platform`. +`keyframe`, `effects`, `audio`, `scopes`, `playback`, `format` and `platform`. Until they exist, that work lives elsewhere: keyframes and effect definitions in `project`, effects and the audio mix in `render`, scopes and playback in `ui-egui`, and OS integration (cpal, rfd, native menus) in `apps/filmcraft`. [ROADMAP.md](../ROADMAP.md) has the milestone status. diff --git a/docs/captions.md b/docs/captions.md index 0cac44d7..b8c03f0a 100644 --- a/docs/captions.md +++ b/docs/captions.md @@ -74,6 +74,5 @@ also splits captions. The Program monitor shows the visible caption tracks. On export, `file.exportMedia` `{"burnCaptions": true}` (Export mode: **Burn Captions Into Video**) draws them into every frame. -Captions are set in Inter SemiBold (OFL, bundled) with our own rasteriser; italics/bold markup is -not yet drawn differently, and text is not shaped (no kerning or complex scripts) until the M10.1 -text engine lands. Nested sequences do not burn in their own captions. +Captions are set in Inter SemiBold (OFL, bundled) by the text engine (`crates/text`: shaping, +kerning, bidi); italics/bold markup is not yet drawn differently. Nested sequences do not burn in their own captions. diff --git a/xtask/src/main.rs b/xtask/src/main.rs index ee24ea1e..ad9cc29b 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -36,11 +36,11 @@ const LAYERS: &[(&str, u8)] = &[ ("media", 1), ("project", 1), ("audio-dsp", 1), + ("text", 1), ("edit", 2), ("codecs", 2), ("keyframe", 2), ("effects", 2), - ("text", 2), ("audio", 2), ("captions", 2), ("interchange", 2),