Character: variable fonts get a slider for each axis

VectorCraft listed a variable font's named instances as styles, but
type could not be set between or past them. The reference app's
Character panel has a Variable Font control that adjusts a variable
font's axes, such as Weight, Width and Slant.

Type now keeps Variable Font settings by axis tag
(CharStyle::font_axes, saved as fontAxes only when set). text.setFormat
and text.setRangeStyle take fontAxes: each value is clamped to its
axis's range, a tag the font has no axis for is left out, and null
goes back to the style's own setting. Another font or style clears
them, including one from a character style that does not set them. A
character style captured from type sets them, with {} for none.
text.fontList {family, style} lists a font's axes.

Layout, outlines, the substituted-glyph highlight and PDF export use
the font instanced at the settings, and neighboring runs at different
settings are shaped apart, as runs in different fonts are. SVG export
writes them as font-variation-settings, the wght setting as the weight
and the wdth setting as font-stretch, and embeds the font instanced
there; SVG import reads font-variation-settings back. Legacy .ai export
warns that the settings are left out.

The Character panel shows a Variable Font button for a variable font.
It opens a slider and a field for each axis the font does not hide,
labeled with the font's name for the axis, and screen readers get each
slider under that name. A drag is one undo step. Reset Panel leaves
the settings, as it leaves the font and style.

Missing Glyph Protection now keeps the settings and the font version
on the characters that keep their font; before, it kept only the
family and style. A character style applied or redefined with another
font now clears the old font's version, as text.setStyle does, unless
the style sets one. SVG export with embedded fonts warns when faces of
one weight, style and width share characters, which browsers then draw
in one of the faces.

Docs: docs/mcp.md, docs/target-app-parity.md, docs/type-parity.md.
This commit is contained in:
dotCooCoo
2026-10-17 19:38:46 +07:00
committed by bflatastic
parent 1e06c82800
commit 4712fb2c2f
53 changed files with 1123 additions and 45 deletions
+6
View File
@@ -32,6 +32,11 @@ pub struct CharStyle {
/// wouldn't pick (an imported file's type matched to it); None = the one they pick.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub font_version: Option<String>,
/// Variation axis settings of a variable font (Character panel › Variable Font), by axis tag
/// (`wght`, `wdth`), in the axis's own units. They replace the named instance's setting of
/// each axis they name; the other axes keep the style's settings. Empty: the style as it is.
#[serde(default, rename = "fontAxes", skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub font_axes: std::collections::BTreeMap<String, f64>,
/// Size in points.
pub size: f64,
/// Leading in points; None = Auto (120% of size).
@@ -231,6 +236,7 @@ impl Default for CharStyle {
font_family: "Source Sans 3".into(),
font_style: "Regular".into(),
font_version: None,
font_axes: std::collections::BTreeMap::new(),
size: 12.0,
leading: None,
tracking: 0.0,
+2
View File
@@ -46,6 +46,8 @@ vectorcraft-affinity = { workspace = true, features = ["synth"] }
crc32fast = { workspace = true }
vectorcraft-testkit = { workspace = true }
proptest = { workspace = true }
# A variable font built in code (no font file is committed).
vectorcraft-text = { workspace = true, features = ["test-fonts"] }
wat = { workspace = true }
# Zstandard streams for the Illustrator editing data fuzz tests.
ruzstd = { workspace = true }
+20 -3
View File
@@ -47,7 +47,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Font List",
[],
None,
"{family?} → {families: [names]} sorted: the bundled fonts, fonts added and the fonts installed on the system (none on the web), each the family's English (canonical) name for `text.setStyle` and documents (without the system's hidden families, whose names start with a dot; they still resolve by name). Preferences › Type › Show Font Names in English only changes the UI menu labels, not this list; with family: {family, styles: [names]} (upright styles by weight, then italics; an error when the family isn't available)",
"{family?, style?} → {families: [names]} sorted: the bundled fonts, fonts added and the fonts installed on the system (none on the web), each the family's English (canonical) name for `text.setStyle` and documents (without the system's hidden families, whose names start with a dot; they still resolve by name). Preferences › Type › Show Font Names in English only changes the UI menu labels, not this list; with family: {family, styles: [names] (upright styles by weight, then italics; an error when the family isn't available), axes: [{tag, name, min, default, max, value, hidden}] (a variable font's variation axes, the Character panel's Variable Font sliders, with `value` the setting of `style`, else of the first style; empty for a static font; `hidden`: the font asks for the axis not to be shown)}",
always,
font_list
),
@@ -408,8 +408,9 @@ fn replace(s: &mut Session, p: &Value) -> Result<Value> {
.unwrap_or_else(|| r.style.font_style.clone());
r.style.font_family = tf.clone();
r.style.font_style = style;
// Another font: none of the old one's versions.
// Another font: none of the old one's versions or Variable Font settings.
r.style.font_version = None;
r.style.font_axes.clear();
changed = true;
n += 1;
}
@@ -453,7 +454,23 @@ fn font_list(_: &mut Session, p: &Value) -> Result<Value> {
}
// The name as the font spells it.
let name = db.family_list().iter().find(|f| f.eq_ignore_ascii_case(family)).cloned().unwrap_or_else(|| family.to_string());
Ok(json!({ "family": name, "styles": db.styles(family) }))
let styles = db.styles(family);
let style = str_param(p, "style").map(str::to_string).or_else(|| styles.first().cloned()).unwrap_or_else(|| "Regular".into());
let axes: Vec<Value> = db
.face(family, &style)
.map(|face| {
let axes = face.axes();
axes.iter()
.map(|a| {
json!({
"tag": String::from_utf8_lossy(&a.tag), "name": a.name, "min": a.min, "default": a.default, "max": a.max,
"value": face.axis_value(a.tag).unwrap_or(a.default), "hidden": a.hidden,
})
})
.collect()
})
.unwrap_or_default();
Ok(json!({ "family": name, "styles": styles, "axes": axes }))
}
None => Ok(json!({ "families": *db.menu_family_list() })),
}
+6 -1
View File
@@ -43,7 +43,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Character / Paragraph",
[],
None,
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)",
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)",
has_doc,
set_format
),
@@ -276,6 +276,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
"charAlign",
"proportionalMetrics",
"digits",
"fontAxes",
"burasagari",
"composer",
];
@@ -293,6 +294,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
let (position, small_caps) = super::docsetup::script_params(p, &s.doc()?.doc.setup, C)?;
let char_align = super::textedit::char_align_param(p, C)?;
let digits = super::textedit::digits_param(p, C)?;
let font_axes = super::textedit::font_axes_param(p, C)?;
let mojikumi = match p.get("mojikumi") {
None => None,
Some(v) => Some(match v.as_str() {
@@ -378,6 +380,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(v) = flag("proportionalMetrics") {
st.proportional_metrics = v;
}
if let Some(v) = &font_axes {
super::textedit::change_font_axes(st, v);
}
};
s.edit("Character", |d, _| {
for id in &ids {
+70 -5
View File
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Character",
[],
None,
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs}",
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs}",
has_doc,
set_range_style
),
@@ -255,6 +255,7 @@ pub(crate) struct CharChange {
char_align: Option<vectorcraft_doc::CharAlign>,
proportional_metrics: Option<bool>,
digits: Option<vectorcraft_doc::Digits>,
font_axes: Option<AxisChange>,
}
/// `digits: "default"|"arabic"|"hindi"|"farsi"` → the numeral system the digits are drawn in.
@@ -270,6 +271,60 @@ pub(crate) fn digits_param(p: &Value, cmd: &str) -> Result<Option<vectorcraft_do
}))
}
/// Variable Font settings to change: by axis tag, a value in the axis's units, or `None` to go
/// back to the style's own setting of that axis.
pub(crate) type AxisChange = std::collections::BTreeMap<String, Option<f64>>;
/// `fontAxes: {"wght": 650, "wdth": null, …}` → the Variable Font settings to change. A tag is four
/// printable ASCII characters (`wght`); a value is a number, or null.
pub(crate) fn font_axes_param(p: &Value, cmd: &str) -> Result<Option<AxisChange>> {
let Some(v) = p.get("fontAxes").filter(|v| !v.is_null()) else { return Ok(None) };
let obj = v.as_object().ok_or_else(|| bad(cmd, "`fontAxes` must be an object of axis tags and values, such as {\"wght\": 650}"))?;
let mut out = AxisChange::new();
for (tag, value) in obj {
if tag.len() != 4 || !tag.bytes().all(|b| (0x20..=0x7e).contains(&b)) {
return Err(bad(cmd, format!("`{tag}` is not an axis tag: a tag is four characters, such as \"wght\"")));
}
let value = match value {
Value::Null => None,
v => Some(v.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad(cmd, format!("the value of axis `{tag}` must be a number or null")))?),
};
out.insert(tag.clone(), value);
}
Ok(Some(out))
}
/// Change the Variable Font settings of `st` by `change`. With its font available, a value is
/// clamped to its axis's range, a tag its font has no axis for is left out, and a value equal to
/// the style's own setting is removed; with the font missing, the values are kept as given.
pub(crate) fn change_font_axes(st: &mut CharStyle, change: &AxisChange) {
let db = vectorcraft_text::FontDb::global();
let face = match db.resolve(&st.font_family, &st.font_style) {
Some((f, m)) if m != vectorcraft_text::FontMatch::Missing => {
Some(db.face_version(&st.font_family, &st.font_style, st.font_version.as_deref()).unwrap_or(f))
}
_ => None,
};
let axes = face.as_ref().map(|f| f.axes()).unwrap_or_default();
for (tag, value) in change {
let Some(v) = value else {
st.font_axes.remove(tag);
continue;
};
let Some(face) = &face else {
st.font_axes.insert(tag.clone(), *v);
continue;
};
let Some(axis) = axes.iter().find(|a| a.tag.as_slice() == tag.as_bytes()) else { continue };
let v = v.clamp(f64::from(axis.min.min(axis.max)), f64::from(axis.max.max(axis.min)));
if face.axis_value(axis.tag).is_some_and(|own| f64::from(own) == v) {
st.font_axes.remove(tag);
} else {
st.font_axes.insert(tag.clone(), v);
}
}
}
/// `features: ["dlig", "-liga", …]` → the canonical tag list (differences from the defaults).
pub(crate) fn features_param(p: &Value, cmd: &str) -> Result<Option<Vec<String>>> {
let Some(v) = p.get("features").filter(|v| !v.is_null()) else { return Ok(None) };
@@ -358,6 +413,7 @@ impl CharChange {
char_align: char_align_param(p, cmd)?,
proportional_metrics: flag("proportionalMetrics"),
digits: digits_param(p, cmd)?,
font_axes: font_axes_param(p, cmd)?,
};
if c.size.is_some_and(|v| v <= 0.0) {
return Err(bad(cmd, "size must be positive"));
@@ -388,6 +444,7 @@ impl CharChange {
&& self.char_align.is_none()
&& self.proportional_metrics.is_none()
&& self.digits.is_none()
&& self.font_axes.is_none()
}
fn localize(&mut self, t: &TextObject, cmd: &str) -> Result<()> {
@@ -416,9 +473,13 @@ impl CharChange {
if let Some(v) = &self.style {
st.font_style = v.clone();
}
// Another font: none of the old one's versions.
// Another font: none of the old one's versions or Variable Font settings.
if self.font.is_some() || self.style.is_some() {
st.font_version = None;
st.font_axes.clear();
}
if let Some(v) = &self.font_axes {
change_font_axes(st, v);
}
if let Some(v) = self.size {
st.size = v;
@@ -484,7 +545,8 @@ impl CharChange {
}
/// Type › Enable Missing Glyph Protection: after a font change, the characters of `runs` their new
/// font has no glyph for, but their font `before` the change had, keep that font.
/// font has no glyph for, but their font `before` the change had, keep that font: its family,
/// style, version and Variable Font settings.
pub(crate) fn protect_missing_glyphs(before: &[TextRun], runs: &mut Vec<TextRun>) {
fn chars(runs: &[TextRun]) -> impl Iterator<Item = (usize, char, &CharStyle)> {
runs.iter()
@@ -503,7 +565,8 @@ pub(crate) fn protect_missing_glyphs(before: &[TextRun], runs: &mut Vec<TextRun>
.or_insert_with(|| db.face_version(&st.font_family, &st.font_style, st.font_version.as_deref()));
face.as_ref().map(|f| f.covers(c))
};
// Byte ranges that keep a font (family, style).
// Byte ranges that keep a font (family, style, version and Variable Font settings).
let font = |st: &CharStyle| (st.font_family.clone(), st.font_style.clone(), st.font_version.clone(), st.font_axes.clone());
let mut keep: Vec<(usize, usize, &CharStyle)> = vec![];
for ((at, c, old), (_, _, new)) in chars(before).zip(chars(runs)) {
let changed = (&old.font_family, &old.font_style) != (&new.font_family, &new.font_style);
@@ -512,7 +575,7 @@ pub(crate) fn protect_missing_glyphs(before: &[TextRun], runs: &mut Vec<TextRun>
}
let end = at + c.len_utf8();
match keep.last_mut() {
Some((_, e, st)) if *e == at && (&st.font_family, &st.font_style) == (&old.font_family, &old.font_style) => *e = end,
Some((_, e, st)) if *e == at && font(st) == font(old) => *e = end,
_ => keep.push((at, end, old)),
}
}
@@ -520,6 +583,8 @@ pub(crate) fn protect_missing_glyphs(before: &[TextRun], runs: &mut Vec<TextRun>
edit::style_range(runs, a, b, |st| {
st.font_family.clone_from(&old.font_family);
st.font_style.clone_from(&old.font_style);
st.font_version.clone_from(&old.font_version);
st.font_axes.clone_from(&old.font_axes);
});
}
}
+84 -2
View File
@@ -141,6 +141,7 @@ pub fn char_attrs(v: &CharStyle) -> Map<String, Value> {
let mut m = to_attrs(v);
for (k, x) in [
("font_version", json!(v.font_version)),
("fontAxes", json!(v.font_axes)),
("leading", json!(v.leading)),
("kerning", json!(v.kerning)),
("features", json!(v.features)),
@@ -250,6 +251,21 @@ fn restyle<T: Serialize + DeserializeOwned + Clone>(
serde_json::from_value(Value::Object(m)).unwrap_or_else(|_| cur.clone())
}
/// `st`, written over `before` by a character style's `attrs`: when its font or style changed,
/// it keeps none of the old one's versions or Variable Font settings unless the style sets them.
fn font_change(before: &CharStyle, mut st: CharStyle, attrs: &Map<String, Value>) -> CharStyle {
if st.font_family == before.font_family && st.font_style == before.font_style {
return st;
}
if st.font_version.as_ref().is_some_and(|v| attrs.get("font_version") != Some(&json!(v))) {
st.font_version = None;
}
if !st.font_axes.is_empty() && attrs.get("fontAxes") != Some(&json!(st.font_axes)) {
st.font_axes.clear();
}
st
}
/// Every text object id in the document.
fn all_text(d: &Document) -> Vec<NodeId> {
let mut v = vec![];
@@ -293,7 +309,7 @@ fn update_users(d: &mut Document, kind: Kind, name: &str, old: &Map<String, Valu
Kind::Char => {
for r in &mut t.runs {
if r.style.style_name == target {
r.style = restyle(&r.style, char_attrs, old, new);
r.style = font_change(&r.style, restyle(&r.style, char_attrs, old, new), new);
changed = true;
}
}
@@ -447,7 +463,7 @@ fn apply(s: &mut Session, p: &Value, kind: Kind) -> Result<Value> {
// Characters: Normal then the style over the defaults (clear) or over the current attributes.
let char_fn = |cur: &CharStyle| -> Result<CharStyle> {
let base = if clear { with_attrs(&CharStyle::default(), &normal, &c)? } else { cur.clone() };
let mut st = with_attrs(&base, &attrs, &c)?;
let mut st = font_change(&base, with_attrs(&base, &attrs, &c)?, &attrs);
st.style_name = style_name.clone();
Ok(st)
};
@@ -827,6 +843,72 @@ mod tests {
assert_eq!(Value::Object(s.doc().unwrap().doc.char_styles[0].attrs.clone()), json!({"charAlign": "romanBaseline"}));
}
/// A style captured from type without Variable Font settings sets none explicitly (`{}`):
/// applying it clears type's settings, and redefining it from type whose settings were reset
/// resets the type that uses it.
#[test]
fn variable_font_settings_are_captured_explicitly() {
use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY, two_axis_font};
vectorcraft_text::FontDb::global().add_font(two_axis_font().unwrap());
let (mut s, source) = session_with_text("Source");
let linked = s.execute("text.create", &json!({"x": 10, "y": 90, "text": "Linked"})).unwrap()["id"].as_u64().unwrap();
let ids = [source, linked];
s.execute("text.setStyle", &json!({"ids": ids, "font": TWO_AXIS_FAMILY})).unwrap();
s.execute("charStyle.new", &json!({"name": "Body", "id": source})).unwrap();
assert_eq!(s.doc().unwrap().doc.char_styles[0].attrs.get("fontAxes"), Some(&json!({})));
s.execute("text.setFormat", &json!({"ids": [linked], "fontAxes": {"wght": 650}})).unwrap();
s.execute("charStyle.apply", &json!({"name": "Body", "ids": ids})).unwrap();
let wght = |s: &Session| ids.map(|id| runs(s, id)[0].style.font_axes.get("wght").copied());
assert_eq!(wght(&s), [None, None], "applying the style clears the settings");
for (set, want) in [(json!(650), Some(650.0)), (json!(null), None)] {
s.execute("text.setFormat", &json!({"ids": [source], "fontAxes": {"wght": set}})).unwrap();
s.execute("charStyle.redefine", &json!({"name": "Body", "id": source})).unwrap();
assert_eq!(wght(&s), [want, want], "redefined from {set}");
}
}
/// A style that changes the font, applied or redefined, leaves none of the old font's
/// version and Variable Font settings, unless the style sets them.
#[test]
fn another_font_from_a_style_clears_the_old_fonts_version_and_settings() {
use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY, two_axis_font};
vectorcraft_text::FontDb::global().add_font(two_axis_font().unwrap());
let (mut s, id) = session_with_text("Source");
let set_variable = |s: &mut Session| {
s.execute("text.setStyle", &json!({"ids": [id], "font": TWO_AXIS_FAMILY})).unwrap();
s.execute("text.setFormat", &json!({"ids": [id], "fontAxes": {"wght": 650}})).unwrap();
s.edit("version", |d, _| {
if let Some(t) = text_mut(d, NodeId(id)) {
t.runs.iter_mut().for_each(|r| r.style.font_version = Some("Version 2.000".into()));
}
Ok(())
})
.unwrap();
};
let font = |s: &Session| {
let st = runs(s, id)[0].style.clone();
(st.font_family, st.font_version, st.font_axes.get("wght").copied())
};
set_variable(&mut s);
s.execute("charStyle.new", &json!({"name": "Plain", "attrs": {"font_family": "Inter"}})).unwrap();
s.execute("charStyle.new", &json!({"name": "Same", "attrs": {"size": 20}})).unwrap();
s.execute("charStyle.apply", &json!({"name": "Same", "id": id})).unwrap();
assert_eq!(font(&s), (TWO_AXIS_FAMILY.to_string(), Some("Version 2.000".to_string()), Some(650.0)), "the same font keeps them");
s.execute("charStyle.apply", &json!({"name": "Plain", "id": id})).unwrap();
assert_eq!(font(&s), ("Inter".to_string(), None, None));
// A style that sets the settings keeps its own.
s.execute("charStyle.new", &json!({"name": "Heavy", "attrs": {"font_family": TWO_AXIS_FAMILY, "fontAxes": {"wght": 600}}})).unwrap();
s.execute("charStyle.apply", &json!({"name": "Heavy", "id": id})).unwrap();
assert_eq!(font(&s), (TWO_AXIS_FAMILY.to_string(), None, Some(600.0)));
// Redefined to another font: its users' settings and versions go too.
s.execute("charStyle.new", &json!({"name": "Sans", "attrs": {"font_family": TWO_AXIS_FAMILY}})).unwrap();
s.execute("charStyle.apply", &json!({"name": "Sans", "id": id})).unwrap();
set_variable(&mut s);
assert_eq!(runs(&s, id)[0].style.style_name.as_deref(), Some("Sans"));
s.execute("charStyle.setAttrs", &json!({"name": "Sans", "attrs": {"font_family": "Inter"}})).unwrap();
assert_eq!(font(&s), ("Inter".to_string(), None, None));
}
#[test]
fn auto_leading_new_distinguishes_unset_attributes() {
let (mut s, id) = session_with_text("Title");
+78 -1
View File
@@ -577,9 +577,10 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(f) = &style {
st.font_style = f.clone();
}
// Another font: none of the old one's versions.
// Another font: none of the old one's versions or Variable Font settings.
if font.is_some() || style.is_some() {
st.font_version = None;
st.font_axes.clear();
}
if let Some(v) = size {
st.size = v;
@@ -613,10 +614,12 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(f) = &font {
st.font_family = f.clone();
st.font_version = None;
st.font_axes.clear();
}
if let Some(f) = &style {
st.font_style = f.clone();
st.font_version = None;
st.font_axes.clear();
}
if let Some(v) = size {
st.size = v.clamp(0.1, 1296.0);
@@ -1248,6 +1251,80 @@ mod area_tests {
assert_eq!(set, [("صفحة ".to_string(), Digits::Default), ("12".to_string(), Digits::Farsi)]);
}
/// Variable Font settings (Character panel › Variable Font): a character attribute, saved only
/// when set, each value clamped to its axis's range, and cleared by another font or style.
#[test]
fn variable_font_settings_are_a_character_attribute_saved_only_when_set() {
use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY as FAMILY, two_axis_font};
vectorcraft_text::FontDb::global().add_font(two_axis_font().unwrap());
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "lIn lIn", "font": FAMILY, "size": 40})).unwrap()["id"].as_u64().unwrap();
let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
NodeKind::Text(t) => t.runs.clone(),
_ => panic!("text"),
};
let saved = |s: &Session| serde_json::to_value(&runs(s)[0].style).unwrap().get("fontAxes").cloned();
let width = |s: &Session| s.doc().unwrap().doc.bounds_of(&[NodeId(id)], false).unwrap().width();
assert_eq!(saved(&s), None);
let regular = width(&s);
s.execute("select.set", &json!({"ids": [id]})).unwrap();
s.execute("text.setFormat", &json!({"fontAxes": {"wght": 650}})).unwrap();
assert_eq!(saved(&s), Some(json!({"wght": 650.0})));
assert!(width(&s) > regular * 1.1, "the type is set heavier and wider: {regular} -> {}", width(&s));
// Past the axis's range: its maximum; an axis the font hasn't: left out; others kept.
s.execute("text.setFormat", &json!({"fontAxes": {"wght": 2000, "opsz": 12, "wdth": 80}})).unwrap();
assert_eq!(saved(&s), Some(json!({"wdth": 80.0, "wght": 700.0})));
// Null goes back to the style's own setting, and so does a value equal to it.
s.execute("text.setFormat", &json!({"fontAxes": {"wght": null}})).unwrap();
assert_eq!(saved(&s), Some(json!({"wdth": 80.0})));
s.execute("text.setFormat", &json!({"fontAxes": {"wdth": 100}})).unwrap();
assert_eq!(saved(&s), None);
assert!((width(&s) - regular).abs() < 1e-6);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(saved(&s), Some(json!({"wdth": 80.0})));
// Characters selected with the Type tool: only the range takes it.
s.execute("text.setRangeStyle", &json!({"id": id, "start": 4, "end": 7, "fontAxes": {"wght": 550}})).unwrap();
let set: Vec<_> = runs(&s).iter().map(|r| (r.text.clone(), r.style.font_axes.clone())).collect();
let axes = |v: &[(&str, f64)]| v.iter().map(|(t, v)| (t.to_string(), *v)).collect::<std::collections::BTreeMap<_, _>>();
assert_eq!(set, [("lIn ".to_string(), axes(&[("wdth", 80.0)])), ("lIn".to_string(), axes(&[("wdth", 80.0), ("wght", 550.0)]))]);
// A saved file keeps them.
let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
let NodeKind::Text(t) = &back.node(NodeId(id)).unwrap().kind else { panic!("text") };
assert_eq!(t.runs, runs(&s));
// Another style or font clears them: their settings were the old face's.
s.execute("text.setRangeStyle", &json!({"id": id, "start": 4, "end": 7, "style": "Bold"})).unwrap();
assert_eq!(runs(&s).last().map(|r| (r.style.font_style.as_str(), r.style.font_axes.is_empty())), Some(("Bold", true)));
s.execute("text.setStyle", &json!({"style": "Bold"})).unwrap();
assert!(runs(&s).iter().all(|r| r.style.font_axes.is_empty()));
s.execute("text.setFormat", &json!({"fontAxes": {"wdth": 75}})).unwrap();
s.execute("text.replaceFont", &json!({"from": {"family": FAMILY}, "to": {"family": "Inter"}})).unwrap();
assert!(runs(&s).iter().all(|r| r.style.font_axes.is_empty()));
// A static font has no axes to set.
s.execute("text.setFormat", &json!({"fontAxes": {"wght": 650}})).unwrap();
assert_eq!(saved(&s), None);
// A missing font keeps them as given, for when it is installed.
s.execute("text.setStyle", &json!({"font": "No Such Variable Sans"})).unwrap();
s.execute("text.setFormat", &json!({"fontAxes": {"wght": 2000}})).unwrap();
assert_eq!(saved(&s), Some(json!({"wght": 2000.0})));
for (bad, says) in
[(json!({"weight": 650}), "not an axis tag"), (json!({"wght": "heavy"}), "must be a number"), (json!(650), "must be an object")]
{
let err = s.execute("text.setFormat", &json!({"fontAxes": bad})).unwrap_err().to_string();
assert!(err.contains(says), "{err}");
}
// The axes and the setting of a style, as the sliders show them.
let list = s.execute("text.fontList", &json!({"family": FAMILY, "style": "Bold"})).unwrap();
assert_eq!(
list["axes"],
json!([
{"tag": "wght", "name": "Weight", "min": 400.0, "default": 400.0, "max": 700.0, "value": 700.0, "hidden": false},
{"tag": "wdth", "name": "Width", "min": 75.0, "default": 100.0, "max": 100.0, "value": 100.0, "hidden": false},
])
);
assert_eq!(s.execute("text.fontList", &json!({"family": "Inter"})).unwrap()["axes"], json!([]));
}
#[test]
fn new_type_is_composed_with_line_end_half_width_punctuation() {
use vectorcraft_doc::Mojikumi;
+49
View File
@@ -1196,6 +1196,55 @@ fn missing_glyph_protection_keeps_glyphs_a_new_font_lacks() {
assert_eq!(fonts(&s), [to, to, to]);
}
/// A character that keeps its font through Missing Glyph Protection keeps that font's Variable
/// Font settings too; the characters that take the new font lose them.
#[test]
fn missing_glyph_protection_keeps_the_variable_font_settings_of_the_font_kept() {
use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY as FROM, two_axis_font};
let (to, c) = ("JetBrains Mono", '\u{180}');
let db = vectorcraft_text::FontDb::global();
db.add_font(two_axis_font().unwrap());
if !db.face(FROM, "Regular").is_some_and(|f| f.covers(c)) || db.face(to, "Regular").is_some_and(|f| f.covers(c)) {
return; // An installed font of that name covers the character.
}
let mut s = new_doc();
let id = NodeId(s.execute("text.create", &json!({"x": 10, "y": 50, "text": "a\u{180}c", "font": FROM})).unwrap()["id"].as_u64().unwrap());
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("text.setFormat", &json!({"fontAxes": {"wght": 650}})).unwrap();
let fonts = |s: &Session| [0, 1, 3].map(|b| style_at(s, id, b)).map(|st| (st.font_family, st.font_axes.get("wght").copied()));
let (kept, moved) = ((FROM.to_string(), Some(650.0)), (to.to_string(), None));
for change in [json!({"font": to}), json!({"id": id.0, "start": 0, "end": 4, "font": to})] {
let cmd = if change.get("id").is_some() { "text.setRangeStyle" } else { "text.setStyle" };
s.execute(cmd, &change).unwrap();
assert_eq!(fonts(&s), [moved.clone(), kept.clone(), moved.clone()], "{cmd}");
s.execute("edit.undo", &json!({})).unwrap();
}
}
/// A character that keeps its font through Missing Glyph Protection keeps the version of it the
/// type names.
#[test]
fn missing_glyph_protection_keeps_the_version_of_the_font_kept() {
use vectorcraft_text::test_fonts::{TWIN_FAMILY as FROM, TWIN_VERSIONS, twin_font};
let (to, c) = ("JetBrains Mono", '\u{180}');
let db = vectorcraft_text::FontDb::global();
db.add_font(twin_font(false).unwrap());
let older = db.face_version(FROM, "Regular", Some(TWIN_VERSIONS[0]));
if !older.is_some_and(|f| f.version == TWIN_VERSIONS[0] && f.covers(c)) || db.face(to, "Regular").is_some_and(|f| f.covers(c)) {
return; // An installed font of that name covers the character.
}
let mut s = new_doc();
let id = NodeId(s.execute("text.create", &json!({"x": 10, "y": 50, "text": "a\u{180}c", "font": FROM})).unwrap()["id"].as_u64().unwrap());
if let Some(NodeKind::Text(t)) = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).map(|n| &mut n.kind) {
t.runs[0].style.font_version = Some(TWIN_VERSIONS[0].into());
}
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("text.setStyle", &json!({"font": to})).unwrap();
let versions = [0, 1, 3].map(|b| (style_at(&s, id, b).font_family, style_at(&s, id, b).font_version));
let (kept, moved) = ((FROM.to_string(), Some(TWIN_VERSIONS[0].to_string())), (to.to_string(), None));
assert_eq!(versions, [moved.clone(), kept, moved]);
}
// ---------- #394, fourth batch ----------
/// Type › Type Object Selection by Path Only (#394): on, a click among point type's glyphs no
+5
View File
@@ -368,6 +368,7 @@ fn arb_svg_attr() -> impl Strategy<Value = String> {
"kerning",
"writing-mode",
"font-weight",
"font-variation-settings",
"text-anchor",
"side",
"path",
@@ -393,6 +394,7 @@ fn arb_svg_attr() -> impl Strategy<Value = String> {
"onclick",
"onload",
]);
let axes = prop::sample::select(vec!["wght", "wdth", "opsz", "wd", "slnt!", ""]);
let value = prop_oneof![
arb_num(),
prop::collection::vec(arb_num(), 0..7).prop_map(|v| v.join(" ")),
@@ -401,6 +403,9 @@ fn arb_svg_attr() -> impl Strategy<Value = String> {
prop::collection::vec((prop::sample::select(vec!["M", "L", "C", "Q", "A", "Z", "H", "V", "S", "T", "m", "a"]), arb_num()), 0..12)
.prop_map(|v| v.iter().map(|(c, n)| format!("{c}{n} {n}")).collect::<Vec<_>>().join(" ")),
prop::collection::vec(arb_num(), 0..2).prop_map(|v| format!("{}%", v.join(""))),
// Variable font axis settings, well formed or not.
prop::collection::vec((axes, arb_num()), 0..5).prop_map(|v| v.iter().map(|(t, n)| format!("'{t}' {n}")).collect::<Vec<_>>().join(", ")),
prop::sample::select(vec!["normal", "initial", "unset"]).prop_map(str::to_string),
// Colours (Lab too), mask options and links.
prop::collection::vec(arb_num(), 0..4).prop_map(|v| format!("lab({})", v.join(" "))),
prop::sample::select(vec!["noclip invert", "invert", "noclip", "alpha", "luminance", "url(#e)", "#e", "#zz", "none", "currentColor"])
+8 -2
View File
@@ -80,7 +80,7 @@ const HEX_LINE: usize = 64;
pub const SUBLAYERS: &str = "sublayers are written as groups in their top-level layer (legacy .ai files have no sublayers)";
pub const TYPE: &str = "type a legacy .ai can't hold as text (characters outside Windows Latin-1, vertical type, inline graphics, gradient or pattern fills) is written as outlines";
pub const TYPE_ATTRIBUTES: &str = "type attributes legacy .ai files don't have (underline, strikethrough, small caps, superscript and subscript, character rotation, OpenType features, area type options, type on a path effects, space after paragraphs, tabs) are left out";
pub const TYPE_ATTRIBUTES: &str = "type attributes legacy .ai files don't have (underline, strikethrough, small caps, superscript and subscript, character rotation, OpenType features, Variable Font settings, area type options, type on a path effects, space after paragraphs, tabs) are left out";
pub const HIDDEN: &str = "hidden objects are left out (hidden layers are kept, hidden)";
pub const HIDDEN_LAYER: &str = "hidden objects are moved to a hidden layer above their own (legacy .ai files can hide only layers)";
pub const HIDDEN_SHOWN: &str = "hidden objects are written visible (legacy .ai files can hide only layers)";
@@ -1279,7 +1279,13 @@ fn writable(t: &TextObject) -> bool {
/// Has type `t` attributes a legacy `.ai` text object can't hold (lost when it's written as text)?
fn lost_attributes(t: &TextObject) -> bool {
let run_lost = |s: &CharStyle| {
s.underline || s.strikethrough || s.small_caps.is_some() || s.position != CharPosition::Normal || s.rotation != 0.0 || !s.features.is_empty()
s.underline
|| s.strikethrough
|| s.small_caps.is_some()
|| s.position != CharPosition::Normal
|| s.rotation != 0.0
|| !s.features.is_empty()
|| !s.font_axes.is_empty()
};
let para_lost = |p: &ParaStyle| p.space_after != 0.0 || !p.tabs.is_empty();
t.runs.iter().any(|r| run_lost(&r.style))
+15
View File
@@ -516,6 +516,21 @@ fn type_is_written_as_text_objects() {
assert!(!out.warnings.iter().any(|w| w == TYPE), "{:?}", out.warnings);
}
/// A legacy `.ai` text object names a font, not a setting of its variation axes: type with
/// Variable Font settings is written in the font's style, with the warning that they are left out.
#[test]
fn variable_font_settings_are_warned_as_left_out() {
let mut doc = typed();
assert!(!write(&doc).warnings.iter().any(|w| w == TYPE_ATTRIBUTES));
let id = texts(&doc)[0].0.id;
if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) {
t.runs[0].style.font_axes.insert("wght".into(), 650.0);
}
let out = write(&doc);
assert!(out.warnings.iter().any(|w| w == TYPE_ATTRIBUTES), "{:?}", out.warnings);
assert!(String::from_utf8(out.bytes).unwrap().contains("(H\\351llo \\2005\\r) Tx\n"), "still written as text");
}
#[test]
fn type_reads_back_as_text() {
use vectorcraft_doc::{Justify, TextKind};
+23
View File
@@ -205,6 +205,29 @@ fn variable_font_instances_are_real_text_in_their_own_glyphs() {
assert!(widths[1] > widths[0] * 1.2, "Bold is wider: {widths:?}");
}
/// Type with Variable Font settings is real text in a font embedded at those settings: it draws
/// where its outlines would, between the named instances on either side.
#[test]
fn variable_font_settings_are_real_text_in_their_own_glyphs() {
use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY, VARIABLE_CHARS, two_axis_font};
FontDb::global().add_font(two_axis_font().unwrap());
let mut widths = vec![];
for (s, wght) in [("Regular", None), ("Regular", Some(650.0)), ("Bold", None)] {
let font_axes = wght.map(|w| [("wght".to_string(), w)].into_iter().collect()).unwrap_or_default();
let st = CharStyle { font_family: TWO_AXIS_FAMILY.into(), font_style: s.into(), font_axes, ..style(48.0) };
let d = doc(vec![TextObject::point(Point::new(20.0, 80.0), VARIABLE_CHARS, st)]);
let r = pdf(&d, false);
assert!(r.warnings.is_empty(), "{s} {wght:?}: {:?}", r.warnings);
assert_eq!(texts(&import_as(&r.bytes, TextAs::Text)), [VARIABLE_CHARS], "{s} {wght:?}: real text");
let (real, outlined) = (ink(&import_as(&r.bytes, TextAs::Outlines)), ink(&import_as(&pdf(&d, true).bytes, TextAs::Outlines)));
let off =
[real.x0 - outlined.x0, real.y0 - outlined.y0, real.x1 - outlined.x1, real.y1 - outlined.y1].iter().fold(0.0f64, |m, v| m.max(v.abs()));
assert!(off < 0.5, "{s} {wght:?}: {real:?} vs {outlined:?}");
widths.push(real.width());
}
assert!(widths[0] * 1.1 < widths[1] && widths[1] < widths[2], "{widths:?}");
}
#[test]
fn gradients_on_real_text_stay_where_they_were() {
let fill = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default())));
+2
View File
@@ -31,6 +31,8 @@ vello_cpu = { workspace = true, features = ["multithreading"] }
[dev-dependencies]
vectorcraft-testkit = { workspace = true }
# A variable font built in code (no font file is committed).
vectorcraft-text = { workspace = true, features = ["test-fonts"] }
# Live-effect plug-ins the cache tests install, written in WebAssembly text.
vectorcraft-plugins = { workspace = true }
wat = { workspace = true }
+3 -2
View File
@@ -2301,9 +2301,10 @@ fn text_geom_snapped(t: &TextObject, snap: Option<Affine>) -> TextGeom {
.runs
.iter()
.map(|r| match db.resolve(&r.style.font_family, &r.style.font_style) {
// The version the type names, when it's installed (see `FontDb::face_version`).
// The version the type names, when it's installed (see `FontDb::face_version`), at its
// Variable Font settings (see `FontDb::style_face`).
Some((f, m)) => {
let face = db.face_version(&r.style.font_family, &r.style.font_style, r.style.font_version.as_deref()).unwrap_or(f);
let face = db.style_face(&r.style).unwrap_or(f);
(m == vectorcraft_text::FontMatch::Missing, Some(face.id()))
}
None => (true, None),
+16
View File
@@ -83,6 +83,22 @@ fn type_with_raster_effects_redraws_when_its_font_becomes_available() {
assert_eq!(after, Renderer::new().render(&d, 300, 150, Affine::IDENTITY, &opts).pixels, "as a new renderer draws it");
}
/// Type with Variable Font settings is drawn in a face of their own, which is not a substitute:
/// Document Setup's highlight of substituted glyphs leaves it alone, and so does Highlight
/// Alternate Glyphs.
#[test]
fn variable_font_settings_are_not_highlighted_as_substituted_glyphs() {
use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY, two_axis_font};
let _adding = adding_fonts();
vectorcraft_text::FontDb::global().add_font(two_axis_font().unwrap());
let font_axes = [("wght".to_string(), 650.0)].into_iter().collect();
let style = CharStyle { size: 40.0, font_family: TWO_AXIS_FAMILY.into(), font_axes, ..Default::default() };
let g = text_geom(&TextObject::point(Point::new(10.0, 60.0), "lIn", style));
assert!(!g.all.elements().is_empty());
assert!(g.substituted_glyphs.elements().is_empty() && g.substituted_fonts.elements().is_empty());
assert!(g.alternate_glyphs.elements().is_empty());
}
/// Type at 80% opacity, in Multiply and with a drop shadow redraws in a font that becomes
/// available: the glyph geometry kept for it is laid out again.
#[test]
+23 -4
View File
@@ -70,15 +70,26 @@ pub(crate) fn transparency(n: &Node) -> Props {
}
/// The weight and italic of the face style `st` asks for: what embedded fonts' `@font-face`
/// rules and the characters using them say, so each face is told apart (Semibold from Bold).
/// rules and the characters using them say, so each face is told apart (Semibold from Bold). A
/// Variable Font `wght` setting is the weight.
pub(crate) fn font_descriptor(st: &CharStyle) -> (u16, bool) {
let fs = st.font_style.to_ascii_lowercase();
(vectorcraft_text::style_weight(&st.font_style).round().clamp(1.0, 1000.0) as u16, fs.contains("italic") || fs.contains("oblique"))
let weight =
st.font_axes.get("wght").copied().filter(|w| w.is_finite()).unwrap_or_else(|| f64::from(vectorcraft_text::style_weight(&st.font_style)));
(weight.round().clamp(1.0, 1000.0) as u16, fs.contains("italic") || fs.contains("oblique"))
}
/// The width character style `st` asks for, in percent of the normal width: its Variable Font
/// `wdth` setting (an axis in those units), which `font-stretch` writes; `None` without one.
pub(crate) fn font_stretch(st: &CharStyle) -> Option<f64> {
st.font_axes.get("wdth").copied().filter(|w| w.is_finite() && *w > 0.0)
}
/// The font of character style `st`: family, size (the vertical scale; writers stretch the
/// horizontal one), weight and style, lengths written by `len`. The weight is the style's number
/// (600 for Semibold, 300 for Light), `bold` for 700 and left out for 400.
/// horizontal one), weight and style, and its Variable Font settings, lengths written by `len`.
/// The weight is the style's number (600 for Semibold, 300 for Light) or the `wght` setting,
/// `bold` for 700 and left out for 400. The settings are `font-variation-settings` (`"wght" 650`),
/// and the `wdth` setting is `font-stretch` too ([`font_stretch`]).
pub(crate) fn font_props(st: &CharStyle, len: &dyn Fn(f64) -> String) -> Props {
let mut p = Props::new();
// A quoted family escapes `\` and `'`, which would otherwise end the string early.
@@ -98,6 +109,14 @@ pub(crate) fn font_props(st: &CharStyle, len: &dyn Fn(f64) -> String) -> Props {
if italic {
p.push(("font-style", "italic".into()));
}
if let Some(w) = font_stretch(st) {
p.push(("font-stretch", format!("{}%", fmt_num(w, 3))));
}
let axes: Vec<String> =
st.font_axes.iter().filter(|(_, v)| v.is_finite()).map(|(tag, v)| format!("{} {}", css_string(tag), fmt_num(*v, 3))).collect();
if !axes.is_empty() {
p.push(("font-variation-settings", axes.join(", ")));
}
p
}
+38 -10
View File
@@ -329,11 +329,13 @@ struct Writer<'a> {
}
/// The characters type uses from one face, under one `@font-face` description: the family the
/// type names and the weight and italic its style asks for.
/// type names and the weight, italic and width its style asks for.
struct FontUse {
family: String,
weight: u16,
italic: bool,
/// The `font-stretch` percentage ([`css::font_stretch`]), as bits; `None` for the normal width.
stretch: Option<u64>,
face: Arc<FontFace>,
chars: BTreeSet<char>,
}
@@ -689,12 +691,13 @@ impl Writer<'_> {
match styling {
Styling::PresentationAttributes => {
// CSS-only properties go in a style attribute (SVG Tiny has none: left out).
let (style, attrs): (Vec<_>, Vec<_>) =
props.iter().partition(|(k, _)| matches!(*k, "mix-blend-mode" | "isolation" | "font-feature-settings" | "font-kerning"));
let (style, attrs): (Vec<_>, Vec<_>) = props.iter().partition(|(k, _)| {
matches!(*k, "mix-blend-mode" | "isolation" | "font-feature-settings" | "font-variation-settings" | "font-kerning")
});
let mut s: String = attrs.iter().map(|(k, v)| format!(" {k}=\"{}\"", xml_escape(v))).collect();
if !style.is_empty() {
if self.tiny() {
self.warn("SVG Tiny 1.2 has no style sheets: blend modes, isolation and font features are left out");
self.warn("SVG Tiny 1.2 has no style sheets: blend modes, isolation, font features and font variation settings are left out");
} else {
s.push_str(&format!(" style=\"{}\"", xml_escape(&css::declarations(style))));
}
@@ -2043,10 +2046,14 @@ impl Writer<'_> {
}
let Some(f) = &face else { continue };
let (weight, italic) = font_descriptor(st);
let at = self.fonts.iter().position(|u| u.face.id() == f.id() && (u.weight, u.italic) == (weight, italic) && u.family == st.font_family);
let stretch = css::font_stretch(st).map(f64::to_bits);
let at = self
.fonts
.iter()
.position(|u| u.face.id() == f.id() && (u.weight, u.italic, u.stretch) == (weight, italic, stretch) && u.family == st.font_family);
let at = at.unwrap_or_else(|| {
let family = st.font_family.clone();
self.fonts.push(FontUse { family, weight, italic, face: f.clone(), chars: BTreeSet::new() });
self.fonts.push(FontUse { family, weight, italic, stretch, face: f.clone(), chars: BTreeSet::new() });
self.fonts.len() - 1
});
let Some(chars) = self.fonts.get_mut(at).map(|u| &mut u.chars) else { continue };
@@ -2065,10 +2072,30 @@ impl Writer<'_> {
/// The `@font-face` rules of the faces type uses ([`ExportOptions::embed_fonts`]): each face
/// subset to its characters (whole when its licence forbids subsetting, left out with a
/// warning when it forbids embedding). Faces sharing a description cover their own
/// characters (`unicode-range`).
/// characters (`unicode-range`); where two of them share characters as well, a warning says
/// that browsers draw those in one of the faces.
fn font_faces(&mut self) -> Vec<String> {
let uses = std::mem::take(&mut self.fonts);
let mut rules = Vec::new();
fn described(u: &FontUse) -> (&str, u16, bool, Option<u64>) {
(&u.family, u.weight, u.italic, u.stretch)
}
let mut clashing: Vec<&String> = vec![];
for (i, u) in uses.iter().enumerate() {
let clash = uses
.get(..i)
.unwrap_or_default()
.iter()
.any(|o| described(o) == described(u) && o.face.id() != u.face.id() && !o.chars.is_disjoint(&u.chars));
if clash && !clashing.contains(&&u.family) {
clashing.push(&u.family);
}
}
for family in clashing {
self.warn(&format!(
"type in {family} uses faces of one weight, style and width that share characters: browsers draw those characters in one of the embedded faces"
));
}
for u in &uses {
let chars: Vec<char> = u.chars.iter().copied().collect();
let name = format!("{} {}", u.face.family, u.face.style);
@@ -2079,11 +2106,12 @@ impl Writer<'_> {
if !font.subset {
self.warn(&format!("the font {name} is embedded whole: its licence doesn't allow subsetting"));
}
let shared = uses.iter().filter(|o| (&o.family, o.weight, o.italic) == (&u.family, u.weight, u.italic)).count() > 1;
let shared = uses.iter().filter(|o| described(o) == described(u)).count() > 1;
let range = if shared { format!(";unicode-range:{}", unicode_range(&u.chars)) } else { String::new() };
let stretch = u.stretch.map(|w| format!(";font-stretch:{}%", fmt_num(f64::from_bits(w), 3))).unwrap_or_default();
let (mime, format) = if font.cff { ("font/otf", "opentype") } else { ("font/ttf", "truetype") };
rules.push(format!(
"@font-face{{font-family:{};font-weight:{};font-style:{};src:url(data:{mime};base64,{}) format(\"{format}\"){range}}}",
"@font-face{{font-family:{};font-weight:{};font-style:{}{stretch};src:url(data:{mime};base64,{}) format(\"{format}\"){range}}}",
css_string(&u.family),
u.weight,
if u.italic { "italic" } else { "normal" },
@@ -2142,7 +2170,7 @@ fn run_diff(base: &Props, run: Props) -> Props {
/// on a `<tspan>`).
fn initial_value(k: &str) -> Option<&'static str> {
Some(match k {
"font-weight" | "font-style" | "font-feature-settings" => "normal",
"font-weight" | "font-style" | "font-stretch" | "font-feature-settings" | "font-variation-settings" => "normal",
"stroke" | "stroke-dasharray" => "none",
"stroke-width" | "stroke-opacity" => "1",
"stroke-linecap" => "butt",
+25
View File
@@ -60,6 +60,27 @@ fn style_name(weight: u16, italic: bool) -> String {
}
}
/// The axis settings of a `font-variation-settings` value (`"wght" 650, "wdth" 87.5`), by tag:
/// none for `normal` and `initial`. `None` when an item isn't a quoted tag of four printable ASCII
/// characters and a finite number, as CSS then ignores the declaration.
fn variation_settings(v: &str) -> Option<std::collections::BTreeMap<String, f64>> {
let mut out = std::collections::BTreeMap::new();
if ["normal", "initial"].iter().any(|k| v.trim().eq_ignore_ascii_case(k)) {
return Some(out);
}
for item in v.split(',') {
let item = item.trim();
let quote = item.chars().next().filter(|c| *c == '"' || *c == '\'')?;
let (tag, value) = item.get(1..)?.split_once(quote)?;
let value = value.trim().parse::<f64>().ok().filter(|v| v.is_finite())?;
if tag.len() != 4 || !tag.bytes().all(|b| (0x20..=0x7e).contains(&b)) {
return None;
}
out.insert(tag.to_string(), value);
}
Some(out)
}
fn solid(c: svgtypes::Color) -> Paint {
Paint::solid(Color::rgb8(c.red, c.green, c.blue))
}
@@ -377,6 +398,10 @@ impl Ctx {
st.size = size;
let italic = css.prop(n, "font-style").is_some_and(|s| s == "italic" || s == "oblique");
st.font_style = style_name(self.weight(n), italic);
// Inherited: `inherit`, `unset`, `revert` and a value CSS ignores leave the one inherited.
let inherits = |v: &String| ["inherit", "unset", "revert", "revert-layer"].iter().any(|k| v.trim().eq_ignore_ascii_case(k));
let own = |a: XNode| css.own(a, "font-variation-settings").filter(|v| !inherits(v)).and_then(|v| variation_settings(&v));
st.font_axes = n.ancestors().filter(|a| a.is_element()).find_map(own).unwrap_or_default();
let (fill, mut fill_url) = self.paint(n, "fill");
st.fill = fill;
let (stroke, mut stroke_url) = self.paint(n, "stroke");
@@ -241,6 +241,49 @@ fn variable_font_instances_embed_as_themselves() {
assert!(advances[1] > advances[0], "{advances:?}");
}
/// Variable Font settings are written as `font-variation-settings`, with the `wght` setting as the
/// weight and the `wdth` setting as the width; the font embeds as a static font at the settings,
/// and the settings read back. Faces that one weight, style and width describe and that share
/// characters are warned about.
#[test]
fn variable_font_settings_are_written_embedded_and_read_back() {
use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY as FAMILY, two_axis_font};
let db = vectorcraft_text::FontDb::global();
db.add_font(two_axis_font().unwrap());
let set = [("wdth".to_string(), 90.0), ("wght".to_string(), 650.0)].into_iter().collect::<std::collections::BTreeMap<_, _>>();
let varied = CharStyle { font_axes: set.clone(), ..style(FAMILY, "Regular") };
let mut d = Document::new(300.0, 200.0);
add(&mut d, text(Point::new(20.0, 60.0), &[("ll", style(FAMILY, "Regular")), ("ll", varied.clone())]));
let o = export_full(&d, &ExportOptions { embed_fonts: true, ..Default::default() }, None);
assert!(o.svg.contains("font-variation-settings:&quot;wdth&quot; 90, &quot;wght&quot; 650"), "{}", o.svg);
assert!(o.svg.contains("font-weight=\"650\"") && o.svg.contains("font-stretch=\"90%\""), "{}", o.svg);
let fonts = embedded(&o.svg);
assert_eq!(fonts.iter().map(|f| f.1).collect::<Vec<_>>(), [400, 650], "{:?}", o.warnings);
assert!(o.svg.contains("font-style:normal;font-stretch:90%;src:"), "{}", o.svg);
assert!(o.warnings.is_empty(), "{:?}", o.warnings);
// The Condensed instance and Regular: one description, the same characters.
let mut same = Document::new(300.0, 200.0);
add(&mut same, text(Point::new(20.0, 60.0), &[("ll", style(FAMILY, "Regular")), ("ll", style(FAMILY, "Condensed"))]));
let warnings = export_full(&same, &ExportOptions { embed_fonts: true, ..Default::default() }, None).warnings;
assert!(warnings.iter().any(|w| w.contains(FAMILY) && w.contains("share characters")), "{warnings:?}");
let face = db.style_face(&varied).unwrap();
let f = skrifa::FontRef::new(&fonts[1].2).unwrap();
assert!(f.fvar().is_err() && f.gvar().is_err(), "a static font");
let adv = f.hmtx().unwrap().advance(f.cmap().unwrap().map_codepoint('l').unwrap()).unwrap();
assert_eq!(f64::from(adv), face.advance(face.glyph_for('l')).round());
let regular = db.face(FAMILY, "Regular").unwrap();
assert!(f64::from(adv) > regular.advance(regular.glyph_for('l')) + 5.0, "the instance's own glyphs");
// Read back: the settings, on the run that has them.
let back = import(&o.svg).unwrap();
let mut axes = vec![];
back.walk(|n| {
if let NodeKind::Text(t) = &n.kind {
axes.extend(t.runs.iter().map(|r| r.style.font_axes.clone()));
}
});
assert_eq!(axes, [Default::default(), set]);
}
/// The bundled Source Sans 3 Regular renamed `family` (13 characters, as long as the original
/// name) with OS/2 `fsType` set to `fs_type`.
fn licensed_font(family: &str, fs_type: u16) -> Vec<u8> {
+19
View File
@@ -232,6 +232,25 @@ fn weights_spacing_and_decoration() {
assert_eq!(style('a').kerning, None);
}
/// `font-variation-settings` becomes the characters' Variable Font settings, inherited as CSS
/// inherits it; `normal` and `initial` set none, and `unset` and a value CSS ignores leave the
/// inherited settings.
#[test]
fn font_variation_settings_become_variable_font_settings() {
let d = open(
r#"<text y="20" style="font-variation-settings: 'wght' 650, &quot;wdth&quot; 87.5">a<tspan>b</tspan><tspan style="font-variation-settings: normal">c</tspan><tspan style="font-variation-settings: 'weight' 650">d</tspan><tspan style="font-variation-settings: 'wght' heavy">e</tspan><tspan style="font-variation-settings: initial">f</tspan><tspan style="font-variation-settings: unset">g</tspan></text>"#,
);
let t = only_text(&d);
let axes = |c: char| t.runs.iter().find(|r| r.text.contains(c)).unwrap().style.font_axes.clone().into_iter().collect::<Vec<_>>();
let set = vec![("wdth".to_string(), 87.5), ("wght".to_string(), 650.0)];
for c in ['a', 'b', 'd', 'e', 'g'] {
assert_eq!(axes(c), set, "{c}");
}
for c in ['c', 'f'] {
assert!(axes(c).is_empty(), "{c}: {:?}", axes(c));
}
}
#[test]
fn css_selectors_reach_text() {
let d = open(
+157 -2
View File
@@ -42,6 +42,41 @@ pub type Variation = ([u8; 4], f32);
const MAX_AXES: usize = 64;
const MAX_INSTANCES: usize = 512;
/// One axis of a variable font's design space, as its `fvar` table lists it: the tag (`wght`), its
/// name in the font's name table (`Weight`; the tag when it has none) and its range and default,
/// in the axis's own units.
#[derive(Clone, Debug, PartialEq)]
pub struct VariationAxis {
pub tag: [u8; 4],
pub name: String,
pub min: f32,
pub default: f32,
pub max: f32,
/// The font asks for the axis not to be shown to users (the `fvar` hidden flag).
pub hidden: bool,
}
/// Faces of variable fonts instanced at axis settings their named instances don't have
/// ([`FontDb::instance`]). Each setting of a face keeps its id for the life of the process, so a
/// laid-out glyph's face can be found again ([`FontDb::face_by_id`]) after the built face was
/// dropped to bound the memory the cache holds.
#[derive(Default)]
struct Instances {
/// The id of each face and setting.
ids: HashMap<InstanceKey, u32>,
/// What each id is: the face it was instanced from and its axis settings.
recipes: HashMap<u32, (u32, Vec<Variation>)>,
/// The instanced faces built, by id; dropped all at once when [`MAX_LIVE_INSTANCES`] are held.
live: HashMap<u32, Arc<FontFace>>,
}
/// A face instanced at axis settings: the id of the face, and the settings by tag, as bits.
type InstanceKey = (u32, Vec<([u8; 4], u32)>);
/// How many instanced faces [`Instances::live`] holds at most.
pub(crate) const MAX_LIVE_INSTANCES: usize = 256;
#[derive(Clone)]
enum FontBytes {
Static(&'static [u8]),
Owned(Arc<Vec<u8>>),
@@ -144,6 +179,27 @@ impl FontFace {
pub fn variations(&self) -> &[Variation] {
&self.variations
}
/// The variation axes of the face's font (its `fvar` table, at most 64); none for a static
/// face.
pub fn axes(&self) -> Vec<VariationAxis> {
let Some(f) = self.skrifa() else { return vec![] };
f.axes()
.iter()
.take(MAX_AXES)
.map(|a| {
let tag = a.tag().to_be_bytes();
let fallback = || String::from_utf8_lossy(&tag).trim().to_string();
let name = name(&f, &[a.name_id()]).unwrap_or_else(fallback);
VariationAxis { tag, name, min: a.min_value(), default: a.default_value(), max: a.max_value(), hidden: a.is_hidden() }
})
.collect()
}
/// The face's setting of axis `tag`: its named instance's ([`Self::variations`]), else the
/// axis default. `None` when the font has no such axis.
pub fn axis_value(&self, tag: [u8; 4]) -> Option<f32> {
let axis = self.axes().into_iter().find(|a| a.tag == tag)?;
Some(self.variations.iter().find(|(t, _)| *t == tag).map_or(axis.default, |(_, v)| *v))
}
/// Unique id of this face within the process.
pub fn id(&self) -> u32 {
self.id
@@ -382,6 +438,8 @@ pub struct FontDb {
/// What the last scan read, to tell when fonts were installed or removed since.
#[cfg(not(target_arch = "wasm32"))]
stamp: Mutex<Option<ScanStamp>>,
/// Faces instanced at the axis settings of type's Variable Font sliders ([`FontDb::instance`]).
instances: Mutex<Instances>,
}
/// Where a database's system font scan looks: folders (read with their subfolders) and font files.
@@ -1378,6 +1436,25 @@ pub(crate) fn fonts_outside(registered: impl IntoIterator<Item = String>, font_d
files
}
/// `face` at the axis settings `variations` (user units), as face `id`: its names, kind, italic
/// and file, and its weight the `wght` setting when there is one.
fn instanced(face: &FontFace, variations: Vec<Variation>, id: u32) -> Option<FontFace> {
let weight = variations.iter().find(|(t, _)| t == b"wght").map_or(face.weight, |(_, v)| *v);
let spec = FaceStyle {
family: face.family.clone(),
native_family: face.native_family.clone(),
style: face.style.clone(),
keys: face.keys.clone(),
variations,
weight: Some(weight),
italic: face.italic,
traits: face.traits,
};
let mut f = make_face(face.bytes.clone(), face.index, spec, face.path.clone())?;
f.id = id;
Some(f)
}
fn make_face(bytes: FontBytes, index: u32, spec: FaceStyle, path: Option<std::path::PathBuf>) -> Option<FontFace> {
let data: &[u8] = match &bytes {
FontBytes::Static(b) => b,
@@ -1525,6 +1602,7 @@ impl FontDb {
sys: Mutex::new(SysFallback { enabled: true, ..Default::default() }),
#[cfg(not(target_arch = "wasm32"))]
stamp: Mutex::new(None),
instances: Mutex::new(Instances::default()),
}
}
@@ -2015,9 +2093,86 @@ impl FontDb {
self.read_catalog().get(&family.to_ascii_lowercase()).is_some_and(|c| c.faces.iter().any(|cf| norm(&cf.style) == ns))
}
/// The loaded face with [`FontFace::id`] `id` (the face a laid-out glyph came from).
/// The loaded face with [`FontFace::id`] `id` (the face a laid-out glyph came from), an
/// instanced face ([`Self::instance`]) included.
pub fn face_by_id(&self, id: u32) -> Option<Arc<FontFace>> {
self.read_faces().iter().find(|f| f.id == id).cloned()
let loaded = self.read_faces().iter().find(|f| f.id == id).cloned();
loaded.or_else(|| self.instance_by_id(id))
}
/// The face of character style `st`: [`Self::face_version`] of its font, instanced at its
/// Variable Font settings ([`Self::instance`]).
pub fn style_face(&self, st: &vectorcraft_doc::CharStyle) -> Option<Arc<FontFace>> {
let face = self.face_version(&st.font_family, &st.font_style, st.font_version.as_deref())?;
Some(self.instance(&face, &st.font_axes))
}
/// `face` with the axis settings `axes` (by tag, in the axes' own units) in place of its own,
/// each clamped to its axis's range: its outlines, metrics and advances taken there, and its
/// file embedded instanced there. Tags the font has no axis for are left out. `face` itself
/// when that changes no setting, and for a static face.
pub fn instance(&self, face: &Arc<FontFace>, axes: &std::collections::BTreeMap<String, f64>) -> Arc<FontFace> {
if axes.is_empty() {
return face.clone();
}
let font_axes = face.axes();
let mut variations = face.variations.clone();
for (tag, value) in axes {
let Some(axis) = font_axes.iter().find(|a| a.tag.as_slice() == tag.as_bytes()) else { continue };
if !value.is_finite() {
continue;
}
let v = (*value as f32).clamp(axis.min.min(axis.max), axis.max.max(axis.min));
match variations.iter_mut().find(|(t, _)| *t == axis.tag) {
Some(setting) => setting.1 = v,
None => variations.push((axis.tag, v)),
}
}
// Every axis at the setting it has in `face`: nothing to instance.
let at = |vs: &[Variation], a: &VariationAxis| vs.iter().find(|(t, _)| *t == a.tag).map_or(a.default, |(_, v)| *v);
if font_axes.iter().all(|a| at(&variations, a) == at(&face.variations, a)) {
return face.clone();
}
variations.sort_by_key(|(t, _)| *t);
let key = (face.id, variations.iter().map(|(t, v)| (*t, v.to_bits())).collect::<Vec<_>>());
let mut inst = self.instances.lock().unwrap_or_else(|e| e.into_inner());
let id = match inst.ids.get(&key) {
Some(id) => *id,
None => {
let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
inst.ids.insert(key, id);
inst.recipes.insert(id, (face.id, variations.clone()));
id
}
};
if let Some(f) = inst.live.get(&id) {
return f.clone();
}
let Some(built) = instanced(face, variations, id) else { return face.clone() };
let built = Arc::new(built);
if inst.live.len() >= MAX_LIVE_INSTANCES {
inst.live.clear();
}
inst.live.insert(id, built.clone());
built
}
/// The instanced face with id `id` ([`Self::instance`]), built again when it was dropped.
fn instance_by_id(&self, id: u32) -> Option<Arc<FontFace>> {
let (base, variations) = {
let inst = self.instances.lock().unwrap_or_else(|e| e.into_inner());
if let Some(f) = inst.live.get(&id) {
return Some(f.clone());
}
inst.recipes.get(&id).cloned()?
};
let base = self.read_faces().iter().find(|f| f.id == base).cloned()?;
let built = Arc::new(instanced(&base, variations, id)?);
let mut inst = self.instances.lock().unwrap_or_else(|e| e.into_inner());
if inst.live.len() >= MAX_LIVE_INSTANCES {
inst.live.clear();
}
Some(inst.live.entry(id).or_insert(built).clone())
}
/// The available family `name` names, ignoring case and anything but letters and digits, as
+2 -4
View File
@@ -347,8 +347,7 @@ fn layout_once(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLayout
TextKind::OnPath { .. } => Affine::IDENTITY,
TextKind::Point => {
let first = cx.style_at(0);
let centre =
db.face_version(&first.font_family, &first.font_style, first.font_version.as_deref()).map_or(EM_CENTER, |f| f.ideographic_centre());
let centre = db.style_face(first).map_or(EM_CENTER, |f| f.ideographic_centre());
Affine::translate((-centre * first.size, 0.0)) * QUARTER_TURN
}
_ => QUARTER_TURN,
@@ -1729,8 +1728,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
let (asc, desc, lead) = style_metrics(cx.db, cx.style_at(pr.start));
let st = cx.style_at(pr.start);
let (cap, xh) = cap_x_heights(cx.db, st);
let centre =
cx.db.face_version(&st.font_family, &st.font_style, st.font_version.as_deref()).map_or(EM_CENTER, |f| f.ideographic_centre());
let centre = cx.db.style_face(st).map_or(EM_CENTER, |f| f.ideographic_centre());
Metrics { asc, desc, lead, cap, xh, top: (centre + 0.5) * st.size * st.v_scale / 100.0 }
};
if pi > 0 {
+2
View File
@@ -525,6 +525,8 @@ pub fn logical_order(visual: &str) -> Option<(Vec<usize>, bool)> {
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_axes;
#[cfg(test)]
mod tests_bidi;
#[cfg(test)]
mod tests_combos;
+6 -4
View File
@@ -281,7 +281,7 @@ pub(crate) fn is_cjk(c: char) -> bool {
/// Vertical metrics (points) of a style's resolved face: (ascent, descent, leading).
pub(crate) fn style_metrics(db: &FontDb, st: &CharStyle) -> (f64, f64, f64) {
let vs = st.v_scale / 100.0;
let Some(face) = db.face_version(&st.font_family, &st.font_style, st.font_version.as_deref()) else {
let Some(face) = db.style_face(st) else {
return (st.size * 0.8 * vs, st.size * 0.2 * vs, st.effective_leading());
};
let k = st.size / face.upem;
@@ -291,7 +291,7 @@ pub(crate) fn style_metrics(db: &FontDb, st: &CharStyle) -> (f64, f64, f64) {
/// Cap height and x height (points) of a style's resolved face.
pub(crate) fn cap_x_heights(db: &FontDb, st: &CharStyle) -> (f64, f64) {
let vs = st.v_scale / 100.0;
let Some(face) = db.face_version(&st.font_family, &st.font_style, st.font_version.as_deref()) else {
let Some(face) = db.style_face(st) else {
return (st.size * 0.7 * vs, st.size * 0.5 * vs);
};
let k = st.size / face.upem * vs;
@@ -357,12 +357,12 @@ pub(crate) fn shape_range(
let a = bytes.start;
let b = bytes.end;
if let Some(art) = art {
if let Some(face) = db.face_version(&st.font_family, &st.font_style, st.font_version.as_deref()).or_else(|| db.face_covering('a')) {
if let Some(face) = db.style_face(st).or_else(|| db.face_covering('a')) {
out.push(inline_glyph(&face, st, art, source_runs.start, a..b, text, level_at(a)));
}
continue;
}
let Some(primary) = db.face_version(&st.font_family, &st.font_style, st.font_version.as_deref()) else { continue };
let Some(primary) = db.style_face(st) else { continue };
let pmap = primary.skrifa().map(|f| f.charmap());
// Synthesized Small Caps shape lowercase letters separately (as smaller capitals).
let small_caps = st.small_caps.is_some() && !st.all_caps;
@@ -438,6 +438,7 @@ fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool {
font_family,
font_style,
font_version,
font_axes,
size,
leading: _,
tracking,
@@ -470,6 +471,7 @@ fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool {
*font_family == b.font_family
&& *font_style == b.font_style
&& *font_version == b.font_version
&& *font_axes == b.font_axes
&& *size == b.size
&& *h_scale == b.h_scale
&& *v_scale == b.v_scale
+79
View File
@@ -91,6 +91,85 @@ pub fn variable_font() -> Option<Vec<u8>> {
Some(builder.build())
}
/// The family of [`two_axis_font`].
pub const TWO_AXIS_FAMILY: &str = "Biaxtest Sans";
/// How much narrower [`two_axis_font`] makes [`VARIABLE_CHARS`] at its narrowest width.
pub const TWO_AXIS_NARROW: f32 = 0.2;
/// The bundled Source Sans 3 Regular as a variable font of family [`TWO_AXIS_FAMILY`] with two axes:
/// weight (`wght` 400–700, default 400), whose maximum makes the glyphs of [`VARIABLE_CHARS`]
/// [`VARIABLE_WIDEN`] wider, and width (`wdth` 75–100, default 100), whose minimum makes them
/// [`TWO_AXIS_NARROW`] narrower (outlines and advances, through `gvar`). Its named instances are
/// Regular (the default), Bold (`wght` 700) and Condensed (`wdth` 75). `None` when it can't be
/// built.
pub fn two_axis_font() -> Option<Vec<u8>> {
const BASE: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf");
let font = skrifa::FontRef::new(BASE).ok()?;
let (glyf, loca) = (font.glyf().ok()?, font.loca(None).ok()?);
let metrics = font.glyph_metrics(Size::unscaled(), LocationRef::default());
let charmap = font.charmap();
let varied: Vec<GlyphId> = VARIABLE_CHARS.chars().filter_map(|c| charmap.map(c)).collect();
let mut variations = Vec::new();
for g in 0..u32::from(font.maxp().ok()?.num_glyphs()) {
let id = GlyphId::new(g);
let mut deltas = vec![];
if varied.contains(&id) {
let Ok(Some(Glyph::Simple(s))) = loca.get_glyf(id, &glyf) else { return None };
let advance = metrics.advance_width(id)?;
// At each axis's far end: every point and the advance phantom point moved by `k` of their x.
let moved = |k: f32| {
let by = |x: f32| (x * k).round() as i16;
let mut d: Vec<GlyphDelta> = s.points().map(|p| GlyphDelta::required(by(f32::from(p.x)), 0)).collect();
d.extend([(0, 0), (by(advance), 0), (0, 0), (0, 0)].map(|(x, y)| GlyphDelta::required(x, y)));
d
};
let peak = |w: f32, d: f32| vec![Tent::new(F2Dot14::from_f32(w), None), Tent::new(F2Dot14::from_f32(d), None)];
deltas.push(GlyphDeltas::new(peak(1.0, 0.0), moved(VARIABLE_WIDEN)));
deltas.push(GlyphDeltas::new(peak(0.0, -1.0), moved(-TWO_AXIS_NARROW)));
}
variations.push(GlyphVariations::new(write_fonts::types::GlyphId::new(g), deltas));
}
let gvar = Gvar::new(variations, 2).ok()?;
let axis = |tag: &[u8; 4], min: i32, default: i32, max: i32, name: u16| VariationAxisRecord {
axis_tag: Tag::new(tag),
min_value: Fixed::from_i32(min),
default_value: Fixed::from_i32(default),
max_value: Fixed::from_i32(max),
flags: 0,
axis_name_id: NameId::new(name),
};
let instance = |name: u16, wght: i32, wdth: i32| InstanceRecord {
subfamily_name_id: NameId::new(name),
flags: 0,
coordinates: vec![Fixed::from_i32(wght), Fixed::from_i32(wdth)],
post_script_name_id: None,
};
let axes = vec![axis(b"wght", 400, 400, 700, 256), axis(b"wdth", 75, 100, 100, 257)];
let fvar = Fvar::new(AxisInstanceArrays::new(axes, vec![instance(258, 400, 100), instance(259, 700, 100), instance(260, 400, 75)]));
let name = name_table(&[
(1, TWO_AXIS_FAMILY),
(2, "Regular"),
(4, "Biaxtest Sans Regular"),
(6, "BiaxtestSans-Regular"),
(256, "Weight"),
(257, "Width"),
(258, "Regular"),
(259, "Bold"),
(260, "Condensed"),
])?;
let mut builder = FontBuilder::new();
builder.add_raw(Tag::new(b"name"), name);
builder.add_table(&fvar).ok()?.add_table(&gvar).ok()?;
for r in font.table_directory.table_records() {
let tag = Tag::new(&r.tag().to_be_bytes());
if !builder.contains(tag) {
builder.add_raw(tag, font.table_data(r.tag())?.as_bytes());
}
}
Some(builder.build())
}
/// A `name` table of Windows English (US) records (name id, string).
pub fn name_table(records: &[(u16, &str)]) -> Option<Vec<u8>> {
let mut records = records.to_vec();
+153
View File
@@ -0,0 +1,153 @@
//! Variable Font settings: type set at axis settings no named instance has is shaped, drawn,
//! measured and embedded there.
use std::collections::BTreeMap;
use kurbo::{Point, Shape};
use skrifa::MetadataProvider;
use skrifa::instance::{LocationRef, Size};
use skrifa::raw::TableProvider;
use vectorcraft_doc::CharStyle;
use super::test_fonts::{TWO_AXIS_FAMILY as FAMILY, TWO_AXIS_NARROW, VARIABLE_CHARS, VARIABLE_WIDEN, two_axis_font};
use super::*;
/// A database of the bundled fonts and the two-axis test font.
fn db() -> FontDb {
let db = FontDb::with_font_dirs(vec![]);
assert_eq!(db.add_font(two_axis_font().expect("the test font builds")), 3, "Regular, Bold and Condensed");
db
}
fn axes(settings: &[(&str, f64)]) -> BTreeMap<String, f64> {
settings.iter().map(|(t, v)| (t.to_string(), *v)).collect()
}
fn text(style: &str, settings: &[(&str, f64)]) -> TextObject {
let st = CharStyle { font_family: FAMILY.into(), font_style: style.into(), size: 100.0, font_axes: axes(settings), ..CharStyle::default() };
TextObject::point(Point::ZERO, VARIABLE_CHARS, st)
}
/// The advances and the ink width of the laid-out text.
fn widths(db: &FontDb, t: &TextObject) -> (f64, f64) {
let l = layout(db, t);
(l.glyphs.iter().map(|g| g.advance).sum(), l.to_bezpath().bounding_box().width())
}
#[test]
fn a_variable_font_lists_its_axes() {
let db = db();
let regular = db.face(FAMILY, "Regular").unwrap();
let list: Vec<(String, f32, f32, f32)> = regular.axes().into_iter().map(|a| (a.name, a.min, a.default, a.max)).collect();
assert_eq!(list, [("Weight".to_string(), 400.0, 400.0, 700.0), ("Width".to_string(), 75.0, 100.0, 100.0)]);
assert_eq!(regular.axes()[0].tag, *b"wght");
// A named instance's settings, and the defaults of the axes it leaves alone.
let bold = db.face(FAMILY, "Bold").unwrap();
assert_eq!((bold.axis_value(*b"wght"), bold.axis_value(*b"wdth"), bold.axis_value(*b"opsz")), (Some(700.0), Some(100.0), None));
assert!(db.face(FALLBACK_FAMILY, "Regular").unwrap().axes().is_empty(), "a static face");
}
#[test]
fn axis_settings_lay_out_and_draw_between_the_named_instances() {
let db = db();
let (regular, bold) = (widths(&db, &text("Regular", &[])), widths(&db, &text("Bold", &[])));
let between = widths(&db, &text("Regular", &[("wght", 650.0)]));
// wght 650 is 5/6 of the way to the heaviest weight: the glyphs are 5/6 of the way wider.
let k = 1.0 + f64::from(VARIABLE_WIDEN) * 250.0 / 300.0;
assert!((between.0 - regular.0 * k).abs() < 1.0, "advances {regular:?} {between:?} {bold:?}");
assert!((between.1 - regular.1 * k).abs() < 1.5, "ink {regular:?} {between:?} {bold:?}");
assert!(regular.0 < between.0 && between.0 < bold.0 && regular.1 < between.1 && between.1 < bold.1);
// The glyphs come from a face of their own, found again by its id, at those settings.
let l = layout(&db, &text("Regular", &[("wght", 650.0)]));
let face = db.face_by_id(l.glyphs[0].font_id).unwrap();
assert_eq!((face.family.as_str(), face.style.as_str(), face.variations(), face.weight), (FAMILY, "Regular", &[(*b"wght", 650.0)][..], 650.0));
assert_ne!(face.id(), db.face(FAMILY, "Regular").unwrap().id());
// The same settings again: the same face.
assert_eq!(layout(&db, &text("Regular", &[("wght", 650.0)])).glyphs[0].font_id, face.id());
// Width on top of the Bold instance: Bold's weight stays, the glyphs narrow.
let narrow = widths(&db, &text("Bold", &[("wdth", 75.0)]));
let k = (1.0 + f64::from(VARIABLE_WIDEN)) - f64::from(TWO_AXIS_NARROW);
assert!((narrow.0 - regular.0 * k).abs() < 1.0, "{narrow:?} vs {regular:?}");
let l = layout(&db, &text("Bold", &[("wdth", 75.0)]));
assert_eq!(db.face_by_id(l.glyphs[0].font_id).unwrap().variations(), [(*b"wdth", 75.0), (*b"wght", 700.0)]);
}
/// Neighboring runs at other settings are shaped apart, each in its own instance; runs at the same
/// settings that differ only in paint are shaped together and keep the font's kerning.
#[test]
fn other_settings_shape_a_run_apart_and_the_same_settings_keep_the_kerning() {
let db = db();
// 40 pt "AVAV", which the font kerns, at `settings`, with the bytes `restyled` restyled by
// `restyle`: each glyph's source byte, pen position, advance and face id.
let avav = |settings: &[(&str, f64)], restyled: std::ops::Range<usize>, restyle: &dyn Fn(&mut CharStyle)| {
let st = CharStyle { font_family: FAMILY.into(), size: 40.0, font_axes: axes(settings), ..CharStyle::default() };
let mut t = TextObject::point(Point::ZERO, "AVAV", st);
edit::style_range(&mut t.runs, restyled.start, restyled.end, restyle);
layout(&db, &t).glyphs.iter().map(|g| (g.byte, g.origin.x, g.advance, g.font_id)).collect::<Vec<_>>()
};
let same = [("wght", 650.0)];
let plain = avav(&same, 0..0, &|_| {});
let unkerned = avav(&same, 0..4, &|s| s.kerning = Some(0.0));
assert!((plain[0].2 - unkerned[0].2).abs() > 0.1, "the font kerns A and V: {plain:?} {unkerned:?}");
assert_eq!(avav(&same, 1..2, &|s| s.fill = Default::default()), plain, "a V in another paint at the same settings");
// The V at another weight: an instance of its own, shaped without the A-V pair.
let apart = avav(&same, 1..2, &|s| {
s.font_axes = axes(&[("wght", 500.0)]);
});
let face = db.face_by_id(apart[1].3).unwrap();
assert_eq!(face.variations(), [(*b"wght", 500.0)], "{apart:?}");
assert_ne!(apart[1].3, plain[1].3);
assert_eq!((apart[0].2, apart[0].3), (unkerned[0].2, plain[0].3), "the A before it is not kerned with it: {apart:?}");
}
#[test]
fn settings_are_clamped_and_unknown_axes_left_out() {
let db = db();
let regular = db.face(FAMILY, "Regular").unwrap();
let past = db.instance(&regular, &axes(&[("wght", 2000.0), ("opsz", 12.0)]));
assert_eq!(past.variations(), [(*b"wght", 700.0)]);
// Drawn as the heaviest weight.
let bold = db.face(FAMILY, "Bold").unwrap();
let l = bold.glyph_for('l');
assert_eq!(past.advance(l), bold.advance(l));
assert_eq!(db.outline(&past, l).to_svg(), db.outline(&bold, l).to_svg());
// Settings that change nothing, and a static face: the face itself.
for same in [axes(&[("wght", 400.0)]), axes(&[("wdth", 100.0), ("opsz", 12.0)]), axes(&[("wght", f64::NAN)])] {
assert_eq!(db.instance(&regular, &same).id(), regular.id(), "{same:?}");
}
assert_eq!(db.instance(&bold, &axes(&[("wght", 700.0)])).id(), bold.id());
let fixed = db.face(FALLBACK_FAMILY, "Regular").unwrap();
assert_eq!(db.instance(&fixed, &axes(&[("wght", 650.0)])).id(), fixed.id());
}
#[test]
fn an_instance_is_found_by_its_id_after_the_cache_drops_it() {
let db = db();
let regular = db.face(FAMILY, "Regular").unwrap();
let first = db.instance(&regular, &axes(&[("wght", 401.0)]));
let (id, l) = (first.id(), first.glyph_for('l'));
let (advance, outline) = (first.advance(l), db.outline(&first, l).to_svg());
// More settings than the cache holds.
for i in 0..fontdb::MAX_LIVE_INSTANCES + 10 {
db.instance(&regular, &axes(&[("wght", 402.0 + i as f64 * 0.5)]));
}
drop(first);
// A glyph laid out before keeps its face: built again, with the same id and settings.
let again = db.face_by_id(id).unwrap();
assert_eq!((again.id(), again.variations(), again.advance(l)), (id, &[(*b"wght", 401.0)][..], advance));
assert_eq!(db.outline(&again, l).to_svg(), outline);
assert_eq!(db.instance(&regular, &axes(&[("wght", 401.0)])).id(), id, "the same settings, the same id");
}
#[test]
fn an_instance_embeds_as_a_static_font_at_its_settings() {
let db = db();
let regular = db.face(FAMILY, "Regular").unwrap();
let face = db.instance(&regular, &axes(&[("wght", 650.0), ("wdth", 90.0)]));
let e = face.embed(&['l']).unwrap();
let f = skrifa::FontRef::new(&e.data).unwrap();
assert!(f.fvar().is_err() && f.gvar().is_err(), "no variations left");
let adv = f.glyph_metrics(Size::unscaled(), LocationRef::default()).advance_width(f.charmap().map('l').unwrap()).unwrap();
assert!((f64::from(adv) - face.advance(face.glyph_for('l'))).abs() <= 1.0, "{adv}");
assert!((f64::from(adv) - regular.advance(regular.glyph_for('l'))).abs() > 5.0, "not the default instance's: {adv}");
}
+2
View File
@@ -41,6 +41,8 @@ web-time = "1"
[dev-dependencies]
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
vectorcraft-testkit = { workspace = true }
# A variable font built in code (no font file is committed).
vectorcraft-text = { workspace = true, features = ["test-fonts"] }
wat = { workspace = true }
[build-dependencies]
+1
View File
@@ -1133,6 +1133,7 @@
Font List Списък с шрифтове
Font Preview Size Размер за преглед на шрифта
Font Size Размер на шрифта
Variable Font Променлив шрифт
Font: Шрифт:
Fonts Шрифтове
Fonts in Document Шрифтове в документа
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -395,6 +395,7 @@
Font Family & Style Rodina a řez písma
Font Family, Style & Size Rodina, řez a velikost písma
Font Size Velikost písma
Variable Font Variabilní písmo
Text Fill Color Barva výplně textu
Text Stroke Color Barva tahu textu
All on Same Layers Vše ve stejných vrstvách
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -361,6 +361,7 @@
Font Family & Style Skrifttypefamilie og -stil
Font Family, Style & Size Skrifttypefamilie, stil og størrelse
Font Size Skriftstørrelse
Variable Font Variabel skrifttype
Text Fill Color Tekstfyldfarve
Text Stroke Color Tekststregfarve
All on Same Layers Alt på samme lag
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1151,6 +1151,7 @@ file-filter Templates Vorlagen
Font List Schriftliste
Font Preview Size Größe der Schriftvorschau
Font Size Schriftgrad
Variable Font Variable Schrift
Font: Schrift:
Fonts Schriften
Fonts in Document Schriften im Dokument
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1099,6 +1099,7 @@
Font List Λίστα γραμματοσειρών
Font Preview Size Μέγεθος προεπισκόπησης γραμματοσειράς
Font Size Μέγεθος γραμματοσειράς
Variable Font Μεταβλητή γραμματοσειρά
Font: Γραμματοσειρά:
Fonts Γραμματοσειρές
Fonts in Document Γραμματοσειρές στο έγγραφο
1 # Ελληνικά (el) — VectorCraft
1099
1100
1101
1102
1103
1104
1105
+1
View File
@@ -1158,6 +1158,7 @@
Font List Lista de fuentes
Font Preview Size Tamaño de vista previa de fuente
Font Size Tamaño de fuente
Variable Font Fuente variable
Font: Fuente:
Fonts Fuentes
Fonts in Document Fuentes del documento
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -392,6 +392,7 @@
Font Family & Style Fonttiperhe ja tyyli
Font Family, Style & Size Fonttiperhe, tyyli ja koko
Font Size Fonttikoko
Variable Font Muuttuva fontti
Text Fill Color Tekstin täyttöväri
Text Stroke Color Tekstin viivan väri
All on Same Layers Kaikki samoilla tasoilla
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1157,6 +1157,7 @@
Font List Liste des polices
Font Preview Size Taille de l’aperçu des polices
Font Size Taille de police
Variable Font Police variable
Font: Police :
Fonts Polices
Fonts in Document Polices du document
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1454,6 +1454,7 @@ file-filter Templates Sablonok
Font List Betűtípuslista
Font Preview Size Betűtípus-előnézet mérete
Font Size Betűméret
Variable Font Változó betűtípus
Font: Betűtípus:
Fonts Betűtípusok
Fonts in Document A dokumentum betűtípusai
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -361,6 +361,7 @@
Font Family & Style Keluarga & gaya font
Font Family, Style & Size Keluarga, gaya & ukuran font
Font Size Ukuran font
Variable Font Font variabel
Text Fill Color Warna isian teks
Text Stroke Color Warna stroke teks
All on Same Layers Semua di lapisan yang sama
1 # Indonesian (id) catalog. Same format as zh-hant.tsv: context <TAB> source <TAB> translation
361
362
363
364
365
366
367
+1
View File
@@ -1167,6 +1167,7 @@ file-filter Templates Modelli
Font List Elenco font
Font Preview Size Dimensione anteprima font
Font Size Dimensione font
Variable Font Font variabile
Font: Font:
Fonts Font
Fonts in Document Font nel documento
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -390,6 +390,7 @@
Font Family & Style フォントファミリー (スタイル)
Font Family, Style & Size フォントファミリー (スタイルとサイズ)
Font Size フォントサイズ
Variable Font バリアブルフォント
Text Fill Color テキストカラー (塗り)
Text Stroke Color テキストカラー (線)
All on Same Layers 同じレイヤー上のすべて
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -386,6 +386,7 @@
Font Family & Style 글꼴 패밀리 및 스타일
Font Family, Style & Size 글꼴 패밀리, 스타일 및 크기
Font Size 글꼴 크기
Variable Font 가변 글꼴
Text Fill Color 문자 칠 색상
Text Stroke Color 문자 선 색상
All on Same Layers 같은 레이어의 모두
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -361,6 +361,7 @@
Font Family & Style Lettertypefamilie en -stijl
Font Family, Style & Size Lettertypefamilie, -stijl en -grootte
Font Size Lettertypegrootte
Variable Font Variabel lettertype
Text Fill Color Tekstvulkleur
Text Stroke Color Tekstcontourkleur
All on Same Layers Alles op dezelfde lagen
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -355,6 +355,7 @@
Font Family & Style Rodzina i styl czcionki
Font Family, Style & Size Rodzina, styl i rozmiar czcionki
Font Size Rozmiar czcionki
Variable Font Czcionka zmienna
Text Fill Color Kolor wypełnienia tekstu
Text Stroke Color Kolor obrysu tekstu
All on Same Layers Wszystko na tych samych warstwach
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1017,6 +1017,7 @@
Font List Lista de Fontes
Font Preview Size Tamanho da Visualização da Fonte
Font Size Tamanho da Fonte
Variable Font Fonte Variável
Font: Fonte:
Fonts Fontes
Fonts in Document Fontes no Documento
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -1165,6 +1165,7 @@ file-filter Templates Шаблоны
Font List Список шрифтов
Font Preview Size Размер предварительного просмотра шрифта
Font Size Размер шрифта
Variable Font Вариативный шрифт
Font: Шрифт:
Fonts Шрифты
Fonts in Document Шрифты в документе
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1135,6 +1135,7 @@
Font List Teckensnittslista
Font Preview Size Storlek på teckensnittsförhandsgranskning
Font Size Teckenstorlek
Variable Font Variabelt teckensnitt
Font: Typsnitt:
Fonts Typsnitt
Fonts in Document Typsnitt i dokumentet
Can't render this file because it contains an unexpected character in line 5 and column 25.
+1
View File
@@ -1161,6 +1161,7 @@ file-filter Templates Шаблони
Font List Список шрифтів
Font Preview Size Розмір попереднього перегляду шрифту
Font Size Розмір шрифту
Variable Font Варіативний шрифт
Font: Шрифт:
Fonts Шрифти
Fonts in Document Шрифти у документі
Can't render this file because it contains an unexpected character in line 723 and column 40.
+1
View File
@@ -1345,6 +1345,7 @@ file-filter Templates Mẫu
Font List Danh sách phông
Font Preview Size Cỡ xem trước phông
Font Size Cỡ chữ
Variable Font Phông chữ biến thiên
Font: Phông:
Fonts Phông chữ
Fonts in Document Phông trong tài liệu
1 # Vietnamese (vi) catalog. Same format as zh-hant.tsv: context <TAB> source <TAB> translation
1345
1346
1347
1348
1349
1350
1351
+1
View File
@@ -1114,6 +1114,7 @@ file-filter Templates 模板
Font List 字体列表
Font Preview Size 字体预览大小
Font Size 字号
Variable Font 可变字体
Font: 字体:
Fonts 字体
Fonts in Document 文档中的字体
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1138,6 +1138,7 @@ file-filter Templates 範本
Font List 字體清單
Font Preview Size 字體預覽大小
Font Size 字體大小
Variable Font 可變字體
Font: 字體:
Fonts 字體
Fonts in Document 文件中的字體
Can't render this file because it contains an unexpected character in line 6 and column 47.
+136
View File
@@ -533,6 +533,72 @@ pub(crate) fn font_pickers(app: &mut VectorcraftApp, ui: &mut Ui, s: &CharStyle,
}
}
/// The Variable Font button, `width` wide, shown when the font of style `s` is a variable font: it
/// opens the font's axis sliders ([`axis_sliders`]).
fn variable_font(app: &mut VectorcraftApp, ui: &mut Ui, s: &CharStyle, width: f32) {
let Some(face) = vectorcraft_text::FontDb::global().face_version(&s.font_family, &s.font_style, s.font_version.as_deref()) else { return };
if face.axes().iter().all(|a| a.hidden) {
return;
}
let resp = widgets::flat_button(ui, tl!("Variable Font"), width);
widgets::popover(&resp, resp.clicked(), |ui| {
ui.set_width(240.0);
axis_sliders(app, ui, &face);
});
}
/// One slider and field per axis of `face`'s variable font (the axes it asks to hide left out),
/// named as the font names it: each sets that axis of the text the panel acts on
/// (`text.setFormat {fontAxes}`, or `text.setRangeStyle` while the Type tool edits text), one undo
/// step per drag. A blank field: the text's settings of that axis differ.
pub(crate) fn axis_sliders(app: &mut VectorcraftApp, ui: &mut Ui, face: &vectorcraft_text::FontFace) {
let t = Tokens::get(ui.ctx());
let db = vectorcraft_text::FontDb::global();
for axis in face.axes().into_iter().filter(|a| !a.hidden) {
let tag = String::from_utf8_lossy(&axis.tag).into_owned();
// Each character style's setting: its own, else its face's (a named instance's, or the default).
let setting = |s: &CharStyle| {
s.font_axes.get(&tag).copied().or_else(|| {
let face = db.face_version(&s.font_family, &s.font_style, s.font_version.as_deref())?;
face.axis_value(axis.tag).map(f64::from)
})
};
let value = shared(app, setting).flatten();
let (min, max) = (f64::from(axis.min.min(axis.max)), f64::from(axis.max.max(axis.min)));
ui.horizontal(|ui| {
let label = widgets::dim_name(ui, &axis.name);
crate::scrub::note_label(ui, label.rect);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if let Some(v) = widgets::spin_plain(ui, ("ch-axis-field", axis.tag), value, "", 1, 72.0, 1.0, min, &[]) {
axes_cmd(app, json!({ &tag: v.clamp(min, max) }), widgets::Live::Released);
}
});
});
let at = value.map_or(0.0, |v| if max > min { ((v - min) / (max - min)) as f32 } else { 0.0 });
let (picked, phase) = widgets::color_slider_named(ui, ("ch-axis", axis.tag), &axis.name, at, ui.available_width(), &|_| t.button_border);
if let Some(p) = picked {
let v = ((min + f64::from(p) * (max - min)) * 10.0).round() / 10.0;
axes_cmd(app, json!({ &tag: v }), phase);
}
}
}
/// Set Variable Font settings `axes` on the text the panel acts on, live while a slider drags.
fn axes_cmd(app: &mut VectorcraftApp, axes: Value, phase: widgets::Live) {
match text_editing(app) {
Some((id, a, b)) => {
end_typing(app);
let mut q = json!({"id": id.0, "fontAxes": axes});
if b > a {
q["start"] = json!(a);
q["end"] = json!(b);
}
super::live_run(app, "Character", "text.setRangeStyle", q, phase);
}
None => super::live_run(app, "Character", "text.setFormat", json!({ "fontAxes": axes }), phase),
}
}
/// The Control bar's type controls while type is selected or edited: the Character link (the
/// Character panel in a popover), the font family and style menus and the Font Size combo.
pub(crate) fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
@@ -557,6 +623,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
};
let w = ui.available_width();
font_pickers(app, ui, &s, ("ch-font", "ch-style"), (w - 4.0, w - 4.0));
variable_font(app, ui, &s, w - 4.0);
ui.add_space(2.0);
let hidden = pstate::<bool>(ui.ctx(), "ch-hide-options");
metrics_grid(app, ui, "ch-grid", &s, w, !hidden);
@@ -860,6 +927,75 @@ mod tests {
assert_eq!((kerning(&app, 1), kerning(&app, 2), kerning(&app, 3)), (Some(-50.0), Some(30.0), Some(30.0)));
}
/// A variable font gets the Variable Font button, which opens one slider per axis named as the
/// font names it; a drag along one sets that axis of the selected type in one undo step.
#[test]
fn variable_fonts_get_a_slider_per_axis() {
use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY, two_axis_font};
let db = vectorcraft_text::FontDb::global();
db.add_font(two_axis_font().unwrap());
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
let plain = app.run("text.create", json!({"x": 20, "y": 50, "text": "Static"})).unwrap()["id"].clone();
let id = app.run("text.create", json!({"x": 20, "y": 120, "text": "lIn", "font": TWO_AXIS_FAMILY})).unwrap()["id"].clone();
app.run("select.set", json!({"ids": [plain]})).unwrap();
assert!(!crate::tests_labels::painted_text(&mut app, show).contains("Variable Font"), "a static font");
app.run("select.set", json!({"ids": [id]})).unwrap();
assert!(crate::tests_labels::painted_text(&mut app, show).contains("Variable Font"));
let face = db.face(TWO_AXIS_FAMILY, "Regular").unwrap();
let text = crate::tests_labels::painted_text(&mut app, |app, ui| axis_sliders(app, ui, &face));
assert!(text.contains("Weight\n") && text.contains("Width\n") && text.contains("400") && text.contains("100"), "{text}");
// The labels are the font's names in every interface language, not catalog words (ja
// translates the interface's Weight as 線幅, the stroke weight).
crate::i18n::set_current(crate::i18n::Lang::from_code("ja").unwrap());
let text = crate::tests_labels::painted_text(&mut app, |app, ui| axis_sliders(app, ui, &face));
crate::i18n::set_current(crate::i18n::Lang::EN);
assert!(text.contains("Weight\n") && text.contains("Width\n") && !text.contains("線幅"), "{text}");
// Screen readers name each slider by its axis.
let tree = crate::widgets::accessibility_tests::tree(|ui| axis_sliders(&mut app, ui, &face));
for name in ["Weight", "Width"] {
assert!(tree.iter().any(|n| n.role() == egui::accesskit::Role::Slider && n.label() == Some(name)), "{name}");
}
// Drag the Weight slider from its start to past its end.
let ctx = egui::Context::default();
let frame = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
let mut slider = None;
let input = egui::RawInput { events, ..Default::default() };
ctx.run_ui(input, |ui| {
axis_sliders(app, ui, &face);
slider = ctx.read_response(ui.id().with(("ch-axis", *b"wght"))).map(|r| r.rect);
})
.textures_delta
.clear();
slider
};
frame(&mut app, vec![]);
let r = frame(&mut app, vec![]).expect("the Weight slider is drawn");
let none = egui::Modifiers::NONE;
let (from, to) = (egui::pos2(r.left() + 4.0, r.center().y), egui::pos2(r.right() + 20.0, r.center().y));
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: none };
frame(&mut app, vec![egui::Event::PointerMoved(from), button(from, true)]);
for i in 1..=4 {
frame(&mut app, vec![egui::Event::PointerMoved(from + (to - from) * i as f32 / 4.0)]);
}
frame(&mut app, vec![button(to, false)]);
frame(&mut app, vec![]);
let axes = |app: &VectorcraftApp| shared(app, |s| s.font_axes.clone()).unwrap();
assert_eq!(axes(&app).into_iter().collect::<Vec<_>>(), [("wght".to_string(), 700.0)]);
app.run("edit.undo", json!({})).unwrap();
assert!(axes(&app).is_empty(), "one undo step");
// Regular and Bold selected: the weights differ and the field is blank; the widths agree.
let bold = app.run("text.create", json!({"x": 20, "y": 160, "text": "lIn", "font": TWO_AXIS_FAMILY, "style": "Bold"})).unwrap()["id"].clone();
app.run("select.set", json!({"ids": [id, bold]})).unwrap();
let text = crate::tests_labels::painted_text(&mut app, |app, ui| axis_sliders(app, ui, &face));
assert!(!text.contains("400") && !text.contains("700") && text.contains("100"), "{text}");
// Reset Panel leaves the settings, as it leaves the font and style.
app.run("select.set", json!({"ids": [id]})).unwrap();
app.run("text.setFormat", json!({"fontAxes": {"wght": 650}})).unwrap();
reset_panel(&mut app);
assert_eq!(axes(&app).into_iter().collect::<Vec<_>>(), [("wght".to_string(), 650.0)]);
}
/// Bold and Italic pick the family's own faces by name, and toggle back (#724).
#[test]
fn bold_and_italic_pick_the_familys_own_faces() {
+24 -1
View File
@@ -3550,6 +3550,27 @@ not kerned). Off by default and saved only when on. One undo step.
{"name":"run_command","arguments":{"command":"text.setFormat","params":{"proportionalMetrics":true}}}
```
## Variable fonts
`text.setFormat {fontAxes: {"wght": 650}}` (the selected type, or `ids`) and `text.setRangeStyle {id, start, end,
fontAxes}` (a range) set variation axes of a variable font, as the Character panel's Variable Font sliders do: each tag
named takes the value given, in the axis's own units and clamped to its range, in place of the style's setting of that
axis, and `null` sets it back to the style's setting. The other axes keep their settings. A tag the font has no axis for is
left out, and a value equal to the style's setting is not kept; for a font that isn't installed the values are kept as
given. Layout, outlines, the substituted-glyph highlight and PDF export use the font instanced at the settings; SVG export
writes them as `font-variation-settings` (and the `wght` setting as the weight, the `wdth` setting as `font-stretch`) and
embeds the font instanced there, with a warning when embedded faces of one weight, style and width share characters; SVG
import reads `font-variation-settings` back. Another font or style (`text.setStyle {font, style}`, `text.setRangeStyle
{font, style}`, `text.replaceFont`, and a character style applied or redefined with another font unless it sets them)
clears the settings, and Missing Glyph Protection keeps them on the characters that keep their font. They are saved as
`fontAxes` only when set, and a character style captured from type (`charStyle.new`, `charStyle.redefine`) sets them
explicitly, `{}` for none. Each command is one undo step.
```json
{"name":"run_command","arguments":{"command":"text.fontList","params":{"family":"Bahnschrift","style":"Regular"}}}
{"name":"run_command","arguments":{"command":"text.setFormat","params":{"fontAxes":{"wght":650,"wdth":87.5}}}}
```
## Digits
`text.setFormat {digits: "default"|"arabic"|"hindi"|"farsi"}` (the selected type, or `ids`) and `text.setRangeStyle {id,
@@ -3720,7 +3741,9 @@ lists every family available, the installed ones included, each by its English (
`text.setStyle {font}` and documents use — without the system's hidden families, whose names start with "."
(macOS's ".SF NS", ".LastResort"), which still resolve when a document names them. Preferences › Type › Show Font
Names in English (`fontNamesInEnglish`) only changes the UI menu labels, not this list. With `family` it gives that
family's styles (upright by weight, then italics) and fails when the family isn't available. `text.rescanFonts`
family's styles (upright by weight, then italics) and fails when the family isn't available; `axes` lists a variable
font's variation axes (`tag`, `name`, `min`, `default`, `max`, `hidden`, and `value`, the setting of `style`, else of the
first style), empty for a static font. `text.rescanFonts`
(Character panel menu › Refresh Font List) scans the font folders again, for fonts installed or removed since the app
started: type set in a font that became available redraws in it, and type set in a family that was removed redraws in
the fallback font and is listed as missing, without editing the document. The app does so by
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -31,7 +31,7 @@ against Illustrator's public documentation; **estimated** unless noted.
| Font menu: samples, live preview, filters, favourites; font matching by any name; Find Font | done | #339; script filter: Japanese, Latin, Thai; the font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux) |
| Missing glyphs set in an installed font made for their script (Thai, Lao, Khmer, Myanmar first; then any font that has them) | done | Thai: Leelawadee UI, Tahoma, Thonburi, Sukhumvit Set, Noto Sans Thai, TLWG |
| Full font-name matching (OpenType name ID 4) | done | #1258: loaded/cataloged faces and Missing Fonts searches; localized names and variable default faces; Windows font-service QA pending |
| Variable fonts | partial | named instances as styles; axis sliders missing |
| Variable fonts | partial | named instances as styles; Character panel › Variable Font sets each axis (`fontAxes`); the font menu's Variable filter is missing |
| Colour fonts (OpenType SVG / COLR) | partial | |
| Every-line and Single-line composers | done | tuned by eye, never compared side by side |
| Optical Margin Alignment | done | Type › Optical Margin Alignment, `type.opticalMarginAlignment`: per type object, in point and area type of left-to-right paragraphs; type on a path and right-to-left paragraphs do not hang |