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:
1 parent
0c2b49bdc7
commit
b141c406e9
40 files changed
+2707
-449
No files matched your search
@@ -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
@@ -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"
|
||||
|
||||
@@ -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.
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 }
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 |
@@ -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);
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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()]);
|
||||
|
||||
@@ -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
@@ -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
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user