From eec4ad3e658cf2739fd61f24f38d6eb1de1a5022 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 15 Oct 2026 06:16:15 +0000 Subject: [PATCH 1/2] fix(typography): every OpenType toggle has a control, single figure choices export, Standard Ligatures sets Ligatures The Typography panel's Other Features section lists the otf::TOGGLES tags no other section shows. Choosing a figure style or width alone also sets the other axis (the one all of the selection has, else Proportional or Lining), so IDML export writes a figure style instead of Default; turning one off clears both axes. Standard Ligatures reads and sets the ligatures attribute through type.char, and type.openTypeFeatures reports liga from it. --- ROADMAP.md | 1 + crates/engine/src/cmd/text.rs | 23 ++- crates/ui-egui/src/panels/typography.rs | 208 ++++++++++++++++++++++-- 3 files changed, 212 insertions(+), 20 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d60a6bf49..dc6b165d1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -347,6 +347,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-15** · The Typography panel controls every OpenType feature a character format carries: Other Features lists Proportional Metrics, Horizontal Kana, Localized Forms, Mark Positioning, Justification Alternates and Roman Italics. Lining, Oldstyle, Tabular or Proportional chosen alone also sets the other figure axis, so IDML export keeps the figure style instead of writing Default. Standard Ligatures follows and sets Character › Ligatures. No readiness percentage change. - **2026-10-14** · The Type tool draws a new text frame over a locked empty frame, as InDesign does, instead of turning the locked frame into a text frame. No readiness percentage change. - **2026-10-14** · 100% black shows as pure black on screen by default, as InDesign's Appearance of Black does out of the box (Display All Blacks as Rich Black); Preferences › Appearance of Black › Display All Blacks Accurately still shows it as the dark grey it prints, and a saved choice is kept. No readiness percentage change. - **2026-10-14** · Pathfinder keeps the frontmost object, as InDesign does: a photo placed over shapes and intersected with them shows through the shapes (it was dropped, and the shapes kept their black fill); Add, Exclude Overlap and Minus Back take the front object's fill too, Subtract still keeps the back one. No readiness percentage change. diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 432297f35..adf297311 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -1454,6 +1454,7 @@ struct FeatureSegment { len: usize, font: (String, String), applied: Vec, + ligatures: bool, } /// The OpenType features of the fonts in the selection, with how much of the selection each one is @@ -1477,13 +1478,13 @@ fn open_type_features(s: &mut Session, _p: &Value) -> Result { let Some(para) = story.paras.get(story.para_at(pos)) else { continue }; let (_, base) = st.doc.styles.resolve_para(para); let cp = st.doc.styles.resolve_char(&base, &cf); - segments.push(FeatureSegment { len, font: (cp.font_family, cp.font_style), applied: cp.otf_features }); + segments.push(FeatureSegment { len, font: (cp.font_family, cp.font_style), applied: cp.otf_features, ligatures: cp.ligatures }); } } if segments.is_empty() { let (_, base) = st.doc.styles.resolve_para_style(designcraft_doc::story::BASIC_PARAGRAPH); let cp = st.doc.styles.resolve_char(&base, &designcraft_doc::story::CharFormat::default()); - segments.push(FeatureSegment { len: 1, font: (cp.font_family, cp.font_style), applied: cp.otf_features }); + segments.push(FeatureSegment { len: 1, font: (cp.font_family, cp.font_style), applied: cp.otf_features, ligatures: cp.ligatures }); } let db = designcraft_fonts::FontDb::global(); let mut fonts: std::collections::BTreeMap<(String, String), designcraft_fonts::FontFeatures> = std::collections::BTreeMap::new(); @@ -1500,7 +1501,9 @@ fn open_type_features(s: &mut Session, _p: &Value) -> Result { let measure = |tag: &str| { let (mut applied, mut available) = (0, 0); for seg in &segments { - if designcraft_doc::otf::is_on(&seg.applied, tag) { + // Standard ligatures are the `ligatures` attribute, which shaping reads. + let on = if tag == "liga" { seg.ligatures } else { designcraft_doc::otf::is_on(&seg.applied, tag) }; + if on { applied += seg.len; } if fonts.get(&seg.font).is_some_and(|f| f.tags.contains(tag)) { @@ -2828,6 +2831,20 @@ mod open_type_features_tests { let v = s.execute("type.openTypeFeatures", &json!({})).unwrap(); assert_eq!(feature(&v, "lnum")["applied"], "none", "{v}"); } + + #[test] + fn standard_ligatures_follow_the_ligatures_attribute() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "office"})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 6})).unwrap(); + s.execute("type.char", &json!({"attrs": {"ligatures": false}})).unwrap(); + let v = s.execute("type.openTypeFeatures", &json!({})).unwrap(); + assert_eq!(feature(&v, "liga")["applied"], "none", "Character › Ligatures off: {v}"); + s.execute("type.char", &json!({"attrs": {"ligatures": true}})).unwrap(); + let v = s.execute("type.openTypeFeatures", &json!({})).unwrap(); + assert_eq!(feature(&v, "liga")["applied"], "all", "{v}"); + } } #[cfg(test)] diff --git a/crates/ui-egui/src/panels/typography.rs b/crates/ui-egui/src/panels/typography.rs index ef34a0110..5cd0814f8 100644 --- a/crates/ui-egui/src/panels/typography.rs +++ b/crates/ui-egui/src/panels/typography.rs @@ -1,7 +1,8 @@ //! The Typography panel: the OpenType features the fonts of the selected text offer, grouped as //! Affinity's are. Every feature is a checkbox that shows how much of the selection it is applied to //! (checked: all of it; a dash: some of it; empty: none). A feature only some of the selection's fonts -//! offer is italic. Changes apply to the selection through `type.feature`. +//! offer is italic. Changes apply to the selection through `type.feature`, and Standard Ligatures +//! through `type.char`'s `ligatures` attribute, which shaping reads. use std::collections::BTreeMap; @@ -46,9 +47,16 @@ const ALTERNATES: &[Switch] = &[ ("zero", "Slashed Zero"), ]; -const FIGURE_STYLE: &[Choice] = &[("Lining", &["lnum"]), ("Oldstyle", &["onum"])]; +/// One axis of the figures (style or width), and the choice that partners a choice on the other +/// axis when no choice on this one applies to all of the selection. +struct FigureAxis { + choices: &'static [Choice], + fallback: usize, +} -const FIGURE_WIDTH: &[Choice] = &[("Tabular", &["tnum"]), ("Proportional", &["pnum"])]; +const FIGURE_STYLE: FigureAxis = FigureAxis { choices: &[("Lining", &["lnum"]), ("Oldstyle", &["onum"])], fallback: 0 }; + +const FIGURE_WIDTH: FigureAxis = FigureAxis { choices: &[("Tabular", &["tnum"]), ("Proportional", &["pnum"])], fallback: 1 }; const FIGURE_POSITION: &[Choice] = &[ ("Numerator", &["numr"]), @@ -62,6 +70,47 @@ const FRACTION_AND_ORDINAL: &[Switch] = &[("frac", "Fractions"), ("ordn", "Ordin const CAPITALS: &[Choice] = &[("Caps to Small Caps", &["c2sc"]), ("Small Caps", &["smcp"]), ("All Small Caps", &["c2sc", "smcp"])]; +/// The sections of on/off switches above Other Features. +const SWITCH_SECTIONS: &[&[Switch]] = &[LIGATURES, ALTERNATES, FRACTION_AND_ORDINAL]; + +/// The character format's on/off features (`otf::TOGGLES`) no other section shows, so each one the +/// character style dialog and IDML carry has a control here too. +fn other_toggles() -> Vec { + designcraft_doc::otf::TOGGLES + .iter() + .filter(|(tag, ..)| !SWITCH_SECTIONS.iter().any(|section| section.iter().any(|(t, _)| t == tag))) + .map(|(tag, label, _)| (*tag, *label)) + .collect() +} + +/// What this frame's clicks set: OpenType feature tags, and the `ligatures` attribute. +#[derive(Default)] +struct Changes { + features: BTreeMap, + ligatures: Option, +} + +impl Changes { + /// Standard Ligatures is the `ligatures` attribute: shaping turns `liga` off through it, and + /// IDML carries it as `Ligatures`, so `liga` stays out of the feature list. + fn set(&mut self, tag: &str, on: bool) { + if tag == "liga" { + self.ligatures = Some(on); + } else { + self.features.insert(tag.to_string(), on); + } + } + + fn apply(self, app: &mut DesignApp) { + if !self.features.is_empty() { + let _ = app.run("type.feature", json!({"features": self.features})); + } + if let Some(on) = self.ligatures { + let _ = app.run("type.char", json!({"attrs": {"ligatures": on}})); + } + } +} + /// A feature the selection's fonts offer, and its state over the selection. #[derive(Clone, Copy)] struct Feature { @@ -118,24 +167,22 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { return; } let lang = app.ui.language.clone(); - // Changes this frame: each feature tag to the state it is set to. - let mut changes: BTreeMap = BTreeMap::new(); + let mut changes = Changes::default(); crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&lang, "Typography")).strong()); egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { section(ui, &lang, "Ligatures", |ui| switches(ui, &lang, &offered, LIGATURES, &mut changes)); section(ui, &lang, "Alternates", |ui| switches(ui, &lang, &offered, ALTERNATES, &mut changes)); section(ui, &lang, "Stylistic Sets", |ui| stylistic_sets(ui, &lang, &offered, &mut changes)); - section(ui, &lang, "Figure Style", |ui| choices(ui, &lang, &offered, FIGURE_STYLE, &mut changes)); - section(ui, &lang, "Figure Width", |ui| choices(ui, &lang, &offered, FIGURE_WIDTH, &mut changes)); + section(ui, &lang, "Figure Style", |ui| figures(ui, &lang, &offered, &FIGURE_STYLE, &FIGURE_WIDTH, &mut changes)); + section(ui, &lang, "Figure Width", |ui| figures(ui, &lang, &offered, &FIGURE_WIDTH, &FIGURE_STYLE, &mut changes)); section(ui, &lang, "Figure Position", |ui| { choices(ui, &lang, &offered, FIGURE_POSITION, &mut changes); switches(ui, &lang, &offered, FRACTION_AND_ORDINAL, &mut changes); }); section(ui, &lang, "Capitals", |ui| choices(ui, &lang, &offered, CAPITALS, &mut changes)); + section(ui, &lang, "Other Features", |ui| switches(ui, &lang, &offered, &other_toggles(), &mut changes)); }); - if !changes.is_empty() { - let _ = app.run("type.feature", json!({"features": changes})); - } + changes.apply(app); } /// A collapsible section, open at first. Its body runs only when it has something to show. @@ -157,23 +204,23 @@ fn tri_checkbox(ui: &mut egui::Ui, text: &str, feature: Feature) -> Option } /// On/off switches the fonts offer. -fn switches(ui: &mut egui::Ui, lang: &str, offered: &Offered, items: &[Switch], changes: &mut BTreeMap) { +fn switches(ui: &mut egui::Ui, lang: &str, offered: &Offered, items: &[Switch], changes: &mut Changes) { for (tag, label) in items { let Some(feature) = offered.get(tag) else { continue }; if let Some(on) = tri_checkbox(ui, crate::i18n::tr(lang, label), feature) { - changes.insert(tag.to_string(), on); + changes.set(tag, on); } } } /// Mutually exclusive choices, as checkboxes: choosing one turns the others off. Choosing a checked /// one turns it off. -fn choices(ui: &mut egui::Ui, lang: &str, offered: &Offered, items: &[Choice], changes: &mut BTreeMap) { +fn choices(ui: &mut egui::Ui, lang: &str, offered: &Offered, items: &[Choice], changes: &mut Changes) { for (i, (label, tags)) in items.iter().enumerate() { let Some(feature) = offered.choice(tags) else { continue }; let Some(on) = tri_checkbox(ui, crate::i18n::tr(lang, label), feature) else { continue }; for t in *tags { - changes.insert(t.to_string(), on); + changes.set(t, on); } if on { for (j, (_, others)) in items.iter().enumerate() { @@ -181,23 +228,55 @@ fn choices(ui: &mut egui::Ui, lang: &str, offered: &Offered, items: &[Choice], c continue; } for t in others.iter().filter(|t| !tags.contains(t)) { - changes.insert(t.to_string(), false); + changes.set(t, false); } } } } } +/// A figure style or width, as checkboxes like `choices`. +fn figures(ui: &mut egui::Ui, lang: &str, offered: &Offered, axis: &FigureAxis, other: &FigureAxis, changes: &mut Changes) { + for (i, (label, tags)) in axis.choices.iter().enumerate() { + let Some(feature) = offered.choice(tags) else { continue }; + if let Some(on) = tri_checkbox(ui, crate::i18n::tr(lang, label), feature) { + figure_change(offered, axis, other, i, on, changes); + } + } +} + +/// The changes turning `choice` on `axis` on or off makes. IDML's figure styles pair a style with a +/// width, and a style or width alone is the default figures there, so turning one on also sets the +/// other axis: the choice all of the selection has, else that axis's fallback. Turning one off +/// clears both axes (the font's default figures). +fn figure_change(offered: &Offered, axis: &FigureAxis, other: &FigureAxis, choice: usize, on: bool, changes: &mut Changes) { + for (_, tags) in axis.choices.iter().chain(other.choices) { + for t in *tags { + changes.set(t, false); + } + } + let Some((_, tags)) = axis.choices.get(choice).filter(|_| on) else { return }; + let partner = other + .choices + .iter() + .find(|(_, t)| offered.choice(t).is_some_and(|f| f.applied == Covered::All)) + .or_else(|| other.choices.get(other.fallback)) + .map_or(&[][..], |(_, t)| *t); + for t in tags.iter().chain(partner) { + changes.set(t, true); + } +} + /// Stylistic sets by the name their fonts give them, or the generic `Stylistic Set NN` when the /// selection's fonts name them differently. -fn stylistic_sets(ui: &mut egui::Ui, lang: &str, offered: &Offered, changes: &mut BTreeMap) { +fn stylistic_sets(ui: &mut egui::Ui, lang: &str, offered: &Offered, changes: &mut Changes) { for set in &offered.sets { let label = match &set.name { Some(name) => format!("{name} ({})", set.tag), None => format!("{} {:02}", crate::i18n::tr(lang, "Stylistic Set"), set.number), }; if let Some(on) = tri_checkbox(ui, &label, set.feature) { - changes.insert(set.tag.clone(), on); + changes.set(&set.tag, on); } } } @@ -223,3 +302,98 @@ fn offered(app: &mut DesignApp) -> Offered { } out } + +#[cfg(test)] +mod tests { + use std::io::Read; + + use egui::vec2; + use egui_kittest::{Harness, kittest::Queryable}; + + use super::*; + + /// A Typography panel over "office" (all selected), its fonts installed before the first frame. + fn harness() -> Harness<'static, DesignApp> { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "text", "text": "office 1024", "caret": true})).unwrap(); + app.run("edit.selectAll", json!({})).unwrap(); + let mut h = Harness::builder().with_size(vec2(320.0, 1400.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let id = egui::Id::new("test_fonts"); + if ui.data(|d| d.get_temp::(id)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(id, true)); + return; + } + show(app, ui); + }, + app, + ); + h.run_steps(3); + h + } + + fn click(h: &mut Harness<'static, DesignApp>, label: &str) { + h.get_by_label(label).click(); + h.run_steps(2); + } + + fn chars(h: &mut Harness<'static, DesignApp>) -> Value { + super::super::text_attrs(h.state_mut()).unwrap()["chars"].clone() + } + + /// Every `OTFFigureStyle` the stories' IDML export writes is `expected`. + fn assert_idml_figures(h: &Harness<'static, DesignApp>, expected: &str, why: &str) { + let idml = designcraft_idml::export_idml(&h.state().session.active().unwrap().doc); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(idml)).unwrap(); + let mut stories = String::new(); + for i in 0..z.len() { + let mut f = z.by_index(i).unwrap(); + if f.name().starts_with("Stories/") { + f.read_to_string(&mut stories).unwrap(); + } + } + let found: Vec<&str> = stories.split("OTFFigureStyle=\"").skip(1).filter_map(|s| s.split('"').next()).collect(); + assert!(!found.is_empty() && found.iter().all(|f| *f == expected), "{why}: {found:?}"); + } + + #[test] + fn every_character_format_toggle_has_a_control() { + let mut tags: Vec<&str> = SWITCH_SECTIONS.iter().flat_map(|s| s.iter().map(|(t, _)| *t)).collect(); + tags.extend(other_toggles().iter().map(|(t, _)| *t)); + for (tag, ..) in designcraft_doc::otf::TOGGLES { + assert!(tags.contains(tag), "{tag} has no Typography panel control"); + } + assert_eq!(tags.len(), tags.iter().collect::>().len(), "a feature shown twice: {tags:?}"); + // Source Serif 4 offers `mark`, one of the toggles only Other Features shows. + let mut h = harness(); + click(&mut h, "Mark Positioning"); + assert_eq!(chars(&mut h)["otfFeatures"], json!(["-mark"])); + } + + #[test] + fn a_figure_style_or_width_chosen_alone_exports_as_a_figure_style() { + let mut h = harness(); + click(&mut h, "Lining"); + assert_idml_figures(&h, "ProportionalLining", "Lining takes the fallback width"); + click(&mut h, "Tabular"); + assert_idml_figures(&h, "TabularLining", "Tabular keeps the applied style"); + click(&mut h, "Oldstyle"); + assert_idml_figures(&h, "TabularOldstyle", "Oldstyle keeps the applied width"); + click(&mut h, "Oldstyle"); + assert_idml_figures(&h, "Default", "turning a choice off clears both axes"); + assert!(!["tnum", "pnum", "lnum", "onum"].iter().any(|t| chars(&mut h)["otfFeatures"].as_array().unwrap().contains(&json!(t)))); + } + + #[test] + fn standard_ligatures_switch_the_ligatures_attribute() { + let mut h = harness(); + click(&mut h, "Standard Ligatures"); + let c = chars(&mut h); + assert_eq!(c["ligatures"], false, "{c}"); + assert!(!c["otfFeatures"].as_array().is_some_and(|l| l.iter().any(|t| t.as_str().is_some_and(|t| t.ends_with("liga")))), "{c}"); + click(&mut h, "Standard Ligatures"); + assert_eq!(chars(&mut h)["ligatures"], true, "the switch turns ligatures back on"); + } +} From ae19df8ef2cb1f524ca85d0aed4f610000901a29 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 15 Oct 2026 06:30:20 +0000 Subject: [PATCH 2/2] fix(typography): Standard Ligatures switches on over a feature-list -liga --- crates/engine/src/cmd/text.rs | 8 ++++++-- crates/ui-egui/src/panels/typography.rs | 16 +++++++++++++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index adf297311..af35bc833 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -1501,8 +1501,9 @@ fn open_type_features(s: &mut Session, _p: &Value) -> Result { let measure = |tag: &str| { let (mut applied, mut available) = (0, 0); for seg in &segments { - // Standard ligatures are the `ligatures` attribute, which shaping reads. - let on = if tag == "liga" { seg.ligatures } else { designcraft_doc::otf::is_on(&seg.applied, tag) }; + // Standard ligatures are the `ligatures` attribute, which shaping reads, unless the + // feature list turns `liga` off. + let on = designcraft_doc::otf::is_on(&seg.applied, tag) && (tag != "liga" || seg.ligatures); if on { applied += seg.len; } @@ -2844,6 +2845,9 @@ mod open_type_features_tests { s.execute("type.char", &json!({"attrs": {"ligatures": true}})).unwrap(); let v = s.execute("type.openTypeFeatures", &json!({})).unwrap(); assert_eq!(feature(&v, "liga")["applied"], "all", "{v}"); + s.execute("type.feature", &json!({"features": {"liga": false}})).unwrap(); + let v = s.execute("type.openTypeFeatures", &json!({})).unwrap(); + assert_eq!(feature(&v, "liga")["applied"], "none", "-liga in the feature list: {v}"); } } diff --git a/crates/ui-egui/src/panels/typography.rs b/crates/ui-egui/src/panels/typography.rs index 5cd0814f8..4206459d0 100644 --- a/crates/ui-egui/src/panels/typography.rs +++ b/crates/ui-egui/src/panels/typography.rs @@ -92,10 +92,12 @@ struct Changes { impl Changes { /// Standard Ligatures is the `ligatures` attribute: shaping turns `liga` off through it, and - /// IDML carries it as `Ligatures`, so `liga` stays out of the feature list. + /// IDML carries it as `Ligatures`, so `liga` stays out of the feature list. The tag is also + /// cleared from the selection's features, since an earlier `-liga` would keep shaping it off. fn set(&mut self, tag: &str, on: bool) { if tag == "liga" { self.ligatures = Some(on); + self.features.insert("liga".to_string(), true); } else { self.features.insert(tag.to_string(), on); } @@ -396,4 +398,16 @@ mod tests { click(&mut h, "Standard Ligatures"); assert_eq!(chars(&mut h)["ligatures"], true, "the switch turns ligatures back on"); } + + #[test] + fn standard_ligatures_switch_on_clears_a_feature_list_liga_off() { + let mut h = harness(); + h.state_mut().run("type.feature", json!({"features": {"liga": false}})).unwrap(); + assert_eq!(chars(&mut h)["otfFeatures"], json!(["-liga"])); + h.run_steps(2); + click(&mut h, "Standard Ligatures"); + let c = chars(&mut h); + assert_eq!(c["ligatures"], true, "{c}"); + assert!(!c["otfFeatures"].as_array().is_some_and(|l| l.iter().any(|t| t.as_str().is_some_and(|t| t.ends_with("liga")))), "{c}"); + } }