T: OpenType menu — feature toggles, figure styles, stylistic sets (type.openType, IDML OTF attributes)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 10:29:36 -04:00
parent 349525bae8
commit 5c547f9a96
9 changed files with 255 additions and 5 deletions

View File

@ -30,6 +30,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool).
- Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`.
- Place text files: Word (.docx: styles by name with their attributes, bold/italic/underline/size/font, footnotes, tables, tabs, breaks), RTF and plain text, into the insertion point, the selected frame or a new frame; autoflow adds pages and threaded frames until the text fits; Remove Styles option (`designcraft-textimport`).
- OpenType menu (Character panel): discretionary ligatures, fractions, ordinal, swash, titling, contextual alternates, slashed zero, the four figure styles and stylistic sets 1–20; `type.openType`; mapped to IDML's OTF attributes.
- Add Anchor Point (=), Delete Anchor Point (-) and Convert Direction Point (⇧C) tools (click toggles smooth/corner, drag pulls out handles), with `path.addAnchor`, `path.deleteAnchor`, `path.convertAnchor`.
- Gradients: the Gradient Swatch tool (G) drags a gradient's start and end across objects (Shift: 45°); the Gradient panel sets type, angle, reverse and edits stops on a ramp (drag, add, drag off to remove, location, colour). Edited gradients are unnamed swatches; `object.gradient`; the vector round-trips through IDML.
- Document Setup (⌥⌘P): intent, number of pages, start page # (an even start begins with a left page), facing pages, size presets and orientation, bleed and slug per edge; `layout.documentSetup {}` reports the setup.

View File

@ -17,6 +17,7 @@ pub mod ids;
pub mod index;
pub mod item;
pub mod notes;
pub mod otf;
pub mod page;
pub mod selection;
mod slice;

83
crates/doc/src/otf.rs Normal file
View File

@ -0,0 +1,83 @@
//! OpenType features as the Character panel's OpenType menu offers them, kept in a character
//! format's `otf_features` tag list (`"dlig"` on, `"-calt"` off).
/// On/off features: (tag, menu label, IDML attribute).
pub const TOGGLES: &[(&str, &str, &str)] = &[
("dlig", "Discretionary Ligatures", "OTFDiscretionaryLigature"),
("frac", "Fractions", "OTFFraction"),
("ordn", "Ordinal", "OTFOrdinal"),
("swsh", "Swash", "OTFSwash"),
("titl", "Titling Alternates", "OTFTitling"),
("calt", "Contextual Alternates", "OTFContextualAlternate"),
("zero", "Slashed Zero", "OTFSlashedZero"),
];
/// Figure styles: (id, menu label, tags, IDML value).
pub const FIGURES: &[(&str, &str, &[&str], &str)] = &[
("tabularLining", "Tabular Lining", &["tnum", "lnum"], "TabularLining"),
("proportionalOldstyle", "Proportional Oldstyle", &["pnum", "onum"], "ProportionalOldstyle"),
("proportionalLining", "Proportional Lining", &["pnum", "lnum"], "ProportionalLining"),
("tabularOldstyle", "Tabular Oldstyle", &["tnum", "onum"], "TabularOldstyle"),
("default", "Default Figure Style", &[], "Default"),
];
/// Features shaping turns on by itself: "off" is an explicit `-tag`.
fn default_on(tag: &str) -> bool {
tag == "calt"
}
pub fn is_on(list: &[String], tag: &str) -> bool {
if default_on(tag) { !list.iter().any(|t| t.strip_prefix('-') == Some(tag)) } else { list.iter().any(|t| t == tag) }
}
pub fn set(list: &mut Vec<String>, tag: &str, on: bool) {
list.retain(|t| t != tag && t.strip_prefix('-') != Some(tag));
if on != default_on(tag) {
list.push(if on { tag.to_string() } else { format!("-{tag}") });
}
}
/// The figure style id of a tag list ("default" when none matches).
pub fn figures(list: &[String]) -> &'static str {
FIGURES.iter().find(|(_, _, tags, _)| !tags.is_empty() && tags.iter().all(|t| list.iter().any(|x| x == t))).map_or("default", |f| f.0)
}
pub fn set_figures(list: &mut Vec<String>, id: &str) -> bool {
let Some((_, _, tags, _)) = FIGURES.iter().find(|f| f.0 == id) else { return false };
list.retain(|t| !matches!(t.as_str(), "tnum" | "pnum" | "lnum" | "onum"));
list.extend(tags.iter().map(|t| t.to_string()));
true
}
/// Stylistic sets 1–20 as a bit mask (bit 0 = ss01), IDML's `OTFStylisticSets`.
pub fn stylistic_sets(list: &[String]) -> u32 {
list.iter().filter_map(|t| t.strip_prefix("ss")?.parse::<u32>().ok()).filter(|n| (1..=20).contains(n)).fold(0, |m, n| m | 1 << (n - 1))
}
pub fn set_stylistic_sets(list: &mut Vec<String>, mask: u32) {
list.retain(|t| !(t.len() == 4 && t.starts_with("ss") && t[2..].parse::<u32>().is_ok()));
list.extend((1..=20).filter(|n| mask & (1 << (n - 1)) != 0).map(|n| format!("ss{n:02}")));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn toggles_figures_and_sets() {
let mut l: Vec<String> = vec![];
assert!(is_on(&l, "calt") && !is_on(&l, "dlig"));
set(&mut l, "dlig", true);
set(&mut l, "calt", false);
assert_eq!(l, ["dlig", "-calt"]);
set(&mut l, "calt", true);
assert_eq!(l, ["dlig"]);
assert!(set_figures(&mut l, "tabularOldstyle"));
assert_eq!(figures(&l), "tabularOldstyle");
set_figures(&mut l, "default");
assert_eq!(figures(&l), "default");
set_stylistic_sets(&mut l, 0b101);
assert_eq!(stylistic_sets(&l), 0b101);
assert!(l.contains(&"ss03".to_string()));
}
}

View File

@ -140,6 +140,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_text_or_frames,
change_case
),
cmd!(
"type.openType",
"OpenType",
[],
None,
"{feature?: dlig|frac|ordn|swsh|titl|calt|zero|ssNN|any tag, on?: bool (default: toggle), figures?: tabularLining|proportionalOldstyle|proportionalLining|tabularOldstyle|default, stylisticSets?: [1–20]} → {features, figures}",
has_text_or_frames,
open_type
),
cmd!(query "type.selectionAttrs", "Selection Attributes", [], None, "{} → resolved character/paragraph attributes at the text selection", has_doc, selection_attrs),
]
}
@ -511,6 +520,31 @@ fn step_size(s: &mut Session, delta: f64) -> Result<Value> {
format_chars(s, &json!({"size": (size + delta).clamp(0.1, 1296.0)}))
}
fn open_type(s: &mut Session, p: &Value) -> Result<Value> {
use designcraft_doc::otf;
let cur = selection_attrs(s, &json!({}))?;
let mut list: Vec<String> =
cur["chars"]["otfFeatures"].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default();
if let Some(tag) = p.get("feature").and_then(Value::as_str) {
if tag.is_empty() || tag.len() > 4 || !tag.chars().all(|c| c.is_ascii_alphanumeric()) {
return Err(bad("type.openType", format!("bad feature tag `{tag}`")));
}
let on = p.get("on").and_then(Value::as_bool).unwrap_or(!otf::is_on(&list, tag));
otf::set(&mut list, tag, on);
}
if let Some(f) = p.get("figures").and_then(Value::as_str)
&& !otf::set_figures(&mut list, f)
{
return Err(bad("type.openType", format!("unknown figure style `{f}`")));
}
if let Some(sets) = p.get("stylisticSets").and_then(Value::as_array) {
let mask = sets.iter().filter_map(Value::as_u64).filter(|n| (1..=20).contains(n)).fold(0u32, |m, n| m | 1 << (n - 1));
otf::set_stylistic_sets(&mut list, mask);
}
format_chars(s, &json!({"otfFeatures": list}))?;
Ok(json!({"features": list, "figures": otf::figures(&list)}))
}
fn selection_attrs(s: &mut Session, _p: &Value) -> Result<Value> {
let st = s.doc()?;
let targets = format_targets(s);
@ -647,3 +681,46 @@ fn change_case(s: &mut Session, p: &Value) -> Result<Value> {
Ok(Value::Null)
})
}
#[cfg(test)]
mod open_type_tests {
use serde_json::json;
use crate::Session;
#[test]
fn open_type_toggles_reach_the_text() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Office 1/2 0"})).unwrap();
s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 12})).unwrap();
let r = s.execute("type.openType", &json!({"feature": "dlig"})).unwrap();
assert_eq!(r["features"], json!(["dlig"]));
let r = s.execute("type.openType", &json!({"feature": "dlig"})).unwrap();
assert_eq!(r["features"], json!([]), "toggles off");
let r = s.execute("type.openType", &json!({"figures": "proportionalOldstyle", "stylisticSets": [1, 2]})).unwrap();
assert_eq!(r["figures"], "proportionalOldstyle");
let a = s.execute("type.selectionAttrs", &json!({})).unwrap();
let f: Vec<&str> = a["chars"]["otfFeatures"].as_array().unwrap().iter().filter_map(|v| v.as_str()).collect();
assert!(f.contains(&"onum") && f.contains(&"ss02"), "{f:?}");
assert!(s.execute("type.openType", &json!({"feature": "bad tag!"})).is_err());
}
/// Old-style figures shape to other glyphs than the default lining ones (Source Serif 4).
#[test]
fn oldstyle_figures_change_the_glyphs() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "2024"})).unwrap();
let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap());
let gids = |s: &Session| {
let d = &s.doc().unwrap().doc;
let cs = designcraft_compose::compose_story(d, sid, &Default::default());
cs.frames[0].lines[0].glyphs.iter().map(|g| g.gid).collect::<Vec<_>>()
};
let lining = gids(&s);
s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 4})).unwrap();
s.execute("type.openType", &json!({"figures": "proportionalOldstyle"})).unwrap();
assert_ne!(gids(&s), lining);
}
}

View File

@ -131,3 +131,24 @@ fn gradient_vector_round_trips_through_idml() {
assert!((v[k] - w[k]).abs() < 1e-3, "{v:?} vs {w:?}");
}
}
#[test]
fn open_type_features_round_trip_through_idml() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "1/2 Office"})).unwrap();
let sid = r["story"].as_u64().unwrap();
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 3})).unwrap();
s.execute("type.openType", &json!({"feature": "frac"})).unwrap();
s.execute("type.openType", &json!({"figures": "tabularOldstyle", "stylisticSets": [3]})).unwrap();
let d = s.doc().unwrap().doc.clone();
let back = designcraft_idml::import_idml(&designcraft_idml::export_idml(&d)).unwrap();
let st = back.stories.values().find(|st| st.text.starts_with("1/2")).unwrap();
let f = st.format_after(0).over.otf_features.clone().unwrap_or_default();
assert!(designcraft_doc::otf::is_on(&f, "frac"), "{f:?}");
assert_eq!(designcraft_doc::otf::figures(&f), "tabularOldstyle");
assert_eq!(designcraft_doc::otf::stylistic_sets(&f), 0b100);
// Text after the selection has no features of its own.
let after = st.format_after(5).over.otf_features.clone().unwrap_or_default();
assert!(!designcraft_doc::otf::is_on(&after, "frac") && designcraft_doc::otf::stylistic_sets(&after) == 0, "{after:?}");
}

View File

@ -875,6 +875,16 @@ impl<'a> Ex<'a> {
if let Some(v) = a.ligatures {
el.set("Ligatures", bool_s(v));
}
if let Some(list) = &a.otf_features {
for (tag, _, attr) in designcraft_doc::otf::TOGGLES {
el.set(attr, bool_s(designcraft_doc::otf::is_on(list, tag)));
}
let fig = designcraft_doc::otf::figures(list);
if let Some(f) = designcraft_doc::otf::FIGURES.iter().find(|f| f.0 == fig) {
el.set("OTFFigureStyle", f.3);
}
el.set("OTFStylisticSets", designcraft_doc::otf::stylistic_sets(list).to_string());
}
if let Some(v) = a.no_break {
el.set("NoBreak", bool_s(v));
}

View File

@ -859,6 +859,28 @@ impl<'r> Importer<'r> {
a.underline = e.boolean("Underline");
a.strikethrough = e.boolean("StrikeThru");
a.ligatures = e.boolean("Ligatures");
// OpenType features (only when the element sets any).
let mut otf_list: Vec<String> = Vec::new();
let mut any = false;
for (tag, _, attr) in designcraft_doc::otf::TOGGLES {
if let Some(on) = e.boolean(attr) {
designcraft_doc::otf::set(&mut otf_list, tag, on);
any = true;
}
}
if let Some(fig) = e.prop("OTFFigureStyle")
&& let Some((id, ..)) = designcraft_doc::otf::FIGURES.iter().find(|f| f.3 == fig.trim())
{
designcraft_doc::otf::set_figures(&mut otf_list, id);
any = true;
}
if let Some(m) = e.num("OTFStylisticSets") {
designcraft_doc::otf::set_stylistic_sets(&mut otf_list, m.max(0.0) as u32);
any = true;
}
if any {
a.otf_features = Some(otf_list);
}
a.no_break = e.boolean("NoBreak");
a.language = e.prop("AppliedLanguage").map(|l| l.trim().trim_start_matches("$ID/").to_string());
a

View File

@ -1184,6 +1184,41 @@ pub fn character_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
if ui.selectable_label(sup, "T¹").on_hover_text("Superscript").clicked() {
let _ = app.run("type.char", json!({"attrs": {"position": if sup { "normal" } else { "superscript" }}}));
}
let features: Vec<String> =
c["otfFeatures"].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default();
ui.menu_button("OpenType", |ui| open_type_menu(app, ui, &features));
});
}
/// The Character panel's OpenType menu: feature toggles, figure styles and stylistic sets.
pub fn open_type_menu(app: &mut DesignApp, ui: &mut egui::Ui, features: &[String]) {
use designcraft_doc::otf;
ui.set_min_width(220.0);
for (tag, label, _) in otf::TOGGLES {
let mut on = otf::is_on(features, tag);
if ui.checkbox(&mut on, *label).clicked() {
let _ = app.run("type.openType", json!({"feature": tag, "on": on}));
ui.close();
}
}
ui.separator();
let fig = otf::figures(features);
for (id, label, ..) in otf::FIGURES {
if ui.radio(fig == *id, *label).clicked() {
let _ = app.run("type.openType", json!({"figures": id}));
ui.close();
}
}
ui.separator();
ui.menu_button("Stylistic Sets", |ui| {
let mask = otf::stylistic_sets(features);
for n in 1..=20u32 {
let mut on = mask & (1 << (n - 1)) != 0;
if ui.checkbox(&mut on, format!("Set {n}")).clicked() {
let sets: Vec<u32> = (1..=20).filter(|k| if *k == n { on } else { mask & (1 << (k - 1)) != 0 }).collect();
let _ = app.run("type.openType", json!({"stylisticSets": sets}));
}
}
});
}

View File

@ -5,11 +5,11 @@ The summary is generated by `cargo xtask parity` from the table at the bottom of
Each row of the InDesign feature catalogue is scored **D** (done), **P** (partial: works but lacks options or UI) or **M** (missing), weighted by priority (**P0** core = 3, **P1** = 2, **P2** = 1). Out-of-scope rows (Adobe cloud services, plug-ins, Typekit, Flash) are excluded.
<!-- SUMMARY -->
**Weighted breadth parity: 68%** over 275 features.
**Weighted breadth parity: 69%** over 275 features.
| Priority | Features | Done | Partial | Missing | Parity |
|---|---|---|---|---|---|
| P0 | 102 | 94 | 8 | 0 | 96% |
| P0 | 102 | 95 | 7 | 0 | 97% |
| P1 | 97 | 33 | 23 | 41 | 46% |
| P2 | 76 | 8 | 7 | 61 | 15% |
@ -24,7 +24,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia
| Effects & transparency | 50% |
| Placing & links | 57% |
| Type & text frames | 77% |
| Typography | 76% |
| Typography | 79% |
| Styles | 61% |
| Tables | 73% |
| Long documents | 58% |
@ -35,7 +35,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia
| Undo, history, saving | 100% |
| Accessibility | 20% |
Open P0 items: Dockable panels, Preferences, Raster formats, Vector/page formats (PDF/AI/SVG/EPS place), Display performance, OpenType features, Export to PDF (print), Display performance.
Open P0 items: Dockable panels, Preferences, Raster formats, Vector/page formats (PDF/AI/SVG/EPS place), Display performance, Export to PDF (print), Display performance.
## Rows
@ -217,7 +217,7 @@ Open P0 items: Dockable panels, Preferences, Raster formats, Vector/page formats
| Type & text frames | Right-to-left / World-Ready composer | P2 | M |
| Typography | Character attributes | P0 | D |
| Typography | Underline/Strikethrough options | P1 | M |
| Typography | OpenType features | P0 | P |
| Typography | OpenType features | P0 | D |
| Typography | Variable fonts | P1 | M |
| Typography | Paragraph attributes | P0 | D |
| Typography | Paragraph Composer (total-fit) | P0 | D |