diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 7b93595a..0ea4d832 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -52,6 +52,10 @@ pub struct CharStyle { pub strikethrough: bool, #[serde(default)] pub all_caps: bool, + /// OpenType features that differ from the defaults (OpenType panel), as tags: `"dlig"` turns + /// a feature on, `"-liga"` off. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub features: Vec, /// Character style (Character Styles panel) these attributes come from; None = Normal. #[serde(default, skip_serializing_if = "Option::is_none")] pub style_name: Option, @@ -83,6 +87,7 @@ impl Default for CharStyle { underline: false, strikethrough: false, all_caps: false, + features: vec![], style_name: None, } } diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index ea5d9d55..6c7c729f 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -26,7 +26,7 @@ pub fn specs() -> Vec { "Character", [], None, - "{id, start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, underline?, strikethrough?, allCaps?: bool} style a character range (runs are split at the range ends) → {id, runs}", + "{id, start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, underline?, strikethrough?, allCaps?: bool, features?: [\"dlig\", \"-liga\", …]} style a character range (runs are split at the range ends) → {id, runs}", has_doc, set_range_style ), @@ -137,6 +137,17 @@ pub(crate) struct CharChange { underline: Option, strikethrough: Option, all_caps: Option, + features: Option>, +} + +/// `features: ["dlig", "-liga", …]` → the canonical tag list (differences from the defaults). +pub(crate) fn features_param(p: &Value, cmd: &str) -> Result>> { + let Some(v) = p.get("features").filter(|v| !v.is_null()) else { return Ok(None) }; + let tags: Vec<&str> = v.as_array().ok_or_else(|| bad(cmd, "features must be a list of tags"))?.iter().filter_map(Value::as_str).collect(); + if let Some(t) = tags.iter().find(|t| !drawcraft_text::OtFeatures::known_tag(t)) { + return Err(bad(cmd, format!("unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh; prefix - to turn off)"))); + } + Ok(Some(drawcraft_text::OtFeatures::default().with_tags(tags).to_tags())) } fn paint_param(p: &Value, k: &str, cmd: &str) -> Result> { @@ -176,6 +187,7 @@ impl CharChange { underline: flag("underline"), strikethrough: flag("strikethrough"), all_caps: flag("allCaps"), + features: features_param(p, cmd)?, }; if c.size.is_some_and(|v| v <= 0.0) { return Err(bad(cmd, "size must be positive")); @@ -200,6 +212,7 @@ impl CharChange { && self.underline.is_none() && self.strikethrough.is_none() && self.all_caps.is_none() + && self.features.is_none() } pub(crate) fn apply(&self, st: &mut CharStyle) { @@ -258,6 +271,9 @@ impl CharChange { if let Some(v) = self.all_caps { st.all_caps = v; } + if let Some(v) = &self.features { + st.features = v.clone(); + } } } diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 749b29ee..a2e285a3 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -54,7 +54,7 @@ pub fn specs() -> Vec { "Character", [], None, - "{ids?|id?, font?, style?, size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour}", + "{ids?|id?, font?, style?, size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour, features?: [\"dlig\", \"-liga\", …] OpenType}", has_doc, set_style ), @@ -188,7 +188,16 @@ fn set_style(s: &mut Session, p: &Value) -> Result { Some(Value::String(n)) if n.eq_ignore_ascii_case("none") => Some(drawcraft_color::Paint::None), Some(v) => Some(drawcraft_color::Paint::solid(color_value(v).ok_or_else(|| bad(C, "bad fill colour"))?)), }; - if font.is_none() && style.is_none() && size.is_none() && leading.is_none() && tracking.is_none() && justify.is_none() && fill.is_none() { + let features = super::textedit::features_param(p, C)?; + if font.is_none() + && style.is_none() + && size.is_none() + && leading.is_none() + && tracking.is_none() + && justify.is_none() + && fill.is_none() + && features.is_none() + { return Err(bad(C, "nothing to change")); } let ids = text_targets(s, p, C)?; @@ -215,6 +224,9 @@ fn set_style(s: &mut Session, p: &Value) -> Result { if let Some(f) = &fill { st.fill = f.clone(); } + if let Some(f) = &features { + st.features = f.clone(); + } } if let Some(j) = justify { t.para.justify = j; diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 89cf40fb..62662139 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -191,7 +191,8 @@ impl Writer<'_> { match self.opts.styling { Styling::PresentationAttributes => { // CSS-only properties go in a style attribute. - let (style, attrs): (Vec<_>, Vec<_>) = props.iter().partition(|(k, _)| matches!(*k, "mix-blend-mode" | "isolation")); + let (style, attrs): (Vec<_>, Vec<_>) = + props.iter().partition(|(k, _)| matches!(*k, "mix-blend-mode" | "isolation" | "font-feature-settings")); let mut s: String = attrs.iter().map(|(k, v)| format!(" {k}=\"{}\"", xml_escape(v))).collect(); if !style.is_empty() { s.push_str(&format!(" style=\"{}\"", xml_escape(&css(&style)))); @@ -715,6 +716,17 @@ impl Writer<'_> { if st.tracking != 0.0 { p.push(("letter-spacing", self.num(st.tracking / 1000.0 * st.size))); } + if !st.features.is_empty() { + let v: Vec = st + .features + .iter() + .map(|t| match t.strip_prefix('-') { + Some(off) => format!("\"{off}\" 0"), + None => format!("\"{t}\" 1"), + }) + .collect(); + p.push(("font-feature-settings", v.join(", "))); + } match (st.underline, st.strikethrough) { (true, true) => p.push(("text-decoration", "underline line-through".into())), (true, false) => p.push(("text-decoration", "underline".into())), diff --git a/crates/svg/tests/roundtrip.rs b/crates/svg/tests/roundtrip.rs index 306fd846..76bdcbd3 100644 --- a/crates/svg/tests/roundtrip.rs +++ b/crates/svg/tests/roundtrip.rs @@ -682,3 +682,14 @@ fn unclipped_inverted_mask_exports_backdrop_and_filter() { }; assert!(!disabled.contains(" Feature { impl OtFeatures { /// Parse a feature list like `["dlig", "smcp", "-liga"]` (unknown tags are ignored). pub fn from_tags<'a>(tags: impl IntoIterator) -> Self { - let mut o = Self::default(); + Self::default().with_tags(tags) + } + + /// The tags this set differs from the defaults by (what `CharStyle::features` stores). + pub fn to_tags(&self) -> Vec { + let d = Self::default(); + let mut v = vec![]; + for (on, def, tag) in [ + (self.ligatures, d.ligatures, "liga"), + (self.contextual, d.contextual, "calt"), + (self.discretionary_ligatures, d.discretionary_ligatures, "dlig"), + (self.small_caps, d.small_caps, "smcp"), + (self.fractions, d.fractions, "frac"), + (self.oldstyle_figures, d.oldstyle_figures, "onum"), + (self.tabular_figures, d.tabular_figures, "tnum"), + (self.ordinals, d.ordinals, "ordn"), + (self.swash, d.swash, "swsh"), + ] { + if on != def { + v.push(if on { tag.to_string() } else { format!("-{tag}") }); + } + } + v + } + + /// Is `tag` (optionally prefixed with `-` or `+`) one this set understands? + pub fn known_tag(tag: &str) -> bool { + let t = tag.trim_start_matches(['-', '+']); + matches!(t, "liga" | "calt" | "dlig" | "smcp" | "frac" | "onum" | "tnum" | "ordn" | "swsh") + } + + /// This set with `tags` applied on top. + pub fn with_tags<'a>(mut self, tags: impl IntoIterator) -> Self { + let o = &mut self; for t in tags { let (on, t) = match t.strip_prefix('-') { Some(r) => (false, r), @@ -69,34 +102,37 @@ impl OtFeatures { _ => {} } } - o + self } /// The harfrust features for text in style `st`. pub(crate) fn resolve(&self, st: &CharStyle) -> Vec { + // The character's own OpenType settings override the layout-wide ones. + let own = self.with_tags(st.features.iter().map(String::as_str)); + let s = &own; let mut v = Vec::with_capacity(8); if st.kerning.is_some() { v.push(f(b"kern", false)); } - let liga = self.ligatures && st.tracking.abs() < 1e-9; + let liga = s.ligatures && st.tracking.abs() < 1e-9; if !liga { v.push(f(b"liga", false)); v.push(f(b"clig", false)); } - if !self.contextual { + if !s.contextual { v.push(f(b"calt", false)); } if st.all_caps { v.push(f(b"case", true)); } for (on, tag) in [ - (self.discretionary_ligatures, b"dlig"), - (self.small_caps, b"smcp"), - (self.fractions, b"frac"), - (self.oldstyle_figures, b"onum"), - (self.tabular_figures, b"tnum"), - (self.ordinals, b"ordn"), - (self.swash, b"swsh"), + (s.discretionary_ligatures, b"dlig"), + (s.small_caps, b"smcp"), + (s.fractions, b"frac"), + (s.oldstyle_figures, b"onum"), + (s.tabular_figures, b"tnum"), + (s.ordinals, b"ordn"), + (s.swash, b"swsh"), ] { if on { v.push(f(tag, true)); @@ -105,3 +141,26 @@ impl OtFeatures { v } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{FontDb, layout}; + use drawcraft_doc::TextObject; + use drawcraft_geom::Point; + + fn glyphs(features: &[&str]) -> usize { + let st = CharStyle { font_family: "Source Serif 4".into(), features: features.iter().map(|s| s.to_string()).collect(), ..Default::default() }; + layout(FontDb::global(), &TextObject::point(Point::ZERO, "fi", st)).glyphs.len() + } + + #[test] + fn per_character_features_drive_shaping() { + let with = glyphs(&[]); + let without = glyphs(&["-liga"]); + assert!(with < without, "the default ligature merges f+i ({with} vs {without})"); + assert_eq!(OtFeatures::from_tags(["-liga", "dlig"]).to_tags(), ["-liga", "dlig"]); + assert!(OtFeatures::default().to_tags().is_empty()); + assert!(OtFeatures::known_tag("-onum") && !OtFeatures::known_tag("zzzz")); + } +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 4e08a60d..803f32f7 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1039,7 +1039,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { panel("Character", "character"), panel("Character Styles", "charStyles"), panel("Glyphs", "glyphs"), - todo("OpenType"), + panel("OpenType", "openType"), panel("Paragraph", "paragraph"), panel("Paragraph Styles", "paraStyles"), todo("Tabs"), diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index d5edc04d..25f761ba 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -21,6 +21,7 @@ pub mod info; pub mod layers; pub mod magic_wand; pub mod navigator; +pub mod opentype; pub mod paragraph; pub mod pathfinder; pub mod pattern_options; @@ -79,6 +80,7 @@ pub fn show_icon_panel(app: &mut DrawcraftApp, ui: &mut Ui, id: &str) { "symbols" => symbols::show(app, ui), "patternOptions" => pattern_options::show(app, ui), "imageTrace" => image_trace::show(app, ui), + "openType" => opentype::show(app, ui), "docInfo" => doc_info::show(app, ui), "charStyles" => text_styles::show(app, ui, text_styles::Kind::Char), "paraStyles" => text_styles::show(app, ui, text_styles::Kind::Para), @@ -115,6 +117,7 @@ pub fn panel_menu_items(app: &mut DrawcraftApp, ui: &mut Ui, id: &str) { "symbols" => symbols::menu(app, ui), "patternOptions" => pattern_options::menu(app, ui), "imageTrace" => image_trace::menu(app, ui), + "openType" => opentype::menu(app, ui), "docInfo" => doc_info::menu(app, ui), "charStyles" => text_styles::menu(app, ui, text_styles::Kind::Char), "paraStyles" => text_styles::menu(app, ui, text_styles::Kind::Para), diff --git a/crates/ui-egui/src/panels/opentype.rs b/crates/ui-egui/src/panels/opentype.rs new file mode 100644 index 00000000..c3874efc --- /dev/null +++ b/crates/ui-egui/src/panels/opentype.rs @@ -0,0 +1,99 @@ +//! OpenType panel: per-character OpenType features (ligatures, alternates, swashes, small caps, +//! ordinals, fractions, figure style) for the selected text or the Type tool's selected characters. + +use drawcraft_text::OtFeatures; +use egui::Ui; +use serde_json::json; + +use super::character::{text_editing, text_style}; +use crate::DrawcraftApp; +use crate::widgets::{self, menu_item}; + +/// A checkbox row: label and the feature flag it toggles. +type Toggle = (&'static str, fn(&mut OtFeatures) -> &mut bool); + +const FIGURES: [&str; 4] = ["Default Figure", "Tabular Lining", "Proportional Oldstyle", "Tabular Oldstyle"]; + +/// Apply `f` to the selection's features (the Type tool range, or whole objects). +fn set(app: &mut DrawcraftApp, f: OtFeatures) { + let tags = f.to_tags(); + let r = match text_editing(app) { + Some((id, a, b)) if b > a => app.run("text.setRangeStyle", json!({ "id": id.0, "start": a, "end": b, "features": tags })), + Some((id, _, _)) => app.run("text.setRangeStyle", json!({ "id": id.0, "features": tags })), + None => app.run("text.setStyle", json!({ "features": tags })), + }; + if let Err(e) = r { + app.ui.status = e; + } +} + +pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { + let Some((st, _)) = text_style(app) else { + widgets::dim_label(ui, "Select text to set its OpenType features."); + return; + }; + let cur = OtFeatures::from_tags(st.features.iter().map(String::as_str)); + let mut next = cur; + ui.horizontal(|ui| { + widgets::dim_label(ui, "Figure:"); + let fig = match (cur.tabular_figures, cur.oldstyle_figures) { + (false, false) => 0, + (true, false) => 1, + (false, true) => 2, + (true, true) => 3, + }; + if let Some(i) = widgets::dropdown(ui, "ot-figure", FIGURES[fig], &FIGURES, 170.0) { + next.tabular_figures = i == 1 || i == 3; + next.oldstyle_figures = i >= 2; + } + }); + widgets::divider(ui); + let rows: [Toggle; 7] = [ + ("Standard Ligatures", |f| &mut f.ligatures), + ("Contextual Alternates", |f| &mut f.contextual), + ("Discretionary Ligatures", |f| &mut f.discretionary_ligatures), + ("Swash", |f| &mut f.swash), + ("Small Caps", |f| &mut f.small_caps), + ("Ordinals", |f| &mut f.ordinals), + ("Fractions", |f| &mut f.fractions), + ]; + for (label, field) in rows { + let on = *field(&mut next.clone()); + if widgets::check(ui, label, on, true) { + *field(&mut next) = !on; + } + } + if st.tracking.abs() > 1e-9 && cur.ligatures { + widgets::dim_label(ui, "Ligatures are off while the text is tracked."); + } + if next != cur { + set(app, next); + } +} + +pub fn menu(app: &mut DrawcraftApp, ui: &mut Ui) { + if menu_item(ui, "Reset OpenType Features", text_style(app).is_some(), false) { + set(app, OtFeatures::default()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn toggles_reach_the_text() { + let mut app = DrawcraftApp::new(drawcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let id = app.session.execute("text.create", &json!({"x": 10, "y": 40, "text": "fi 1/2"})).unwrap()["id"].clone(); + app.session.execute("select.set", &json!({"ids": [id]})).unwrap(); + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + show(&mut app, ui); + menu(&mut app, ui); + }); + out.textures_delta.clear(); + set(&mut app, OtFeatures { ligatures: false, fractions: true, ..Default::default() }); + assert_eq!(text_style(&app).unwrap().0.features, ["-liga", "frac"]); + } +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index a2364c82..8d16fc71 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -77,6 +77,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[ ("paragraph", "Paragraph", "pilcrow"), ("glyphs", "Glyphs", "text-cursor-input"), ("charStyles", "Character Styles", "type"), + ("openType", "OpenType", "dc-touch-type"), ("paraStyles", "Paragraph Styles", "pilcrow"), ("history", "History", "history"), ("actions", "Actions", "dc-actions"),