mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-02 22:42:24 -06:00
M0.1–M0.7: workspace, time/geom/color/keyframe/project/raster/path/text/effects/render crates
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
9f19158a59
commit
808cd490cd
@@ -0,0 +1,474 @@
|
||||
//! 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 {
|
||||
// Held for the whole scan: a concurrent caller waits for the complete list instead of
|
||||
// returning early and resolving against a half-filled database.
|
||||
static SCAN: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
let _scan = SCAN.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if system_scanned() {
|
||||
return 0;
|
||||
}
|
||||
let n = if cfg!(target_arch = "wasm32") { 0 } else { add_source(&DirectorySource::system()) };
|
||||
db().write().unwrap_or_else(|e| e.into_inner()).scanned = true;
|
||||
n
|
||||
}
|
||||
|
||||
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 (shown as missing in the UI).
|
||||
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_text("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 (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,228 @@
|
||||
//! EffectCraft text engine.
|
||||
//!
|
||||
//! - [`fonts`], [`sfnt`], [`layout`]: font database, shaping (harfrust), bidi, line breaking and
|
||||
//! paragraph layout (shared design with FilmCraft's text engine).
|
||||
//! - This module turns a layer's [`TextDoc`] into per-character glyph outlines (Bezier paths) with
|
||||
//! character / word / line indices, which is what text animators and selectors work on.
|
||||
|
||||
pub mod fonts;
|
||||
pub mod layout;
|
||||
pub mod sfnt;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use effectcraft_keyframe::{Justify, TextDoc};
|
||||
pub use fonts::{FaceId, Resolved, families, resolve};
|
||||
use kurbo::{Affine, BezPath, Point};
|
||||
pub use layout::{Align, Caps, Glyph, Layout, Line, ParagraphStyle, TextStyle, layout as layout_text, measure};
|
||||
use skrifa::instance::{LocationRef, Size};
|
||||
use skrifa::outline::{DrawSettings, OutlinePen};
|
||||
use skrifa::{GlyphId, MetadataProvider};
|
||||
|
||||
struct Pen(BezPath);
|
||||
|
||||
impl OutlinePen for Pen {
|
||||
fn move_to(&mut self, x: f32, y: f32) {
|
||||
self.0.move_to((x as f64, y as f64));
|
||||
}
|
||||
fn line_to(&mut self, x: f32, y: f32) {
|
||||
self.0.line_to((x as f64, y as f64));
|
||||
}
|
||||
fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) {
|
||||
self.0.quad_to((cx0 as f64, cy0 as f64), (x as f64, y as f64));
|
||||
}
|
||||
fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) {
|
||||
self.0.curve_to((cx0 as f64, cy0 as f64), (cx1 as f64, cy1 as f64), (x as f64, y as f64));
|
||||
}
|
||||
fn close(&mut self) {
|
||||
self.0.close_path();
|
||||
}
|
||||
}
|
||||
|
||||
/// Glyph outline in font units (y up), cached.
|
||||
fn outline_units(face: FaceId, gid: u32) -> Option<Arc<BezPath>> {
|
||||
static C: OnceLock<Mutex<HashMap<(FaceId, u32), Option<Arc<BezPath>>>>> = 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 pen = Pen(BezPath::new());
|
||||
g.draw(DrawSettings::unhinted(Size::unscaled(), LocationRef::default()), &mut pen).ok()?;
|
||||
Some(Arc::new(pen.0))
|
||||
});
|
||||
let mut m = c.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if m.len() > 50_000 {
|
||||
m.clear();
|
||||
}
|
||||
m.insert((face, gid), v.clone());
|
||||
v
|
||||
}
|
||||
|
||||
/// The outline of a laid-out glyph in pixels, origin on its baseline (y down).
|
||||
pub fn glyph_outline(g: &Glyph) -> BezPath {
|
||||
let Some(units) = outline_units(g.face, g.id) else { return BezPath::new() };
|
||||
let k = g.size as f64 / fonts::face(g.face).units_per_em() as f64;
|
||||
let slant = if g.synth_italic { 0.21 } else { 0.0 };
|
||||
Affine::new([k, 0.0, slant * k, -k, 0.0, 0.0]) * (*units).clone()
|
||||
}
|
||||
|
||||
/// One character of laid-out text.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CharGlyph {
|
||||
/// Outline relative to `origin`.
|
||||
pub path: BezPath,
|
||||
/// Baseline origin in layer space.
|
||||
pub origin: Point,
|
||||
pub advance: f64,
|
||||
/// Indices for selectors (characters, characters excluding spaces, words, lines).
|
||||
pub char_index: usize,
|
||||
pub char_index_no_space: usize,
|
||||
pub word_index: usize,
|
||||
pub line_index: usize,
|
||||
pub is_space: bool,
|
||||
pub synth_bold: bool,
|
||||
pub size: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct TextLayout {
|
||||
pub glyphs: Vec<CharGlyph>,
|
||||
pub chars: usize,
|
||||
pub chars_no_space: usize,
|
||||
pub words: usize,
|
||||
pub lines: usize,
|
||||
/// Bounds in layer space (x0, y0, x1, y1).
|
||||
pub bounds: [f64; 4],
|
||||
/// Line baselines (layer space) and widths for the viewer's text cursor.
|
||||
pub line_boxes: Vec<[f64; 4]>,
|
||||
}
|
||||
|
||||
/// Lay out a Source Text value in layer space. Point text: the origin is the start of the first
|
||||
/// baseline (left), its centre (centre) or end (right). Paragraph text fills `box_size` from
|
||||
/// `box_pos`.
|
||||
pub fn layout_doc(doc: &TextDoc) -> TextLayout {
|
||||
let text = if doc.all_caps { doc.text.to_uppercase() } else { doc.text.clone() };
|
||||
let style = TextStyle {
|
||||
family: doc.font.clone(),
|
||||
style: doc.style.clone(),
|
||||
size: doc.size as f32,
|
||||
tracking: doc.tracking as f32,
|
||||
baseline_shift: doc.baseline_shift as f32,
|
||||
faux_bold: doc.faux_bold,
|
||||
faux_italic: doc.faux_italic,
|
||||
caps: if doc.small_caps { Caps::Small } else { Caps::Normal },
|
||||
..Default::default()
|
||||
};
|
||||
let align = match doc.justify {
|
||||
Justify::Left | Justify::JustifyLastLeft => Align::Left,
|
||||
Justify::Center | Justify::JustifyLastCenter => Align::Center,
|
||||
Justify::Right | Justify::JustifyLastRight => Align::Right,
|
||||
Justify::JustifyAll => Align::Justify,
|
||||
};
|
||||
let natural = doc.size * 1.2;
|
||||
let leading = doc.leading.map(|l| (l - natural) as f32).unwrap_or(0.0);
|
||||
let para = ParagraphStyle { align, leading, width: doc.box_size.map(|b| b[0] as f32), rtl: None };
|
||||
let lay = layout_text(&text, &style, ¶);
|
||||
let first_base = lay.lines.first().map(|l| l.baseline as f64).unwrap_or(0.0);
|
||||
// Point text origin: (0, 0) at the first baseline; alignment pivots around x = 0.
|
||||
let (dx, dy) = match doc.box_size {
|
||||
Some(_) => (doc.box_pos[0], doc.box_pos[1]),
|
||||
// The layout already pivots point text around x = 0 by alignment.
|
||||
None => (0.0, -first_base),
|
||||
};
|
||||
let hs = doc.h_scale / 100.0;
|
||||
let vs = doc.v_scale / 100.0;
|
||||
let mut out = TextLayout { lines: lay.lines.len(), ..Default::default() };
|
||||
// Map byte clusters → char/word indices.
|
||||
let mut char_of_byte = vec![0usize; text.len() + 1];
|
||||
let mut word_of_char = Vec::new();
|
||||
let mut nospace_of_char = Vec::new();
|
||||
let mut words = 0usize;
|
||||
let mut in_word = false;
|
||||
let mut nospace = 0usize;
|
||||
for (ci, (b, ch)) in text.char_indices().enumerate() {
|
||||
for k in b..b + ch.len_utf8() {
|
||||
char_of_byte[k] = ci;
|
||||
}
|
||||
let space = ch.is_whitespace();
|
||||
if !space && !in_word {
|
||||
words += 1;
|
||||
}
|
||||
in_word = !space;
|
||||
word_of_char.push(words.saturating_sub(1));
|
||||
nospace_of_char.push(nospace);
|
||||
if !space {
|
||||
nospace += 1;
|
||||
}
|
||||
}
|
||||
out.chars = text.chars().count();
|
||||
out.chars_no_space = nospace;
|
||||
out.words = words;
|
||||
let mut b = [f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY];
|
||||
for (li, line) in lay.lines.iter().enumerate() {
|
||||
out.line_boxes.push([line.x as f64 + dx, line.baseline as f64 + dy, line.width as f64, (line.ascent + line.descent) as f64]);
|
||||
for gi in line.glyphs.clone() {
|
||||
let g = &lay.glyphs[gi];
|
||||
let ci = char_of_byte.get(g.cluster).copied().unwrap_or(0);
|
||||
let ch = text[g.cluster..].chars().next().unwrap_or(' ');
|
||||
let mut path = glyph_outline(g);
|
||||
if hs != 1.0 || vs != 1.0 {
|
||||
path = Affine::scale_non_uniform(hs, vs) * path;
|
||||
}
|
||||
let next_x = lay.glyphs.get(gi + 1).filter(|_| line.glyphs.contains(&(gi + 1))).map(|n| n.x).unwrap_or(line.x + line.width);
|
||||
let origin = Point::new(g.x as f64 * hs + dx, g.y as f64 + dy);
|
||||
if let Some(r) = (!path.elements().is_empty()).then(|| kurbo::Shape::bounding_box(&path)) {
|
||||
b[0] = b[0].min(origin.x + r.x0);
|
||||
b[1] = b[1].min(origin.y + r.y0);
|
||||
b[2] = b[2].max(origin.x + r.x1);
|
||||
b[3] = b[3].max(origin.y + r.y1);
|
||||
}
|
||||
out.glyphs.push(CharGlyph {
|
||||
path,
|
||||
origin,
|
||||
advance: (next_x - g.x) as f64 * hs,
|
||||
char_index: ci,
|
||||
char_index_no_space: nospace_of_char.get(ci).copied().unwrap_or(0),
|
||||
word_index: word_of_char.get(ci).copied().unwrap_or(0),
|
||||
line_index: li,
|
||||
is_space: ch.is_whitespace(),
|
||||
synth_bold: g.synth_bold,
|
||||
size: g.size as f64,
|
||||
});
|
||||
}
|
||||
}
|
||||
if b[0].is_finite() {
|
||||
out.bounds = b;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn layout_counts_and_bounds() {
|
||||
let doc = TextDoc { text: "Hello big world".into(), size: 100.0, ..Default::default() };
|
||||
let l = layout_doc(&doc);
|
||||
assert_eq!(l.chars, 15);
|
||||
assert_eq!(l.words, 3);
|
||||
assert_eq!(l.chars_no_space, 13);
|
||||
assert!(l.bounds[2] > 500.0, "{:?}", l.bounds);
|
||||
// Baseline at y = 0: caps rise above it.
|
||||
assert!(l.bounds[1] < -60.0 && l.bounds[3] < 30.0, "{:?}", l.bounds);
|
||||
assert!(l.glyphs.iter().filter(|g| !g.is_space).all(|g| !g.path.elements().is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn centered_point_text_straddles_origin() {
|
||||
let doc = TextDoc { text: "CENTER".into(), size: 80.0, justify: Justify::Center, ..Default::default() };
|
||||
let l = layout_doc(&doc);
|
||||
assert!(l.bounds[0] < -100.0 && l.bounds[2] > 100.0, "{:?}", l.bounds);
|
||||
assert!((l.bounds[0] + l.bounds[2]).abs() < 20.0);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user