diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index b80f0f687..b0e29883e 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -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, + /// 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, /// 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, diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index c51728ef2..4e871482f 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -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 } diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs index bab4ab82c..1d4785b55 100644 --- a/crates/engine/src/cmd/fonts.rs +++ b/crates/engine/src/cmd/fonts.rs @@ -47,7 +47,7 @@ pub fn specs() -> Vec { "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 { .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 { } // 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 = 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() })), } diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index b70da25f6..34576b927 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -43,7 +43,7 @@ pub fn specs() -> Vec { "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 { "charAlign", "proportionalMetrics", "digits", + "fontAxes", "burasagari", "composer", ]; @@ -293,6 +294,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { 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 { 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 { diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 345151ab4..2ef5451f1 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -27,7 +27,7 @@ pub fn specs() -> Vec { "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, proportional_metrics: Option, digits: Option, + font_axes: Option, } /// `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>; + +/// `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> { + 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>> { 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) { fn chars(runs: &[TextRun]) -> impl Iterator { runs.iter() @@ -503,7 +565,8 @@ pub(crate) fn protect_missing_glyphs(before: &[TextRun], runs: &mut Vec .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 } 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 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); }); } } diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index e025c1adf..a5f6361db 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -141,6 +141,7 @@ pub fn char_attrs(v: &CharStyle) -> Map { 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( 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) -> 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 { let mut v = vec![]; @@ -293,7 +309,7 @@ fn update_users(d: &mut Document, kind: Kind, name: &str, old: &Map { 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 { // Characters: Normal then the style over the defaults (clear) or over the current attributes. let char_fn = |cur: &CharStyle| -> Result { 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"); diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 56f2f4747..7217f705f 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -577,9 +577,10 @@ fn set_style(s: &mut Session, p: &Value) -> Result { 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 { 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::>(); + 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; diff --git a/crates/engine/src/tests_prefs.rs b/crates/engine/src/tests_prefs.rs index e4b0645ec..c831f3b05 100644 --- a/crates/engine/src/tests_prefs.rs +++ b/crates/engine/src/tests_prefs.rs @@ -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 diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 6047126e0..caa518e55 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -368,6 +368,7 @@ fn arb_svg_attr() -> impl Strategy { "kerning", "writing-mode", "font-weight", + "font-variation-settings", "text-anchor", "side", "path", @@ -393,6 +394,7 @@ fn arb_svg_attr() -> impl Strategy { "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 { 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::>().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::>().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"]) diff --git a/crates/eps/src/legacy_ai.rs b/crates/eps/src/legacy_ai.rs index b5e0c126f..43ef9c06b 100644 --- a/crates/eps/src/legacy_ai.rs +++ b/crates/eps/src/legacy_ai.rs @@ -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)) diff --git a/crates/eps/src/tests_legacy_ai.rs b/crates/eps/src/tests_legacy_ai.rs index 44f533fb4..78ee7ddbd 100644 --- a/crates/eps/src/tests_legacy_ai.rs +++ b/crates/eps/src/tests_legacy_ai.rs @@ -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}; diff --git a/crates/pdf/src/tests_realtext.rs b/crates/pdf/src/tests_realtext.rs index 943661b2e..45ae74830 100644 --- a/crates/pdf/src/tests_realtext.rs +++ b/crates/pdf/src/tests_realtext.rs @@ -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()))); diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index 5039995f1..374674329 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -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 } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 80780fff9..6c2317fd7 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -2301,9 +2301,10 @@ fn text_geom_snapped(t: &TextObject, snap: Option) -> 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), diff --git a/crates/render/src/tests_fontchange.rs b/crates/render/src/tests_fontchange.rs index 8732e7ad3..70b88234c 100644 --- a/crates/render/src/tests_fontchange.rs +++ b/crates/render/src/tests_fontchange.rs @@ -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] diff --git a/crates/svg/src/css.rs b/crates/svg/src/css.rs index e835f4def..802c19ffe 100644 --- a/crates/svg/src/css.rs +++ b/crates/svg/src/css.rs @@ -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 { + 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 = + 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 } diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 609848aa9..c16d47308 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -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, face: Arc, chars: BTreeSet, } @@ -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 { let uses = std::mem::take(&mut self.fonts); let mut rules = Vec::new(); + fn described(u: &FontUse) -> (&str, u16, bool, Option) { + (&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 = 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 ``). 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", diff --git a/crates/svg/src/import/text.rs b/crates/svg/src/import/text.rs index 8ddd0f052..a922a70e3 100644 --- a/crates/svg/src/import/text.rs +++ b/crates/svg/src/import/text.rs @@ -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> { + 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::().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"); diff --git a/crates/svg/tests/encoding_profile_fonts.rs b/crates/svg/tests/encoding_profile_fonts.rs index c805548cc..722e90b30 100644 --- a/crates/svg/tests/encoding_profile_fonts.rs +++ b/crates/svg/tests/encoding_profile_fonts.rs @@ -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::>(); + 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::>(), [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 { diff --git a/crates/svg/tests/import_text.rs b/crates/svg/tests/import_text.rs index 4d862b58b..a65a33c69 100644 --- a/crates/svg/tests/import_text.rs +++ b/crates/svg/tests/import_text.rs @@ -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#"abcdefg"#, + ); + 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::>(); + 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( diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index c37ca8e9f..a119fd323 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -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, + /// What each id is: the face it was instanced from and its axis settings. + recipes: HashMap)>, + /// The instanced faces built, by id; dropped all at once when [`MAX_LIVE_INSTANCES`] are held. + live: HashMap>, +} + +/// 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>), @@ -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 { + 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 { + 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>, + /// Faces instanced at the axis settings of type's Variable Font sliders ([`FontDb::instance`]). + instances: Mutex, } /// 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, 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, id: u32) -> Option { + 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) -> Option { 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> { - 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> { + 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, axes: &std::collections::BTreeMap) -> Arc { + 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::>()); + 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> { + 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 diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index b0f3de266..97c08ed05 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -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], 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 { diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 70e210ed3..d690a01a3 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -525,6 +525,8 @@ pub fn logical_order(visual: &str) -> Option<(Vec, bool)> { #[cfg(test)] mod tests; #[cfg(test)] +mod tests_axes; +#[cfg(test)] mod tests_bidi; #[cfg(test)] mod tests_combos; diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index 6f973a0e9..2e324c2bf 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -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 diff --git a/crates/text/src/test_fonts.rs b/crates/text/src/test_fonts.rs index 80f074301..1f271d3e7 100644 --- a/crates/text/src/test_fonts.rs +++ b/crates/text/src/test_fonts.rs @@ -91,6 +91,85 @@ pub fn variable_font() -> Option> { 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> { + 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 = 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 = 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> { let mut records = records.to_vec(); diff --git a/crates/text/src/tests_axes.rs b/crates/text/src/tests_axes.rs new file mode 100644 index 000000000..8273cd8dd --- /dev/null +++ b/crates/text/src/tests_axes.rs @@ -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 { + 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, 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::>() + }; + 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}"); +} diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 193832b12..2ad5d60b4 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -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] diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index e71cd258e..c9a29ed58 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1133,6 +1133,7 @@ Font List Списък с шрифтове Font Preview Size Размер за преглед на шрифта Font Size Размер на шрифта + Variable Font Променлив шрифт Font: Шрифт: Fonts Шрифтове Fonts in Document Шрифтове в документа diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 58c5877af..b1726ccae 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 9cf789ac5..6b28a1974 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 3fe4c7acf..c990a0ef6 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 8784f0e01..e772083c8 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1099,6 +1099,7 @@ Font List Λίστα γραμματοσειρών Font Preview Size Μέγεθος προεπισκόπησης γραμματοσειράς Font Size Μέγεθος γραμματοσειράς + Variable Font Μεταβλητή γραμματοσειρά Font: Γραμματοσειρά: Fonts Γραμματοσειρές Fonts in Document Γραμματοσειρές στο έγγραφο diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 45c28febb..bdcb57b4f 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 81aa0e5aa..9edb45382 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index dd4263fd8..5d9b7213f 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 38c20cab4..9417656e9 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 201c96ba7..d0a831b6d 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 0fe7f7209..53e391866 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 962caa0a0..de9fbf4a6 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -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 同じレイヤー上のすべて diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 4becd3292..5c5d7b821 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -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 같은 레이어의 모두 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index fca56ff67..297be4d13 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 00e128a37..2dbf04b2c 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 42db059fb..2aaab4c6e 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index f3172f779..11531fd84 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1165,6 +1165,7 @@ file-filter Templates Шаблоны Font List Список шрифтов Font Preview Size Размер предварительного просмотра шрифта Font Size Размер шрифта + Variable Font Вариативный шрифт Font: Шрифт: Fonts Шрифты Fonts in Document Шрифты в документе diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 9c9249909..6808e6408 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index d63b0b27e..1ed86261c 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1161,6 +1161,7 @@ file-filter Templates Шаблони Font List Список шрифтів Font Preview Size Розмір попереднього перегляду шрифту Font Size Розмір шрифту + Variable Font Варіативний шрифт Font: Шрифт: Fonts Шрифти Fonts in Document Шрифти у документі diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 88dbab7b6..da42688b5 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index bfc4ad1d4..7b6559739 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1114,6 +1114,7 @@ file-filter Templates 模板 Font List 字体列表 Font Preview Size 字体预览大小 Font Size 字号 + Variable Font 可变字体 Font: 字体: Fonts 字体 Fonts in Document 文档中的字体 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 21a188c4f..e49894894 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1138,6 +1138,7 @@ file-filter Templates 範本 Font List 字體清單 Font Preview Size 字體預覽大小 Font Size 字體大小 + Variable Font 可變字體 Font: 字體: Fonts 字體 Fonts in Document 文件中的字體 diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index 390c89491..fdad86744 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -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::(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| { + 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::>(), [("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::>(), [("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() { diff --git a/docs/mcp.md b/docs/mcp.md index cde63dac5..97ba33286 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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 diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 011028f06..95d0fefd5 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -588,13 +588,13 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Nearly unchanged appearance is expected when reducing points. Percentage/auto-simplify controls and Retain my latest settings are not implemented; the checklist is partial, without raising scores. Simplify and Add Anchor Points record no undo step when they change no path, and a path they leave as it is keeps its live shape. The Simplify dialog also has Corner Point Angle Threshold, Convert to Straight Lines and Show Original Path. The threshold is a slider from 0° to 180° and opens at 150°: an anchor whose corner is sharper than the threshold stays a corner, so moving the slider right keeps more corners, and the dialog sends `cornerAngle` as 180° less the threshold. While Preview is on, Show Original Path draws the paths as they were before the preview in a wide, faint band of their layer's color under the selection's outlines. -- **Type:** Character size, leading, baseline shift and horizontal-scale controls account for object/group transforms (#1034): exact document-point sizes, mixed-value display and keyboard increments, range edits, resettable horizontal stretch, undo/redo and native save/reopen (`tests_typescale`, Character panel tests). Point size is the transformed em-height axis, independent of glyph ink bounds; affine rotation, reflection and shear are retained. Group scaling still scales text; preserving font sizes is a separate enhancement. Behaviour reference: [Transform text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/transform-text.html) (public documentation). Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Cmd+Shift+C (Align center) works on Windows and Linux too (#1940). Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Arabic typefaces: builds made with a craft-fonts checkout that holds its Arabic families (Noto Sans Arabic, the Arabic fallback, plus Cairo, Tajawal, Almarai, IBM Plex Sans Arabic, Noto Kufi Arabic, Amiri, Alexandria, Fustat, Reem Kufi, Aref Ruqaa and Gulzar once craft-fonts adds them, storytold/craft-fonts#11; releases get them when their craft-fonts pin moves) list them in the font menus on desktop; the web build fetches them from beside the wasm (Noto Sans Arabic before start, so Arabic never shows as boxes, the rest in the background). Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458). Type › Bold and Italic (beyond Illustrator, as page layout apps have them; Cmd+Shift+B / Cmd+Shift+I while the Type tool edits text) switch the selected type to its family's own Bold or Italic face and back, keeping its width (a Narrow face stays Narrow); a family without that face says so (#724). The commands that make or restyle type (and the MCP `add_text` tool) say in their result when area type does not fit its frame, and `add_text` takes Auto Size and Shrink Text to Fit (#1500, fixes #1471). A tab stop in the Tabs panel drags from where the press landed, a stop just added included (#1487, fixes #1458). Area type in a non-rectangular shape moves a word too wide for the top lines down to the first line that holds it instead of breaking it (#1617, fixes #1615). Stylistic sets (ss01–ss20), stylistic and titling alternates, proportional and lining figures, and superscript and subscript glyphs can be turned on as OpenType features of a character style (`text.setStyle {features}`), and the OpenType panel keeps them (#1611, fixes #1607). Escape while typing ends the edit, keeps the type selected and switches to the Selection tool (#1802, #1686, #1656). Select › Same matches type on its characters' fill and stroke (#1746, #1737). `text.create`, `text.createInPath` and the MCP `add_text` tool take `justify`, so point type can be centred on or end at its point (#1752, #1748). -- **Type:** Character size, leading, baseline shift and horizontal-scale controls account for object/group transforms (#1034): exact document-point sizes, mixed-value display and keyboard increments, range edits, resettable horizontal stretch, undo/redo and native save/reopen (`tests_typescale`, Character panel tests). Point size is the transformed em-height axis, independent of glyph ink bounds; affine rotation, reflection and shear are retained. Group scaling still scales text; preserving font sizes is a separate enhancement. Behaviour reference: [Transform text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/transform-text.html) (public documentation). Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. A double-click on area type's bottom middle handle turns on Auto Size (#1940). Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Arabic typefaces: builds made with a craft-fonts checkout that holds its Arabic families (Noto Sans Arabic, the Arabic fallback, plus Cairo, Tajawal, Almarai, IBM Plex Sans Arabic, Noto Kufi Arabic, Amiri, Alexandria, Fustat, Reem Kufi, Aref Ruqaa and Gulzar once craft-fonts adds them, storytold/craft-fonts#11; releases get them when their craft-fonts pin moves) list them in the font menus on desktop; the web build fetches them from beside the wasm (Noto Sans Arabic before start, so Arabic never shows as boxes, the rest in the background). Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458). Type › Bold and Italic (beyond Illustrator, as page layout apps have them; Cmd+Shift+B / Cmd+Shift+I while the Type tool edits text) switch the selected type to its family's own Bold or Italic face and back, keeping its width (a Narrow face stays Narrow); a family without that face says so (#724). The commands that make or restyle type (and the MCP `add_text` tool) say in their result when area type does not fit its frame, and `add_text` takes Auto Size and Shrink Text to Fit (#1500, fixes #1471). A tab stop in the Tabs panel drags from where the press landed, a stop just added included (#1487, fixes #1458). Area type in a non-rectangular shape moves a word too wide for the top lines down to the first line that holds it instead of breaking it (#1617, fixes #1615). Stylistic sets (ss01–ss20), stylistic and titling alternates, proportional and lining figures, and superscript and subscript glyphs can be turned on as OpenType features of a character style (`text.setStyle {features}`), and the OpenType panel keeps them (#1611, fixes #1607). Escape while typing ends the edit, keeps the type selected and switches to the Selection tool (#1802, #1686, #1656). Select › Same matches type on its characters' fill and stroke (#1746, #1737). `text.create`, `text.createInPath` and the MCP `add_text` tool take `justify`, so point type can be centred on or end at its point (#1752, #1748). +- **Type:** Character size, leading, baseline shift and horizontal-scale controls account for object/group transforms (#1034): exact document-point sizes, mixed-value display and keyboard increments, range edits, resettable horizontal stretch, undo/redo and native save/reopen (`tests_typescale`, Character panel tests). Point size is the transformed em-height axis, independent of glyph ink bounds; affine rotation, reflection and shear are retained. Group scaling still scales text; preserving font sizes is a separate enhancement. Behaviour reference: [Transform text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/transform-text.html) (public documentation). Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Cmd+Shift+C (Align center) works on Windows and Linux too (#1940). Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. The Character panel's Variable Font sliders set each axis (see Fonts). Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Arabic typefaces: builds made with a craft-fonts checkout that holds its Arabic families (Noto Sans Arabic, the Arabic fallback, plus Cairo, Tajawal, Almarai, IBM Plex Sans Arabic, Noto Kufi Arabic, Amiri, Alexandria, Fustat, Reem Kufi, Aref Ruqaa and Gulzar once craft-fonts adds them, storytold/craft-fonts#11; releases get them when their craft-fonts pin moves) list them in the font menus on desktop; the web build fetches them from beside the wasm (Noto Sans Arabic before start, so Arabic never shows as boxes, the rest in the background). Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458). Type › Bold and Italic (beyond Illustrator, as page layout apps have them; Cmd+Shift+B / Cmd+Shift+I while the Type tool edits text) switch the selected type to its family's own Bold or Italic face and back, keeping its width (a Narrow face stays Narrow); a family without that face says so (#724). The commands that make or restyle type (and the MCP `add_text` tool) say in their result when area type does not fit its frame, and `add_text` takes Auto Size and Shrink Text to Fit (#1500, fixes #1471). A tab stop in the Tabs panel drags from where the press landed, a stop just added included (#1487, fixes #1458). Area type in a non-rectangular shape moves a word too wide for the top lines down to the first line that holds it instead of breaking it (#1617, fixes #1615). Stylistic sets (ss01–ss20), stylistic and titling alternates, proportional and lining figures, and superscript and subscript glyphs can be turned on as OpenType features of a character style (`text.setStyle {features}`), and the OpenType panel keeps them (#1611, fixes #1607). Escape while typing ends the edit, keeps the type selected and switches to the Selection tool (#1802, #1686, #1656). Select › Same matches type on its characters' fill and stroke (#1746, #1737). `text.create`, `text.createInPath` and the MCP `add_text` tool take `justify`, so point type can be centred on or end at its point (#1752, #1748). +- **Type:** Character size, leading, baseline shift and horizontal-scale controls account for object/group transforms (#1034): exact document-point sizes, mixed-value display and keyboard increments, range edits, resettable horizontal stretch, undo/redo and native save/reopen (`tests_typescale`, Character panel tests). Point size is the transformed em-height axis, independent of glyph ink bounds; affine rotation, reflection and shear are retained. Group scaling still scales text; preserving font sizes is a separate enhancement. Behaviour reference: [Transform text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/transform-text.html) (public documentation). Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. A double-click on area type's bottom middle handle turns on Auto Size (#1940). Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. The Character panel's Variable Font sliders set each axis (see Fonts). Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Arabic typefaces: builds made with a craft-fonts checkout that holds its Arabic families (Noto Sans Arabic, the Arabic fallback, plus Cairo, Tajawal, Almarai, IBM Plex Sans Arabic, Noto Kufi Arabic, Amiri, Alexandria, Fustat, Reem Kufi, Aref Ruqaa and Gulzar once craft-fonts adds them, storytold/craft-fonts#11; releases get them when their craft-fonts pin moves) list them in the font menus on desktop; the web build fetches them from beside the wasm (Noto Sans Arabic before start, so Arabic never shows as boxes, the rest in the background). Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458). Type › Bold and Italic (beyond Illustrator, as page layout apps have them; Cmd+Shift+B / Cmd+Shift+I while the Type tool edits text) switch the selected type to its family's own Bold or Italic face and back, keeping its width (a Narrow face stays Narrow); a family without that face says so (#724). The commands that make or restyle type (and the MCP `add_text` tool) say in their result when area type does not fit its frame, and `add_text` takes Auto Size and Shrink Text to Fit (#1500, fixes #1471). A tab stop in the Tabs panel drags from where the press landed, a stop just added included (#1487, fixes #1458). Area type in a non-rectangular shape moves a word too wide for the top lines down to the first line that holds it instead of breaking it (#1617, fixes #1615). Stylistic sets (ss01–ss20), stylistic and titling alternates, proportional and lining figures, and superscript and subscript glyphs can be turned on as OpenType features of a character style (`text.setStyle {features}`), and the OpenType panel keeps them (#1611, fixes #1607). Escape while typing ends the edit, keeps the type selected and switches to the Selection tool (#1802, #1686, #1656). Select › Same matches type on its characters' fill and stroke (#1746, #1737). `text.create`, `text.createInPath` and the MCP `add_text` tool take `justify`, so point type can be centred on or end at its point (#1752, #1748). - **Devanagari shaping:** with an OpenType Devanagari font, Ka, virama and Ssa (क्ष) form one conjunct glyph, Ra and virama before a consonant (र्क) become a reph drawn after it, the i matra (कि) is drawn before its consonant, and Ka with a nukta is drawn like the precomposed क़ (U+0958), as [Microsoft's Devanagari shaping specification](https://learn.microsoft.com/en-us/typography/script-development/devanagari) describes. Each syllable is one cluster of its source bytes, and the arrow keys stop at its ends. A click inside a syllable drawn with several glyphs puts the caret at the syllable's nearer end, in point, area and path type. Tests: `crates/text/src/tests_devanagari.rs`; they print SKIPPED when no such font is installed. - **Vertical punctuation:** fullwidth `!` (U+FF01) and `?` (U+FF1F) use their nominal glyph upright when no vertical alternate is present, following the Tu fallback in [Unicode UAX #50 §3.1](https://www.unicode.org/reports/tr50/#vo). Code-built test fonts cover point and area type with and without `vert` alternates, retain the rotated fallback for a bracket and long vowel mark, and leave horizontal type unchanged. Tests: `crates/text/src/tests_vertical.rs`. - Free Distort's dialog (Effect › Distort & Transform › Free Distort…) shows a box in the proportions of the first selected object's bounds with a handle on each corner. Dragging a handle moves that corner. A handle focused by a click or Tab moves 1% of the box with each arrow key, and 10% with Shift. Reset puts the four corners back on the box. The preview and OK run `effect.apply`, or `effect.setParams` when the dialog edits an applied effect, and the Appearance panel's inline editor opens the dialog with Edit…. Behavior reference: Illustrator CC Help (2014, [PDF](https://help.adobe.com/archive/en/illustrator/cc/2014/illustrator_reference.pdf)), page 285 (PDF page 291). - **Kerning across style runs:** characters that differ only in fill, stroke, underline, strikethrough, overprint, character style name, leading, baseline shift, rotation, Character Alignment, kerning, or tracking that keeps or drops ligatures alike are shaped together, so restyling one character keeps the font's kerning pairs with its neighbors. A character with manual kerning that follows one with automatic kerning keeps the font's kerning with it (unless All Caps or Small Caps sets it as two letters, as ß becomes SS), and its value replaces the font's kerning after it. A ligature does not form across a change of style, and a change of font, size or OpenType features still shapes the characters apart. Behavior reference: [Line and character spacing](https://web.archive.org/web/20260916050333/https://helpx.adobe.com/illustrator/using/line-character-spacing.html) (public documentation). Tests: `crates/text/src/tests_typo.rs`, `crates/text/src/tests_bidi.rs`. -- **Type:** Character size, leading, baseline shift and horizontal-scale controls account for object/group transforms (#1034): exact document-point sizes, mixed-value display and keyboard increments, range edits, resettable horizontal stretch, undo/redo and native save/reopen (`tests_typescale`, Character panel tests). Point size is the transformed em-height axis, independent of glyph ink bounds; affine rotation, reflection and shear are retained. Group scaling still scales text; preserving font sizes is a separate enhancement. Behaviour reference: [Transform text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/transform-text.html) (public documentation). Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Arabic typefaces: builds made with a craft-fonts checkout that holds its Arabic families (Noto Sans Arabic, the Arabic fallback, plus Cairo, Tajawal, Almarai, IBM Plex Sans Arabic, Noto Kufi Arabic, Amiri, Alexandria, Fustat, Reem Kufi, Aref Ruqaa and Gulzar once craft-fonts adds them, storytold/craft-fonts#11; releases get them when their craft-fonts pin moves) list them in the font menus on desktop; the web build fetches them from beside the wasm (Noto Sans Arabic before start, so Arabic never shows as boxes, the rest in the background). Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458). Type › Bold and Italic (beyond Illustrator, as page layout apps have them; Cmd+Shift+B / Cmd+Shift+I while the Type tool edits text) switch the selected type to its family's own Bold or Italic face and back, keeping its width (a Narrow face stays Narrow); a family without that face says so (#724). The commands that make or restyle type (and the MCP `add_text` tool) say in their result when area type does not fit its frame, and `add_text` takes Auto Size and Shrink Text to Fit (#1500, fixes #1471). Print and Print Preview warn about the area type and type on a path that prints without the text that doesn't fit, and give their ids. A tab stop in the Tabs panel drags from where the press landed, a stop just added included (#1487, fixes #1458). Area type in a non-rectangular shape moves a word too wide for the top lines down to the first line that holds it instead of breaking it (#1617, fixes #1615). Stylistic sets (ss01–ss20), stylistic and titling alternates, proportional and lining figures, and superscript and subscript glyphs can be turned on as OpenType features of a character style (`text.setStyle {features}`), and the OpenType panel keeps them (#1611, fixes #1607). Escape while typing ends the edit, keeps the type selected and switches to the Selection tool (#1802, #1686, #1656). Select › Same matches type on its characters' fill and stroke (#1746, #1737). `text.create`, `text.createInPath` and the MCP `add_text` tool take `justify`, so point type can be centred on or end at its point (#1752, #1748). +- **Type:** Character size, leading, baseline shift and horizontal-scale controls account for object/group transforms (#1034): exact document-point sizes, mixed-value display and keyboard increments, range edits, resettable horizontal stretch, undo/redo and native save/reopen (`tests_typescale`, Character panel tests). Point size is the transformed em-height axis, independent of glyph ink bounds; affine rotation, reflection and shear are retained. Group scaling still scales text; preserving font sizes is a separate enhancement. Behaviour reference: [Transform text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/transform-text.html) (public documentation). Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. The Character panel's Variable Font sliders set each axis (see Fonts). Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Arabic typefaces: builds made with a craft-fonts checkout that holds its Arabic families (Noto Sans Arabic, the Arabic fallback, plus Cairo, Tajawal, Almarai, IBM Plex Sans Arabic, Noto Kufi Arabic, Amiri, Alexandria, Fustat, Reem Kufi, Aref Ruqaa and Gulzar once craft-fonts adds them, storytold/craft-fonts#11; releases get them when their craft-fonts pin moves) list them in the font menus on desktop; the web build fetches them from beside the wasm (Noto Sans Arabic before start, so Arabic never shows as boxes, the rest in the background). Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458). Type › Bold and Italic (beyond Illustrator, as page layout apps have them; Cmd+Shift+B / Cmd+Shift+I while the Type tool edits text) switch the selected type to its family's own Bold or Italic face and back, keeping its width (a Narrow face stays Narrow); a family without that face says so (#724). The commands that make or restyle type (and the MCP `add_text` tool) say in their result when area type does not fit its frame, and `add_text` takes Auto Size and Shrink Text to Fit (#1500, fixes #1471). Print and Print Preview warn about the area type and type on a path that prints without the text that doesn't fit, and give their ids. A tab stop in the Tabs panel drags from where the press landed, a stop just added included (#1487, fixes #1458). Area type in a non-rectangular shape moves a word too wide for the top lines down to the first line that holds it instead of breaking it (#1617, fixes #1615). Stylistic sets (ss01–ss20), stylistic and titling alternates, proportional and lining figures, and superscript and subscript glyphs can be turned on as OpenType features of a character style (`text.setStyle {features}`), and the OpenType panel keeps them (#1611, fixes #1607). Escape while typing ends the edit, keeps the type selected and switches to the Selection tool (#1802, #1686, #1656). Select › Same matches type on its characters' fill and stroke (#1746, #1737). `text.create`, `text.createInPath` and the MCP `add_text` tool take `justify`, so point type can be centred on or end at its point (#1752, #1748). The Character panel's Small Caps, Superscript and Subscript buttons and the panel menu's items of the same names turn small caps and the superscript or subscript position on and off for the selected type, or for the text the Type tool has selected (`text.setFormat` or `text.setRangeStyle` `{smallCaps, position}`), in Document Setup's proportions. Turning Superscript on turns Subscript off, and turning Subscript on turns Superscript off. A button or item shows as on when the first selected character (at a caret, the character before it) has its attribute. - **Paragraph alignment:** the shared Paragraph/Properties alignment row leaves every button unselected when the paragraphs it acts on differ. Auto resolves per paragraph; choosing an alignment uses the existing command. Headless paragraph_mixed_alignment_* regressions exercise mixed → Center → Undo, caret/uniform states, Unicode range boundaries, Auto/direction, split runs and grouped text. These regressions and full integration CI pass. Original captures confirm mixed → Center → Undo in the existing alignment-button layout. The [public Paragraph panel guide](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/paragraph-panel-overview.html) lists the seven controls, but does not specify mixed-state rendering. Other paragraph fields remain outside this change; scores and hours unchanged. For Auto Size area type in columns, the bisection for the frame's height runs on the lines of the one-column layout, and its result is checked with at most two layouts of the columns. When the text wraps around objects or does not fit the one-column layout, or when the check fails, the columns are laid out at every step of the bisection, as before. Typing at the start of a story of 50,000 characters in 2 columns took 1,670 ms a keystroke and now takes 292 ms (release build, `auto_height_in_columns_typing_timing`). @@ -655,6 +655,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Create Object Mosaic (its columns and rows), Transform Each (its reference point and Random seed) and Rearrange All Artboards (its columns) refuse a fraction, a negative number or a string, such as 2.5, -1 or "4"; 4.0 counts as 4. Change Case, Smart Punctuation, Find and Replace, Convert To Area Type, Convert To Point Type, Type on a Path Options, the Type on a Path effects and Fill with Placeholder Text record no undo step when they change nothing. - **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing. PDF and `.ai` import brings Japanese text back as live type: CID fonts without a ToUnicode map read through the standard CMaps, vertical columns (WMode 1) as vertical type, letter spacing as tracking; a glyph is live type only when the installed font (in the closest style of its family) draws the same glyph, and text in a font that isn't installed comes in as live type shown in the fallback font until the font is installed (exports that draw it — PDF, EPS, EMF/WMF, raster, SVG outlines or embedded fonts — warn that they wrote the fallback font, and Print and Print Preview warn about the type that prints in it; `textAs: outlines` keeps the PDF's embedded glyphs). Two installed versions of one family and style (a font library keeping an old file beside a new one) both load, the first staying the one the family and style resolve to; PDF and `.ai` import tries each version when the glyphs differ, and type set in a version other than that one names it (`CharStyle::font_version`), which layout, the substituted-glyph highlight, Find Font, Package and Document Info follow. Fonts outside the font folders are listed too: those font services such as Adobe Fonts load on Windows, through DirectWrite, and those apps and font managers register with CoreText on macOS (#579). When a document opens with fonts that aren't available, fonts used only in symbols included, a Missing Fonts dialog lists them after the linked-file questions, with Find Fonts (Type › Find Font on them) and Close. Its Find in Folder… (in Find Font too) searches a folder on several threads, reads only what the font scan reads of font files and skips app packages, the Program Files and ProgramData folders, the system's folders and the app data folders in a home folder; Add Fonts copies the font files the user chooses, suitcase fonts with their resource fork, into VectorCraft's own Fonts folder, which the desktop app and `vectorcraft-cli` read (`text.missingFonts`, `text.findFontFiles`, `text.addFontFiles`). 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). + A variable font's type gets one slider and field per variation axis under the Character panel's Variable Font button, each named as the font names the axis (Weight, Width), so the type can be set between or past its named instances: the settings stay with the characters (`CharStyle::font_axes`, `text.setFormat {fontAxes}` and `text.setRangeStyle {fontAxes}`, clamped to each axis's range; `text.fontList {family, style}` lists the axes), and layout, outlines, the substituted-glyph highlight and PDF and SVG export (`font-variation-settings`, embedded fonts) use the font instanced at them. Another font or style clears them; legacy `.ai` export writes the style and warns that they are left out. The font database looks up a name that is neither a loaded nor an installed family among the other names of the loaded and installed fonts once for each style it is asked in, and keeps the answer until fonts are added or rescanned. Laying out a story of 1,000 paragraphs set in a font that is not installed, with 400 more fonts loaded, takes a median of 56 to 71 ms instead of 409 to 507 ms on Windows 11 (`CRAFT_FONTS_DIR= cargo test --release -p vectorcraft-text --lib missing_font_story_layout_timing -- --ignored --nocapture`). A font family removed while the app runs leaves the font menus when the app scans the font folders again (when its window comes to the front after a font folder changed, or Refresh Font List), also when type had used it: that type then draws in the fallback font and Find Font lists the font as missing, until the font is installed again and the folders are scanned. - **Files (M4):** diff --git a/docs/type-parity.md b/docs/type-parity.md index 9d85181b6..07bf72659 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -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 |