mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-29 04:26:55 -06:00
Merge pull request #1572
This commit is contained in:
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
@@ -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`,
|
||||
|
||||
Reference in New Issue
Block a user