mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -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:
@@ -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