Merge pull request #1572

This commit is contained in:
Brandon Thomas
2026-10-18 09:35:34 -04:00
7 changed files with 440 additions and 20 deletions
+60
View File
@@ -317,6 +317,66 @@ fn fonts_filter_variable_families() {
assert!(menu.iter().any(|r| r.family == "Inter" && !r.variable));
}
/// A variable font of `name` and `fvar` only: one `wght` axis 100..900 (default 300, style
/// `Light`) and the named `instances` (style, `wght`), each with a PostScript name
/// `<family without spaces>-<style without spaces>`.
fn instanced_variable_font(family: &str, instances: &[(&str, f32)]) -> Vec<u8> {
let u16s = |v: &[u16]| -> Vec<u8> { v.iter().flat_map(|x| x.to_be_bytes()).collect() };
let squash = |s: &str| s.chars().filter(char::is_ascii_alphanumeric).collect::<String>();
let mut strings: Vec<(u16, String)> = vec![(1, family.into()), (2, "Light".into())];
let mut fvar = u16s(&[1, 0, 16, 2, 1, 20, instances.len() as u16, 10]);
fvar.extend(b"wght".iter().copied().chain(u16s(&[100, 0, 300, 0, 900, 0, 0, 256])));
for (i, (style, w)) in instances.iter().enumerate() {
let id = 256 + 2 * i as u16;
strings.push((id, (*style).into()));
strings.push((id + 1, format!("{}-{}", squash(family), squash(style))));
fvar.extend(u16s(&[id, 0]));
fvar.extend(((w * 65536.0) as i32).to_be_bytes());
fvar.extend(u16s(&[id + 1]));
}
let enc: Vec<(u16, Vec<u8>)> = strings.iter().map(|(id, s)| (*id, s.encode_utf16().flat_map(|u| u.to_be_bytes()).collect())).collect();
let mut name = u16s(&[0, enc.len() as u16, 6 + 12 * enc.len() as u16]);
let mut off = 0;
for (id, st) in &enc {
name.extend(u16s(&[3, 1, 0x409, *id, st.len() as u16, off]));
off += st.len() as u16;
}
enc.iter().for_each(|(_, st)| name.extend(st));
let mut out = u16s(&[1, 0, 2, 0, 0, 0]);
let mut body: Vec<u8> = vec![];
for (tag, data) in [(b"fvar", fvar), (b"name", name)] {
out.extend(tag);
out.extend([0u8; 4]);
out.extend(((12 + 32 + body.len()) as u32).to_be_bytes());
out.extend((data.len() as u32).to_be_bytes());
body.extend(data);
}
out.extend(body);
out
}
/// #1563: a variable font's named instances are styles of its family in `text.fonts`, and a
/// text layer set in one (by style, or by the instance's PostScript name as After Effects scripts
/// do) is laid out at the instance's axis values; an axis set on the text still wins.
#[test]
fn variable_font_named_instances_are_styles() {
const FAMILY: &str = "EffectCraft Named Instance Test";
assert_eq!(effectcraft_text::fonts::add_font_data(instanced_variable_font(FAMILY, &[("Light", 300.0), ("Regular", 400.0), ("Black", 900.0)])).len(), 1);
let (mut s, t) = session("Hi");
let fonts = s.execute("text.fonts", json!({"query": FAMILY})).unwrap();
assert_eq!(fonts["families"][0]["styles"], json!(["Light", "Regular", "Black"]), "{fonts}");
let wght = |s: &Session| -> Vec<Vec<(String, f32)>> { effectcraft_text::layout_doc(&doc(s, t)).glyphs.iter().map(|g| g.variations.clone()).collect() };
let black = vec![("wght".to_string(), 900.0)];
s.execute("layer.setText", json!({"layer": t, "font": FAMILY, "style": "Black"})).unwrap();
let by_style = wght(&s);
assert!(!by_style.is_empty() && by_style.iter().all(|v| v == &black), "{by_style:?}");
s.execute("layer.setText", json!({"layer": t, "font": "EffectCraftNamedInstanceTest-Black", "style": "Regular"})).unwrap();
let by_ps = wght(&s);
assert!(!by_ps.is_empty() && by_ps.iter().all(|v| v == &black), "{by_ps:?}");
s.execute("layer.setText", json!({"layer": t, "variations": {"wght": 650}})).unwrap();
assert!(wght(&s).iter().all(|v| v == &[("wght".to_string(), 650.0)]), "{:?}", wght(&s));
}
/// `layer.setText`'s schema lists every OpenType key as optional, so checked execution (control
/// channel, MCP) accepts them and requires none (#520).
#[test]
+128 -12
View File
@@ -15,7 +15,7 @@ use std::sync::{Arc, OnceLock, RwLock};
use skrifa::instance::{LocationRef, Size};
use skrifa::{FontRef, MetadataProvider};
use crate::sfnt::{FaceNames, read_faces, read_faces_bytes};
use crate::sfnt::{FaceNames, NamedInstance, 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");
@@ -60,6 +60,8 @@ pub struct FaceInfo {
pub postscript: Option<String>,
/// The face has variation axes (an `fvar` table), read from the table directory at scan time.
pub variable: bool,
/// A variable face's named instances (`Light` … `Black`), read from `fvar` at scan time.
pub instances: Vec<NamedInstance>,
}
/// Something that can list fonts (system folders, a web font picker, a project's font folder…).
@@ -152,6 +154,7 @@ fn info_from(n: FaceNames, data: FaceData, origin: &'static str) -> FaceInfo {
full_name: n.full_name,
postscript: n.postscript,
variable: n.variable,
instances: n.instances,
}
}
@@ -425,8 +428,14 @@ 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()));
// A variable face lists its named instances (`Light` … `Black`) as styles, as After
// Effects' font menu does; its own style name is usually one of them.
let styles = std::iter::once((f.info.italic, f.info.weight, f.info.style.clone()))
.chain(f.info.instances.iter().map(|i| (i.italic(f.info.italic), i.weight(f.info.weight), i.style.clone())));
for (italic, weight, style) in styles {
if !v.iter().any(|(_, _, s)| s.eq_ignore_ascii_case(&style)) {
v.push((italic, weight, style));
}
}
}
m.into_iter()
@@ -446,6 +455,26 @@ pub struct Resolved {
pub synth_italic: bool,
/// The requested family was not found (shown as missing in the UI).
pub missing: bool,
/// The named instance of a variable face the request asked for (an index into the face's
/// [`FaceInfo::instances`]); its axis values are the text's base variations (see
/// [`instance_variations`]).
pub instance: Option<u16>,
}
/// The axis values a resolved request implies: the named instance's coordinates (sorted by tag),
/// or none for a static face or the face's own default.
pub fn instance_variations(r: &Resolved) -> Vec<(String, f32)> {
let Some(i) = r.instance else { return Vec::new() };
face(r.face).info.instances.get(i as usize).map(|n| n.coords.clone()).unwrap_or_default()
}
/// `explicit` axis values over the instance's `base` ones: a tag set explicitly wins, the others
/// keep the instance's value. Sorted by tag, as `TextDoc` keeps them.
pub fn merge_variations(base: &[(String, f32)], explicit: &[(String, f32)]) -> Vec<(String, f32)> {
let mut out: Vec<(String, f32)> = base.iter().filter(|(t, _)| !explicit.iter().any(|(e, _)| e.trim_end() == t.trim_end())).cloned().collect();
out.extend(explicit.iter().cloned());
out.sort_by(|a, b| a.0.cmp(&b.0));
out
}
fn style_wants(style: &str) -> (u16, bool) {
@@ -486,7 +515,7 @@ pub fn resolve(family: &str, style: &str) -> Resolved {
}
}
// Inter is bundled; face 0 (the first bundled face) is the last resort.
let mut r = resolve_in(DEFAULT_FAMILY, style).unwrap_or(Resolved { face: 0, synth_bold: false, synth_italic: false, missing: true });
let mut r = resolve_in(DEFAULT_FAMILY, style).unwrap_or(Resolved { face: 0, synth_bold: false, synth_italic: false, missing: true, instance: None });
r.missing = !family.is_empty() && !family.eq_ignore_ascii_case(DEFAULT_FAMILY);
r
}
@@ -497,15 +526,32 @@ fn resolve_in(family: &str, style: &str) -> Option<Resolved> {
if fam.is_empty() {
return None;
}
let instance_named = |f: &Face, name: &str| f.info.instances.iter().position(|i| i.style.eq_ignore_ascii_case(name)).and_then(|i| u16::try_from(i).ok());
// A face's own style (a variable face's is its default instance) needs no axis values.
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 });
return Some(Resolved { face: f.id, synth_bold: false, synth_italic: false, missing: false, instance: None });
}
// A named instance of a variable face (`Figtree` `Black`): that face at the instance's axis
// values, not the nearest static weight made bold synthetically.
if let Some((f, i)) = fam.iter().find_map(|f| Some((f, instance_named(f, style)?))) {
return Some(Resolved { face: f.id, synth_bold: false, synth_italic: false, missing: false, instance: Some(i) });
}
// The nearest weight among the faces and the named instances.
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()
let candidates = fam.iter().flat_map(|f| {
std::iter::once((f, None, f.info.weight, f.info.italic)).chain(
f.info
.instances
.iter()
.enumerate()
.filter_map(move |(i, n)| Some((f, Some(u16::try_from(i).ok()?), n.weight(f.info.weight), n.italic(f.info.italic)))),
)
});
let (best, instance, weight, italic) = candidates.min_by_key(|(_, _, weight, italic)| {
let italic_pen = if *italic == it { 0 } else { 1000 };
italic_pen + (*weight as i32 - w as i32).unsigned_abs()
})?;
Some(Resolved { face: best.id, synth_bold: w >= 600 && best.info.weight <= 500, synth_italic: it && !best.info.italic, missing: false })
Some(Resolved { face: best.id, synth_bold: w >= 600 && weight <= 500, synth_italic: it && !italic, missing: false, instance })
}
/// A face whose full name or PostScript name is `name` (case-insensitive), the way After Effects
@@ -516,11 +562,31 @@ fn resolve_by_name(name: &str) -> Option<Resolved> {
return None;
}
let faces = all_faces();
let f = faces
let found = |f: &Arc<Face>, instance: Option<usize>| Resolved {
face: f.id,
synth_bold: false,
synth_italic: false,
missing: false,
instance: instance.and_then(|i| u16::try_from(i).ok()),
};
if let Some(f) = faces
.iter()
.find(|f| f.info.postscript.as_deref().is_some_and(|p| p.eq_ignore_ascii_case(name)))
.or_else(|| faces.iter().find(|f| f.info.full_name.eq_ignore_ascii_case(name)))?;
Some(Resolved { face: f.id, synth_bold: false, synth_italic: false, missing: false })
.or_else(|| faces.iter().find(|f| f.info.full_name.eq_ignore_ascii_case(name)))
{
// A variable face's own name is its default instance's (`Figtree-Light`).
return Some(found(f, None));
}
// A named instance of a variable face, by its PostScript name (`Figtree-Black`, what After
// Effects scripts use) or its full name (`Figtree Black`).
faces.iter().find_map(|f| {
let i = f
.info
.instances
.iter()
.position(|i| i.postscript.eq_ignore_ascii_case(name) || format!("{} {}", f.info.family, i.style).eq_ignore_ascii_case(name))?;
Some(found(f, Some(i)))
})
}
/// The writing systems that need a dedicated fallback family: the bundled fonts cover none of
@@ -965,6 +1031,56 @@ mod tests {
assert_eq!(face(resolve("Inter", "Bold").face).info.style, "Bold");
}
/// #1563: a variable font's named instances are styles of its family. `fontStyle = "Black"`,
/// the instance's PostScript name (`font = "Figtree-Black"`) and its full name select the
/// instance's axis values instead of the default instance made bold synthetically, and the
/// font menus list every instance.
#[test]
fn variable_named_instances_resolve_and_list() {
const FAMILY: &str = "EffectCraft Instance Test";
let instances = [
("Light", 300.0, Some("EffectCraftInstanceTest-Light")),
("Regular", 400.0, Some("EffectCraftInstanceTest-Regular")),
("Black", 900.0, Some("EffectCraftInstanceTest-Black")),
("Extra Bold", 800.0, None),
];
assert_eq!(add_font_data(crate::sfnt::test_font::variable(FAMILY, &instances, true)).len(), 1);
let wght = |r: &Resolved| instance_variations(r);
// By style name.
let black = resolve(FAMILY, "Black");
assert!(!black.missing && !black.synth_bold && !black.synth_italic, "{black:?}");
assert_eq!(wght(&black), vec![("wght".to_string(), 900.0)]);
assert_eq!(wght(&resolve(FAMILY, "regular")), vec![("wght".to_string(), 400.0)]);
// The face's own style is its default instance: no axis values to set.
let light = resolve(FAMILY, "Light");
assert_eq!((light.face, light.instance), (black.face, None));
// A style the family lacks takes the nearest instance's weight, no synthetic bold.
let bold = resolve(FAMILY, "Bold");
assert!(!bold.synth_bold, "{bold:?}");
assert_eq!(wght(&bold), vec![("wght".to_string(), 800.0)]);
// By PostScript name, the font's own and the derived one, and by full name.
let ps = resolve("EffectCraftInstanceTest-Black", "Regular");
assert!(!ps.missing && ps.face == black.face, "{ps:?}");
assert_eq!(wght(&ps), vec![("wght".to_string(), 900.0)]);
assert_eq!(wght(&resolve("EffectCraftInstanceTest-ExtraBold", "")), vec![("wght".to_string(), 800.0)]);
assert_eq!(wght(&resolve("effectcraft instance test extra bold", "")), vec![("wght".to_string(), 800.0)]);
// The font menu lists every instance, in weight order.
let fams = families();
let styles = &fams.iter().find(|(f, _)| f == FAMILY).unwrap().1;
assert_eq!(styles, &["Light", "Regular", "Extra Bold", "Black"]);
// Static fonts are untouched.
assert_eq!(resolve("Inter", "Bold").instance, None);
}
/// Axis values set on the text win over the instance's; the others keep the instance's.
#[test]
fn explicit_variations_override_the_instance() {
let base = vec![("wdth".to_string(), 75.0), ("wght".to_string(), 900.0)];
assert_eq!(merge_variations(&base, &[("wght".to_string(), 450.0)]), vec![("wdth".to_string(), 75.0), ("wght".to_string(), 450.0)]);
assert_eq!(merge_variations(&base, &[]), base);
assert_eq!(merge_variations(&[], &[("opsz".to_string(), 12.0)]), vec![("opsz".to_string(), 12.0)]);
}
#[test]
fn scripts_are_classified() {
assert_eq!(script_of('あ'), FallbackScript::Japanese);
+44
View File
@@ -955,6 +955,26 @@ pub fn layout_rich_uncached(text: &str, runs: &[(Range<usize>, TextStyle)], para
let para_at = |i: usize| paras.get(i).or(paras.last()).unwrap_or(&default_para);
let width = para_at(0).width;
let primaries: Vec<Resolved> = runs.iter().map(|(_, s)| fonts::resolve(&s.family, &s.style)).collect();
// A named instance of a variable font (`Figtree` `Black`, or `Figtree-Black`) sets the base
// axis values; axes set explicitly in the style keep theirs.
let instanced: Vec<(Range<usize>, TextStyle)>;
let runs = if primaries.iter().any(|p| p.instance.is_some()) {
instanced = runs
.iter()
.zip(&primaries)
.map(|((r, s), p)| {
let base = fonts::instance_variations(p);
let mut s = s.clone();
if !base.is_empty() {
s.variations = fonts::merge_variations(&base, &s.variations);
}
(r.clone(), s)
})
.collect();
&instanced[..]
} else {
runs
};
let mut lay = Layout { missing_font: primaries.iter().any(|p| p.missing), text_len: text.len(), ..Default::default() };
let mut plines: Vec<(usize, ParaLine)> = Vec::new();
for (pi, span) in paragraph_ranges(text).enumerate() {
@@ -1406,4 +1426,28 @@ mod tests {
assert_eq!(l.hit(xs[1] + 0.5, 0.0), 2);
assert_eq!(l.hit(xs[0] + 50.0, 0.0), 0);
}
/// #1563: text set in a named instance (`Black`) is laid out at the instance's axis values,
/// as if they had been set as variations; axes set on the text itself keep their values.
#[test]
fn named_instance_sets_the_base_variations() {
const FAMILY: &str = "EffectCraft Layout Instance Test";
fonts::add_font_data(crate::sfnt::test_font::variable(FAMILY, &[("Light", 300.0, None), ("Black", 900.0, None)], true));
let st = |style: &str, variations: Vec<(String, f32)>| TextStyle {
family: FAMILY.into(),
style: style.into(),
size: 50.0,
variations,
..Default::default()
};
let coords = |l: &Layout| l.glyphs.iter().map(|g| crate::variable::coords(g.variations).to_vec()).collect::<Vec<_>>();
let black = layout_uncached("Hi", &st("Black", vec![]), &ParagraphStyle::default());
assert!(!black.glyphs.is_empty());
assert!(coords(&black).iter().all(|c| c == &[("wght".to_string(), 900.0)]), "{:?}", coords(&black));
let set = layout_uncached("Hi", &st("Black", vec![("wght".into(), 500.0)]), &ParagraphStyle::default());
assert!(coords(&set).iter().all(|c| c == &[("wght".to_string(), 500.0)]), "{:?}", coords(&set));
// A static family keeps no variations.
let inter = layout_uncached("Hi", &TextStyle::default(), &ParagraphStyle::default());
assert!(inter.glyphs.iter().all(|g| g.variations == 0));
}
}
+192 -1
View File
@@ -22,6 +22,38 @@ pub struct FaceNames {
/// A variable font: it has an `fvar` table with at least one axis (the font menu's
/// Variable Fonts filter).
pub variable: bool,
/// The named instances of a variable font (`fvar`): `Light`, `Black`, … with their axis
/// values, so a style name or an instance's PostScript name selects that design-space position.
pub instances: Vec<NamedInstance>,
}
/// One named instance of a variable font (an `fvar` instance record).
#[derive(Clone, Debug, PartialEq)]
pub struct NamedInstance {
/// The subfamily name (`Black`, `Bold Italic`).
pub style: String,
/// The instance's PostScript name (`Figtree-Black`): the one the font names, or else the one
/// Adobe Technical Note #5902 derives from the family's PostScript prefix and the style.
pub postscript: String,
/// Axis values in user units, one per axis, sorted by tag (the order `TextDoc` keeps).
pub coords: Vec<(String, f32)>,
}
impl NamedInstance {
/// The instance's weight class: the `wght` coordinate, or `fallback` without a weight axis.
pub fn weight(&self, fallback: u16) -> u16 {
self.coords.iter().find(|(t, _)| t == "wght").map_or(fallback, |(_, v)| v.round().clamp(1.0, 1000.0) as u16)
}
/// The instance is italic: its name says so, or its `ital` / `slnt` axis is off zero.
pub fn italic(&self, fallback: bool) -> bool {
let s = self.style.to_ascii_lowercase();
let axis = |tag: &str| self.coords.iter().find(|(t, _)| t == tag).map(|(_, v)| *v);
if s.contains("italic") || s.contains("oblique") || axis("ital").is_some_and(|v| v >= 0.5) || axis("slnt").is_some_and(|v| v != 0.0) {
return true;
}
fallback && axis("ital").is_none() && axis("slnt").is_none()
}
}
fn be16(b: &[u8], o: usize) -> Option<u16> {
@@ -99,7 +131,69 @@ fn read_face<R: Read + Seek>(r: &mut R, off: u64, index: u32) -> Option<FaceName
italic |= sl.contains("italic") || sl.contains("oblique");
// fvar header: version (4), axesArrayOffset (2), reserved (2), axisCount (2).
let variable = fvar.filter(|(_, l)| *l >= 10).and_then(|(fo, _)| read_at(r, fo, 10)).and_then(|fb| be16(&fb, 8)).is_some_and(|axes| axes > 0);
Some(FaceNames { index, family, style, full_name, weight, italic, native_family, postscript, variable })
let instances = if variable {
let prefix = get(25).map(|p| ps_part(&p)).filter(|p| !p.is_empty()).unwrap_or_else(|| ps_part(&family));
fvar.and_then(|(fo, fl)| read_at(r, fo, fl)).map(|fb| named_instances(&fb, &nb, &prefix)).unwrap_or_default()
} else {
Vec::new()
};
Some(FaceNames { index, family, style, full_name, weight, italic, native_family, postscript, variable, instances })
}
/// Most named instances read from one face: real fonts have a few dozen at most, so this only
/// bounds a hostile `instanceCount`.
const MAX_INSTANCES: usize = 512;
/// `s` with every character outside `A–Z a–z 0–9` removed (PostScript name parts, Adobe Technical
/// Note #5902).
fn ps_part(s: &str) -> String {
s.chars().filter(char::is_ascii_alphanumeric).collect()
}
/// The named instances of an `fvar` table (OpenType `fvar`: a 16-byte header, `axisCount` axis
/// records of `axisSize` bytes, then `instanceCount` instance records of `instanceSize` bytes:
/// subfamily name id, flags, one Fixed 16.16 coordinate per axis, and the PostScript name id when
/// the record has room for it). Records that point outside the table or name no style are skipped.
fn named_instances(fb: &[u8], nb: &[u8], ps_prefix: &str) -> Vec<NamedInstance> {
let (Some(axes_off), Some(axis_count), Some(axis_size), Some(count), Some(size)) = (be16(fb, 4), be16(fb, 8), be16(fb, 10), be16(fb, 12), be16(fb, 14))
else {
return Vec::new();
};
let (axes_off, axis_count, axis_size, size) = (axes_off as usize, axis_count as usize, axis_size as usize, size as usize);
if axis_count == 0 || axis_size < 20 || size < 4 + 4 * axis_count {
return Vec::new();
}
// Offsets in checked arithmetic: on 32-bit targets (wasm) the products of hostile 16-bit
// counts and sizes can overflow `usize`.
let at_of = |base: usize, i: usize, stride: usize| i.checked_mul(stride).and_then(|o| o.checked_add(base));
let tags: Option<Vec<String>> = (0..axis_count)
.map(|a| {
let at = at_of(axes_off, a, axis_size)?;
fb.get(at..at.checked_add(4)?).map(|t| String::from_utf8_lossy(t).trim_end().to_string())
})
.collect();
let (Some(tags), Some(first)) = (tags, at_of(axes_off, axis_count, axis_size)) else { return Vec::new() };
let mut out: Vec<NamedInstance> = Vec::new();
for i in 0..(count as usize).min(MAX_INSTANCES) {
let Some(rec) = at_of(first, i, size).and_then(|at| fb.get(at..at.checked_add(size)?)) else { break };
let Some(style) = be16(rec, 0).and_then(|id| name_string(nb, id)).map(|s| s.trim().to_string()) else { continue };
let coords: Option<Vec<(String, f32)>> =
tags.iter().enumerate().map(|(a, t)| be32(rec, 4 + 4 * a).map(|v| (t.clone(), v as i32 as f32 / 65536.0))).collect();
let Some(mut coords) = coords else { continue };
coords.sort_by(|a, b| a.0.cmp(&b.0));
let named_ps = (size >= 6 + 4 * axis_count)
.then(|| be16(rec, 4 + 4 * axis_count))
.flatten()
.filter(|&id| id != 0xFFFF)
.and_then(|id| name_string(nb, id))
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let postscript = named_ps.unwrap_or_else(|| format!("{ps_prefix}-{}", ps_part(&style)));
if !out.iter().any(|o| o.style.eq_ignore_ascii_case(&style)) {
out.push(NamedInstance { style, postscript, coords });
}
}
out
}
/// A Windows Unicode name-table string by id in a language other than US English.
@@ -155,6 +249,62 @@ fn name_string(b: &[u8], id: u16) -> Option<String> {
best.map(|(_, s)| s).filter(|s| !s.trim().is_empty())
}
/// Synthetic font files for tests: just `name` and `fvar`, enough for the scan and the resolver.
#[cfg(test)]
pub(crate) mod test_font {
fn u16s(v: &[u16]) -> Vec<u8> {
v.iter().flat_map(|x| x.to_be_bytes()).collect()
}
/// A variable font of family `family` (name ids 1 and 2, style `Light`), one `wght` axis
/// 100..900 (default 300) and the named `instances` (style, `wght`, PostScript name), each
/// with its own name ids; `with_ps_ids` gives the instance records room for a PostScript name
/// id (names without one get 0xFFFF, "none").
pub(crate) fn variable(family: &str, instances: &[(&str, f32, Option<&str>)], with_ps_ids: bool) -> Vec<u8> {
let mut strings: Vec<(u16, String)> = vec![(1, family.into()), (2, "Light".into())];
let mut recs = Vec::new();
for (i, (style, w, ps)) in instances.iter().enumerate() {
let sid = 256 + 2 * i as u16;
strings.push((sid, (*style).into()));
let pid = ps.map(|p| {
strings.push((sid + 1, p.into()));
sid + 1
});
let mut r = u16s(&[sid, 0]);
r.extend(((w * 65536.0) as i32).to_be_bytes());
if with_ps_ids {
r.extend(pid.unwrap_or(0xFFFF).to_be_bytes());
}
recs.push(r);
}
let enc: Vec<(u16, Vec<u8>)> = strings.iter().map(|(id, s)| (*id, s.encode_utf16().flat_map(|u| u.to_be_bytes()).collect())).collect();
let mut name = u16s(&[0, enc.len() as u16, 6 + 12 * enc.len() as u16]);
let mut off = 0;
for (id, st) in &enc {
name.extend(u16s(&[3, 1, 0x409, *id, st.len() as u16, off]));
off += st.len() as u16;
}
enc.iter().for_each(|(_, st)| name.extend(st));
let isize = if with_ps_ids { 10 } else { 8 };
let mut fvar = u16s(&[1, 0, 16, 2, 1, 20, instances.len() as u16, isize]);
// wght: min 100, default 300, max 900 (Fixed 16.16), flags 0, axis name id 256.
fvar.extend(b"wght".iter().copied().chain(u16s(&[100, 0, 300, 0, 900, 0, 0, 256])));
recs.iter().for_each(|r| fvar.extend(r));
let tables: [(&[u8; 4], Vec<u8>); 2] = [(b"fvar", fvar), (b"name", name)];
let mut out = u16s(&[1, 0, 2, 0, 0, 0]);
let mut body: Vec<u8> = vec![];
for (tag, data) in &tables {
out.extend(*tag);
out.extend([0u8; 4]);
out.extend(((12 + 32 + body.len()) as u32).to_be_bytes());
out.extend((data.len() as u32).to_be_bytes());
body.extend(data);
}
out.extend(body);
out
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -225,4 +375,45 @@ mod tests {
assert!(!read_faces_bytes(&names_font("No Axes", Some(Some(0))))[0].variable);
assert!(!read_faces_bytes(&names_font("Cut", Some(None)))[0].variable);
}
/// #1563: the scan reads a variable font's named instances: the style name, the axis values
/// and the PostScript name the font gives it, or the one Adobe Technical Note #5902 derives
/// (family prefix, `-`, style without spaces) when it gives none.
#[test]
fn reads_named_instances_from_fvar() {
let f = read_faces_bytes(&test_font::variable(
"Fig Test",
&[("Light", 300.0, Some("FigTest-Light")), ("Semi Bold", 600.0, None), ("Black", 900.0, Some("FigTest-Black"))],
true,
));
assert_eq!(f.len(), 1);
let i = &f[0].instances;
assert_eq!(i.iter().map(|x| x.style.as_str()).collect::<Vec<_>>(), ["Light", "Semi Bold", "Black"], "{i:?}");
assert_eq!(i[2].coords, vec![("wght".to_string(), 900.0)]);
assert_eq!(i[2].postscript, "FigTest-Black");
assert_eq!(i[1].postscript, "FigTest-SemiBold", "derived when the font names none");
assert_eq!((i[1].weight(400), i[1].italic(false)), (600, false));
// Instance records without the PostScript name id field.
let short = read_faces_bytes(&test_font::variable("Fig Test", &[("Black", 900.0, None)], false));
assert_eq!(short[0].instances[0].postscript, "FigTest-Black");
// A static font has none.
assert!(read_faces_bytes(crate::fonts::INTER_BOLD)[0].instances.is_empty());
}
/// Hostile `fvar` counts: records past the end of the table are dropped, never read out of
/// bounds, and a huge instance count is bounded.
#[test]
fn named_instances_survive_truncated_and_hostile_fvar() {
let mut data = test_font::variable("Cut Test", &[("Black", 900.0, None)], false);
// instanceCount (fvar offset 44 + 12) to 0xFFFF: only the one real record is read.
data[44 + 12] = 0xFF;
data[44 + 13] = 0xFF;
let f = read_faces_bytes(&data);
assert_eq!(f.len(), 1);
assert!(f[0].instances.len() <= 1, "{:?}", f[0].instances);
// An axis array offset past the table: no instances, but the face still scans.
data[44 + 4] = 0xFF;
let f = read_faces_bytes(&data);
assert!(f[0].variable && f[0].instances.is_empty(), "{:?}", f[0]);
}
}
+7 -3
View File
@@ -154,11 +154,15 @@ pub fn char_advance_delta(g: &CharGlyph, deltas: &[(String, f32)]) -> Option<f64
Some((varied - own) as f64 * sx)
}
/// The first installed face with variation axes (system fonts are scanned), for tests and
/// demos: (face, its axes).
/// The first installed face with variation axes and Latin glyphs (system fonts are scanned), for
/// tests and demos: (face, its axes). Faces without an `H` or `A` (the name-and-`fvar`-only
/// fonts tests add) are skipped.
pub fn find_variable_face() -> Option<(FaceId, Vec<FontAxis>)> {
fonts::scan_system();
(0..fonts::all_faces().len()).map(|f| (f, font_axes(f))).find(|(_, a)| a.iter().any(|x| x.max > x.min))
(0..fonts::all_faces().len())
.filter(|&f| fonts::face(f).glyph('H').or_else(|| fonts::face(f).glyph('A')).is_some())
.map(|f| (f, font_axes(f)))
.find(|(_, a)| a.iter().any(|x| x.max > x.min))
}
#[cfg(test)]
+5 -2
View File
@@ -272,8 +272,11 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
// Variable Font Axes: one value per axis of a variable font (`character.axis.<tag>`).
let face = effectcraft_engine::text::resolve(&doc.font, &doc.style).face;
let resolved = effectcraft_engine::text::resolve(&doc.font, &doc.style);
let face = resolved.face;
let axes = effectcraft_engine::text::variable::font_axes(face);
// A named instance (`Black`) sets the axes the text doesn't set itself.
let instance = effectcraft_engine::text::fonts::instance_variations(&resolved);
if !axes.is_empty() {
y += 26.0;
p.text(pos2(x0, y + 7.0), Align2::LEFT_CENTER, tr("Variable Font Axes"), Tokens::semibold(11.0), t.text_dim);
@@ -282,7 +285,7 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let fx = x0 + col as f32 * (w / 2.0);
let fy = y + 20.0 + row as f32 * 24.0;
let tag = a.tag.trim_end().to_string();
let v = doc.variations.iter().find(|(t2, _)| t2.trim_end() == tag).map(|(_, v)| *v).unwrap_or(a.default);
let v = doc.variations.iter().chain(&instance).find(|(t2, _)| t2.trim_end() == tag).map(|(_, v)| *v).unwrap_or(a.default);
p.text(pos2(fx, fy + 9.0), Align2::LEFT_CENTER, &tag, Tokens::semibold(10.5), t.text_dim);
let (r, nv, _) =
widgets::hot_number_at(ui, pos2(fx + 34.0, fy), egui::Id::new(("char-axis", &tag)), v as f64, 1.0, (a.min as f64, a.max as f64), 0, "", &t);
+4 -2
View File
@@ -540,8 +540,10 @@ Fonts: `text.fonts` (MCP `list_fonts`) lists every family a text layer can use,
styles, origin (`bundled`, `system`, `user`), `variable` (it has variation axes) and own-language name
(`{"query":"gothic"}` filters by name, `{"variable":true}` keeps only variable fonts, like the font
menu's Variable Fonts filter; `{"rescan":true}` first picks up fonts installed since launch). Pass a listed `family` and
one of its `styles` to `layer.setText {"font", "style"}`; `text.fontFeatures` says which OpenType
options a font draws with its own glyphs.
one of its `styles` to `layer.setText {"font", "style"}`. A variable font's named instances (`Light` …
`Black`) are styles of its family; a style or an instance's PostScript name (`"font": "Figtree-Black"`)
sets the instance's axis values, and `variations` set on the text override them axis by axis.
`text.fontFeatures` says which OpenType options a font draws with its own glyphs.
A tool with its own font library can hand it over without installing it: folders listed in
`EFFECTCRAFT_FONT_DIRS` (a path list, `:`-separated, `;` on Windows) are scanned like the system font folders.
On Linux and the BSDs the system font folders are `/usr/share/fonts`, `/usr/local/share/fonts`,