mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-28 11:47:21 -06:00
Character: variable fonts get a slider for each axis
VectorCraft listed a variable font's named instances as styles, but
type could not be set between or past them. The reference app's
Character panel has a Variable Font control that adjusts a variable
font's axes, such as Weight, Width and Slant.
Type now keeps Variable Font settings by axis tag
(CharStyle::font_axes, saved as fontAxes only when set). text.setFormat
and text.setRangeStyle take fontAxes: each value is clamped to its
axis's range, a tag the font has no axis for is left out, and null
goes back to the style's own setting. Another font or style clears
them, including one from a character style that does not set them. A
character style captured from type sets them, with {} for none.
text.fontList {family, style} lists a font's axes.
Layout, outlines, the substituted-glyph highlight and PDF export use
the font instanced at the settings, and neighboring runs at different
settings are shaped apart, as runs in different fonts are. SVG export
writes them as font-variation-settings, the wght setting as the weight
and the wdth setting as font-stretch, and embeds the font instanced
there; SVG import reads font-variation-settings back. Legacy .ai export
warns that the settings are left out.
The Character panel shows a Variable Font button for a variable font.
It opens a slider and a field for each axis the font does not hide,
labeled with the font's name for the axis, and screen readers get each
slider under that name. A drag is one undo step. Reset Panel leaves
the settings, as it leaves the font and style.
Missing Glyph Protection now keeps the settings and the font version
on the characters that keep their font; before, it kept only the
family and style. A character style applied or redefined with another
font now clears the old font's version, as text.setStyle does, unless
the style sets one. SVG export with embedded fonts warns when faces of
one weight, style and width share characters, which browsers then draw
in one of the faces.
Docs: docs/mcp.md, docs/target-app-parity.md, docs/type-parity.md.
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user