M7.5: OpenType as character attributes — per-run features (ligatures, contextual alternates, discretionary ligatures, swash, small caps, ordinals, fractions, figure styles) drive shaping; OpenType panel; SVG font-feature-settings
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
d9df5b0135
commit
d5aa387367
|
|
@ -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<String>,
|
||||
/// Character style (Character Styles panel) these attributes come from; None = Normal.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub style_name: Option<String>,
|
||||
|
|
@ -83,6 +87,7 @@ impl Default for CharStyle {
|
|||
underline: false,
|
||||
strikethrough: false,
|
||||
all_caps: false,
|
||||
features: vec![],
|
||||
style_name: None,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"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<bool>,
|
||||
strikethrough: Option<bool>,
|
||||
all_caps: Option<bool>,
|
||||
features: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// `features: ["dlig", "-liga", …]` → the canonical tag list (differences from the defaults).
|
||||
pub(crate) fn features_param(p: &Value, cmd: &str) -> Result<Option<Vec<String>>> {
|
||||
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<Option<Paint>> {
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"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<Value> {
|
|||
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<Value> {
|
|||
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;
|
||||
|
|
|
|||
|
|
@ -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<String> = 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())),
|
||||
|
|
|
|||
|
|
@ -682,3 +682,14 @@ fn unclipped_inverted_mask_exports_backdrop_and_filter() {
|
|||
};
|
||||
assert!(!disabled.contains("<mask"), "disabled masks are not exported");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opentype_features_export_as_css() {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let st = CharStyle { features: vec!["-liga".into(), "dlig".into()], ..Default::default() };
|
||||
let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 50.0), "office", st))));
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
let s = export(&d, &ExportOptions::default());
|
||||
assert!(s.contains("font-feature-settings:"liga" 0, "dlig" 1"), "{s}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,7 +50,40 @@ fn f(tag: &[u8; 4], on: bool) -> Feature {
|
|||
impl OtFeatures {
|
||||
/// Parse a feature list like `["dlig", "smcp", "-liga"]` (unknown tags are ignored).
|
||||
pub fn from_tags<'a>(tags: impl IntoIterator<Item = &'a str>) -> 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<String> {
|
||||
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<Item = &'a str>) -> 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<Feature> {
|
||||
// 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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1039,7 +1039,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
|
|||
panel("Character", "character"),
|
||||
panel("Character Styles", "charStyles"),
|
||||
panel("Glyphs", "glyphs"),
|
||||
todo("OpenType"),
|
||||
panel("OpenType", "openType"),
|
||||
panel("Paragraph", "paragraph"),
|
||||
panel("Paragraph Styles", "paraStyles"),
|
||||
todo("Tabs"),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
Loading…
Reference in New Issue