mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 10:47:11 -06:00
Merge pull request #2710 from dotCooCoo/feat/variable-font-axes
Character: variable fonts get a slider for each axis
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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() })),
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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())));
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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:"wdth" 90, "wght" 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> {
|
||||
|
||||
@@ -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, "wdth" 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
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(®ular, &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(®ular, &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(®ular, &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(®ular, &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(®ular, &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(®ular, &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}");
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -1099,6 +1099,7 @@
|
||||
Font List Λίστα γραμματοσειρών
|
||||
Font Preview Size Μέγεθος προεπισκόπησης γραμματοσειράς
|
||||
Font Size Μέγεθος γραμματοσειράς
|
||||
Variable Font Μεταβλητή γραμματοσειρά
|
||||
Font: Γραμματοσειρά:
|
||||
Fonts Γραμματοσειρές
|
||||
Fonts in Document Γραμματοσειρές στο έγγραφο
|
||||
|
||||
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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
|
||||
|
||||
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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
|
||||
|
||||
|
@@ -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.
|
@@ -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.
|
@@ -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
@@ -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
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user