From 88b5170622f21e6b76111896579a5e993cde7306 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 4 Nov 2026 22:29:53 +0700 Subject: [PATCH] Glyphs panel: alternates for the current text selection Squashed from #5442 onto main. --- crates/doc/src/lib.rs | 1 + crates/doc/src/text.rs | 2 +- crates/doc/src/text/char_style.rs | 23 ++ crates/engine/src/cmd/textedit.rs | 116 ++++++++ crates/engine/src/cmd/textstyles.rs | 13 +- crates/engine/src/cmd/textstyles/tests.rs | 46 ++- crates/engine/src/tests/textedit.rs | 184 ++++++++++++ crates/svg/src/export/text.rs | 9 +- crates/svg/tests/it/text_layout.rs | 18 ++ crates/text/src/edit.rs | 46 ++- crates/text/src/fontdb.rs | 2 + crates/text/src/fontdb/alternates.rs | 165 +++++++++++ crates/text/src/lib.rs | 4 +- crates/text/src/shape.rs | 69 ++++- crates/text/src/test_fonts.rs | 124 +++++++++ crates/text/src/tests.rs | 1 + crates/text/src/tests/alternates.rs | 176 ++++++++++++ crates/ui-egui/src/i18n/bg.tsv | 5 + crates/ui-egui/src/i18n/bs.tsv | 5 + crates/ui-egui/src/i18n/ca.tsv | 5 + crates/ui-egui/src/i18n/cs.tsv | 5 + crates/ui-egui/src/i18n/da.tsv | 5 + crates/ui-egui/src/i18n/de.tsv | 5 + crates/ui-egui/src/i18n/el.tsv | 5 + crates/ui-egui/src/i18n/es.tsv | 5 + crates/ui-egui/src/i18n/fi.tsv | 5 + crates/ui-egui/src/i18n/fr.tsv | 5 + crates/ui-egui/src/i18n/hu.tsv | 5 + crates/ui-egui/src/i18n/id.tsv | 5 + crates/ui-egui/src/i18n/it.tsv | 5 + crates/ui-egui/src/i18n/ja.tsv | 5 + crates/ui-egui/src/i18n/ka.tsv | 5 + crates/ui-egui/src/i18n/ko.tsv | 5 + crates/ui-egui/src/i18n/nl.tsv | 5 + crates/ui-egui/src/i18n/pl.tsv | 5 + crates/ui-egui/src/i18n/pt-br.tsv | 5 + crates/ui-egui/src/i18n/ru.tsv | 5 + crates/ui-egui/src/i18n/sv.tsv | 5 + crates/ui-egui/src/i18n/tr.tsv | 5 + crates/ui-egui/src/i18n/uk.tsv | 5 + crates/ui-egui/src/i18n/vi.tsv | 5 + crates/ui-egui/src/i18n/zh-hans.tsv | 5 + crates/ui-egui/src/i18n/zh-hant.tsv | 5 + crates/ui-egui/src/panels/glyphs.rs | 323 ++++++++++++++++++++-- docs/mcp.md | 27 +- docs/target-app-parity.md | 1 + docs/type-parity.md | 5 +- 47 files changed, 1433 insertions(+), 52 deletions(-) create mode 100644 crates/text/src/fontdb/alternates.rs create mode 100644 crates/text/src/tests/alternates.rs diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 133bc4120..2a51b662b 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -112,6 +112,7 @@ pub use setup::{Background, DocSetup, ExportText, GridSize, Quotes}; pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOptions, SliceSource}; pub use style_libs::StyleLibrary; pub use text::FontHeightReference; +pub use text::GlyphChoice; pub use text::{ AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, HyphenationOptions, InlineArt, JustificationOptions, Justify, Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, RubyAlign, RubyPosition, diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 064ecbda1..a2f21e856 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -16,7 +16,7 @@ mod wrap; pub use area::{AreaFit, AreaOptions, PathAlign, PathEffect, TextFlow, TextWrap, VerticalAlign, WrapShape}; use char_style::yes; -pub use char_style::{CharAlign, CharPosition, CharStyle, Digits, FontHeightReference, ScriptMetrics}; +pub use char_style::{CharAlign, CharPosition, CharStyle, Digits, FontHeightReference, GlyphChoice, ScriptMetrics}; pub use para_style::{ Burasagari, Composer, FirstBaseline, HyphenationOptions, JustificationOptions, Justify, Kashida, Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, SingleWordJustify, diff --git a/crates/doc/src/text/char_style.rs b/crates/doc/src/text/char_style.rs index 5461f005e..731480fed 100644 --- a/crates/doc/src/text/char_style.rs +++ b/crates/doc/src/text/char_style.rs @@ -147,6 +147,28 @@ pub struct CharStyle { /// document's language (Document Setup). Saved only when set. #[serde(default, skip_serializing_if = "Option::is_none")] pub language: Option, + /// Another glyph of the font for the run's first character (Glyphs panel › Show › Alternates + /// for Current Selection). The text keeps the character; layout draws the chosen glyph while + /// the run starts with that character and is set in the face the choice names, and the + /// default glyph otherwise. Saved only when set. + #[serde(default, rename = "glyphAlternate", skip_serializing_if = "Option::is_none")] + pub glyph_alternate: Option, +} + +/// A glyph chosen for one character ([`CharStyle::glyph_alternate`]): the glyph id and the face it +/// belongs to, named by its PostScript name and version string, so that a glyph id is never used +/// in another font or another version of the font. +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct GlyphChoice { + /// The character the glyph is drawn for. + pub ch: char, + /// The glyph id in the face. + pub gid: u32, + /// The face's PostScript name (`SourceSans3-Regular`). + pub font: String, + /// The face's version string (`Version 3.052`); empty when it has none. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub version: String, } /// What a character's font size measures ([`CharStyle::font_height_reference`]): the em square @@ -420,6 +442,7 @@ impl Default for CharStyle { font_height_reference: FontHeightReference::EmBox, no_break: false, language: None, + glyph_alternate: None, } } } diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 5f3d0231a..4b7703a20 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -53,6 +53,24 @@ pub fn specs() -> Vec { has_doc, touch_type ), + cmd!( + "text.setGlyphAlternate", + "Glyphs", + [], + None, + "{id, start: byte (a character of it), gid: glyph id|null} draw that one character with another glyph of its font as one undo step, what a double-click in the Glyphs panel's Show › Alternates for Current Selection does: gid is one of the glyphs text.glyphAlternates lists for it; its own default glyph, or null, puts the default glyph back. The text keeps the character, and its other attributes do not change. The choice is saved with the character's style and names the face it was made in (PostScript name and version): set in another font or version, or after the font goes missing, the character takes its default glyph; typed characters take their default glyphs. Choosing the glyph the character already draws is not an edit. An error when the character's font is not available, the glyph is not one of its alternates, or the character's style does not draw it (All Caps, Small Caps, another numeral system) → {id, start, end (the character's bytes), gid (the glyph drawn)}", + has_doc, + set_glyph_alternate + ), + cmd!( + query "text.glyphAlternates", + "Glyphs", + [], + None, + "{id, start: byte (a character of it)} the alternate glyphs of that character in its font, what the Glyphs panel's Show › Alternates for Current Selection lists: from the font's OpenType GSUB table, the glyphs that single and alternate substitutions put in place of the character's default glyph, `aalt` first, then `salt`, `ss01` to `ss20`, `swsh`, `cv01` to `cv99`, `hist`, `jalt`, `nalt`, `ornm` and `titl`, each glyph once, the default glyph only when `aalt` lists it → {char, font: family, style: style, default: glyph id, chosen: glyph id (the glyph drawn now), alternates: [{gid, feature}]} (empty when the font has none for it; an error when the character's font is not available)", + has_doc, + glyph_alternates + ), cmd!( query "text.getRange", "Get Text Range", @@ -1108,6 +1126,104 @@ pub fn effective_kerning(db: &vectorcraft_text::FontDb, t: &TextObject, lay: &ve if em > 0.0 { drawn_gap(original).zip(drawn_gap(&base)).map_or(0.0, |(a, b)| (a - b) * 1000.0 / em) } else { 0.0 } } +/// The character at byte `start` of `p` in text object `t`, its bytes and its font's face. An error +/// when there is no character there, it is an inline graphic, or its font is not available. +fn glyph_target(t: &TextObject, p: &Value, c: &str) -> Result<(char, std::ops::Range, std::sync::Arc)> { + check_numbers(p, &["start"], c)?; + let start = byte_param(p, "start").ok_or_else(|| bad(c, "missing `start` (the byte offset of a character)"))?; + let mut at = 0; + for run in &t.runs { + let end = at + run.text.len(); + if (at..end).contains(&start) { + let ch = run.text.get(start - at..).and_then(|s| s.chars().next()).ok_or_else(|| bad(c, format!("no character at byte {start}")))?; + if run.inline.is_some() { + return Err(bad(c, "an inline graphic has no glyphs")); + } + let db = vectorcraft_text::FontDb::global(); + let face = db.style_face(&run.style).filter(|_| db.has_family(&run.style.font_family)); + let face = face.ok_or_else(|| bad(c, format!("the font {} is not available", run.style.font_family)))?; + return Ok((ch, start..start + ch.len_utf8(), face)); + } + at = end; + } + Err(bad(c, format!("no character at byte {start}"))) +} + +fn set_glyph_alternate(s: &mut Session, p: &Value) -> Result { + const C: &str = "text.setGlyphAlternate"; + let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?; + let gid = match p.get("gid") { + None => return Err(bad(C, "missing `gid` (a glyph id, or null for the default glyph)")), + Some(Value::Null) => None, + Some(v) => Some(v.as_u64().and_then(|g| u32::try_from(g).ok()).ok_or_else(|| bad(C, format!("`gid` must be a glyph id, not {v}")))?), + }; + let t = text_ref(s, id)?; + let (ch, bytes, face) = glyph_target(&t, p, C)?; + let default = face.glyph_for(ch); + let choice = match gid.filter(|g| *g != default) { + None => None, + Some(g) if face.alternates(ch).iter().any(|a| a.gid == g) => Some(face.choice(ch, g)), + Some(g) => return Err(bad(C, format!("glyph {g} is not an alternate of {ch} in {} {}", face.family, face.style))), + }; + let drawn = choice.as_ref().map_or(default, |c| c.gid); + // The run holding the character. The request changes nothing when the choice is the one the + // character's run holds and that run holds the character alone; otherwise the edit splits the + // run, so that the following text does not take the character into a ligature. + let holder = t + .runs + .iter() + .scan(0, |at, r| { + let start = *at; + *at += r.text.len(); + Some((start..*at, r)) + }) + .find(|(bytes_of_run, _)| bytes_of_run.contains(&bytes.start)); + let held = holder.as_ref().filter(|(bytes_of_run, _)| bytes_of_run.start == bytes.start).and_then(|(_, r)| r.style.glyph_alternate.clone()); + let alone = holder.as_ref().is_some_and(|(bytes_of_run, _)| *bytes_of_run == bytes); + // A choice that the character's style keeps from being drawn (All Caps, Small Caps, another + // numeral system) is an error, not an edit that leaves the page as it was. The style decides + // for a character outside its frame too; one that is laid out is checked in the layout. + if let Some(c) = &choice { + let styled = holder + .as_ref() + .is_some_and(|(_, r)| r.style.all_caps || (r.style.small_caps.is_some() && ch.is_lowercase()) || r.style.digits.map(ch) != ch); + let shown = || { + let mut probe = t.clone(); + edit::style_range(&mut probe.runs, bytes.start, bytes.end, |st| st.glyph_alternate = Some(c.clone())); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &probe); + lay.glyphs.iter().find(|g| g.byte <= bytes.start && bytes.start < g.byte.saturating_add(g.len)).map(|g| g.gid) + }; + if styled || shown().is_some_and(|g| g != c.gid) { + return Err(bad( + C, + format!("glyph {} is not drawn for {ch} with the character's style (such as All Caps, Small Caps or a numeral system)", c.gid), + )); + } + } + if held == choice && (choice.is_none() || alone) { + return Ok(json!({"id": id.0, "start": bytes.start, "end": bytes.end, "gid": drawn})); + } + s.edit("Glyphs", |d, _| { + let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?; + edit::style_range(&mut t.runs, bytes.start, bytes.end, |st| st.glyph_alternate = choice.clone()); + refresh_bounds(t); + Ok(()) + })?; + Ok(json!({"id": id.0, "start": bytes.start, "end": bytes.end, "gid": drawn})) +} + +fn glyph_alternates(s: &mut Session, p: &Value) -> Result { + const C: &str = "text.glyphAlternates"; + let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?; + let t = text_ref(s, id)?; + let (ch, bytes, face) = glyph_target(&t, p, C)?; + let default = face.glyph_for(ch); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + let chosen = lay.glyphs.iter().find(|g| g.byte <= bytes.start && bytes.start < g.byte.saturating_add(g.len)).map_or(default, |g| g.gid); + let alternates: Vec = face.alternates(ch).iter().map(|a| json!({"gid": a.gid, "feature": String::from_utf8_lossy(&a.feature)})).collect(); + Ok(json!({"char": ch.to_string(), "font": face.family, "style": face.style, "default": default, "chosen": chosen, "alternates": alternates})) +} + fn touch_type(s: &mut Session, p: &Value) -> Result { const C: &str = "text.touchType"; let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?; diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index 7e4786ed9..c6442c193 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -161,6 +161,8 @@ fn to_attrs(v: &T) -> Map { /// from a style instead leaves the text's own value alone. pub fn char_attrs(v: &CharStyle) -> Map { let mut m = to_attrs(v); + // A chosen glyph belongs to one character, not to a reusable character style. + m.remove("glyphAlternate"); for (k, x) in [ ("font_version", json!(v.font_version)), ("fontAxes", json!(v.font_axes)), @@ -276,11 +278,9 @@ fn restyle( new: &Map, base: &Map, ) -> T { - let Some(full) = to_map(cur) else { return cur.clone() }; - let mut m = attrs(cur); - if let Some(name) = full.get("style_name") { - m.insert("style_name".into(), name.clone()); - } + // Starts from every key `cur` writes, so values no style sets (a glyph choice) are kept. + let Some(mut m) = to_map(cur) else { return cur.clone() }; + m.extend(attrs(cur)); for (k, v) in new { if old.get(k).is_none_or(|o| m.get(k) == Some(o)) { m.insert(k.clone(), v.clone()); @@ -498,6 +498,9 @@ fn attrs_param(d: &Document, p: &Value, kind: Kind, verb: &str, kept: Option<&Ma let c = kind.cmd(verb); let full = match kind { Kind::Char => { + if m.contains_key("glyphAlternate") { + return Err(bad(&c, "`glyphAlternate` belongs to one character, not to a character style (text.setGlyphAlternate sets it)")); + } let mut st = with_attrs(&CharStyle::default(), m, &c)?; let changed = |key: &str| m.contains_key(key) && kept.and_then(|k| k.get(key)) != m.get(key); for (key, value, lo, hi) in diff --git a/crates/engine/src/cmd/textstyles/tests.rs b/crates/engine/src/cmd/textstyles/tests.rs index 221b09cab..7c0fd9963 100644 --- a/crates/engine/src/cmd/textstyles/tests.rs +++ b/crates/engine/src/cmd/textstyles/tests.rs @@ -1,5 +1,42 @@ use super::*; +#[test] +fn captured_character_style_excludes_a_one_character_glyph_choice() { + let style = CharStyle { + glyph_alternate: Some(vectorcraft_doc::GlyphChoice { ch: 'a', gid: 42, font: "Test-Regular".into(), version: "1".into() }), + ..Default::default() + }; + assert!(!char_attrs(&style).contains_key("glyphAlternate")); + assert!(style.glyph_alternate.is_some(), "the selected text keeps its direct glyph choice"); +} + +#[test] +fn character_style_edit_keeps_a_linked_runs_glyph_choice() { + let (mut s, id) = session_with_text("Styled"); + s.execute("charStyle.new", &json!({"name": "Body", "attrs": {"size": 24}})).unwrap(); + s.execute("charStyle.apply", &json!({"name": "Body", "id": id})).unwrap(); + let choice = vectorcraft_doc::GlyphChoice { ch: 'S', gid: 42, font: "Test-Regular".into(), version: "1".into() }; + if let Some(NodeKind::Text(t)) = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(NodeId(id)).map(|n| &mut n.kind) { + t.runs[0].style.glyph_alternate = Some(choice.clone()); + } + s.execute("charStyle.edit", &json!({"name": "Body", "to": "Body", "attrs": {"size": 18}})).unwrap(); + let run = &runs(&s, id)[0]; + assert_eq!((run.style.style_name.as_deref(), run.style.size), (Some("Body"), 18.0)); + assert_eq!(run.style.glyph_alternate, Some(choice), "the style edit keeps the run's own glyph choice"); +} + +/// A glyph choice given to a character style is an error, not a value the style drops. +#[test] +fn character_style_attributes_refuse_a_glyph_choice() { + let (mut s, _) = session_with_text("Styled"); + let choice = json!({"ch": "S", "gid": 42, "font": "Test-Regular", "version": "1"}); + let err = s.execute("charStyle.new", &json!({"name": "Body", "attrs": {"size": 24, "glyphAlternate": choice}})).unwrap_err(); + assert!(err.to_string().contains("glyphAlternate"), "{err}"); + s.execute("charStyle.new", &json!({"name": "Body", "attrs": {"size": 24}})).unwrap(); + let err = s.execute("charStyle.edit", &json!({"name": "Body", "to": "Body", "attrs": {"glyphAlternate": choice}})).unwrap_err(); + assert!(err.to_string().contains("glyphAlternate"), "{err}"); +} + fn session_with_text(text: &str) -> (Session, u64) { let mut s = Session::new(); s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); @@ -824,7 +861,8 @@ fn para_style_redefined_to_the_defaults_updates_its_paragraphs() { } /// The same for character styles: Auto kerning and the Roman baseline alignment, which the -/// run's JSON leaves out, are captured and reach the style's users. +/// run's JSON leaves out, are captured and reach the style's users. A glyph choice belongs to +/// one character, so it is not a reusable style attribute. #[test] fn char_style_redefined_to_the_defaults_updates_its_text() { use vectorcraft_doc::CharAlign; @@ -842,9 +880,11 @@ fn char_style_redefined_to_the_defaults_updates_its_text() { s.execute("text.setFormat", &json!({"ids": [source], "kerning": "auto", "charAlign": "romanBaseline"})).unwrap(); s.execute("charStyle.redefine", &json!({"name": "Body", "id": source})).unwrap(); assert_eq!((kerning(&s), aligns(&s)), ([None, None, Some(60.0)], [CharAlign::RomanBaseline; 3])); - assert_eq!(defined(&s, 0), settable::(), "Redefine captures every attribute"); + let mut reusable = settable::(); + reusable.remove("glyphAlternate"); + assert_eq!(defined(&s, 0), reusable, "Redefine captures every reusable attribute"); s.execute("charStyle.new", &json!({"name": "Copy", "id": local})).unwrap(); - assert_eq!(defined(&s, 1), settable::(), "New captures every attribute"); + assert_eq!(defined(&s, 1), reusable, "New captures every reusable attribute"); s.execute("charStyle.setAttrs", &json!({"name": "Body", "attrs": {"kerning": 10, "charAlign": "emBoxTop"}})).unwrap(); assert_eq!((kerning(&s), aligns(&s)), ([Some(10.0), Some(10.0), Some(60.0)], [CharAlign::EmBoxTop; 3])); s.execute("charStyle.setAttrs", &json!({"name": "Body", "attrs": {"charAlign": "romanBaseline"}})).unwrap(); diff --git a/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs index fa73e9d50..4fe6ac847 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -2294,3 +2294,187 @@ fn a_family_without_its_style_takes_its_closest_style() { s.execute("text.setStyle", &json!({"ids": [set.0], "font": FAMILY})).unwrap(); assert_eq!(style(&s, set), "W6", "a style the family has is kept"); } + +/// The Glyphs panel's Show › Alternates for Current Selection: `text.glyphAlternates` lists a +/// character's alternates and `text.setGlyphAlternate` draws one of them, a glyph no character maps +/// to, in place of that character alone, as one undo step that native files keep. +#[test] +fn a_glyph_alternate_replaces_one_character_through_undo_and_reopen() { + use vectorcraft_text::test_fonts::{alternates_family, alternates_font, unencoded_glyphs}; + let db = vectorcraft_text::FontDb::global(); + db.add_font(alternates_font(true).expect("the test font builds")); + let [u1, ..] = unencoded_glyphs().expect("unencoded glyphs"); + let face = db.face(alternates_family(true), "Regular").expect("the test face"); + let g = |c| face.glyph_for(c); + let mut s = session(); + let r = s.execute("text.create", &json!({"x": 100, "y": 100, "text": "xaay", "size": 20, "font": alternates_family(true)})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": 4, "underline": true})).unwrap(); + let gids = |t: &TextObject| vectorcraft_text::layout(db, t).glyphs.iter().map(|g| g.gid).collect::>(); + assert_eq!(gids(&obj(&s, id)), [g('x'), g('a'), g('a'), g('y')]); + + let listed = s.execute("text.glyphAlternates", &json!({"id": id.0, "start": 2})).unwrap(); + assert_eq!((listed["char"].clone(), listed["default"].clone(), listed["chosen"].clone()), (json!("a"), json!(g('a')), json!(g('a')))); + assert_eq!(listed["alternates"][1], json!({"gid": u1, "feature": "aalt"})); + assert_eq!(listed["font"], json!(alternates_family(true))); + + // Create Outlines, undone at once: the bounds of the outlines. + let outlines = |s: &mut Session| { + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + let group = s.execute("type.createOutlines", &json!({})).unwrap()["ids"][0].as_u64().unwrap(); + let d = &s.doc().unwrap().doc; + let bounds: Vec = d.node(NodeId(group)).unwrap().children().unwrap().iter().filter_map(|c| c.geometric_bounds()).collect(); + s.execute("edit.undo", &json!({})).unwrap(); + bounds + }; + let default = outlines(&mut s); + + let undo = s.doc().unwrap().history.undo.len(); + let r = s.execute("text.setGlyphAlternate", &json!({"id": id.0, "start": 2, "gid": u1})).unwrap(); + assert_eq!(r, json!({"id": id.0, "start": 2, "end": 3, "gid": u1})); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + assert_eq!(undo_labels(&s).last().map(String::as_str), Some("Glyphs")); + let t = obj(&s, id); + assert_eq!(t.plain_text(), "xaay", "the text keeps the character"); + assert!(t.runs.iter().all(|r| r.style.underline), "its other attributes are kept"); + assert_eq!(gids(&t), [g('x'), g('a'), u1, g('y')], "the second a alone"); + assert_eq!(s.execute("text.glyphAlternates", &json!({"id": id.0, "start": 2})).unwrap()["chosen"], json!(u1)); + + assert_ne!(outlines(&mut s), default, "Create Outlines draws the chosen glyph"); + + // Native files keep the choice. + let bytes = vectorcraft_format::save_file(&s.doc().unwrap().doc); + let opened = vectorcraft_format::load(&bytes).unwrap(); + let NodeKind::Text(reopened) = &opened.node(id).unwrap().kind else { panic!("text missing") }; + assert_eq!(gids(reopened), [g('x'), g('a'), u1, g('y')], "reopened"); + // A font that is not installed draws the fallback font's default glyph. + let mut missing = (**reopened).clone(); + for r in &mut missing.runs { + r.style.font_family = "No Such Font Family".into(); + } + let fallback = db.face("No Such Font Family", "Regular").expect("the fallback face"); + assert_eq!(gids(&missing)[2], fallback.glyph_for('a'), "a missing font: the default glyph"); + + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(gids(&obj(&s, id)), [g('x'), g('a'), g('a'), g('y')], "undone"); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(gids(&obj(&s, id)), [g('x'), g('a'), u1, g('y')], "redone"); + + // The default glyph puts the character back, as null does. + let r = s.execute("text.setGlyphAlternate", &json!({"id": id.0, "start": 2, "gid": g('a')})).unwrap(); + assert_eq!(r["gid"], json!(g('a'))); + assert!(obj(&s, id).runs.iter().all(|r| r.style.glyph_alternate.is_none())); + s.execute("text.setGlyphAlternate", &json!({"id": id.0, "start": 1, "gid": u1})).unwrap(); + s.execute("text.setGlyphAlternate", &json!({"id": id.0, "start": 1, "gid": null})).unwrap(); + assert_eq!(gids(&obj(&s, id)), [g('x'), g('a'), g('a'), g('y')]); + + let err = |s: &mut Session, p: Value| s.execute("text.setGlyphAlternate", &p).unwrap_err().to_string(); + assert!(err(&mut s, json!({"id": id.0, "start": 2, "gid": g('z')})).contains("is not an alternate")); + assert!(err(&mut s, json!({"id": id.0, "start": 3, "gid": u1})).contains("is not an alternate"), "y has none"); + assert!(err(&mut s, json!({"id": id.0, "start": 4, "gid": u1})).contains("no character at byte 4")); + assert!(err(&mut s, json!({"id": id.0, "start": 2})).contains("missing `gid`")); + assert!(err(&mut s, json!({"id": id.0, "start": 2, "gid": -1})).contains("must be a glyph id")); + s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 2, "end": 3, "font": "No Such Font Family"})).unwrap(); + assert!(err(&mut s, json!({"id": id.0, "start": 2, "gid": u1})).contains("is not available")); + assert!(s.execute("text.glyphAlternates", &json!({"id": id.0, "start": 2})).unwrap_err().to_string().contains("is not available")); +} + +/// The glyph already drawn, chosen again, and null for a character drawn with its default glyph +/// leave the document and its undo history as they are. An empty run before the character does +/// not stand in for the character's own choice. +#[test] +fn a_glyph_alternate_already_drawn_is_not_an_edit() { + use vectorcraft_text::test_fonts::{alternates_family, alternates_font, unencoded_glyphs}; + let db = vectorcraft_text::FontDb::global(); + db.add_font(alternates_font(true).expect("the test font builds")); + let [u1, ..] = unencoded_glyphs().expect("unencoded glyphs"); + let face = db.face(alternates_family(true), "Regular").expect("the test face"); + let mut s = session(); + let create = |s: &mut Session, y: f64| { + let r = s.execute("text.create", &json!({"x": 100, "y": y, "text": "xaay", "size": 20, "font": alternates_family(true)})).unwrap(); + NodeId(r["id"].as_u64().unwrap()) + }; + let id = create(&mut s, 100.0); + s.execute("text.setGlyphAlternate", &json!({"id": id.0, "start": 2, "gid": u1})).unwrap(); + let state = |s: &Session| (s.doc().unwrap().history.undo.len(), s.doc().unwrap().revision); + let before = state(&s); + let r = s.execute("text.setGlyphAlternate", &json!({"id": id.0, "start": 2, "gid": u1})).unwrap(); + assert_eq!(r, json!({"id": id.0, "start": 2, "end": 3, "gid": u1})); + let r = s.execute("text.setGlyphAlternate", &json!({"id": id.0, "start": 1, "gid": null})).unwrap(); + assert_eq!(r, json!({"id": id.0, "start": 1, "end": 2, "gid": face.glyph_for('a')})); + assert_eq!(state(&s), before, "no edit"); + + // An empty run that holds the choice, as a file may have, right before the first a. + let other = create(&mut s, 150.0); + let mut runs = obj(&s, other).runs; + let mut empty = vectorcraft_doc::TextRun { text: String::new(), ..runs[0].clone() }; + empty.style.glyph_alternate = Some(face.choice('a', u1)); + runs = vec![ + vectorcraft_doc::TextRun { text: "x".into(), ..runs[0].clone() }, + empty, + vectorcraft_doc::TextRun { text: "aay".into(), ..runs[0].clone() }, + ]; + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let Some(NodeKind::Text(text)) = doc.node_mut(other).map(|n| &mut n.kind) else { panic!("text") }; + text.runs = runs; + s.execute("text.setGlyphAlternate", &json!({"id": other.0, "start": 1, "gid": u1})).unwrap(); + let gids: Vec = vectorcraft_text::layout(db, &obj(&s, other)).glyphs.iter().map(|g| g.gid).collect(); + assert_eq!(gids[1], u1, "the a takes the choice: {gids:?}"); + + // A run that holds the choice and goes on past its first character, as a file may have: the + // request splits it, so the character is shaped alone. + let shared = create(&mut s, 200.0); + let mut runs = obj(&s, shared).runs; + let mut chosen = vectorcraft_doc::TextRun { text: "aay".into(), ..runs[0].clone() }; + chosen.style.glyph_alternate = Some(face.choice('a', u1)); + runs = vec![vectorcraft_doc::TextRun { text: "x".into(), ..runs[0].clone() }, chosen]; + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let Some(NodeKind::Text(text)) = doc.node_mut(shared).map(|n| &mut n.kind) else { panic!("text") }; + text.runs = runs; + let before = state(&s); + s.execute("text.setGlyphAlternate", &json!({"id": shared.0, "start": 1, "gid": u1})).unwrap(); + assert_eq!(state(&s).0, before.0 + 1, "an edit"); + let texts: Vec = obj(&s, shared).runs.iter().map(|r| r.text.clone()).collect(); + assert_eq!(texts, ["x", "a", "ay"]); +} + +/// A glyph the character's style keeps from being drawn (here All Caps draws the a as A) is an +/// error, and the document and its undo history are left as they were. +#[test] +fn a_glyph_alternate_that_the_style_does_not_draw_is_an_error() { + use vectorcraft_text::test_fonts::{alternates_family, alternates_font, unencoded_glyphs}; + vectorcraft_text::FontDb::global().add_font(alternates_font(true).expect("the test font builds")); + let [u1, ..] = unencoded_glyphs().expect("unencoded glyphs"); + let mut s = session(); + let r = s.execute("text.create", &json!({"x": 100, "y": 100, "text": "xaay", "size": 20, "font": alternates_family(true)})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": 4, "allCaps": true})).unwrap(); + let state = |s: &Session| (s.doc().unwrap().history.undo.len(), s.doc().unwrap().revision); + let before = state(&s); + let err = s.execute("text.setGlyphAlternate", &json!({"id": id.0, "start": 1, "gid": u1})).unwrap_err(); + assert!(err.to_string().contains("is not drawn"), "{err}"); + assert_eq!(state(&s), before, "no edit"); + assert!(obj(&s, id).runs.iter().all(|r| r.style.glyph_alternate.is_none())); +} + +#[test] +fn glyph_alternates_query_reports_the_fallback_glyph_that_is_drawn() { + use vectorcraft_text::test_fonts::{alternates_family, alternates_font}; + let db = vectorcraft_text::FontDb::global(); + db.add_font(alternates_font(true).expect("the test font builds")); + let requested = db.face(alternates_family(true), "Regular").expect("the test face"); + let fallback = db.face("Inter", "Regular").expect("the bundled fallback face"); + let ch = 'Ƃ'; + assert_eq!(requested.glyph_for(ch), 0); + assert_ne!(fallback.glyph_for(ch), 0); + let mut s = session(); + let id = NodeId( + s.execute("text.create", &json!({"x": 0, "y": 0, "text": ch.to_string(), "font": alternates_family(true)})).unwrap()["id"].as_u64().unwrap(), + ); + let glyph = vectorcraft_text::layout(db, &obj(&s, id)).glyphs.remove(0); + assert_ne!(glyph.font_id, requested.id()); + assert_ne!(glyph.gid, 0); + let query = s.execute("text.glyphAlternates", &json!({"id": id.0, "start": 0})).unwrap(); + assert_eq!(query["chosen"], json!(glyph.gid), "the query reports the visible fallback glyph"); + assert!(query["alternates"].as_array().unwrap().is_empty()); +} diff --git a/crates/svg/src/export/text.rs b/crates/svg/src/export/text.rs index 5fc015039..387a1a342 100644 --- a/crates/svg/src/export/text.rs +++ b/crates/svg/src/export/text.rs @@ -83,9 +83,12 @@ impl Writer<'_> { let t = &*heights; let inline = t.runs.iter().any(|r| r.inline.is_some()); // Ruby is written as outlines with its base text: a reader's can't place it. So are - // small capitals, superscript and subscript and tab leaders, which SVG text doesn't draw (#3869). - let unwritable = t.runs.iter().any(|r| r.style.small_caps.is_some() || r.style.position != vectorcraft_doc::CharPosition::Normal) - || std::iter::once(&t.para).chain(&t.paras).any(|p| p.tabs.iter().any(|tab| !tab.leader.is_empty())); + // small capitals, superscript and subscript and tab leaders, which SVG text doesn't draw (#3869), + // and a glyph chosen for a character, which a reader's draws with the default glyph. + let unwritable = + t.runs.iter().any(|r| { + r.style.small_caps.is_some() || r.style.position != vectorcraft_doc::CharPosition::Normal || r.style.glyph_alternate.is_some() + }) || std::iter::once(&t.para).chain(&t.paras).any(|p| p.tabs.iter().any(|tab| !tab.leader.is_empty())); let outlined = self.opts.outline_text || t.vertical || !t.ruby.is_empty() || (inline && matches!(t.kind, TextKind::OnPath { .. })) || unwritable; let chars = |w: &mut Self, n: &Node| if outlined { w.text_outlines(n, t) } else { w.text(n, t) }; diff --git a/crates/svg/tests/it/text_layout.rs b/crates/svg/tests/it/text_layout.rs index 3107a1f44..eee6e2fbf 100644 --- a/crates/svg/tests/it/text_layout.rs +++ b/crates/svg/tests/it/text_layout.rs @@ -176,6 +176,24 @@ fn digits_are_written_as_drawn() { assert!(!text.contains("page 4") && !text.contains("page ٥"), "{svg}"); } +/// A character drawn with a chosen glyph is written as outlines: a reader's `` would draw the +/// default glyph. +#[test] +fn a_chosen_glyph_is_written_as_outlines() { + use vectorcraft_text::test_fonts::{alternates_family, alternates_font, unencoded_glyphs}; + let db = vectorcraft_text::FontDb::global(); + db.add_font(alternates_font(true).unwrap()); + let [u1, ..] = unencoded_glyphs().unwrap(); + let face = db.face(alternates_family(true), "Regular").unwrap(); + let plain = CharStyle { font_family: alternates_family(true).into(), ..style(12.0) }; + let chosen = CharStyle { glyph_alternate: Some(face.choice('a', u1)), ..plain.clone() }; + let svg = export(&area_doc(vec![run("xay", plain.clone())], 280.0, Justify::Left), &ExportOptions::default()); + assert!(svg.contains("` element's 12. diff --git a/crates/text/src/edit.rs b/crates/text/src/edit.rs index 1dcb40f01..a4505f959 100644 --- a/crates/text/src/edit.rs +++ b/crates/text/src/edit.rs @@ -49,24 +49,31 @@ pub fn style_at(runs: &[TextRun], byte: usize) -> CharStyle { } /// Style for text typed over `start..end` (Illustrator: the first replaced character's style, or -/// the character before the caret). +/// the character before the caret), without a glyph choice ([`CharStyle::glyph_alternate`]): +/// typed characters take their default glyphs. pub fn insertion_style(runs: &[TextRun], start: usize, end: usize) -> CharStyle { - if end > start { - // The character at `start` (not the one before it). - let mut off = 0; - for r in runs { - if start < off + r.text.len() { - return r.style.clone(); + let mut style = 'found: { + if end > start { + // The character at `start` (not the one before it). + let mut off = 0; + for r in runs { + if start < off + r.text.len() { + break 'found r.style.clone(); + } + off += r.text.len(); } - off += r.text.len(); } - } - style_at(runs, start) + style_at(runs, start) + }; + style.glyph_alternate = None; + style } /// Merge adjacent runs with equal styles and drop empty runs (keeping one run so the object /// remembers its style). Inline graphic runs never merge: each stays one [`INLINE_CHAR`]; one -/// whose text is anything else (malformed input) becomes plain text. +/// whose text is anything else (malformed input) becomes plain text. A run with a glyph choice +/// ([`CharStyle::glyph_alternate`]) is not merged into the run before it, and loses the choice +/// when its text does not start with the character the choice is for. pub fn normalize(runs: &mut Vec) { let first_style = runs.first().map(|r| r.style.clone()); let mut out: Vec = Vec::with_capacity(runs.len()); @@ -77,8 +84,13 @@ pub fn normalize(runs: &mut Vec) { if r.inline.is_some() && !is_inline_text(&r.text) { r.inline = None; } + if r.style.glyph_alternate.as_ref().is_some_and(|c| !r.text.starts_with(c.ch)) { + r.style.glyph_alternate = None; + } match out.last_mut() { - Some(l) if l.style == r.style && l.inline.is_none() && r.inline.is_none() => l.text.push_str(&r.text), + Some(l) if l.style == r.style && l.inline.is_none() && r.inline.is_none() && r.style.glyph_alternate.is_none() => { + l.text.push_str(&r.text) + } _ => out.push(r), } } @@ -100,7 +112,8 @@ fn split_at(runs: &mut Vec, byte: usize) -> usize { if byte < off + len { let at = floor_char(&runs[i].text, byte - off); let tail = runs[i].text.split_off(at); - let style = runs[i].style.clone(); + // A glyph choice is for the run's first character, which stays in the head. + let style = CharStyle { glyph_alternate: None, ..runs[i].style.clone() }; // An inline graphic is one character: a split lands before it (`at` == 0), the empty // head is dropped by `normalize` and the tail keeps the graphic. let inline = runs[i].inline.clone(); @@ -177,7 +190,12 @@ pub fn slice_runs(runs: &[TextRun], start: usize, end: usize) -> Vec { off = re; continue; } - out.push(TextRun { text: t.to_string(), style: r.style.clone(), inline: r.inline.clone() }); + let mut style = r.style.clone(); + // A glyph choice is for the run's first character. + if floor_char(&r.text, a - rs) > 0 { + style.glyph_alternate = None; + } + out.push(TextRun { text: t.to_string(), style, inline: r.inline.clone() }); } off = re; } diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 1972577ef..d42e8f351 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -17,6 +17,7 @@ use crate::GlyphImage; use crate::colr::ColourContent; use vectorcraft_doc::color::Color; +mod alternates; mod build; mod cache; mod db; @@ -30,6 +31,7 @@ mod stylistic_sets; mod traits; mod wanted; +pub use alternates::GlyphAlternate; pub use build::style_weight; #[cfg(test)] use cache::GlyphCache; diff --git a/crates/text/src/fontdb/alternates.rs b/crates/text/src/fontdb/alternates.rs new file mode 100644 index 000000000..2a582bbe7 --- /dev/null +++ b/crates/text/src/fontdb/alternates.rs @@ -0,0 +1,165 @@ +//! The alternate glyphs a face's `GSUB` table gives one character: the Glyphs panel's Show › +//! Alternates for Current Selection. The application finds the character's default glyph in the +//! `cmap` and looks it up in the coverage of each lookup of the features that substitute one glyph +//! for another, as the OpenType registry describes `aalt`. +//! +//! + +use skrifa::MetadataProvider; +use skrifa::raw::TableProvider; +use skrifa::raw::tables::gsub::{AlternateSubstFormat1, SingleSubst, SubstitutionSubtables}; +use vectorcraft_doc::GlyphChoice; + +use super::FontFace; + +/// One glyph [`FontFace::alternates`] lists: its id and the feature it was found in (`aalt`). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct GlyphAlternate { + pub gid: u32, + pub feature: [u8; 4], +} + +/// Caps on what a damaged or very large `GSUB` table can make [`FontFace::alternates`] read: +/// feature records, lookups per feature and in all, subtables per lookup and in all, glyphs per +/// alternate set and glyphs listed in all. +const MAX_FEATURES: usize = 4096; +const MAX_FEATURE_LOOKUPS: usize = 256; +const MAX_LOOKUPS: usize = 4096; +const MAX_SUBTABLES: usize = 256; +const MAX_SUBTABLE_READS: usize = 16384; +const MAX_SET: usize = 512; +const MAX_ALTERNATES: usize = 1024; + +/// The other discretionary features that substitute one glyph for another (historical forms, +/// justification alternates, alternate annotation forms, ornaments, titling), after `cv99`, in +/// tag order. +const OTHER_FEATURES: [&[u8; 4]; 5] = [b"hist", b"jalt", b"nalt", b"ornm", b"titl"]; + +/// Where the glyphs of feature `tag` come in the list: `aalt`, `salt`, `ss01` to `ss20`, `swsh`, +/// `cv01` to `cv99`, then [`OTHER_FEATURES`]. `None` for a feature that is not listed. +fn feature_rank(tag: [u8; 4]) -> Option<(u8, u8)> { + let number = |t: &[u8]| std::str::from_utf8(t).ok().filter(|s| s.bytes().all(|b| b.is_ascii_digit())).and_then(|s| s.parse::().ok()); + match &tag { + b"aalt" => Some((0, 0)), + b"salt" => Some((1, 0)), + [b's', b's', ..] => number(&tag[2..]).filter(|n| (1..=20).contains(n)).map(|n| (2, n)), + b"swsh" => Some((3, 0)), + [b'c', b'v', ..] => number(&tag[2..]).filter(|n| (1..=99).contains(n)).map(|n| (4, n)), + _ => OTHER_FEATURES.iter().position(|t| **t == tag).map(|i| (5, i as u8)), + } +} + +impl FontFace { + /// The face's alternate glyphs for `c`, from its `GSUB` table: the glyphs single substitutions + /// (lookup type 1) and alternate substitutions (type 3, the whole set in the font's order), in + /// extension lookups (type 7) too, put in place of `c`'s default glyph. `aalt` comes first, + /// then `salt`, `ss01` to `ss20`, `swsh`, `cv01` to `cv99`, `hist`, `jalt`, `nalt`, `ornm` and + /// `titl`; within a feature its lookups go in lookup order. A glyph found again is left out. + /// The default glyph is listed only when `aalt` lists it. Ligatures and contextual + /// substitutions are not read. Empty when the face does not map `c`, has no `GSUB` table, or + /// the table cannot be read; glyph ids past the face's glyph count are left out. + pub fn alternates(&self, c: char) -> Vec { + let mut out: Vec = vec![]; + let Some(f) = self.skrifa() else { return out }; + let Some(default) = f.charmap().map(c) else { return out }; + let Ok(gsub) = f.gsub() else { return out }; + let (Ok(features), Ok(lookups)) = (gsub.feature_list(), gsub.lookup_list()) else { return out }; + let glyphs = f.maxp().map_or(0, |m| u32::from(m.num_glyphs())); + // The lookups of the listed features, by rank and lookup index. + // The listed feature records are read in rank order, so the cap on lookups in all leaves + // out the lowest ranked ones. + let records = features.feature_records(); + let mut ranked: Vec<((u8, u8), usize, [u8; 4])> = records + .iter() + .take(MAX_FEATURES) + .enumerate() + .filter_map(|(i, r)| { + let tag = r.feature_tag().to_be_bytes(); + feature_rank(tag).map(|rank| (rank, i, tag)) + }) + .collect(); + ranked.sort_unstable_by_key(|r| (r.0, r.1)); + let mut wanted: Vec<((u8, u8), u16, [u8; 4])> = vec![]; + for (rank, i, tag) in ranked { + let room = MAX_LOOKUPS.saturating_sub(wanted.len()); + if room == 0 { + break; + } + let Some(record) = records.get(i) else { continue }; + let Ok(feature) = record.feature(features.offset_data()) else { continue }; + wanted.extend(feature.lookup_list_indices().iter().take(MAX_FEATURE_LOOKUPS.min(room)).map(|i| (rank, i.get(), tag))); + } + wanted.sort_unstable_by_key(|w| (w.0, w.1)); + wanted.dedup_by_key(|w| (w.0, w.1)); + let mut push = |gid: u32, feature: [u8; 4]| { + let listed = gid != default.to_u32() || feature == *b"aalt"; + if listed && gid < glyphs && out.len() < MAX_ALTERNATES && !out.iter().any(|a| a.gid == gid) { + out.push(GlyphAlternate { gid, feature }); + } + }; + let all = lookups.lookups(); + let mut reads = MAX_SUBTABLE_READS; + for (_, index, tag) in wanted { + if reads == 0 { + break; + } + let Ok(subtables) = all.get(usize::from(index)).and_then(|l| l.subtables()) else { continue }; + let n = MAX_SUBTABLES.min(reads); + // A glyph takes the first subtable of a lookup whose coverage holds it. + match subtables { + SubstitutionSubtables::Single(s) => { + if let Some(gid) = s.iter().take(n).inspect(|_| reads -= 1).flatten().find_map(|t| single(&t, default.to_u32())) { + push(gid, tag); + } + } + SubstitutionSubtables::Alternate(s) => { + if let Some(set) = s.iter().take(n).inspect(|_| reads -= 1).flatten().find_map(|t| alternate_set(&t, default.to_u32())) { + set.into_iter().for_each(|gid| push(gid, tag)); + } + } + _ => {} + } + } + out + } + + /// How many glyphs the face has (its `maxp` table; 0 when it can't be read). + pub fn glyph_count(&self) -> u32 { + self.skrifa().and_then(|f| f.maxp().ok()).map_or(0, |m| u32::from(m.num_glyphs())) + } + + /// Does this face draw `choice` for character `c`? It does when the choice is for `c`, names + /// this face's PostScript name and version, and its glyph id is one of the face's glyphs. + pub fn draws(&self, choice: &GlyphChoice, c: char) -> bool { + choice.ch == c && choice.version == self.version && choice.gid < self.glyph_count() && choice.font == self.postscript_name() + } + + /// `c` drawn with glyph `gid` of this face ([`CharStyle::glyph_alternate`]). + /// + /// [`CharStyle::glyph_alternate`]: vectorcraft_doc::CharStyle::glyph_alternate + pub fn choice(&self, c: char, gid: u32) -> GlyphChoice { + GlyphChoice { ch: c, gid, font: self.postscript_name(), version: self.version.clone() } + } +} + +/// The glyph single substitution `t` puts in place of `gid`, when its coverage holds `gid`. +fn single(t: &SingleSubst<'_>, gid: u32) -> Option { + match t { + SingleSubst::Format1(t) => { + t.coverage().ok()?.get(skrifa::GlyphId::new(gid))?; + // The delta is added modulo 65536. + Some((gid as i32 + i32::from(t.delta_glyph_id())).rem_euclid(0x1_0000) as u32) + } + SingleSubst::Format2(t) => { + let i = t.coverage().ok()?.get(skrifa::GlyphId::new(gid))?; + t.substitute_glyph_ids().get(usize::from(i)).map(|g| u32::from(g.get().to_u16())) + } + } +} + +/// The alternate set of alternate substitution `t` for `gid`, when its coverage holds `gid`. +fn alternate_set(t: &AlternateSubstFormat1<'_>, gid: u32) -> Option> { + let i = t.coverage().ok()?.get(skrifa::GlyphId::new(gid))?; + let set = t.alternate_sets().get(usize::from(i)).ok()?; + Some(set.alternate_glyph_ids().iter().take(MAX_SET).map(|g| u32::from(g.get().to_u16())).collect()) +} diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index eb9f14bf3..54333a83c 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -35,8 +35,8 @@ pub use bitmap::GlyphImage; pub use craft_fonts::{CRAFT_FONTS, CraftFont, WEB_FONTS, WebFont}; pub use features::{EXTRA_FEATURES, LIGATURE_TRACKING_LIMITS, MAX_STYLE_FEATURES, OtFeatures, explicit_ligatures, ligatures_suppressed_by}; pub use fontdb::{ - FALLBACK_FAMILY, FontClass, FontDb, FontFace, FontMatch, FontTraits, IcfMargins, PlatformFontFiles, StylisticSet, WantedFont, WantedFonts, - app_font_dir, is_font_file, is_suitcase, set_app_font_dir, set_platform_font_files, set_user_font_dirs, set_user_font_dirs_confined, + FALLBACK_FAMILY, FontClass, FontDb, FontFace, FontMatch, FontTraits, GlyphAlternate, IcfMargins, PlatformFontFiles, StylisticSet, WantedFont, + WantedFonts, app_font_dir, is_font_file, is_suitcase, set_app_font_dir, set_platform_font_files, set_user_font_dirs, set_user_font_dirs_confined, style_weight, system_font_dirs, user_font_dirs, }; pub use hyphen::{hyphenation_exceptions, set_hyphenation_exceptions}; diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index 454fe0dba..f1aa3f504 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -743,6 +743,7 @@ fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool { font_height_reference, no_break, language, + glyph_alternate, } = a; *font_family == b.font_family && *font_style == b.font_style @@ -761,6 +762,10 @@ fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool { && *font_height_reference == b.font_height_reference && *no_break == b.no_break && *language == b.language + // A choice applies to the first character of its source run. Even equal choices on + // neighboring runs must shape separately so ligatures cannot absorb either character. + && glyph_alternate.is_none() + && b.glyph_alternate.is_none() && ligatures_suppressed_by(*tracking) == ligatures_suppressed_by(b.tracking) } @@ -1136,15 +1141,75 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe if !inside_clusters.is_empty() { return shape_apart(inside_clusters, out); } + // A run's chosen character that the font joins with the characters after it in the run (an f + // with a chosen glyph in a `fi` ligature) is shaped apart from them, so the choice is drawn. + // A character that starts a longer grapheme (a letter with combining marks, a Hangul syllable + // of jamo) stays whole. + let ligated: Vec = runs + .get(source_runs.clone()) + .unwrap_or_default() + .iter() + .filter(|(r, s)| { + let ch = first_char(r.start); + s.glyph_alternate.as_ref().is_some_and(|c| face.draws(c, ch)) && !upper && s.digits.map(ch) == ch && range.contains(&r.start) + }) + .map(|(r, _)| { + let grapheme = text.get(r.start..).and_then(|s| s.graphemes(true).next()).map_or(0, str::len); + (r.start + first_char(r.start).len_utf8(), r.start + grapheme, r.end) + }) + .filter(|&(b, grapheme_end, end)| b == grapheme_end && b < end && b < range.end && cluster_starts.binary_search(&b).is_err()) + .map(|(b, ..)| b) + .collect(); + if !ligated.is_empty() { + return shape_apart(ligated, out); + } let n = raw.len(); + // Cluster end: the next larger cluster value in the segment, else the segment end. + let cluster_end = |cl: usize| cluster_starts.get(cluster_starts.partition_point(|&c| c <= cl)).copied().unwrap_or(range.end); + // A glyph chosen for the run's first character replaces the one glyph shaped for it alone, + // drawn as typed (not All Caps, Small Caps or another numeral system), in the face the choice + // names. + let chosen_at = |gi: usize| { + let cl = raw[gi].1 as usize; + let (run, gst) = run_at(cl); + let ch = first_char(cl); + let last_in_cluster = gi + 1 == n || raw[gi + 1].1 as usize != cl; + let alone = last_in_cluster && (gi == 0 || raw[gi - 1].1 as usize != cl) && cluster_end(cl).saturating_sub(cl) == ch.len_utf8(); + gst.glyph_alternate + .as_ref() + .filter(|c| alone && !upper && gst.digits.map(ch) == ch && runs.get(run).is_some_and(|(r, _)| r.start == cl) && face.draws(c, ch)) + }; + // The font's positioning (pair kerning, mark placement) was applied to the default glyph, not + // to the chosen one: a character drawn with another glyph is shaped apart from its neighbors. + let mut chosen_apart: Vec = (0..n) + .filter(|&gi| chosen_at(gi).is_some_and(|c| c.gid != raw[gi].0)) + .flat_map(|gi| { + let cl = raw[gi].1 as usize; + [cl, cl + first_char(cl).len_utf8()] + }) + .filter(|&b| b > range.start && b < range.end) + .collect(); + if !chosen_apart.is_empty() { + chosen_apart.sort_unstable(); + chosen_apart.dedup(); + return shape_apart(chosen_apart, out); + } for (gi, &(gid, cl, xa, xo, yo)) in raw.iter().enumerate() { let cl = cl as usize; let (run, gst) = run_at(cl); - // Cluster end: the next larger cluster value in the segment, else the segment end. - let end = cluster_starts.get(cluster_starts.partition_point(|&c| c <= cl)).copied().unwrap_or(range.end); + let end = cluster_end(cl); let last_in_cluster = gi + 1 == n || raw[gi + 1].1 as usize != cl; let ch = first_char(cl); let mut adv = xa as f64 * k * hs; + // The chosen glyph takes its own advance. + let chosen = chosen_at(gi); + let gid = match chosen { + Some(c) => { + adv += (face.advance(c.gid) - face.advance(gid)) * k * hs; + c.gid + } + None => gid, + }; let mut kern_shift = 0.0; if ch == SOFT_HYPHEN || ch == '\u{2028}' { adv = 0.0; diff --git a/crates/text/src/test_fonts.rs b/crates/text/src/test_fonts.rs index 2a6072762..2b32b9997 100644 --- a/crates/text/src/test_fonts.rs +++ b/crates/text/src/test_fonts.rs @@ -489,6 +489,130 @@ pub fn lam_alef_font(feature: &[u8; 4]) -> Option> { Some(builder.build()) } +/// The family of [`alternates_font`] with an `aalt` feature, and without one. +pub fn alternates_family(aalt: bool) -> &'static str { + if aalt { "Alttest Sans" } else { "Alttest Sans Noaalt" } +} + +/// Four glyphs of the bundled Source Sans 3 Regular that its `cmap` maps no character to, the +/// lowest ids first: the unencoded alternates of [`alternates_font`]. `None` when there are not +/// four. +pub fn unencoded_glyphs() -> Option<[u32; 4]> { + const BASE: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf"); + let font = skrifa::FontRef::new(BASE).ok()?; + let mapped: std::collections::HashSet = font.charmap().mappings().map(|(_, g)| g.to_u32()).collect(); + let glyphs = u32::from(font.maxp().ok()?.num_glyphs()); + let found: Vec = (1..glyphs).filter(|g| !mapped.contains(g)).take(4).collect(); + found.try_into().ok() +} + +/// The family of [`crowded_alternates_font`]. +pub const CROWDED_ALTERNATES_FAMILY: &str = "Crowdtest Sans"; + +/// The bundled Source Sans 3 Regular, its `cmap` kept, with a `GSUB` table whose 17 `cv01` feature +/// records each list a single substitution of `a` by `c` 256 times, followed by a `salt` record +/// whose single substitution gives `b`. Family [`CROWDED_ALTERNATES_FAMILY`]. `None` when it can't +/// be built. +pub fn crowded_alternates_font() -> Option> { + use write_fonts::tables::gsub::{Gsub, SingleSubst, SingleSubstFormat2, SubstitutionLookup, SubstitutionLookupList}; + use write_fonts::tables::layout::{CoverageFormat1, Feature, FeatureList, FeatureRecord, LangSys, Lookup, Script, ScriptList, ScriptRecord}; + use write_fonts::types::GlyphId16; + + const BASE: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf"); + let font = skrifa::FontRef::new(BASE).ok()?; + let charmap = font.charmap(); + let family = CROWDED_ALTERNATES_FAMILY; + let name = name_table(&[(1, family), (2, "Regular"), (4, "Crowdtest Sans Regular"), (5, "Version 1.000"), (6, "CrowdtestSans-Regular")])?; + let mut builder = FontBuilder::new(); + builder.add_raw(Tag::new(b"name"), name); + let gid = |c| Some(GlyphId16::new(u16::try_from(charmap.map(c)?.to_u32()).ok()?)); + let a = gid('a')?; + let single = |to: GlyphId16| { + SubstitutionLookup::Single(Lookup::new( + Default::default(), + vec![SingleSubst::from(SingleSubstFormat2::new(CoverageFormat1::new(vec![a]).into(), vec![to]))], + )) + }; + let lookups = SubstitutionLookupList::new(vec![single(gid('c')?), single(gid('b')?)]); + let mut records: Vec = (0..17).map(|_| FeatureRecord::new(Tag::new(b"cv01"), Feature::new(None, vec![0; 256]))).collect(); + records.push(FeatureRecord::new(Tag::new(b"salt"), Feature::new(None, vec![1]))); + let indices = (0..records.len() as u16).collect(); + let scripts = ScriptList::new(vec![ScriptRecord::new(Tag::new(b"DFLT"), Script::new(Some(LangSys::new(indices)), vec![]))]); + builder.add_table(&Gsub::new(scripts, FeatureList::new(records), lookups)).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()) +} + +/// The bundled Source Sans 3 Regular, its `cmap` kept, with a `GSUB` table of alternates for `a` +/// and nothing else. With [`unencoded_glyphs`] `[u1, u2, u3, u4]`: `aalt` (alternate +/// substitution) lists a, u1 and b; `cv01` (single substitution) gives c; `salt` (single) gives b; +/// `smcp` (single) gives d; `ss01` (single, a glyph-id delta) gives u2; `ss02` (single, in an +/// extension lookup) gives u4; and `swsh` (alternate substitution) lists u3 and u1. Without `aalt` +/// the font has the others only. Family [`alternates_family`]. `None` when it can't be built. +pub fn alternates_font(aalt: bool) -> Option> { + use write_fonts::tables::gsub::{ + AlternateSet, AlternateSubstFormat1, ExtensionSubstFormat1, ExtensionSubtable, Gsub, SingleSubst, SingleSubstFormat1, SingleSubstFormat2, + SubstitutionLookup, SubstitutionLookupList, + }; + use write_fonts::tables::layout::{CoverageFormat1, Feature, FeatureList, FeatureRecord, LangSys, Lookup, Script, ScriptList, ScriptRecord}; + use write_fonts::types::GlyphId16; + + const BASE: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf"); + let font = skrifa::FontRef::new(BASE).ok()?; + let charmap = font.charmap(); + let family = alternates_family(aalt); + let full = format!("{family} Regular"); + let postscript = format!("{}-Regular", family.replace(' ', "")); + let name = name_table(&[(1, family), (2, "Regular"), (4, &full), (5, "Version 1.000"), (6, &postscript)])?; + let mut builder = FontBuilder::new(); + builder.add_raw(Tag::new(b"name"), name); + let id = |g: u32| Some(GlyphId16::new(u16::try_from(g).ok()?)); + let gid = |c| id(charmap.map(c)?.to_u32()); + let [u1, u2, u3, u4] = unencoded_glyphs()?.map(id); + let (u1, u2, u3, u4, a) = (u1?, u2?, u3?, u4?, gid('a')?); + let cover = || CoverageFormat1::new(vec![a]); + let single = |to: GlyphId16| SingleSubst::from(SingleSubstFormat2::new(cover().into(), vec![to])); + let lookup = |subtable: SingleSubst| SubstitutionLookup::Single(Lookup::new(Default::default(), vec![subtable])); + let alternates = |set: Vec| { + SubstitutionLookup::Alternate(Lookup::new(Default::default(), vec![AlternateSubstFormat1::new(cover().into(), vec![AlternateSet::new(set)])])) + }; + let delta = i16::try_from(i32::from(u2.to_u16()) - i32::from(a.to_u16())).ok()?; + let extension = ExtensionSubtable::Single(ExtensionSubstFormat1::new(1, single(u4))); + // Lookups: 0 aalt, 1 cv01, 2 salt, 3 smcp, 4 ss01, 5 ss02, 6 swsh. + let lookups = SubstitutionLookupList::new(vec![ + alternates(vec![a, u1, gid('b')?]), + lookup(single(gid('c')?)), + lookup(single(gid('b')?)), + lookup(single(gid('d')?)), + lookup(SingleSubst::from(SingleSubstFormat1::new(cover().into(), delta))), + SubstitutionLookup::Extension(Lookup::new(Default::default(), vec![extension])), + alternates(vec![u3, u1]), + ]); + // Features sorted by tag, as OpenType requires. + let tags: [&[u8; 4]; 7] = [b"aalt", b"cv01", b"salt", b"smcp", b"ss01", b"ss02", b"swsh"]; + let records: Vec = tags + .iter() + .zip(0u16..) + .filter(|(t, _)| aalt || **t != b"aalt") + .map(|(t, i)| FeatureRecord::new(Tag::new(t), Feature::new(None, vec![i]))) + .collect(); + let indices = (0..records.len() as u16).collect(); + let scripts = ScriptList::new(vec![ScriptRecord::new(Tag::new(b"DFLT"), Script::new(Some(LangSys::new(indices)), vec![]))]); + builder.add_table(&Gsub::new(scripts, FeatureList::new(records), lookups)).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()) +} + /// The family of [`ligature_caret_font`]. pub const LIGATURE_CARET_FAMILY: &str = "Carettest Sans"; diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 002e4b921..a5d8b5063 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -3,6 +3,7 @@ use kurbo::{Affine, Shape}; use vectorcraft_doc::{CharStyle, Justify, TextKind, TextRun}; use vectorcraft_geom::PathData; +mod alternates; mod axes; mod bidi; mod bitmaps; diff --git a/crates/text/src/tests/alternates.rs b/crates/text/src/tests/alternates.rs new file mode 100644 index 000000000..74fef9d04 --- /dev/null +++ b/crates/text/src/tests/alternates.rs @@ -0,0 +1,176 @@ +//! A face's alternate glyphs for a character (Glyphs panel › Show › Alternates for Current +//! Selection) and a glyph chosen for one character: read from a `GSUB` table built in code, set by +//! layout, kept by text edits. + +use kurbo::Point; +use vectorcraft_doc::{CharStyle, GlyphChoice, TextRun}; + +use crate::test_fonts::{alternates_family, alternates_font, unencoded_glyphs}; +use crate::*; + +fn db() -> FontDb { + let db = FontDb::with_font_dirs(vec![]); + db.add_font(alternates_font(true).expect("the test font builds")); + db.add_font(alternates_font(false).expect("the test font builds")); + db +} + +fn style(aalt: bool) -> CharStyle { + CharStyle { font_family: alternates_family(aalt).into(), size: 100.0, ..CharStyle::default() } +} + +#[test] +fn alternates_come_in_feature_order_once_each() { + let db = db(); + let [u1, u2, u3, u4] = unencoded_glyphs().expect("unencoded glyphs"); + let face = db.face(alternates_family(true), "Regular").expect("the test face"); + let g = |c| face.glyph_for(c); + let listed: Vec<(u32, [u8; 4])> = face.alternates('a').into_iter().map(|a| (a.gid, a.feature)).collect(); + assert_eq!( + listed, + [(g('a'), *b"aalt"), (u1, *b"aalt"), (g('b'), *b"aalt"), (u2, *b"ss01"), (u4, *b"ss02"), (u3, *b"swsh"), (g('c'), *b"cv01")], + "aalt first, then salt (b again, left out), ss01, ss02 (an extension lookup), swsh (u1 again, left out), cv01; smcp is not read" + ); + assert!(face.alternates('b').is_empty(), "b has no alternates"); + assert!(face.alternates('\u{E000}').is_empty(), "a character the face does not map"); + let plain = db.face(alternates_family(false), "Regular").expect("the test face"); + let listed: Vec = plain.alternates('a').into_iter().map(|a| a.gid).collect(); + assert_eq!(listed, [g('b'), u2, u4, u3, u1, g('c')], "without aalt the default glyph is not listed"); + let bundled = db.face(FALLBACK_FAMILY, "Regular").expect("the bundled face"); + assert!(bundled.alternates('a').iter().all(|a| a.gid < bundled.glyph_count())); +} + +/// The cap on lookups read in all leaves out the lowest ranked features: `salt` is read even when +/// the `cv01` records before it list more lookups than the cap. +#[test] +fn the_lookup_cap_keeps_the_higher_ranked_features() { + use crate::test_fonts::{CROWDED_ALTERNATES_FAMILY, crowded_alternates_font}; + let db = FontDb::with_font_dirs(vec![]); + db.add_font(crowded_alternates_font().expect("the test font builds")); + let face = db.face(CROWDED_ALTERNATES_FAMILY, "Regular").expect("the test face"); + let listed: Vec<(u32, [u8; 4])> = face.alternates('a').into_iter().map(|a| (a.gid, a.feature)).collect(); + assert_eq!(listed, [(face.glyph_for('b'), *b"salt"), (face.glyph_for('c'), *b"cv01")]); +} + +#[test] +fn a_chosen_glyph_is_drawn_for_its_character_only() { + let db = db(); + let [u1, ..] = unencoded_glyphs().expect("unencoded glyphs"); + let face = db.face(alternates_family(true), "Regular").expect("the test face"); + let choice = face.choice('a', u1); + assert_eq!(choice, GlyphChoice { ch: 'a', gid: u1, font: "AlttestSans-Regular".into(), version: "Version 1.000".into() }); + let chosen = CharStyle { glyph_alternate: Some(choice.clone()), ..style(true) }; + let runs = |first: CharStyle| { + vec![TextRun { text: "aa".into(), style: first, inline: None }, TextRun { text: "ab".into(), style: style(true), inline: None }] + }; + let set = |runs: Vec| { + let mut t = TextObject::point(Point::ZERO, "", style(true)); + t.runs = runs; + layout(&db, &t).glyphs.into_iter().map(|g| (g.gid, g.advance)).collect::>() + }; + let a = face.glyph_for('a'); + let k = 100.0 / face.units_per_em(); + let drawn = set(runs(chosen.clone())); + let gids: Vec = drawn.iter().map(|g| g.0).collect(); + assert_eq!(gids, [u1, a, a, face.glyph_for('b')], "the run's first character only"); + assert!((drawn[0].1 - face.advance(u1) * k).abs() < 1e-6, "it takes the chosen glyph's advance: {drawn:?}"); + // A choice that does not hold for this text and face draws the default glyph. + let other = |f: &dyn Fn(&mut GlyphChoice)| { + let mut c = choice.clone(); + f(&mut c); + set(runs(CharStyle { glyph_alternate: Some(c), ..style(true) }))[0].0 + }; + assert_eq!(other(&|c| c.ch = 'b'), a, "a choice for another character"); + assert_eq!(other(&|c| c.version = "Version 2.000".into()), a, "another version of the font"); + assert_eq!(other(&|c| c.font = "OtherSans-Regular".into()), a, "another font"); + assert_eq!(other(&|c| c.gid = face.glyph_count()), a, "a glyph id past the face's glyphs"); + let elsewhere = CharStyle { font_family: alternates_family(false).into(), glyph_alternate: Some(choice.clone()), ..style(false) }; + assert_eq!(set(runs(elsewhere))[0].0, a, "the same glyph id in another face"); + let caps = CharStyle { all_caps: true, ..chosen.clone() }; + assert_eq!(set(runs(caps))[0].0, face.glyph_for('A'), "All Caps draws the capital"); +} + +#[test] +fn adjacent_characters_with_the_same_choice_keep_both_glyphs() { + let db = db(); + let face = db.face(alternates_family(true), "Regular").expect("the test face"); + let [u1, ..] = unencoded_glyphs().expect("unencoded glyphs"); + let style = CharStyle { glyph_alternate: Some(face.choice('f', u1)), ..style(true) }; + let mut text = TextObject::point(Point::ZERO, "", style.clone()); + text.runs = vec![TextRun { text: "f".into(), style: style.clone(), inline: None }, TextRun { text: "f".into(), style, inline: None }]; + let glyphs = layout(&db, &text).glyphs; + assert_eq!(glyphs.iter().map(|g| g.gid).collect::>(), [u1, u1], "each chosen f draws independently"); +} + +/// A run "fi" whose f has a chosen glyph draws that glyph and the i, not the font's fi ligature. +#[test] +fn a_chosen_character_is_not_taken_into_a_ligature_in_its_run() { + use crate::test_fonts::{LIGATURE_CARET_FAMILY, ligature_caret_font}; + let db = FontDb::with_font_dirs(vec![]); + db.add_font(ligature_caret_font(None).expect("the test font builds")); + let face = db.face(LIGATURE_CARET_FAMILY, "Regular").expect("the test face"); + let plain = CharStyle { font_family: LIGATURE_CARET_FAMILY.into(), size: 100.0, ..CharStyle::default() }; + let gids = |st: CharStyle| layout(&db, &TextObject::point(Point::ZERO, "fi", st)).glyphs.into_iter().map(|g| g.gid).collect::>(); + assert_eq!(gids(plain.clone()).len(), 1, "without a choice the font joins f and i"); + let i = face.glyph_for('i'); + let chosen = CharStyle { glyph_alternate: Some(face.choice('f', i)), ..plain }; + assert_eq!(gids(chosen), [i, i], "the f draws the chosen glyph and the i stays apart"); +} + +/// The font's pair kerning was looked up for the default glyph: a character drawn with another +/// glyph takes no part in it, before or after it. +#[test] +fn a_chosen_glyph_takes_no_kerning_found_for_the_default_glyph() { + use crate::test_fonts::{PAIR_KERNING_FAMILY, pair_kerning_font}; + let db = FontDb::with_font_dirs(vec![]); + db.add_font(pair_kerning_font().expect("the test font builds")); + let face = db.face(PAIR_KERNING_FAMILY, "Regular").expect("the test face"); + let st = CharStyle { font_family: PAIR_KERNING_FAMILY.into(), size: 100.0, ..CharStyle::default() }; + // Each glyph's id, advance and how far it is drawn from its pen position, in one run "AV" + // whose A is drawn with the glyph of `with` (the choice applies to the run's first character). + let set = |with: char| { + let style = CharStyle { glyph_alternate: Some(face.choice('A', face.glyph_for(with))), ..st.clone() }; + let t = TextObject::point(Point::ZERO, "AV", style); + layout(&db, &t).glyphs.into_iter().map(|g| (g.gid, g.advance, g.xf.as_coeffs()[4] - g.origin.x)).collect::>() + }; + let k = 100.0 / face.units_per_em(); + let plain = |c: char| face.advance(face.glyph_for(c)) * k; + let chosen = set('B'); + assert_eq!(chosen.iter().map(|g| g.0).collect::>(), [face.glyph_for('B'), face.glyph_for('V')]); + assert!((chosen[0].1 - plain('B')).abs() < 1e-6, "the chosen glyph keeps none of the A-V pair's advance: {chosen:?}"); + assert!(chosen[1].2.abs() < 1e-6, "the V after it is not moved by the A-V pair: {chosen:?}"); + // The default glyph chosen for the A keeps the pair: 6 pt off its advance, the V 10 pt left. + let same = set('A'); + assert!((same[0].1 - (plain('A') - 6.0)).abs() < 1e-6 && (same[1].2 + 10.0).abs() < 1e-6, "{same:?}"); +} + +#[test] +fn edits_keep_a_choice_on_its_character() { + let face = db().face(alternates_family(true), "Regular").expect("the test face"); + let [u1, ..] = unencoded_glyphs().expect("unencoded glyphs"); + let choice = face.choice('a', u1); + let mut runs = vec![TextRun { text: "xaay".into(), style: style(true), inline: None }]; + edit::style_range(&mut runs, 1, 2, |st| st.glyph_alternate = Some(choice.clone())); + let texts = |runs: &[TextRun]| runs.iter().map(|r| (r.text.clone(), r.style.glyph_alternate.is_some())).collect::>(); + assert_eq!(texts(&runs), [("x".into(), false), ("a".into(), true), ("ay".into(), false)]); + // The second a takes the same choice: a run of its own, not merged with the first. + edit::style_range(&mut runs, 2, 3, |st| st.glyph_alternate = Some(choice.clone())); + assert_eq!(texts(&runs), [("x".into(), false), ("a".into(), true), ("a".into(), true), ("y".into(), false)]); + // Typed text takes the default glyph, also an a typed after a chosen one. + let caret = edit::replace_range(&mut runs, 2, 2, "a"); + assert_eq!(caret, 3); + assert_eq!(texts(&runs), [("x".into(), false), ("a".into(), true), ("a".into(), false), ("a".into(), true), ("y".into(), false)]); + // Styling part of a run keeps the choice on its first character only. + let mut runs = vec![TextRun { text: "ab".into(), style: CharStyle { glyph_alternate: Some(choice.clone()), ..style(true) }, inline: None }]; + edit::style_range(&mut runs, 1, 2, |st| st.underline = true); + assert_eq!(texts(&runs), [("a".into(), true), ("b".into(), false)]); + assert_eq!(texts(&edit::slice_runs(&runs, 0, 2)), [("a".into(), true), ("b".into(), false)]); + // Deleting the character drops its choice from the run that is left. + let mut runs = vec![TextRun { text: "ab".into(), style: CharStyle { glyph_alternate: Some(choice.clone()), ..style(true) }, inline: None }]; + edit::replace_range(&mut runs, 0, 1, ""); + assert_eq!(texts(&runs), [("b".into(), false)]); + let mut runs = vec![TextRun { text: "ab".into(), style: CharStyle { glyph_alternate: Some(choice), ..style(true) }, inline: None }]; + assert_eq!(texts(&edit::slice_runs(&runs, 1, 2)), [("b".into(), false)], "a copy from inside the run"); + edit::normalize(&mut runs); + assert_eq!(texts(&runs), [("ab".into(), true)]); +} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 1af49d987..7a7a8a348 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -841,6 +841,11 @@ font-technology Variable Променливи Double-click to edit the characters' paint Двойно щракване за редактиране на оцветяването на знаците Click to edit Щракнете за редактиране Double-click to edit Двойно щракване за редактиране + Alternates for Current Selection Алтернативи за текущата селекция + Select a character with the Type tool to see its alternates. Изберете знак с инструмента „Текст“, за да видите алтернативите му. + The selected character's font is not available. Шрифтът на избрания знак не е наличен. + No alternates for the selected character. Няма алтернативи за избрания знак. + Double-click to replace Двойно щракване за замяна Double-click to insert Двойно щракване за вмъкване Double-click to rename Двойно щракване за преименуване Double-click to target the contents Двойно щракване за насочване към съдържанието diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index e87f4ad18..5ccea4d95 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -1547,6 +1547,11 @@ digits Malayalam Malajalam Save Transparency Flattener Preset… Sačuvaj predložak spljoštavanja prozirnosti… Basic Latin Osnovna latinica Currency & Symbols Valuta i simboli + Alternates for Current Selection Alternative za trenutni odabir + Select a character with the Type tool to see its alternates. Odaberite znak alatom za tekst da vidite njegove alternative. + The selected character's font is not available. Font odabranog znaka nije dostupan. + No alternates for the selected character. Nema alternativa za odabrani znak. + Double-click to replace Dvostruki klik za zamjenu Double-click to insert Dvostruki klik za umetanje Entire Font Cijeli font glyph {id} glif {id} diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index a6accb7bf..d95f73b70 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -1200,6 +1200,11 @@ Double Slash Barra inclinada doble Double-click Doble clic Double-click to edit the characters' paint Feu doble clic per editar la pintura dels caràcters + Alternates for Current Selection Alternatives per a la selecció actual + Select a character with the Type tool to see its alternates. Selecciona un caràcter amb l’eina de text per veure’n les alternatives. + The selected character's font is not available. El tipus de lletra del caràcter seleccionat no està disponible. + No alternates for the selected character. No hi ha alternatives per al caràcter seleccionat. + Double-click to replace Feu doble clic per substituir Double-click to insert Feu doble clic per inserir Double-click to rename Feu doble clic per canviar el nom Double-click to target the contents Feu doble clic per apuntar al contingut diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 3f4cefc21..152b95424 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1571,6 +1571,11 @@ digits Malayalam Malajálamské Save Transparency Flattener Preset… Uložit přednastavení sloučení průhlednosti… Basic Latin Základní latinka Currency & Symbols Měny a symboly + Alternates for Current Selection Alternativy pro aktuální výběr + Select a character with the Type tool to see its alternates. Alternativy znaku uvidíte, když ho vyberete nástrojem Text. + The selected character's font is not available. Písmo vybraného znaku není k dispozici. + No alternates for the selected character. Vybraný znak nemá žádné alternativy. + Double-click to replace Dvojklikem nahradíte Double-click to insert Dvojklikem vložíte Entire Font Celé písmo glyph {id} glyf {id} diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index b9d6c9806..b8e2a8b50 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -1481,6 +1481,11 @@ digits Malayalam Malayalam Save Transparency Flattener Preset… Gem forudindstilling til fladtrykning af gennemsigtighed… Basic Latin Grundlæggende latinsk Currency & Symbols Valuta & symboler + Alternates for Current Selection Alternativer til den aktuelle markering + Select a character with the Type tool to see its alternates. Markér et tegn med tekstværktøjet for at se dets alternativer. + The selected character's font is not available. Skrifttypen for det markerede tegn er ikke tilgængelig. + No alternates for the selected character. Ingen alternativer til det markerede tegn. + Double-click to replace Dobbeltklik for at erstatte Double-click to insert Dobbeltklik for at indsætte Entire Font Hele skrifttypen glyph {id} glyf {id} diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index a850882af..6cc71b5a2 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -835,6 +835,11 @@ font-technology Variable Variabel Double-click to edit the characters' paint Doppelklicken, um die Farbe der Zeichen zu bearbeiten Click to edit Klicken, um zu bearbeiten Double-click to edit Doppelklicken, um zu bearbeiten + Alternates for Current Selection Alternativen für aktuelle Auswahl + Select a character with the Type tool to see its alternates. Wählen Sie mit dem Text-Werkzeug ein Zeichen aus, um seine Alternativen zu sehen. + The selected character's font is not available. Die Schrift des ausgewählten Zeichens ist nicht verfügbar. + No alternates for the selected character. Keine Alternativen für das ausgewählte Zeichen. + Double-click to replace Doppelklicken zum Ersetzen Double-click to insert Doppelklicken zum Einfügen Double-click to rename Doppelklicken zum Umbenennen Double-click to target the contents Doppelklicken, um den Inhalt als Ziel auszuwählen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 2510a2a97..579ee3062 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -814,6 +814,11 @@ font-technology Variable Μεταβλητές Double-click to edit the characters' paint Διπλό κλικ για επεξεργασία του χρωματισμού χαρακτήρων Click to edit Κλικ για επεξεργασία Double-click to edit Διπλό κλικ για επεξεργασία + Alternates for Current Selection Εναλλακτικοί για την τρέχουσα επιλογή + Select a character with the Type tool to see its alternates. Επιλέξτε έναν χαρακτήρα με το εργαλείο κειμένου για να δείτε τους εναλλακτικούς του. + The selected character's font is not available. Η γραμματοσειρά του επιλεγμένου χαρακτήρα δεν είναι διαθέσιμη. + No alternates for the selected character. Δεν υπάρχουν εναλλακτικοί για τον επιλεγμένο χαρακτήρα. + Double-click to replace Διπλό κλικ για αντικατάσταση Double-click to insert Διπλό κλικ για εισαγωγή Double-click to rename Διπλό κλικ για μετονομασία Double-click to target the contents Διπλό κλικ για επιλογή του περιεχομένου ως στόχου diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 2d37d779c..c45f7e58f 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -856,6 +856,11 @@ font-technology Variable Variables Double-click to edit the characters' paint Doble clic para editar la pintura de los caracteres Click to edit Clic para editar Double-click to edit Doble clic para editar + Alternates for Current Selection Alternativas para la selección actual + Select a character with the Type tool to see its alternates. Selecciona un carácter con la herramienta Texto para ver sus alternativas. + The selected character's font is not available. La fuente del carácter seleccionado no está disponible. + No alternates for the selected character. No hay alternativas para el carácter seleccionado. + Double-click to replace Doble clic para reemplazar Double-click to insert Doble clic para insertar Double-click to rename Doble clic para cambiar el nombre Double-click to target the contents Doble clic para seleccionar el contenido como destino diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 9e856f29f..59a76fc83 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -1544,6 +1544,11 @@ digits Malayalam Malajalam Save Transparency Flattener Preset… Tallenna läpinäkyvyyden litistyksen esiasetus… Basic Latin Latinalainen perusmerkistö Currency & Symbols Valuutat ja symbolit + Alternates for Current Selection Nykyisen valinnan vaihtoehdot + Select a character with the Type tool to see its alternates. Näet merkin vaihtoehdot valitsemalla sen tekstityökalulla. + The selected character's font is not available. Valitun merkin fontti ei ole saatavilla. + No alternates for the selected character. Valitulla merkillä ei ole vaihtoehtoja. + Double-click to replace Korvaa kaksoisnapsauttamalla Double-click to insert Lisää kaksoisnapsauttamalla Entire Font Koko fontti glyph {id} merkki {id} diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 88cbbfb05..f792a533f 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -859,6 +859,11 @@ font-technology Variable Variables Double-click to edit the characters' paint Double-cliquez pour modifier la peinture des caractères Click to edit Cliquez pour modifier Double-click to edit Double-cliquez pour modifier + Alternates for Current Selection Variantes de la sélection + Select a character with the Type tool to see its alternates. Sélectionnez un caractère avec l’outil Texte pour afficher ses variantes. + The selected character's font is not available. La police du caractère sélectionné n’est pas disponible. + No alternates for the selected character. Aucune variante pour le caractère sélectionné. + Double-click to replace Double-cliquez pour remplacer Double-click to insert Double-cliquez pour insérer Double-click to rename Double-cliquez pour renommer Double-click to target the contents Double-cliquez pour cibler le contenu diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index b6a95e05f..35f360496 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1106,6 +1106,11 @@ font-technology Variable Változó Double-click to edit the characters' paint Dupla kattintással a karakterek festése szerkeszthető Click to edit Kattintással szerkeszthető Double-click to edit Dupla kattintással szerkeszthető + Alternates for Current Selection Az aktuális kijelölés változatai + Select a character with the Type tool to see its alternates. A változatok megtekintéséhez jelölj ki egy karaktert a Szöveg eszközzel. + The selected character's font is not available. A kijelölt karakter betűtípusa nem érhető el. + No alternates for the selected character. A kijelölt karakternek nincsenek változatai. + Double-click to replace Csere dupla kattintással Double-click to insert Beszúrás dupla kattintással Double-click to rename Dupla kattintással átnevezhető Double-click to target the contents Dupla kattintással a tartalom lesz a cél diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 5e6fab981..bbd1cd732 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1481,6 +1481,11 @@ digits Malayalam Malayalam Save Transparency Flattener Preset… Simpan preset flattener transparansi… Basic Latin Latin dasar Currency & Symbols Mata uang & simbol + Alternates for Current Selection Alternatif untuk Pilihan Saat Ini + Select a character with the Type tool to see its alternates. Pilih karakter dengan alat teks untuk melihat alternatifnya. + The selected character's font is not available. Font karakter yang dipilih tidak tersedia. + No alternates for the selected character. Tidak ada alternatif untuk karakter yang dipilih. + Double-click to replace Klik ganda untuk mengganti Double-click to insert Klik ganda untuk menyisipkan Entire Font Seluruh font glyph {id} glif {id} diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index c7885a331..8a23b7d8e 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -856,6 +856,11 @@ font-technology Variable Variabili Double-click to edit the characters' paint Fai doppio clic per modificare il colore dei caratteri Click to edit Fai clic per modificare Double-click to edit Fai doppio clic per modificare + Alternates for Current Selection Alternative per la selezione corrente + Select a character with the Type tool to see its alternates. Seleziona un carattere con lo strumento Testo per vederne le alternative. + The selected character's font is not available. Il font del carattere selezionato non è disponibile. + No alternates for the selected character. Nessuna alternativa per il carattere selezionato. + Double-click to replace Fai doppio clic per sostituire Double-click to insert Fai doppio clic per inserire Double-click to rename Fai doppio clic per rinominare Double-click to target the contents Fai doppio clic per impostare il contenuto come destinazione diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 586a6c273..3af0bdb02 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1419,6 +1419,11 @@ digits Malayalam マラヤーラム数字 Double-click to edit the characters' paint ダブルクリックで文字のペイントを編集 Click to edit クリックで編集 Double-click to edit ダブルクリックで編集 + Alternates for Current Selection 選択文字の異体字 + Select a character with the Type tool to see its alternates. 文字ツールで文字を選択すると、その異体字が表示されます。 + The selected character's font is not available. 選択した文字のフォントを使用できません。 + No alternates for the selected character. 選択した文字には異体字がありません。 + Double-click to replace ダブルクリックで置換 Double-click to insert ダブルクリックで挿入 Double-click to rename ダブルクリックで名前を変更 Double-click to target the contents ダブルクリックで内容をターゲット diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 6067c70c2..bf2684eb9 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -1406,6 +1406,11 @@ digits Malayalam მალაიალამური Save Transparency Flattener Preset… გამჭვირვალობის გაბრტყელების პრესეტის შენახვა… Basic Latin საბაზო ლათინური Currency & Symbols ვალუტა და სიმბოლოები + Alternates for Current Selection მიმდინარე მონიშვნის ალტერნატივები + Select a character with the Type tool to see its alternates. ალტერნატივების სანახავად ტექსტის ხელსაწყოთი მონიშნეთ სიმბოლო. + The selected character's font is not available. მონიშნული სიმბოლოს შრიფტი მიუწვდომელია. + No alternates for the selected character. მონიშნულ სიმბოლოს ალტერნატივები არ აქვს. + Double-click to replace ორჯერ დაწკაპეთ ჩასანაცვლებლად Double-click to insert ორჯერ დაწკაპეთ ჩასასმელად Entire Font მთელი შრიფტი glyph {id} გლიფი {id} diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 8ac96cd97..4d86c3321 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1409,6 +1409,11 @@ digits Malayalam 말라얄람어 Double-click to edit the characters' paint 더블클릭하여 문자의 색 편집 Click to edit 클릭하여 편집 Double-click to edit 더블클릭하여 편집 + Alternates for Current Selection 현재 선택 항목의 대체 글리프 + Select a character with the Type tool to see its alternates. 문자 도구로 문자를 선택하면 대체 글리프가 표시됩니다. + The selected character's font is not available. 선택한 문자의 글꼴을 사용할 수 없습니다. + No alternates for the selected character. 선택한 문자의 대체 글리프가 없습니다. + Double-click to replace 더블클릭하여 바꾸기 Double-click to insert 더블클릭하여 삽입 Double-click to rename 더블클릭하여 이름 변경 Double-click to target the contents 더블클릭하여 내용 선택 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index e8035f8f1..d79824a47 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -1495,6 +1495,11 @@ digits Malayalam Malayalam Save Transparency Flattener Preset… Voorinstelling voor transparantieafvlakking opslaan… Basic Latin Basic Latin Currency & Symbols Valuta & symbolen + Alternates for Current Selection Alternatieven voor huidige selectie + Select a character with the Type tool to see its alternates. Selecteer een teken met het Type-gereedschap om de alternatieven te zien. + The selected character's font is not available. Het lettertype van het geselecteerde teken is niet beschikbaar. + No alternates for the selected character. Geen alternatieven voor het geselecteerde teken. + Double-click to replace Dubbelklik om te vervangen Double-click to insert Dubbelklik om in te voegen Entire Font Volledig lettertype glyph {id} glyph {id} diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 330f31744..83046c58a 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1484,6 +1484,11 @@ digits Malayalam Malajalam Save Transparency Flattener Preset… Zapisz ustawienie spłaszczania przezroczystości… Basic Latin Łaciński podstawowy Currency & Symbols Waluty i symbole + Alternates for Current Selection Alternatywy bieżącego zaznaczenia + Select a character with the Type tool to see its alternates. Zaznacz znak narzędziem Tekst, aby zobaczyć jego alternatywy. + The selected character's font is not available. Czcionka zaznaczonego znaku jest niedostępna. + No alternates for the selected character. Brak alternatyw dla zaznaczonego znaku. + Double-click to replace Kliknij dwukrotnie, aby zastąpić Double-click to insert Kliknij dwukrotnie, aby wstawić Entire Font Cała czcionka glyph {id} glif {id} diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index cb3af6490..4199f00a6 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -766,6 +766,11 @@ Double-click to edit the characters' paint Clique duplo para editar a pintura dos caracteres Click to edit Clique para editar Double-click to edit Clique duplo para editar + Alternates for Current Selection Alternativas para a seleção atual + Select a character with the Type tool to see its alternates. Selecione um caractere com a ferramenta Texto para ver as alternativas. + The selected character's font is not available. A fonte do caractere selecionado não está disponível. + No alternates for the selected character. Nenhuma alternativa para o caractere selecionado. + Double-click to replace Clique duplo para substituir Double-click to insert Clique duplo para inserir Double-click to rename Clique duplo para renomear Double-click to target the contents Clique duplo para direcionar o conteúdo diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index c2e9f88ef..004d92746 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -854,6 +854,11 @@ font-technology Variable Вариативные Double-click to edit the characters' paint Дважды щёлкните, чтобы редактировать заливку символов Click to edit Щёлкните, чтобы редактировать Double-click to edit Дважды щёлкните, чтобы редактировать + Alternates for Current Selection Альтернативы для текущего выделения + Select a character with the Type tool to see its alternates. Выделите символ инструментом «Текст», чтобы увидеть его альтернативы. + The selected character's font is not available. Шрифт выделенного символа недоступен. + No alternates for the selected character. У выделенного символа нет альтернатив. + Double-click to replace Дважды щёлкните для замены Double-click to insert Дважды щёлкните для вставки Double-click to rename Дважды щёлкните для переименования Double-click to target the contents Дважды щёлкните, чтобы выбрать содержимое как объект diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 0c1a4403a..81e308099 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -843,6 +843,11 @@ font-technology Variable Variabla Double-click to edit the characters' paint Dubbelklicka för att redigera tecknens färg Click to edit Klicka för att redigera Double-click to edit Dubbelklicka för att redigera + Alternates for Current Selection Alternativ för aktuell markering + Select a character with the Type tool to see its alternates. Markera ett tecken med textverktyget för att se dess alternativ. + The selected character's font is not available. Teckensnittet för det markerade tecknet är inte tillgängligt. + No alternates for the selected character. Inga alternativ för det markerade tecknet. + Double-click to replace Dubbelklicka för att ersätta Double-click to insert Dubbelklicka för att infoga Double-click to rename Dubbelklicka för att byta namn Double-click to target the contents Dubbelklicka för att välja innehållet som mål diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 2332baf16..3ca7e892d 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -835,6 +835,11 @@ font-technology Variable Değişken Double-click to edit the characters' paint Karakterlerin boyasını düzenlemek için çift tıklayın Click to edit Düzenlemek için tıklayın Double-click to edit Düzenlemek için çift tıklayın + Alternates for Current Selection Geçerli Seçim için Alternatifler + Select a character with the Type tool to see its alternates. Alternatiflerini görmek için Yazım aracıyla bir karakter seçin. + The selected character's font is not available. Seçili karakterin yazı tipi kullanılamıyor. + No alternates for the selected character. Seçili karakter için alternatif yok. + Double-click to replace Değiştirmek için çift tıklayın Double-click to insert Eklemek için çift tıklayın Double-click to rename Yeniden adlandırmak için çift tıklayın Double-click to target the contents İçeriği hedeflemek için çift tıklayın diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 6816dbecd..74e866c72 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -850,6 +850,11 @@ font-technology Variable Варіативні Double-click to edit the characters' paint Двічі клацніть, щоб редагувати заливку символів Click to edit Клацніть, щоб редагувати Double-click to edit Двічі клацніть, щоб редагувати + Alternates for Current Selection Альтернативи для поточного виділення + Select a character with the Type tool to see its alternates. Виділіть символ інструментом «Текст», щоб побачити його альтернативи. + The selected character's font is not available. Шрифт виділеного символу недоступний. + No alternates for the selected character. Виділений символ не має альтернатив. + Double-click to replace Двічі клацніть для заміни Double-click to insert Двічі клацніть для вставки Double-click to rename Двічі клацніть для перейменування Double-click to target the contents Двічі клацніть, щоб вибрати вміст як об’єкт diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 86db175fc..8742397b4 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -991,6 +991,11 @@ font-technology Variable Biến thiên Double-click to edit the characters' paint Bấm đúp để chỉnh sửa màu ký tự Click to edit Bấm để chỉnh sửa Double-click to edit Bấm đúp để chỉnh sửa + Alternates for Current Selection Glyph thay thế cho vùng chọn hiện tại + Select a character with the Type tool to see its alternates. Chọn một ký tự bằng công cụ Chữ để xem các glyph thay thế. + The selected character's font is not available. Phông chữ của ký tự đã chọn không khả dụng. + No alternates for the selected character. Ký tự đã chọn không có glyph thay thế. + Double-click to replace Bấm đúp để thay thế Double-click to insert Bấm đúp để chèn Double-click to rename Bấm đúp để đổi tên Double-click to target the contents Bấm đúp để chọn nội dung diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index bea443d8e..a69069d5f 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -798,6 +798,11 @@ Double-click to edit the characters' paint 双击以编辑字符的上色 Click to edit 单击以编辑 Double-click to edit 双击以编辑 + Alternates for Current Selection 当前所选字符的替代字 + Select a character with the Type tool to see its alternates. 使用文字工具选择一个字符以查看其替代字。 + The selected character's font is not available. 所选字符的字体不可用。 + No alternates for the selected character. 所选字符没有替代字。 + Double-click to replace 双击以替换 Double-click to insert 双击以插入 Double-click to rename 双击以重命名 Double-click to target the contents 双击以指定内容 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index a61287b96..a4f887993 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -818,6 +818,11 @@ font-technology Variable 可變 Double-click to edit the characters' paint 按兩下以編輯字元的上色 Click to edit 按一下以編輯 Double-click to edit 按兩下以編輯 + Alternates for Current Selection 目前選取字元的替代字 + Select a character with the Type tool to see its alternates. 使用文字工具選取一個字元以檢視其替代字。 + The selected character's font is not available. 選取字元的字體無法使用。 + No alternates for the selected character. 選取的字元沒有替代字。 + Double-click to replace 按兩下以取代 Double-click to insert 按兩下以插入 Double-click to rename 按兩下以重新命名 Double-click to target the contents 按兩下以指定內容 diff --git a/crates/ui-egui/src/panels/glyphs.rs b/crates/ui-egui/src/panels/glyphs.rs index e69e97c46..dce106543 100644 --- a/crates/ui-egui/src/panels/glyphs.rs +++ b/crates/ui-egui/src/panels/glyphs.rs @@ -1,12 +1,14 @@ //! Glyphs panel: every character the current font maps (its cmap), drawn from the font's own //! outlines. Double-click inserts the character at the Type tool's caret (or appends it to the //! selected text objects). The character selected with the Type tool is highlighted in the grid. +//! Show › Alternates for Current Selection lists that character's alternate glyphs instead, and a +//! double-click draws the character with one of them. use std::collections::HashMap; use egui::{Color32, Sense, Stroke, StrokeKind, Ui, vec2}; use serde_json::json; -use vectorcraft_doc::NodeKind; +use vectorcraft_doc::{CharStyle, NodeId, NodeKind}; use vectorcraft_geom::{Affine, BezPath, PathEl}; use vectorcraft_text::FontDb; @@ -55,6 +57,13 @@ const SUBSETS: [(&str, u32, u32); 7] = [ ("Greek & Cyrillic", 0x370, 0x52F), ]; +/// The Show menu's choice after [`SUBSETS`] (its index is `SUBSETS.len()`): the alternate glyphs +/// of the Type tool's selected character. Public behavior: [Replace characters with alternate +/// glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html) +/// (select Alternates For Current Selection from the Show menu; highlight a character, then +/// double-click the glyph to replace it). +const ALTERNATES: &str = "Alternates for Current Selection"; + thread_local! { static TEX: crate::graphics::TexCache> = crate::graphics::TexCache::default(); } @@ -150,19 +159,35 @@ fn glyph_texture(ctx: &egui::Context, face: &vectorcraft_text::FontFace, gid: u3 /// (the overview page cited at [`CELL_STEPS`]). Walks the runs, not the characters, so it stays /// cheap on every frame. fn selected_char(app: &VectorcraftApp) -> Option { + selected_target(app).map(|t| t.ch) +} + +/// The character [`selected_char`] picks, where it is and the style of its run. +struct Target<'a> { + id: NodeId, + /// The character's first byte in the text. + byte: usize, + ch: char, + style: &'a CharStyle, +} + +fn selected_target(app: &VectorcraftApp) -> Option> { let (id, a, b) = super::character::text_editing(app)?; let NodeKind::Text(t) = &app.session.active()?.doc.node(id)?.kind else { return None }; let (at, range) = (a.min(b), a != b); let mut start = 0; for run in &t.runs { let end = start + run.text.len(); - if range && (start..end).contains(&at) { - return run.text.get(at - start..)?.chars().next(); - } - if !range && at > start && at <= end { - return run.text.get(..at - start)?.chars().next_back(); - } - start = end; + let byte = if range && (start..end).contains(&at) { + at + } else if !range && at > start && at <= end { + at - run.text.get(..at - start)?.chars().next_back()?.len_utf8() + } else { + start = end; + continue; + }; + let ch = run.text.get(byte - start..)?.chars().next()?; + return Some(Target { id, byte, ch, style: &run.style }); } None } @@ -194,20 +219,25 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { // The panel browses the current text's font unless another font was picked here. let picked: Option<(String, String)> = pstate(ui.ctx(), "gl-font"); let (family, style) = picked.or(current).unwrap_or_else(|| (vectorcraft_text::FALLBACK_FAMILY.to_string(), "Regular".to_string())); - let Some(face) = db.face(&family, &style) else { - ui.label(egui::RichText::new(tl!("No fonts are available.")).color(t.text_dim)); - return; - }; let subset: usize = pstate(ui.ctx(), "gl-subset"); let (_, lo, hi) = SUBSETS[subset.min(SUBSETS.len() - 1)]; let w = ui.available_width(); - let names: Vec<&str> = SUBSETS.iter().map(|s| s.0).collect(); + let names: Vec<&str> = SUBSETS.iter().map(|s| s.0).chain([ALTERNATES]).collect(); ui.horizontal(|ui| { ui.label(egui::RichText::new(tl!("Show:")).color(t.text_dim)); if let Some(i) = widgets::dropdown(ui, "gl-subset", names[subset.min(names.len() - 1)], &names, (w - 50.0).max(80.0)) { set_pstate(ui.ctx(), "gl-subset", i); } }); + if subset >= SUBSETS.len() { + alternates(app, ui, w); + font_row(app, ui, &family, &style, w); + return; + } + let Some(face) = db.face(&family, &style) else { + ui.label(egui::RichText::new(tl!("No fonts are available.")).color(t.text_dim)); + return; + }; let chars: Vec<(char, u32)> = face.chars().into_iter().filter(|(c, _)| (lo..=hi).contains(&(*c as u32)) && !c.is_control() && !c.is_whitespace()).collect(); let cell = CELL_STEPS[cell_index(app)]; @@ -272,6 +302,12 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { .size(11.0) .color(t.text_dim), ); + font_row(app, ui, &family, &style, w); +} + +/// The family and style menus of the font the panel browses, and Zoom Out and Zoom In. +fn font_row(app: &mut VectorcraftApp, ui: &mut Ui, family: &str, style: &str, w: f32) { + let db = FontDb::global(); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 4.0; // Picks the font the panel browses only: nothing is previewed on the document. @@ -282,17 +318,17 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let style_w = (menus - family_w).max(40.0); let sample = crate::font_menu::sample_text(app); let look = crate::font_menu::MenuLook::of(app); - let pick = crate::font_menu::font_menu(ui, "gl-family", &family, family_w, sample.as_deref(), look); + let pick = crate::font_menu::font_menu(ui, "gl-family", family, family_w, sample.as_deref(), look); if let Some((f, style)) = crate::font_menu::picked(app, pick) { let st = style.unwrap_or_else(|| db.face(&f, "Regular").map_or_else(|| "Regular".into(), |face| face.style.clone())); set_pstate(ui.ctx(), "gl-font", Some((f, st))); } - let styles = db.styles(&family); + let styles = db.styles(family); let snames: Vec<&str> = styles.iter().map(String::as_str).collect(); - if let Some(i) = widgets::dropdown_names(ui, "gl-style", &style, &snames, style_w) + if let Some(i) = widgets::dropdown_names(ui, "gl-style", style, &snames, style_w) && let Some(name) = snames.get(i) { - set_pstate(ui.ctx(), "gl-font", Some((family.clone(), (*name).to_string()))); + set_pstate(ui.ctx(), "gl-font", Some((family.to_string(), (*name).to_string()))); } let step = cell_index(app); if zoom_button(ui, "out", "−", "Zoom Out", step > 0) { @@ -304,6 +340,113 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { }); } +/// The grid of Show › Alternates for Current Selection: the alternate glyphs of the Type tool's +/// selected character in the face its text is set in, whatever font the panel browses +/// ([`vectorcraft_text::FontFace::alternates`]), with that face's name under the grid. The glyph +/// the character is drawn with is highlighted. A double-click draws the character with the glyph +/// (`text.setGlyphAlternate`); the default glyph, when the font lists it, puts it back. +fn alternates(app: &mut VectorcraftApp, ui: &mut Ui, w: f32) { + let t = Tokens::get(ui.ctx()); + let db = FontDb::global(); + let note = |ui: &mut Ui, text: &str| { + ui.add_space(8.0); + ui.label(egui::RichText::new(text).color(t.text_dim)); + }; + let Some(target) = selected_target(app) else { + return note(ui, tl!("Select a character with the Type tool to see its alternates.")); + }; + let face = db.style_face(target.style).filter(|_| db.has_family(&target.style.font_family)); + let Some(face) = face else { + return note(ui, tl!("The selected character's font is not available.")); + }; + let (id, byte, ch) = (target.id, target.byte, target.ch); + let (list, drawn) = alternates_and_drawn(app, ui, &face, id, byte, ch); + let font = format!("{} {}", face.family, face.style); + if list.is_empty() { + note(ui, tl!("No alternates for the selected character.")); + ui.label(egui::RichText::new(font).size(11.0).color(t.text_dim)); + return; + } + let cell = CELL_STEPS[cell_index(app)]; + let cols = ((w / cell).floor() as usize).max(1); + let rows = list.len().div_ceil(cols); + let px = (cell * ui.ctx().pixels_per_point()).round().clamp(8.0, 256.0) as u32; + let list_h = (ui.available_height() - 64.0).max(cell * 3.0); + let mut pick = None; + // A scroll position of its own for each character, so the grid starts at the top for another. + let area = egui::ScrollArea::vertical().id_salt(("gl-alternates", id.0, byte)).max_height(list_h).auto_shrink([false, false]); + area.show_rows(ui, cell, rows, |ui, range| { + for r in range { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing = vec2(0.0, 0.0); + for a in list.iter().skip(r * cols).take(cols) { + let (rect, resp) = ui.allocate_exact_size(vec2(cell, cell), Sense::click()); + let current = drawn.is_some_and(|(font_id, gid)| font_id == face.id() && a.gid == gid); + if resp.hovered() { + ui.painter().rect_filled(rect, 2, t.hover); + } else if current { + ui.painter().rect_filled(rect, 2, t.row_selected); + } + let edge = if current { Stroke::new(1.5, t.accent) } else { Stroke::new(0.5, t.border) }; + ui.painter().rect_stroke(rect.shrink(0.5), 0, edge, StrokeKind::Inside); + let tex = glyph_texture(ui.ctx(), &face, a.gid, px); + ui.painter().image(tex.id(), rect, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), t.text_strong); + let glyph = crate::i18n::fmt(tl!("glyph {id}"), &[("id", &a.gid.to_string())]); + let resp = resp.on_hover_text(format!("{glyph} ({})\n{}", String::from_utf8_lossy(&a.feature), tl!("Double-click to replace"))); + if resp.double_clicked() { + pick = Some(a.gid); + } + } + }); + } + }); + ui.add_space(4.0); + let count = crate::i18n::tn(list.len() as u64, "{n} glyph", "{n} glyphs"); + ui.label(egui::RichText::new(format!("{font} · {count}")).size(11.0).color(t.text_dim)); + if let Some(gid) = pick { + super::character::end_typing(app); + if let Err(e) = app.run("text.setGlyphAlternate", json!({"id": id.0, "start": byte, "gid": gid})) { + app.status(e.to_string()); + } + } +} + +/// The alternates of `ch` in `face` and the face id and glyph the text object `id` draws the +/// character at `byte` with. Both are kept in egui's temporary data until the document, the +/// character or the face changes, as [`super::character`] keeps the kerning at the caret. +fn alternates_and_drawn( + app: &VectorcraftApp, + ui: &Ui, + face: &vectorcraft_text::FontFace, + id: NodeId, + byte: usize, + ch: char, +) -> (Vec, Option<(u32, u32)>) { + type Found = (Vec, Option<(u32, u32)>); + let state = app.session.active(); + let key = state.map(|s| (s.uid, s.revision, id, byte, face.id())); + let slot = ui.scope_id().with("gl-alternates-found"); + if let Some(key) = key + && let Some((cached_key, found)) = ui.ctx().data(|d| d.get_temp::<((u64, u64, NodeId, usize, u32), Found)>(slot)) + && cached_key == key + { + return found; + } + let drawn = state + .and_then(|s| s.doc.node(id)) + .and_then(|node| match &node.kind { + NodeKind::Text(text) => Some(vectorcraft_text::layout(FontDb::global(), text)), + _ => None, + }) + .and_then(|layout| layout.glyphs.into_iter().find(|g| g.byte <= byte && byte < g.byte.saturating_add(g.len))) + .map(|g| (g.font_id, g.gid)); + let found = (face.alternates(ch), drawn); + if let Some(key) = key { + ui.ctx().data_mut(|d| d.insert_temp(slot, (key, found.clone()))); + } + found +} + /// A square [ − ] or [ + ] beside the font menus. `label` is the mark itself (not translated). fn zoom_button(ui: &mut Ui, id: &str, label: &str, tip: &str, enabled: bool) -> bool { let t = Tokens::get(ui.ctx()); @@ -330,6 +473,10 @@ pub fn menu(_app: &mut VectorcraftApp, ui: &mut Ui) { set_pstate(ui.ctx(), "gl-subset", i); } } + let cur: usize = pstate(ui.ctx(), "gl-subset"); + if menu_item(ui, tl!("Alternates for Current Selection"), true, cur >= SUBSETS.len()) { + set_pstate(ui.ctx(), "gl-subset", SUBSETS.len()); + } } #[cfg(test)] @@ -393,12 +540,17 @@ mod tests { /// Sides of the square glyph previews (font-menu icons are smaller). fn square_sides(shapes: &[Shape]) -> Vec { - fn walk(s: &Shape, out: &mut Vec) { + squares(shapes).iter().map(|r| r.width()).collect() + } + + /// The square glyph previews, in the order they are drawn. + fn squares(shapes: &[Shape]) -> Vec { + fn walk(s: &Shape, out: &mut Vec) { match s { Shape::Mesh(m) => { let r = m.calc_bounds(); if (r.width() - r.height()).abs() < 1.0 && r.width() >= 14.0 { - out.push(r.width()); + out.push(r); } } Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)), @@ -486,6 +638,139 @@ mod tests { assert!(highlighted(&shapes(&ctx, &mut app, vec![]), &t).is_empty()); } + /// The panel menu's texts and where each is drawn. + fn menu_texts(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec) -> Vec<(String, Pos2)> { + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(280.0, 420.0))), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| menu(app, ui)); + out.textures_delta.clear(); + text_anchors(&out.shapes.into_iter().map(|c| c.shape).collect::>()) + } + + /// Choose `label` in the panel menu (its items start with a check mark or spaces). + fn choose(ctx: &egui::Context, app: &mut VectorcraftApp, label: &str) { + let items = menu_texts(ctx, app, vec![]); + let item = items.iter().find(|(t, _)| t.trim_start_matches(['✓', ' ']) == label); + let at = item.unwrap_or_else(|| panic!("{label} missing in {items:?}")).1 + vec2(16.0, 4.0); + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + menu_texts(ctx, app, vec![egui::Event::PointerMoved(at), button(true)]); + menu_texts(ctx, app, vec![button(false)]); + } + + fn texts(ctx: &egui::Context, app: &mut VectorcraftApp) -> Vec { + text_anchors(&shapes(ctx, app, vec![])).into_iter().map(|(t, _)| t).collect() + } + + /// Show › Alternates for Current Selection lists the selected character's alternates from the + /// font of its text, whatever font the panel browses; a double-click draws the character with + /// one. A character without alternates, no selected character and a missing font each show a + /// note, and Show stays on the alternates. + #[test] + fn alternates_for_current_selection_lists_and_replaces_the_selected_character() { + use vectorcraft_text::test_fonts::{alternates_family, alternates_font, unencoded_glyphs}; + FontDb::global().add_font(alternates_font(true).expect("the test font builds")); + let [u1, ..] = unencoded_glyphs().expect("unencoded glyphs"); + let (mut app, id) = editing("xay"); + app.run("text.setRangeStyle", json!({"id": id, "font": alternates_family(true)})).unwrap(); + select(&mut app, 2, 2); + let ctx = egui::Context::default(); + let t = Tokens::get(&ctx); + let entire = squares(&shapes(&ctx, &mut app, vec![])).len(); + choose(&ctx, &mut app, "Alternates for Current Selection"); + assert_eq!(pstate::(&ctx, "gl-subset"), SUBSETS.len()); + let all = texts(&ctx, &mut app); + assert!(all.iter().any(|s| s == "Alternates for Current Selection"), "the Show menu names it: {all:?}"); + assert!(all.iter().any(|s| s == &format!("{} Regular · 7 glyphs", alternates_family(true))), "the font and the count: {all:?}"); + let found = shapes(&ctx, &mut app, vec![]); + let cells = squares(&found); + assert_eq!(cells.len(), 7, "a, its six alternates and nothing else (the whole font drew {entire})"); + assert_eq!(highlighted(&found, &t), [cells[0].center()], "the default glyph is the one drawn"); + app.run("text.setRangeStyle", json!({"id": id, "start": 1, "end": 2, "allCaps": true})).unwrap(); + let caps = shapes(&ctx, &mut app, vec![]); + assert!(highlighted(&caps, &t).is_empty(), "All Caps draws a different glyph, so no alternate of a is highlighted"); + app.run("text.setRangeStyle", json!({"id": id, "start": 1, "end": 2, "allCaps": false})).unwrap(); + let found = shapes(&ctx, &mut app, vec![]); + assert_eq!(highlighted(&found, &t), [squares(&found)[0].center()]); + + // The panel's own font does not change what is listed. + set_pstate(&ctx, "gl-font", Some(("Source Serif 4".to_string(), "Regular".to_string()))); + assert_eq!(squares(&shapes(&ctx, &mut app, vec![])).len(), 7); + set_pstate(&ctx, "gl-font", Some(("Unavailable Panel Font".to_string(), "Regular".to_string()))); + assert_eq!(squares(&shapes(&ctx, &mut app, vec![])).len(), 7, "the selected text's face still supplies alternates"); + + // A double-click on the second cell draws the a with that glyph, as one undo step. + let at = cells[1].center(); + let undo = app.session.active().unwrap().history.undo.len(); + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let mut time = 10.0; + let mut timed = |app: &mut VectorcraftApp, events: Vec| { + time += 0.05; + let screen = Some(Rect::from_min_size(Pos2::ZERO, vec2(280.0, 420.0))); + let mut out = ctx.run_ui(egui::RawInput { screen_rect: screen, time: Some(time), events, ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + }; + timed(&mut app, vec![egui::Event::PointerMoved(at)]); + for _ in 0..2 { + timed(&mut app, vec![button(true)]); + timed(&mut app, vec![button(false)]); + } + assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1, "status: {}", app.ui.status); + let NodeKind::Text(text) = &app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap().kind else { + panic!("text") + }; + assert_eq!(text.plain_text(), "xay"); + let gids: Vec = vectorcraft_text::layout(FontDb::global(), text).glyphs.iter().map(|g| g.gid).collect(); + assert_eq!(gids[1], u1, "the a is drawn with the chosen glyph"); + shapes(&ctx, &mut app, vec![egui::Event::PointerMoved(Pos2::ZERO)]); + let found = shapes(&ctx, &mut app, vec![]); + assert_eq!(highlighted(&found, &t), [squares(&found)[1].center()], "the chosen glyph is highlighted"); + + select(&mut app, 1, 1); + let all = texts(&ctx, &mut app); + assert!(all.iter().any(|s| s == "No alternates for the selected character."), "{all:?}"); + assert_eq!(pstate::(&ctx, "gl-subset"), SUBSETS.len(), "Show stays on the alternates"); + assert!(squares(&shapes(&ctx, &mut app, vec![])).is_empty()); + + app.run("text.setRangeStyle", json!({"id": id, "font": "No Such Font Family"})).unwrap(); + select(&mut app, 2, 2); + assert!(texts(&ctx, &mut app).iter().any(|s| s == "The selected character's font is not available.")); + + app.session.select_tool("select", Default::default()).unwrap(); + assert!(texts(&ctx, &mut app).iter().any(|s| s == "Select a character with the Type tool to see its alternates.")); + + // Back to Entire Font: the font's characters again. + choose(&ctx, &mut app, "Entire Font"); + assert_eq!(pstate::(&ctx, "gl-subset"), 0); + assert!(squares(&shapes(&ctx, &mut app, vec![])).len() > 7); + } + + /// The alternates and the glyph drawn are found once per document revision: a change that does + /// not count as an edit leaves the grid as it was, and the next revision finds them again. + #[test] + fn alternates_are_found_once_per_revision() { + use vectorcraft_text::test_fonts::{alternates_family, alternates_font, unencoded_glyphs}; + FontDb::global().add_font(alternates_font(true).expect("the test font builds")); + let [u1, ..] = unencoded_glyphs().expect("unencoded glyphs"); + let (mut app, id) = editing("xay"); + app.run("text.setRangeStyle", json!({"id": id, "font": alternates_family(true)})).unwrap(); + select(&mut app, 2, 2); + let ctx = egui::Context::default(); + let t = Tokens::get(&ctx); + choose(&ctx, &mut app, "Alternates for Current Selection"); + let found = shapes(&ctx, &mut app, vec![]); + let cells = squares(&found); + assert_eq!(highlighted(&found, &t), [cells[0].center()], "the default glyph is drawn"); + let face = FontDb::global().face(alternates_family(true), "Regular").expect("the test face"); + let node = vectorcraft_doc::NodeId(id.as_u64().unwrap()); + let state = app.session.active_mut().unwrap(); + let Some(NodeKind::Text(text)) = std::sync::Arc::make_mut(&mut state.doc).node_mut(node).map(|n| &mut n.kind) else { panic!("text") }; + vectorcraft_text::edit::style_range(&mut text.runs, 1, 2, |st| st.glyph_alternate = Some(face.choice('a', u1))); + let found = shapes(&ctx, &mut app, vec![]); + assert_eq!(highlighted(&found, &t), [cells[0].center()], "the same revision keeps what was found"); + app.session.active_mut().unwrap().revision += 1; + let found = shapes(&ctx, &mut app, vec![]); + assert_eq!(highlighted(&found, &t), [cells[1].center()], "the next revision finds the chosen glyph"); + } + #[test] fn zoom_stops_at_the_ends() { assert_eq!(nudge_cell(CELL_DEFAULT, 1), CELL_DEFAULT + 1); diff --git a/docs/mcp.md b/docs/mcp.md index 3c57dc208..4a7e04c65 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1209,7 +1209,8 @@ in `shape.flare`; `decay` and `segments` in `shape.spiral`; `rows` and `columns` and `radial` in `shape.polarGrid`; `size` and the `width`, `height` and `fitMinPercent` of `area` in `text.create`; `size` and `fitMinPercent` in `text.createInPath`; `start`, `end`, `size`, `tracking`, `baselineShift`, `hScale`, `vScale`, `rotation` and `strokeWidth` in `text.setRangeStyle`; `start` and `end` in -`text.editRange` and `text.getRange`; `at` in `text.startList`; `start`, `hScale`, `vScale` and `rotation` in `text.touchType`; `by`, `start` and +`text.editRange` and `text.getRange`; `at` in `text.startList`; `start`, `hScale`, `vScale` and `rotation` in `text.touchType`; `start` in +`text.glyphAlternates` and `text.setGlyphAlternate`; `by`, `start` and `end` in `type.step`; `by` in `type.size.increase` and `type.size.decrease`; `size` in `type.ruby`; `width`, `height` and `fitMinPercent` in `text.areaOptions`; `x` and `y` in `symbol.place`; `radius`, `density` and `scale` in `symbol.spray`; `radius` and `intensity` in `symbol.adjust`; `to` in `swatch.move`; `weight` and `opacity` in @@ -5599,6 +5600,30 @@ it, as with any style change to one character (`text.setRangeStyle`). The type s {"name":"run_command","arguments":{"command":"text.touchType","params":{"id":7,"start":1,"dx":10,"dy":4}}} ``` +## Alternate glyphs + +The Glyphs panel's Show › Alternates for Current Selection lists the alternate glyphs of the character selected with +the Type tool (a range's first character, else the one before the caret), and a double-click on one draws that character +with it. `text.glyphAlternates {id, start}` lists them for the character at byte `start`: `{char, font, style, default, +chosen, alternates: [{gid, feature}]}`, where `default` is the glyph the font's character map gives, `chosen` the glyph +drawn now, and `alternates` the glyphs the font's `GSUB` single and alternate substitutions put in place of the default +glyph: `aalt` first, then `salt`, `ss01` to `ss20`, `swsh`, `cv01` to `cv99`, `hist`, `jalt`, `nalt`, `ornm` and +`titl`, in lookup order within each, each glyph once. The default glyph is listed only when `aalt` lists it. Ligatures +and contextual substitutions are not listed. `text.setGlyphAlternate {id, start, gid}` draws the character with glyph +`gid`, one of those alternates, as one undo step; the default glyph or `gid: null` puts the default glyph back. The text +keeps the character and its other attributes, and characters typed next to it take their default glyphs. The choice is +saved with the character's style as `glyphAlternate: {ch, gid, font, version}`, the face's PostScript name and version +string: set in another font or version, or with the font missing, the character takes its default glyph. Both commands +refuse a character whose font is not available, and `text.setGlyphAlternate` refuses a glyph that is not one of the +character's alternates and a glyph that the character's style does not draw (All Caps, Small Caps or another numeral +system). Choosing the glyph a character already draws is not an edit. Character styles do not take `glyphAlternate`: +`charStyle.new`, `charStyle.edit` and `charStyle.setAttrs` refuse it. + +```json +{"name":"run_command","arguments":{"command":"text.glyphAlternates","params":{"id":7,"start":2}}} +{"name":"run_command","arguments":{"command":"text.setGlyphAlternate","params":{"id":7,"start":2,"gid":1043}}} +``` + ## New type in a Japanese interface While the interface is in Japanese (Preferences › User Interface › Language, or `auto` on a Japanese system), new type starts with em diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 38be922ad..29b4de49c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -926,6 +926,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur 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. The OpenType panel's Stylistic Alternates and Titling Alternates checkboxes turn `salt` and `titl` on and off for the selected type, and its Figure menu lists Default Figure, Tabular Lining, Proportional Lining, Proportional Oldstyle and Tabular Oldstyle; Proportional Lining sets `pnum` and `lnum` and turns `tnum` and `onum` off, Tabular Lining turns `pnum` off and keeps `lnum`, Proportional Oldstyle turns `lnum` off and keeps `pnum`, and Default Figure and Tabular Oldstyle turn `pnum` and `lnum` off. Behavior reference: [Format text with OpenType features](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/format-text-with-opentype-features.html) and [Use swashes, titling alternates, or stylistic alternates](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/use-swashes-titling-alternates-or-stylistic-alternates.html) (public documentation). Tests: `panels::opentype::tests`. The OpenType panel's Position menu lists Default Position, Superscript, Subscript, Numerator and Denominator, as [Format text with OpenType features](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/format-text-with-opentype-features.html) describes; Superscript, Subscript, Numerator and Denominator set `sups`, `subs`, `numr` and `dnom` for the selected type and turn the other three off, and Default Position turns all four off. The menu shows Mixed when the characters it acts on differ, and an entry chosen then applies to all of them. These two rules are VectorCraft's own, as is setting the feature on type whose font lacks the glyphs, which then draws unchanged. The panel's menus and checkboxes change only their own features on each selected character, so characters keep their other OpenType features (`text.setStyle` and `text.setRangeStyle` `{featureChanges}`). Tests: `panels::opentype::tests`, `features::tests::numerators_and_denominators_are_kept_and_shaped`. + The Glyphs panel's Show menu and panel menu have Alternates for Current Selection, as [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html) describes: the grid lists the alternate glyphs of the character selected with the Type tool, and a double-click draws that character with the glyph as one undo step (`text.glyphAlternates`, `text.setGlyphAlternate`). The alternates come from the font's `GSUB` single and alternate substitutions of `aalt`, then `salt`, `ss01` to `ss20`, `swsh`, `cv01` to `cv99` and a few other alternate features, each glyph once. The text keeps the character, native files keep the choice with the face it was made in, and type set in another font or version draws the default glyph. The list uses the font of the character's text even when the panel browses another font, and the panel shows a note when the character has no alternates, its font is missing or no character is selected; these rules are VectorCraft's own. Tests: `panels::glyphs::tests`, `tests::alternates`, `tests::textedit::a_glyph_alternate_replaces_one_character_through_undo_and_reopen`. The OpenType panel's Stylistic Sets menu offers `ss01`–`ss20` sets present in the selected text's font, using the font's custom labels when available and numbered labels otherwise. Sets can be combined and deselected individually. With mixed fonts, the menu shows the first selected character's font's sets and applies the feature tag to all selected text; a font without that set draws unchanged. The mixed-font rule is a VectorCraft design choice. Behavior reference: [Add stylistic sets to selected text](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/add-stylistic-sets-to-selected-text.html) and the [OpenType specification](https://learn.microsoft.com/en-us/typography/opentype/spec/features_pt). `text.setStyle {stroke, strokeWidth}` sets the character stroke's color and weight of the selected type or of a range of its characters (the weight in document points, as `stroke.set` sets it), in the same undo step as the command's other changes. Tests: `tests::textedit::set_style_sets_the_character_stroke`. - **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. diff --git a/docs/type-parity.md b/docs/type-parity.md index 15a86aff0..a3dd392c3 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -1,6 +1,6 @@ # Type parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (OpenType panel lists font-defined stylistic sets; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (OpenType panel lists font-defined stylistic sets; Glyphs panel shows and applies alternates for the current text selection; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in [target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the @@ -29,7 +29,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Character and Paragraph panels, styles with overrides | done | Character-style Auto-leading resets, partial definitions, local leading overrides, override markers, Undo/Redo and native serialization have regression coverage; differing paragraph alignments leave the shared alignment buttons unselected (headless regressions, full integration CI and visual review pass); mixed-value display for other paragraph fields remains missing | | Character size and horizontal scale after object/group scaling | done | #1034: fields and setters compensate the stored affine; point size follows the transformed em-height axis, not the glyph ink bounds. Range edits, keyboard increments, leading and baseline shift use document points; rotation, reflection and shear remain editable. | | Preserve font sizes while scaling a group | missing | Separate enhancement; scaling still scales text with the other artwork. | -| OpenType panel, Glyphs panel, alternates | done | Highlight Alternate Glyphs preference (#394, off by default) paints pink behind OpenType substitutions on screen when on (required shaping such as Arabic joining forms and the lam-alef ligature, including a lam-alef older fonts file under `liga`, is not highlighted; an optional feature that changes the ligature is); stylistic sets ss01–ss20, stylistic and titling alternates, proportional and lining figures, superscript and subscript are character-style features (`text.setStyle {features}`, #1607); the OpenType panel's Position menu sets superscript, subscript, numerator or denominator glyphs (`sups`, `subs`, `numr`, `dnom`). The OpenType panel's Stylistic Sets menu lists the selected font's `ss01`–`ss20` features, using a font-supplied name where available and `Set N` otherwise; several sets can be active, and selecting an active one removes it. For a mixed-font selection it uses the first selected character's font, a VectorCraft design choice ([public help](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/add-stylistic-sets-to-selected-text.html), [OpenType feature specification](https://learn.microsoft.com/en-us/typography/opentype/spec/features_pt)). The Glyphs panel highlights the character selected with the Type tool (a range's first character, else the one before the caret) and scrolls it into view as the selection moves (#4908; `panels::glyphs` tests); its Show menu lists Unicode ranges, and Alternates for Current Selection is not there yet | +| OpenType panel, Glyphs panel, alternates | done | Highlight Alternate Glyphs preference (#394, off by default) paints pink behind OpenType substitutions on screen when on (required shaping such as Arabic joining forms and the lam-alef ligature, including a lam-alef older fonts file under `liga`, is not highlighted; an optional feature that changes the ligature is); stylistic sets ss01–ss20, stylistic and titling alternates, proportional and lining figures, superscript and subscript are character-style features (`text.setStyle {features}`, #1607); the OpenType panel's Position menu sets superscript, subscript, numerator or denominator glyphs (`sups`, `subs`, `numr`, `dnom`). The OpenType panel's Stylistic Sets menu lists the selected font's `ss01`–`ss20` features, using a font-supplied name where available and `Set N` otherwise; several sets can be active, and selecting an active one removes it. For a mixed-font selection it uses the first selected character's font, a VectorCraft design choice ([public help](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/add-stylistic-sets-to-selected-text.html), [OpenType feature specification](https://learn.microsoft.com/en-us/typography/opentype/spec/features_pt)). The Glyphs panel highlights the character selected with the Type tool (a range's first character, else the one before the caret) and scrolls it into view as the selection moves (#4908; `panels::glyphs` tests); its Show menu lists Unicode ranges and Alternates for Current Selection, the selected character's alternate glyphs from the font's `GSUB` table, one of which a double-click draws for that character (`text.setGlyphAlternate`); the glyph-cell pop-up of alternates is not there yet | | Font menu: samples, live preview, filters, favourites, recent families; font matching by any name; Find Font | done | #3515: recently applied families appear first, once each; missing families are omitted and the existing Number of Recent Fonts setting applies; #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 | @@ -75,6 +75,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | Glyphs panel adds Alternates for Current Selection. A chosen alternate changes one character's drawn glyph while keeping its Unicode text; character-style capture does not copy that per-character choice. Scores and estimates unchanged. | | 2026-11-03 | minor | The OpenType panel lists the selected font's available Stylistic Sets with its custom names or numbered labels; sets can be combined and deselected. Scores and estimates unchanged. | | 2026-11-03 | minor | Character-menu Kashidas controls selected characters separately from the paragraph's Insert Kashidas length. Existing text allows Kashidas by default; scores and estimates unchanged. | | 2026-11-03 | minor | Type on a Path Options has a Preview checkbox: the type follows the fields while it is on, Cancel restores it, OK is one undo step (#5360, as public tutorials of the dialog show); scores and estimates unchanged. |