Type: Kerning has an Optical option that spaces characters by their shapes

Squashed from #4825 onto main.
This commit is contained in:
dotCooCoo
2026-11-01 01:22:04 +07:00
committed by bflatastic
parent 012af4fd49
commit 9896f633da
47 changed files with 1165 additions and 69 deletions
File diff suppressed because one or more lines are too long
+83 -14
View File
@@ -39,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Character",
[],
None,
"{id? (default: the one selected type object), 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\", keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}",
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}",
has_doc,
set_range_style
),
@@ -129,7 +129,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Reset Tracking and Kerning",
[],
Some("Cmd+Alt+Q"),
"{ids?: [id…]} set tracking to 0 and kerning to Auto (the font's own kerning, which the Character panel shows as 0) in all the text of the type objects in `ids`, else in the text the Type tool has selected (at a caret: only the kerning of the character before it), else in every selected type object, as one undo step → {ids}",
"{ids?: [id…]} set tracking to 0 and clear manual kerning without changing the automatic method in all the text of the type objects in `ids`, else in the text the Type tool has selected (at a caret: only the kerning of the character before it), else in every selected type object, as one undo step → {ids}",
has_selection,
reset_tracking
),
@@ -300,6 +300,7 @@ pub(crate) struct CharChange {
leading: Option<Option<f64>>,
tracking: Option<f64>,
kerning: Option<Option<f64>>,
optical_kerning: Option<bool>,
baseline_shift: Option<f64>,
h_scale: Option<f64>,
v_scale: Option<f64>,
@@ -476,7 +477,17 @@ impl CharChange {
size: num("size"),
leading: auto_or_num(p, "leading", cmd)?,
tracking: num("tracking"),
kerning: auto_or_num(p, "kerning", cmd)?,
kerning: match p.get("kerning") {
None | Some(Value::Null) => None,
Some(Value::String(v)) if v.eq_ignore_ascii_case("optical") => None,
Some(Value::String(v)) if v.eq_ignore_ascii_case("auto") => Some(None),
Some(v) => Some(Some(v.as_f64().ok_or_else(|| bad(cmd, "kerning must be a number, \"auto\", or \"optical\""))?)),
},
optical_kerning: match p.get("kerning") {
Some(Value::String(v)) if v.eq_ignore_ascii_case("optical") => Some(true),
Some(Value::String(v)) if v.eq_ignore_ascii_case("auto") => Some(false),
_ => None,
},
baseline_shift: num("baselineShift"),
h_scale: num("hScale"),
v_scale: num("vScale"),
@@ -521,6 +532,7 @@ impl CharChange {
&& self.leading.is_none()
&& self.tracking.is_none()
&& self.kerning.is_none()
&& self.optical_kerning.is_none()
&& self.baseline_shift.is_none()
&& self.h_scale.is_none()
&& self.v_scale.is_none()
@@ -583,6 +595,9 @@ impl CharChange {
if let Some(v) = self.kerning {
st.kerning = v.map(|k| k.clamp(-1000.0, 10000.0));
}
if let Some(v) = self.optical_kerning {
st.optical_kerning = v;
}
if let Some(v) = self.baseline_shift {
st.baseline_shift = v;
}
@@ -734,18 +749,53 @@ fn set_range_style(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(w) = super::fonts::set_font_warning(set) {
out["warning"] = json!(w);
}
if let Some(kerning) = p.get("kerning") {
out["kerning"] = kerning.clone();
}
Ok(out)
}
/// The em a laid-out glyph's kerning and tracking are measured in, in text space: the size it
/// was shaped at (superscript, small caps and Shrink Text to Fit included).
fn glyph_em(t: &TextObject, g: &vectorcraft_text::PositionedGlyph) -> Option<f64> {
let face = vectorcraft_text::FontDb::global().face_by_id(g.font_id)?;
fn glyph_em(db: &vectorcraft_text::FontDb, t: &TextObject, g: &vectorcraft_text::PositionedGlyph) -> Option<f64> {
let face = db.face_by_id(g.font_id)?;
let h_scale = edit::slice_runs(&t.runs, g.byte, g.byte + g.len).first()?.style.h_scale / 100.0;
let [a, b, ..] = g.xf.as_coeffs();
Some(a.hypot(b) * face.units_per_em() / h_scale).filter(|em| em.is_finite() && *em > 0.0)
}
/// The current pair correction in thousandths of an em, including an automatic correction.
pub fn effective_kerning(db: &vectorcraft_text::FontDb, t: &TextObject, lay: &vectorcraft_text::TextLayout, x: usize, y: usize) -> f64 {
if let Some(value) = edit::slice_runs(&t.runs, x, y).first().and_then(|r| r.style.kerning) {
return value;
}
// Measure both layouts without wrapping so the same pair stays on one line.
let mut trial = t.clone();
trial.kind = TextKind::Point;
let unwrapped = (!matches!(t.kind, TextKind::Point)).then(|| vectorcraft_text::layout(db, &trial));
let original = unwrapped.as_ref().unwrap_or(lay);
edit::style_range(&mut trial.runs, x, y, |st| st.kerning = Some(0.0));
let base = vectorcraft_text::layout(db, &trial);
let owner = original.glyphs.iter().find(|g| g.byte == x);
let rtl = owner.is_some_and(|g| g.rtl);
let drawn_gap = |layout: &vectorcraft_text::TextLayout| {
let first = layout.glyphs.iter().find(|g| g.byte == if rtl { y } else { x })?;
let following = layout.glyphs.iter().find(|g| g.byte == if rtl { x } else { y })?;
if first.line != following.line {
return None;
}
let axis = vectorcraft_geom::Vec2::new(first.angle.cos(), first.angle.sin());
let along = if rtl { -axis } else { axis };
let drawn = |g: &vectorcraft_text::PositionedGlyph| {
let [_, _, _, _, tx, ty] = g.xf.as_coeffs();
Point::new(tx, ty)
};
Some((drawn(following) - drawn(first)).dot(along))
};
let em = owner.and_then(|g| glyph_em(db, t, g)).unwrap_or(0.0);
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 }
}
fn touch_type(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.touchType";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
@@ -780,18 +830,21 @@ fn touch_type(s: &mut Session, p: &Value) -> Result<Value> {
// A list marker's glyphs (length 0) stand for no character.
let prev = lay.glyphs.iter().find(|h| h.line == g.line && h.len > 0 && h.byte + h.len == a);
let pr = prev.map(|h| (h.byte, h.byte + h.len));
let em = glyph_em(&t, g).ok_or_else(|| bad(C, "the character's size can't be measured"))?;
let db = vectorcraft_text::FontDb::global();
let em = glyph_em(db, &t, g).ok_or_else(|| bad(C, "the character's size can't be measured"))?;
let em_prev = match prev {
Some(h) => Some(glyph_em(&t, h).ok_or_else(|| bad(C, "the character's size can't be measured"))?),
Some(h) => Some(glyph_em(db, &t, h).ok_or_else(|| bad(C, "the character's size can't be measured"))?),
None if dx != 0.0 => return Err(bad(C, "the first character of a line has no character before it to kern: it moves only up and down (dy)")),
None => None,
};
let kerning_in = |(x, y): (usize, usize)| edit::slice_runs(&t.runs, x, y).first().and_then(|r| r.style.kerning).unwrap_or(0.0);
let kerning_in = |(x, y): (usize, usize)| effective_kerning(db, &t, &lay, x, y);
let original_before = pr.map(kerning_in);
let original_own = kerning_in((a, b));
// The move along the line, in text space, limited to what kerning from -1000 to 10000 (1/1000
// em) on the character before it and on the character itself can give.
let mut moved = dx / doc_len(along);
if let (Some(range), Some(ep)) = (pr, em_prev) {
let (kp, kc) = (kerning_in(range), kerning_in((a, b)));
if let (Some(_range), Some(ep)) = (pr, em_prev) {
let (kp, kc) = (original_before.unwrap_or(0.0), original_own);
let hi = ((10000.0 - kp) * ep).min((kc + 1000.0) * em) / 1000.0;
let lo = ((-1000.0 - kp) * ep).max((kc - 10000.0) * em) / 1000.0;
if lo <= hi {
@@ -803,7 +856,8 @@ fn touch_type(s: &mut Session, p: &Value) -> Result<Value> {
let touch = |t: &mut TextObject, before: Option<f64>, own: Option<f64>, raise: f64| {
for (range, by) in [(pr, before), (Some((a, b)), own)] {
if let (Some((x, y)), Some(by)) = (range, by) {
edit::style_range(&mut t.runs, x, y, |st| st.kerning = Some((st.kerning.unwrap_or(0.0) + by).clamp(-1000.0, 10000.0)));
let original = if (x, y) == (a, b) { original_own } else { original_before.unwrap_or(0.0) };
edit::style_range(&mut t.runs, x, y, |st| st.kerning = Some((original + by).clamp(-1000.0, 10000.0)));
}
}
edit::style_range(&mut t.runs, a, b, |st| {
@@ -1086,9 +1140,20 @@ fn type_step(s: &mut Session, p: &Value) -> Result<Value> {
}
_ => None,
})?;
let mut caret_kerning = Vec::with_capacity(ranges.len());
for &(id, a, b, caret) in &ranges {
let value = if matches!(step, Step::Kerning) && caret {
let t = text_ref(s, id)?;
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
Some(effective_kerning(vectorcraft_text::FontDb::global(), &t, &lay, a, b))
} else {
None
};
caret_kerning.push(value);
}
if !ranges.is_empty() {
s.edit("Character", |d, _| {
for &(id, a, b, _) in &ranges {
for (range_index, &(id, a, b, _)) in ranges.iter().enumerate() {
let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?;
let points = if matches!(step, Step::Size | Step::Leading | Step::BaselineShift) {
t.style_scale().ok_or_else(|| bad(C, "text transform is collapsed or unrepresentable"))?.points
@@ -1127,7 +1192,10 @@ fn type_step(s: &mut Session, p: &Value) -> Result<Value> {
st.leading = Some(super::typecmd::local_type_value(t, leading * points + inc, (0.1, 5000.0), false, C)?);
}
Step::Tracking => st.tracking = (st.tracking + inc).clamp(-1000.0, 10000.0),
Step::Kerning => st.kerning = Some((st.kerning.unwrap_or(0.0) + inc).clamp(-1000.0, 10000.0)),
Step::Kerning => {
let current = caret_kerning.get(range_index).copied().flatten().unwrap_or_else(|| st.kerning.unwrap_or(0.0));
st.kerning = Some((current + inc).clamp(-1000.0, 10000.0));
}
Step::BaselineShift => {
st.baseline_shift = super::typecmd::local_type_value(t, st.baseline_shift * points + inc, (-1296.0, 1296.0), false, C)?;
}
@@ -1142,9 +1210,10 @@ fn type_step(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "ids": ranges.iter().map(|(id, ..)| id.0).collect::<Vec<_>>() }))
}
/// Cmd/Ctrl+Alt+Q: tracking 0 and kerning Auto (0 in the Character panel) in the type objects in
/// Cmd/Ctrl+Alt+Q: tracking 0 and manual kerning cleared in the type objects in
/// `ids`, else in the Type tool's selected text, else in every selected type object. At a caret,
/// only the kerning of the character before it changes, as Alt+←/→ in horizontal type or Alt+↓/↑ in vertical type kerns there.
/// The automatic method remains selected.
fn reset_tracking(s: &mut Session, p: &Value) -> Result<Value> {
let ranges = type_ranges(s, p, "type.resetTracking", |text, a| Some((edit::prev_char(text, a), a)))?;
if !ranges.is_empty() {
+1
View File
@@ -152,6 +152,7 @@ pub fn char_attrs(v: &CharStyle) -> Map<String, Value> {
("fontAxes", json!(v.font_axes)),
("leading", json!(v.leading)),
("kerning", json!(v.kerning)),
("opticalKerning", json!(v.optical_kerning)),
("features", json!(v.features)),
("overprint_fill", json!(v.overprint_fill)),
("overprint_stroke", json!(v.overprint_stroke)),
+14
View File
@@ -460,6 +460,20 @@ fn auto_leading_new_distinguishes_unset_attributes() {
assert_eq!(s.doc().unwrap().doc.char_styles[2].attrs.get("leading"), Some(&Value::Null));
}
#[test]
fn optical_character_style_captures_and_redefines_automatic_mode() {
let (mut s, id) = session_with_text("III");
s.execute("text.setRangeStyle", &json!({"id": id, "kerning": "optical"})).unwrap();
s.execute("charStyle.new", &json!({"name": "Profile", "id": id})).unwrap();
assert_eq!(s.doc().unwrap().doc.char_styles[0].attrs.get("opticalKerning"), Some(&json!(true)));
s.execute("text.setRangeStyle", &json!({"id": id, "kerning": "auto"})).unwrap();
s.execute("charStyle.apply", &json!({"name": "Profile", "id": id})).unwrap();
assert!(runs(&s, id)[0].style.optical_kerning);
s.execute("text.setRangeStyle", &json!({"id": id, "kerning": "auto"})).unwrap();
s.execute("charStyle.redefine", &json!({"name": "Profile", "id": id})).unwrap();
assert_eq!(s.doc().unwrap().doc.char_styles[0].attrs.get("opticalKerning"), Some(&json!(false)));
}
#[test]
fn paragraph_styles_and_survive_save() {
let (mut s, id) = session_with_text("Body");