mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-01 13:47:21 -06:00
Type: Kerning has an Optical option that spaces characters by their shapes
Squashed from #4825 onto main.
This commit is contained in:
@@ -87,6 +87,7 @@ pub mod swatchlib;
|
||||
pub mod symbollib;
|
||||
pub(crate) mod tabs;
|
||||
mod textedit;
|
||||
pub use textedit::effective_kerning;
|
||||
pub mod textstyles;
|
||||
pub(crate) mod textwrap;
|
||||
pub(crate) mod threads;
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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() {
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! on-path type from paths, Fit Headline, Convert To Area/Point Type.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{NodeId, NodeKind, TextKind, TextObject};
|
||||
use vectorcraft_doc::{CharStyle, NodeId, NodeKind, TextKind, TextObject};
|
||||
use vectorcraft_geom::Point;
|
||||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
|
||||
|
||||
@@ -87,6 +87,168 @@ fn set_range_style_splits_runs() {
|
||||
assert!(s.execute("text.setRangeStyle", &json!({"id": id.0, "size": -1})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optical_command_manual_pair_undo_redo_and_native_reopen() {
|
||||
let mut s = session();
|
||||
let id = text(&mut s, "III");
|
||||
s.execute("text.setRangeStyle", &json!({"id": id.0, "kerning": 70})).unwrap();
|
||||
let result = s.execute("text.setRangeStyle", &json!({"id": id.0, "kerning": "optical"})).unwrap();
|
||||
assert_eq!(result["kerning"], "optical");
|
||||
assert!(obj(&s, id).runs[0].style.optical_kerning);
|
||||
assert_eq!(obj(&s, id).runs[0].style.kerning, Some(70.0));
|
||||
s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 1, "end": 2, "kerning": 0})).unwrap();
|
||||
let styled = obj(&s, id);
|
||||
assert_eq!(styled.runs.iter().map(|r| r.style.kerning).collect::<Vec<_>>(), vec![Some(70.0), Some(0.0), Some(70.0)]);
|
||||
assert!(styled.runs.iter().all(|r| r.style.optical_kerning));
|
||||
let bytes = vectorcraft_format::save_file(&s.doc().unwrap().doc);
|
||||
let opened = vectorcraft_format::load(&bytes).unwrap();
|
||||
let NodeKind::Text(reopened) = &opened.node(id).unwrap().kind else { panic!("text missing") };
|
||||
assert_eq!(
|
||||
reopened.runs.iter().map(|r| (r.style.optical_kerning, r.style.kerning)).collect::<Vec<_>>(),
|
||||
styled.runs.iter().map(|r| (r.style.optical_kerning, r.style.kerning)).collect::<Vec<_>>()
|
||||
);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(obj(&s, id).runs[0].style.kerning, Some(70.0));
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(obj(&s, id).runs[1].style.kerning, Some(0.0));
|
||||
assert!(obj(&s, id).runs.iter().all(|r| r.style.optical_kerning));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optical_set_format_keeps_existing_pair_override() {
|
||||
let mut s = session();
|
||||
let id = text(&mut s, "III");
|
||||
s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": 1, "kerning": 40})).unwrap();
|
||||
let result = s.execute("text.setFormat", &json!({"ids": [id.0], "kerning": "optical"})).unwrap();
|
||||
assert_eq!(result["kerning"], "optical");
|
||||
let t = obj(&s, id);
|
||||
assert!(t.runs.iter().all(|r| r.style.optical_kerning));
|
||||
assert_eq!(t.runs[0].style.kerning, Some(40.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optical_kerning_step_starts_from_the_automatic_pair() {
|
||||
let mut s = session();
|
||||
let id = NodeId(
|
||||
s.execute("text.create", &json!({"x": 10, "y": 10, "text": "AVA", "size": 100, "font": "JetBrains Mono"})).unwrap()["id"].as_u64().unwrap(),
|
||||
);
|
||||
s.execute("text.setRangeStyle", &json!({"id": id.0, "kerning": "optical"})).unwrap();
|
||||
let t = obj(&s, id);
|
||||
let optical = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
|
||||
let mut manual = t.clone();
|
||||
vectorcraft_text::edit::style_range(&mut manual.runs, 0, 1, |st| st.kerning = Some(0.0));
|
||||
let unkerned = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &manual);
|
||||
let auto = (optical.glyphs[0].advance - unkerned.glyphs[0].advance) * 1000.0 / 100.0;
|
||||
assert!(auto.abs() > 1.0, "the font needs a measurable shape correction");
|
||||
s.execute("type.step", &json!({"id": id.0, "start": 1, "end": 1, "attribute": "kerning", "by": 1})).unwrap();
|
||||
let after = obj(&s, id);
|
||||
assert!((after.runs[0].style.kerning.unwrap() - auto - s.prefs.tracking_increment).abs() < 0.01);
|
||||
assert!(after.runs.iter().all(|r| r.style.optical_kerning));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metrics_pair_step_preserves_the_drawn_auto_position() {
|
||||
use vectorcraft_text::test_fonts::{PAIR_KERNING_FAMILY, pair_kerning_font};
|
||||
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
db.add_font(pair_kerning_font().unwrap());
|
||||
let mut s = session();
|
||||
let id = NodeId(
|
||||
s.execute("text.create", &json!({"x": 10, "y": 10, "text": "AV", "size": 100, "font": PAIR_KERNING_FAMILY})).unwrap()["id"].as_u64().unwrap(),
|
||||
);
|
||||
let before = obj(&s, id);
|
||||
let auto = vectorcraft_text::layout(db, &before);
|
||||
let drawn_v = |layout: &vectorcraft_text::TextLayout| layout.glyphs.iter().find(|g| g.byte == 1).unwrap().xf.as_coeffs()[4];
|
||||
assert!((crate::cmd::effective_kerning(db, &before, &auto, 0, 1) + 160.0).abs() < 0.01);
|
||||
let mut same = before.clone();
|
||||
vectorcraft_text::edit::style_range(&mut same.runs, 0, 1, |st| st.kerning = Some(-160.0));
|
||||
assert!((drawn_v(&vectorcraft_text::layout(db, &same)) - drawn_v(&auto)).abs() < 0.01);
|
||||
|
||||
s.execute("type.step", &json!({"id": id.0, "start": 1, "end": 1, "attribute": "kerning", "by": 1})).unwrap();
|
||||
let after = obj(&s, id);
|
||||
assert!((after.runs[0].style.kerning.unwrap() + 160.0 - s.prefs.tracking_increment).abs() < 0.01);
|
||||
let moved = drawn_v(&vectorcraft_text::layout(db, &after)) - drawn_v(&auto);
|
||||
assert!((moved - s.prefs.tracking_increment * 0.1).abs() < 0.01, "V moves by one kerning increment: {moved}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_auto_kerning_at_area_line_end_survives_unkerned_wrap() {
|
||||
use vectorcraft_text::test_fonts::{PAIR_KERNING_FAMILY, pair_kerning_font};
|
||||
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
db.add_font(pair_kerning_font().unwrap());
|
||||
let style = CharStyle { font_family: PAIR_KERNING_FAMILY.into(), size: 100.0, ..CharStyle::default() };
|
||||
let mut text = TextObject::point(Point::ZERO, "AV", style);
|
||||
let mut wrapping_pair = None;
|
||||
for width in 80..160 {
|
||||
text.kind = TextKind::Area { frame: vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, f64::from(width), 300.0)) };
|
||||
let auto = vectorcraft_text::layout(db, &text);
|
||||
let mut trial = text.clone();
|
||||
vectorcraft_text::edit::style_range(&mut trial.runs, 0, 1, |st| st.kerning = Some(0.0));
|
||||
let unkerned = vectorcraft_text::layout(db, &trial);
|
||||
if auto.glyphs.len() >= 2
|
||||
&& unkerned.glyphs.len() >= 2
|
||||
&& auto.glyphs[0].line == auto.glyphs[1].line
|
||||
&& unkerned.glyphs[0].line != unkerned.glyphs[1].line
|
||||
{
|
||||
wrapping_pair = Some((text.clone(), auto, unkerned));
|
||||
break;
|
||||
}
|
||||
}
|
||||
let (text, auto, unkerned) = wrapping_pair.expect("the kerned pair fits on a line that wraps the unkerned pair");
|
||||
assert_ne!(unkerned.glyphs[0].line, unkerned.glyphs[1].line);
|
||||
assert!((crate::cmd::effective_kerning(db, &text, &auto, 0, 1) + 160.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_optical_kerning_reads_the_right_to_left_pair() {
|
||||
use vectorcraft_text::test_fonts::{RIGHT_TO_LEFT_OPTICAL_FAMILY, right_to_left_optical_font};
|
||||
|
||||
let db = vectorcraft_text::FontDb::with_font_dirs(vec![]);
|
||||
assert_eq!(db.add_font(right_to_left_optical_font().unwrap()), 1);
|
||||
let style = CharStyle { font_family: RIGHT_TO_LEFT_OPTICAL_FAMILY.into(), size: 100.0, optical_kerning: true, ..CharStyle::default() };
|
||||
let text = TextObject::point(Point::ZERO, "אב", style);
|
||||
let optical = vectorcraft_text::layout(&db, &text);
|
||||
let mut manual = text.clone();
|
||||
manual.runs[0].style.kerning = Some(0.0);
|
||||
let unkerned = vectorcraft_text::layout(&db, &manual);
|
||||
let drawn_gap = |lay: &vectorcraft_text::TextLayout| {
|
||||
let drawn = |byte| lay.glyphs.iter().find(|g| g.byte == byte).unwrap().xf.as_coeffs()[4];
|
||||
drawn(2) - drawn(0)
|
||||
};
|
||||
let expected = (drawn_gap(&optical) - drawn_gap(&unkerned)) * 10.0;
|
||||
assert!(expected.abs() > 1.0, "the outlined pair has a measurable Optical correction");
|
||||
let actual = crate::cmd::effective_kerning(&db, &text, &optical, 0, 2);
|
||||
assert!((actual - expected).abs() < 0.01, "the caret reads the adjacent right-to-left pair");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_tracking_clears_manual_pairs_without_changing_optical_mode() {
|
||||
let mut s = session();
|
||||
let id = text(&mut s, "III");
|
||||
s.execute("text.setRangeStyle", &json!({"id": id.0, "kerning": "optical"})).unwrap();
|
||||
s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": 1, "kerning": 80})).unwrap();
|
||||
s.select_tool("type", ViewInfo::default()).unwrap();
|
||||
let t = obj(&s, id);
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
|
||||
let (a, b) = vectorcraft_text::caret_position(&lay, 1);
|
||||
let p = t.xf * a.midpoint(b);
|
||||
click(&mut s, p.x, p.y);
|
||||
assert!(s.tool_wants_text());
|
||||
s.set_tool_option("select", &json!({"start": 1, "end": 1}));
|
||||
s.execute("type.resetTracking", &json!({})).unwrap();
|
||||
let caret = obj(&s, id);
|
||||
assert_eq!(caret.runs[0].style.kerning, None);
|
||||
assert!(caret.runs.iter().all(|r| r.style.optical_kerning));
|
||||
|
||||
s.execute("text.setRangeStyle", &json!({"id": id.0, "kerning": 60, "tracking": 30})).unwrap();
|
||||
s.set_tool_option("select", &json!({"start": 0, "end": 3}));
|
||||
s.execute("type.resetTracking", &json!({})).unwrap();
|
||||
let range = obj(&s, id);
|
||||
assert!(range.runs.iter().all(|r| r.style.kerning.is_none() && r.style.tracking == 0.0));
|
||||
assert!(range.runs.iter().all(|r| r.style.optical_kerning));
|
||||
}
|
||||
|
||||
/// A character style applied from byte 2^32 to 2^32 + 5 styles no character, as a range past the
|
||||
/// end does. A 32-bit build read the range as bytes 0 to 5 and styled "Hello".
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user