M10.1: text engine (crates/text): font database with bundled OFL fonts and system-font discovery, harfrust shaping, bidi, line breaking, paragraph layout, glyph cache and rasteriser; captions and Timecode/Clip Name burn-ins use it

- New L1 crate filmcraft-text: fonts (bundled Inter Regular/Medium/SemiBold/Bold/Italic,
  JetBrains Mono, Noto Serif; DirectorySource scans system font folders reading only name/OS2
  tables; family/style resolution with synthetic bold/italic; per-char fallback), layout
  (harfrust kerning/ligatures, unicode-bidi, unicode-linebreak, alignment incl. justify,
  leading, tracking, baseline shift, all/small caps, underline, carets/hit-test/selection,
  cached), raster (moved from captions; active-edge list, shapes, emboldening), render
  (outline + sub-pixel glyph-mask caches; transformed-outline path for rotation/skew),
  mask (distance-transform strokes, blur).
- Captions burn-in now shapes and kerns via the engine (font.rs/raster.rs removed).
- Timecode / Clip Name effects draw real text (full clip names, not seven-segment digits);
  text_burnin golden re-blessed.
- UI takes its font bytes from the text crate (no duplicate copies in the binary).
- New fonts: Inter Bold, Inter Italic (OFL-1.1, rsms/inter v4.1), Noto Serif Regular
  (OFL-1.1, notofonts) with sidecars, ATTRIBUTION.md rows and licence text.
- Perf: 3-line 96 px title at 1080p ~1.6 ms cold / 0.15 ms warm (release).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-01 01:22:30 -04:00
1 parent 0c2b49bdc7
commit b141c406e9
40 files changed
+2707 -449

No files matched your search

+3
View File
@@ -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 |
Generated
+26 -1
View File
@@ -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"
+1
View File
@@ -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" }
Binary file not shown.
+8
View File
@@ -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.
Binary file not shown.
@@ -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.
+1 -1
View File
@@ -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).
+1 -1
View File
@@ -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).
+1 -1
View File
@@ -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).
@@ -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).
Binary file not shown.
@@ -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.
+93
View File
@@ -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.
+1 -2
View File
@@ -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 }
+4 -7
View File
@@ -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.
+18 -26
View File
@@ -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<String> {
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<f32> = lines.iter().map(|l| font::measure(l, px)).collect();
let widths: Vec<f32> = lines.iter().map(|l| measure(l, px)).collect();
let xs: Vec<f32> = 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));
-137
View File
@@ -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<'static>> {
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<HashMap<Key, Option<Arc<Glyph>>>> {
static C: OnceLock<Mutex<HashMap<Key, Option<Arc<Glyph>>>>> = 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<Arc<Glyph>> {
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<Glyph> {
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<f32> = (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);
}
}
+2 -4
View File
@@ -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;
-228
View File
@@ -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<f32>,
}
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}");
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

+1 -1
View File
@@ -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);
+1
View File
@@ -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 }
+4 -28
View File
@@ -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;
+72
View File
@@ -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);
}
+1
View File
@@ -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;
+21
View File
@@ -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"
+30
View File
@@ -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.
+475
View File
@@ -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<Vec<u8>>),
/// 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<FaceInfo>;
}
/// 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<PathBuf>,
}
impl DirectorySource {
/// The platform's standard font folders.
pub fn system() -> Self {
let mut dirs: Vec<PathBuf> = 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<PathBuf>) {
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<FaceInfo> {
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<Option<FaceBytes>>,
shaper: OnceLock<harfrust::ShaperData>,
}
#[derive(Clone)]
enum FaceBytes {
Static(&'static [u8]),
Shared(Arc<Vec<u8>>),
}
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<'_>> {
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<u32> {
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<Arc<Face>>,
scanned: bool,
}
fn db() -> &'static RwLock<Db> {
static DB: OnceLock<RwLock<Db>> = 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<u8>) -> Vec<FaceId> {
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<Face> {
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<Arc<Face>> {
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<String>)> {
let mut m: std::collections::BTreeMap<String, Vec<(bool, u16, String)>> = 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<Resolved> {
let faces = all_faces();
let fam: Vec<&Arc<Face>> = 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"));
}
}
+723
View File
@@ -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<f32>,
/// Base direction: None = from the first strong character.
pub rtl: Option<bool>,
}
fn hf(h: &mut impl Hasher, v: f32) {
v.to_bits().hash(h);
}
impl Hash for TextStyle {
fn hash<H: Hasher>(&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<H: Hasher>(&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<usize>,
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<usize>,
/// 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<Glyph>,
pub lines: Vec<Line>,
/// 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<f32> = 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<usize>,
rtl: bool,
glyphs: Vec<ShapedGlyph>,
}
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<ShapedGlyph> {
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<usize>,
glyphs: Vec<Glyph>,
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<ParaLine> {
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<Item> = 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<usize>| -> f32 { char_adv[r].iter().sum() };
let trailing_ws = |r: &Range<usize>| -> (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<Range<usize>> = Vec::new();
match para.width {
None => ranges.push(0..text.len()),
Some(maxw) => {
let mut start = 0usize;
let mut last_ok: Option<usize> = 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<usize, (f32, f32, bool)> = HashMap::new();
for run in runs {
let rtl = levels[run.start].is_rtl();
let mut idx: Vec<usize> = (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<usize> = extents.keys().copied().collect();
cluster_starts.sort_unstable();
let char_bytes: Vec<usize> = 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<usize> = 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<HashMap<u64, (u64, Arc<Layout>)>> {
static C: OnceLock<Mutex<HashMap<u64, (u64, Arc<Layout>)>>> = 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<Layout> {
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<u64> = 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<usize> = l.lines[0].carets.iter().map(|c| c.0).collect();
assert_eq!(stops, vec![0, 1, 2, 3, 4, 5, 6]);
let xs: Vec<f32> = 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]);
}
}
+52
View File
@@ -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:?}");
}
}
+213
View File
@@ -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<f32> {
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<f32> {
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);
}
}
+484
View File
@@ -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<Vec<(f32, f32)>>,
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<f32>,
}
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<Edge> = 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<usize> = 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}");
}
}
+302
View File
@@ -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<Cmd>);
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<Arc<Vec<Cmd>>> {
static C: OnceLock<Mutex<HashMap<(FaceId, u32), Option<Arc<Vec<Cmd>>>>>> = 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<HashMap<GKey, Option<Arc<GlyphMask>>>> {
static C: OnceLock<Mutex<HashMap<GKey, Option<Arc<GlyphMask>>>>> = 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<Arc<GlyphMask>> {
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<f32> = (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<f32> = (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::<f32>() / s.max(1e-6)
};
let rows: Vec<usize> = (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);
}
}
+140
View File
@@ -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<u16> {
Some(u16::from_be_bytes([*b.get(o)?, *b.get(o + 1)?]))
}
fn be32(b: &[u8], o: usize) -> Option<u32> {
Some(u32::from_be_bytes([*b.get(o)?, *b.get(o + 1)?, *b.get(o + 2)?, *b.get(o + 3)?]))
}
fn read_at<R: Read + Seek>(r: &mut R, off: u64, len: usize) -> Option<Vec<u8>> {
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: Read + Seek>(r: &mut R) -> Vec<FaceNames> {
let Some(head) = read_at(r, 0, 12) else { return Vec::new() };
let offsets: Vec<u32> = 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<FaceNames> {
read_faces(&mut std::io::Cursor::new(data))
}
fn read_face<R: Read + Seek>(r: &mut R, off: u64, index: u32) -> Option<FaceNames> {
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<String> {
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<u16> = 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<u16> = 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());
}
}
+1
View File
@@ -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 }
+4 -4
View File
@@ -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()]);
+4 -3
View File
@@ -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.
+2 -3
View File
@@ -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.
+1 -1
View File
@@ -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),