diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index a13fb2168..0b205a5b1 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -388,6 +388,18 @@ fn char_position_round_trips() { assert!((scale - 0.583).abs() < 1e-9 && (shift + 3.33).abs() < 1e-9); } +#[test] +fn optical_kerning_round_trips_and_old_style_defaults_to_auto() { + let st = CharStyle { optical_kerning: true, kerning: Some(120.0), ..CharStyle::default() }; + let value = serde_json::to_value(&st).unwrap(); + assert_eq!(value["opticalKerning"], true); + assert_eq!(serde_json::from_value::(value.clone()).unwrap(), st); + let mut old = value; + old.as_object_mut().unwrap().remove("opticalKerning"); + assert!(!serde_json::from_value::(old).unwrap().optical_kerning); + assert!(serde_json::to_value(CharStyle::default()).unwrap().get("opticalKerning").is_none()); +} + #[test] fn units() { assert_eq!(Unit::Inches.parse("1"), Some(72.0)); diff --git a/crates/doc/src/text/char_style.rs b/crates/doc/src/text/char_style.rs index 0e3aea70e..5189e7aa2 100644 --- a/crates/doc/src/text/char_style.rs +++ b/crates/doc/src/text/char_style.rs @@ -25,9 +25,12 @@ pub struct CharStyle { /// Tracking in 1/1000 em. #[serde(default)] pub tracking: f64, - /// Kerning: None = Auto (metrics), Some(v) = manual in 1/1000 em. + /// Manual pair spacing in 1/1000 em; None uses the automatic mode. #[serde(default, skip_serializing_if = "Option::is_none")] pub kerning: Option, + /// Use glyph shapes for automatic pair spacing; false uses font metrics. + #[serde(default, rename = "opticalKerning", skip_serializing_if = "crate::skip::is_default")] + pub optical_kerning: bool, #[serde(default)] pub baseline_shift: f64, #[serde(default = "hundred")] @@ -266,6 +269,7 @@ impl Default for CharStyle { leading: None, tracking: 0.0, kerning: None, + optical_kerning: false, baseline_shift: 0.0, h_scale: 100.0, v_scale: 100.0, diff --git a/crates/doc/src/text/tabs_lists.rs b/crates/doc/src/text/tabs_lists.rs index 47106d788..b66795e89 100644 --- a/crates/doc/src/text/tabs_lists.rs +++ b/crates/doc/src/text/tabs_lists.rs @@ -223,6 +223,7 @@ impl ParaList { position: CharPosition::Normal, tracking: 0.0, kerning: None, + optical_kerning: first.optical_kerning, tate_chu_yoko: false, underline: false, strikethrough: false, diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 2d9fea923..875826262 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -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; diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 969daf4f6..4c52da717 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -43,7 +43,7 @@ pub fn specs() -> Vec { "Character / Paragraph", [], None, - "{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\" (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), 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, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)", + "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\" (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), 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, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)", has_doc, set_format ), @@ -323,7 +323,13 @@ fn set_format(s: &mut Session, p: &Value) -> Result { let kerning = match p.get("kerning") { None | Some(Value::Null) => None, Some(Value::String(a)) if a.eq_ignore_ascii_case("auto") => Some(None), - Some(v) => Some(Some(v.as_f64().ok_or_else(|| bad(C, "kerning must be a number or \"auto\""))?.clamp(-1000.0, 10000.0))), + Some(Value::String(a)) if a.eq_ignore_ascii_case("optical") => None, + Some(v) => Some(Some(v.as_f64().ok_or_else(|| bad(C, "kerning must be a number, \"auto\", or \"optical\""))?.clamp(-1000.0, 10000.0))), + }; + let optical_kerning = match p.get("kerning") { + Some(Value::String(a)) if a.eq_ignore_ascii_case("auto") => Some(false), + Some(Value::String(a)) if a.eq_ignore_ascii_case("optical") => Some(true), + _ => None, }; let keys = [ "kerning", @@ -479,6 +485,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result { if let Some(k) = kerning { st.kerning = k; } + if let Some(v) = optical_kerning { + st.optical_kerning = v; + } if let Some(v) = baseline_shift { st.baseline_shift = v; } @@ -649,7 +658,11 @@ fn set_format(s: &mut Session, p: &Value) -> Result { } Ok(()) })?; - Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) + let mut result = json!({ "ids": ids.iter().map(|i| i.0).collect::>() }); + if let Some(kerning) = p.get("kerning") { + result["kerning"] = kerning.clone(); + } + Ok(result) } /// The part of a story paragraph in one frame, as [`convert_list_to_text`] reads it: the frame, diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index cb4a51354..8d393a58e 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -39,7 +39,7 @@ pub fn specs() -> Vec { "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 { "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>, tracking: Option, kerning: Option>, + optical_kerning: Option, baseline_shift: Option, h_scale: Option, v_scale: Option, @@ -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 { 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 { - 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 { + 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 { 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 { // 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 { let touch = |t: &mut TextObject, before: Option, own: Option, 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 { } _ => 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 { 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 { Ok(json!({ "ids": ranges.iter().map(|(id, ..)| id.0).collect::>() })) } -/// 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 { let ranges = type_ranges(s, p, "type.resetTracking", |text, a| Some((edit::prev_char(text, a), a)))?; if !ranges.is_empty() { diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index 2a8d48fab..c1312fa9b 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -152,6 +152,7 @@ pub fn char_attrs(v: &CharStyle) -> Map { ("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)), diff --git a/crates/engine/src/cmd/textstyles/tests.rs b/crates/engine/src/cmd/textstyles/tests.rs index 2cdcaf77a..670e3bbb7 100644 --- a/crates/engine/src/cmd/textstyles/tests.rs +++ b/crates/engine/src/cmd/textstyles/tests.rs @@ -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"); diff --git a/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs index be9861316..025cf6d30 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -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![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::>(), + styled.runs.iter().map(|r| (r.style.optical_kerning, r.style.kerning)).collect::>() + ); + 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] diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index 2c79099b4..5c69a6a93 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -1,5 +1,6 @@ //! Shaping: text runs -> positioned glyphs in points (before line breaking). +use std::collections::HashMap; use std::ops::Range; use std::sync::Arc; @@ -8,6 +9,7 @@ use unicode_script::{Script, UnicodeScript}; use unicode_segmentation::UnicodeSegmentation; use harfrust::{Direction, Feature, ShapeOptions, Tag, UnicodeBuffer}; +use kurbo::Shape; use skrifa::MetadataProvider; use skrifa::charmap::Charmap; use skrifa::instance::Size; @@ -446,6 +448,216 @@ pub(crate) fn shape_range( { o.sort_by_key(|g| g.byte); } + if !feats.vertical { + optical_pairs(db, text, out.get_mut(output_start..).unwrap_or_default(), runs); + } +} + +/// Line segments of a glyph outline in font units, cached for the current shaping pass. +type Profile = Vec<(f64, f64, f64, f64)>; +type CachedProfile = (Profile, (f64, f64)); + +fn outline_profile(db: &FontDb, g: &SGlyph, cache: &mut HashMap<(u32, u32), Option>>) -> Option> { + let key = (g.face.id(), g.gid); + cache + .entry(key) + .or_insert_with(|| { + let path = db.outline(&g.face, g.gid); + let bounds = path.bounding_box(); + if path.elements().is_empty() + || path.elements().len() > 512 + || ![bounds.x0, bounds.y0, bounds.x1, bounds.y1].iter().all(|v| v.is_finite() && v.abs() <= 100_000.0) + { + return None; + } + let mut lines = Vec::new(); + let mut previous = None; + let mut contour_start = None; + kurbo::flatten(path.iter(), 2.0, |el| match el { + kurbo::PathEl::MoveTo(p) => { + previous = Some(p); + contour_start = Some(p); + } + kurbo::PathEl::LineTo(p) => { + if let Some(a) = previous + && lines.len() < 8192 + { + lines.push((a.x, a.y, p.x, p.y)); + } + previous = Some(p); + } + kurbo::PathEl::ClosePath => { + if let Some((a, b)) = previous.zip(contour_start) + && lines.len() < 8192 + { + lines.push((a.x, a.y, b.x, b.y)); + } + previous = None; + contour_start = None; + } + _ => {} + }); + (!lines.is_empty() && lines.len() < 8192).then(|| Arc::new((lines, (bounds.y0, bounds.y1)))) + }) + .clone() +} + +/// The left and right ink edges where a horizontal slice crosses an outline. +fn profile_edges(lines: &[(f64, f64, f64, f64)], y: f64, sx: f64, sy: f64, dx: f64, dy: f64) -> Option<(f64, f64)> { + let mut left = f64::INFINITY; + let mut right = f64::NEG_INFINITY; + for &(x0, y0, x1, y1) in lines { + let (a, b) = (y0 * sy + dy, y1 * sy + dy); + if (a <= y && y < b) || (b <= y && y < a) { + let x = (x0 + (x1 - x0) * (y - a) / (b - a)) * sx + dx; + left = left.min(x); + right = right.max(x); + } + } + (left.is_finite() && right.is_finite()).then_some((left, right)) +} + +#[allow(clippy::too_many_arguments)] +fn profile_correction( + a: &[(f64, f64, f64, f64)], + b: &[(f64, f64, f64, f64)], + left: (f64, f64, f64, f64), + right: (f64, f64, f64, f64), + advance: f64, + em: f64, + upem: f64, + low: f64, + high: f64, +) -> Option { + if !low.is_finite() || !high.is_finite() || high <= low || !em.is_finite() || em <= 0.0 || !upem.is_finite() || upem <= 0.0 { + return None; + } + let mut gaps = Vec::new(); + for j in 0..24 { + let y = low + (high - low) * (f64::from(j) + 0.5) / 24.0; + if let (Some((_, ar)), Some((bl, _))) = + (profile_edges(a, y, left.0, left.1, left.2, left.3), profile_edges(b, y, right.0, right.1, right.2, right.3)) + { + gaps.push(advance + bl - ar); + } + } + if gaps.is_empty() { + return None; + } + gaps.sort_by(f64::total_cmp); + let gap = gaps.get(gaps.len() / 2).copied().unwrap_or(0.0); + let unit = em / upem; + let correction = ((0.08 * em - gap).clamp(-0.25 * em, 0.25 * em) / unit).round() * unit; + let correction = correction.clamp(-0.25 * em, 0.25 * em); + correction.is_finite().then_some(correction) +} + +/// Move each Optical pair toward an ink gap of 0.08 em, at 24 overlapping vertical slices. +/// The median avoids a single narrow tip deciding the entire pair. Corrections are rounded to +/// one font unit and limited to 1/4 em in either direction. Missing ink gives zero correction. +fn optical_pairs(db: &FontDb, text: &str, glyphs: &mut [SGlyph], runs: &[(Range, &CharStyle)]) { + let mut cache = HashMap::new(); + let mut corrections: HashMap<[u64; 15], Option> = HashMap::new(); + let shaped_advances: Vec = glyphs.iter().map(|g| g.adv).collect(); + for i in 0..glyphs.len().saturating_sub(1) { + let Some((a, b)) = glyphs.get(i).zip(glyphs.get(i + 1)) else { continue }; + if a.level != b.level { + continue; + } + let Some(owner) = runs.get(a.run).map(|(_, s)| *s) else { continue }; + let invisible = |ch: char| matches!(ch, SOFT_HYPHEN | '\u{2028}') || ch.is_control(); + if !owner.optical_kerning + || a.byte == b.byte + || invisible(a.ch) + || invisible(b.ch) + || text.get(a.byte..b.byte).is_some_and(|between| between.chars().skip(1).any(invisible)) + || a.rotation != 0.0 + || b.rotation != 0.0 + { + continue; + } + if owner.kerning.is_some() { + continue; + } + let mut start = i; + while start > 0 && glyphs[start - 1].byte == a.byte { + start -= 1; + } + let mut end = i + 1; + while end + 1 < glyphs.len() && glyphs[end + 1].byte == b.byte { + end += 1; + } + // A mark may precede its base inside either direction's shaped cluster. + let first_base = glyphs[start..=i] + .iter() + .enumerate() + .max_by(|(a, _), (b, _)| shaped_advances[start + *a].total_cmp(&shaped_advances[start + *b])) + .map_or(start, |(offset, _)| start + offset); + let second_base = glyphs[i + 1..=end] + .iter() + .enumerate() + .max_by(|(a, _), (b, _)| shaped_advances[i + 1 + *a].total_cmp(&shaped_advances[i + 1 + *b])) + .map_or(i + 1, |(offset, _)| i + 1 + offset); + let (left_index, right_index, target, cluster_start, cluster_end) = + if a.level.is_rtl() { (second_base, first_base, end, i + 1, end) } else { (first_base, second_base, i, start, i) }; + let (left, right) = (&glyphs[left_index], &glyphs[right_index]); + if left.byte == right.byte + || left.is_space() + || right.is_space() + || is_mark(right.ch) + || left.inline.is_some() + || right.inline.is_some() + || left.ch == '\n' + || right.ch == '\n' + { + continue; + } + let left_y = left.dy - left.bshift; + let right_y = right.dy - right.bshift; + let Some(left_style) = runs.get(left.run).map(|(_, s)| *s) else { continue }; + let em = left.sx * left.face.upem / (left_style.h_scale / 100.0); + let tracking = left_style.tracking / 1000.0 * em; + let manual = left_style.kerning.unwrap_or(0.0) / 1000.0 * em; + let advance = glyphs[cluster_start..=cluster_end].iter().map(|g| g.adv).sum::() - tracking - manual; + let key = [ + u64::from(left.face.id()), + u64::from(left.gid), + u64::from(right.face.id()), + u64::from(right.gid), + left.sx.to_bits(), + left.sy.to_bits(), + left.dx.to_bits(), + left_y.to_bits(), + right.sx.to_bits(), + right.sy.to_bits(), + right.dx.to_bits(), + right_y.to_bits(), + advance.to_bits(), + em.to_bits(), + left.face.upem.to_bits(), + ]; + let correction = *corrections.entry(key).or_insert_with(|| { + let (Some(a), Some(b)) = (outline_profile(db, left, &mut cache), outline_profile(db, right, &mut cache)) else { return None }; + let low = (a.1.0 * left.sy + left_y).max(b.1.0 * right.sy + right_y); + let high = (a.1.1 * left.sy + left_y).min(b.1.1 * right.sy + right_y); + profile_correction( + &a.0, + &b.0, + (left.sx, left.sy, left.dx, left_y), + (right.sx, right.sy, right.dx, right_y), + advance, + em, + left.face.upem, + low, + high, + ) + }); + if let (Some(correction), Some(glyph)) = (correction, glyphs.get_mut(target)) + && !invisible(glyph.ch) + { + glyph.adv += correction; + } + } } /// Index in the original run list containing `byte`, limited to one coalesced shaping span. @@ -474,6 +686,7 @@ fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool { leading: _, tracking, kerning: _, + optical_kerning: _, baseline_shift: _, h_scale, v_scale, @@ -696,6 +909,15 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe }; let manual = |byte: usize| run_at(byte).1.kerning.is_some(); let mixed_kerning = boundaries().any(|b| manual(b) != manual(range.start)); + let optical = |byte: usize| run_at(byte).1.optical_kerning; + let mode_boundaries: Vec = boundaries().filter(|&b| !feats.vertical && optical(b - 1) && !optical(b)).collect(); + if !mode_boundaries.is_empty() { + return shape_apart(mode_boundaries, out); + } + let manual_ends: Vec = boundaries().filter(|&b| manual(b - 1) && !manual(b)).collect(); + if !manual_ends.is_empty() { + return shape_apart(manual_ends, out); + } // Shape the segment as if all of it were in `st`, with the features `more` on top. let shape_in = |st: &CharStyle, more: &[Feature]| { @@ -793,17 +1015,43 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe } shaped }; - let Shaped { raw, proportional, vpal } = if mixed_kerning { + let first_optical = if optical(range.start) { Some(range.start) } else { boundaries().find(|&b| optical(b)) }; + let Shaped { raw, proportional, vpal } = if let Some(first) = first_optical.filter(|_| !feats.vertical) { + // The pair entering Optical keeps the left character's font kerning. Subsequent + // Optical pairs use outline spacing, so font pair features stop at its second character. + let optical_off = if first == range.start { + first + } else { + text.get(first..range.end).and_then(|s| s.chars().next()).map_or(range.end, |c| first + c.len_utf8()) + }; + let first_manual = if manual(range.start) { Some(range.start) } else { boundaries().find(|&b| manual(b)) }; + if let Some(start) = first_manual.filter(|&b| b > range.start) + && upper + && first_char(start).to_uppercase().nth(1).is_some() + { + return shape_apart(vec![start], out); + } + let manual_off = first_manual.map_or(range.end, |start| { + if start == range.start { + start + } else { + text.get(start..range.end).and_then(|s| s.chars().next()).map_or(range.end, |c| start + c.len_utf8()) + } + }); + let off = optical_off.min(manual_off); + let more: Vec = [b"kern", b"apkn", b"vkrn", b"vapk"] + .into_iter() + .filter(|_| off < range.end) + .map(|t| Feature::new(Tag::new(t), 0, off..range.end)) + .collect(); + shape_in(&CharStyle { kerning: None, ..run_at(range.start).1.clone() }, &more) + } else if mixed_kerning { // A font's kerning pair applies after a character with automatic kerning and not after one // with manual kerning. The segment is shaped apart where manual kerning ends, so each piece // is text with automatic kerning followed by text with manual kerning. A piece is shaped // with the font's kerning on, and off (`kern`, and the `apkn`, `vkrn` and `vapk` that // `OtFeatures` turns off with it) from the second character with manual kerning on: a // pair is applied only when both of its glyphs have the feature on. - let ends: Vec = boundaries().filter(|&b| manual(b - 1) && !manual(b)).collect(); - if !ends.is_empty() { - return shape_apart(ends, out); - } let first = boundaries().find(|&b| manual(b)).unwrap_or(range.end); // A first character with manual kerning that All Caps or Small Caps sets as several letters // (ß as SS) gives them one cluster, which a feature range cannot split, so the font's @@ -879,3 +1127,27 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe }); } } + +#[cfg(test)] +mod optical_tests { + use super::*; + + fn box_profile(left: f64, right: f64) -> Profile { + vec![(left, 0.0, left, 100.0), (right, 0.0, right, 100.0)] + } + + #[test] + fn optical_side_profiles_tighten_loosen_and_stay_bounded() { + let a = box_profile(0.0, 80.0); + let b = box_profile(0.0, 20.0); + let place = (1.0, 1.0, 0.0, 0.0); + let tight = profile_correction(&a, &b, place, place, 120.0, 100.0, 1000.0, 0.0, 100.0).unwrap(); + let loose = profile_correction(&a, &b, place, place, 60.0, 100.0, 1000.0, 0.0, 100.0).unwrap(); + assert_eq!(tight, -25.0); + assert_eq!(loose, 25.0); + assert_eq!(profile_correction(&a, &b, place, place, 88.0, 100.0, 1000.0, 0.0, 100.0), Some(0.0)); + assert_eq!(profile_correction(&a, &b, place, place, 120.0, 100.0, 1000.0, 0.0, 100.0), Some(tight)); + assert_eq!(profile_correction(&a, &b, place, place, 120.0, 100.0, 1000.0, 100.0, 100.0), None); + assert_eq!(profile_correction(&[], &b, place, place, 120.0, 100.0, 1000.0, 0.0, 100.0), None); + } +} diff --git a/crates/text/src/test_fonts.rs b/crates/text/src/test_fonts.rs index f3619679f..817f1689f 100644 --- a/crates/text/src/test_fonts.rs +++ b/crates/text/src/test_fonts.rs @@ -412,6 +412,81 @@ pub fn lam_alef_font(feature: &[u8; 4]) -> Option> { /// The family of [`pair_kerning_font`]. pub const PAIR_KERNING_FAMILY: &str = "Pairtest Sans"; +/// The family of [`no_pair_kerning_font`]. +pub const NO_PAIR_KERNING_FAMILY: &str = "No Pairtest Sans"; + +/// A font with the bundled Source Sans 3 outlines and no `GPOS` or `kern` table. +pub fn no_pair_kerning_font() -> Option> { + const BASE: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf"); + let font = skrifa::FontRef::new(BASE).ok()?; + let family = NO_PAIR_KERNING_FAMILY; + let name = name_table(&[(1, family), (2, "Regular"), (4, "No Pairtest Sans Regular"), (6, "NoPairtestSans-Regular")])?; + let mut builder = FontBuilder::new(); + builder.add_raw(Tag::new(b"name"), name); + for r in font.table_directory.table_records() { + let tag = Tag::new(&r.tag().to_be_bytes()); + if !matches!(tag, t if t == Tag::new(b"name") || t == Tag::new(b"GPOS") || t == Tag::new(b"kern")) { + builder.add_raw(tag, font.table_data(r.tag())?.as_bytes()); + } + } + Some(builder.build()) +} + +/// The family of [`right_to_left_optical_font`]. +pub const RIGHT_TO_LEFT_OPTICAL_FAMILY: &str = "Right-to-left Profile Sans"; + +/// Two Hebrew characters mapped to the bundled Source Sans 3 A and V outlines. +pub fn right_to_left_optical_font() -> Option> { + use write_fonts::tables::cmap::Cmap; + + const BASE: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf"); + let font = skrifa::FontRef::new(BASE).ok()?; + let charmap = font.charmap(); + let mapping = [('א', 'A'), ('ב', 'V')]; + let mappings = mapping.into_iter().map(|(c, source)| Some((c, write_fonts::types::GlyphId::new(charmap.map(source)?.to_u32())))); + let cmap = Cmap::from_mappings(mappings.collect::>>()?).ok()?; + let family = RIGHT_TO_LEFT_OPTICAL_FAMILY; + let name = name_table(&[(1, family), (2, "Regular"), (4, "Right-to-left Profile Sans Regular"), (6, "RtlProfileSans-Regular")])?; + let mut builder = FontBuilder::new(); + builder.add_table(&cmap).ok()?; + builder.add_raw(Tag::new(b"name"), name); + for r in font.table_directory.table_records() { + let tag = Tag::new(&r.tag().to_be_bytes()); + if tag != Tag::new(b"GPOS") && tag != Tag::new(b"kern") && !builder.contains(tag) { + builder.add_raw(tag, font.table_data(r.tag())?.as_bytes()); + } + } + Some(builder.build()) +} + +/// The family of [`soft_hyphen_outline_font`]. +pub const SOFT_HYPHEN_OUTLINE_FAMILY: &str = "Soft Hyphen Profile Sans"; + +/// A face that maps the invisible soft hyphen to the bundled hyphen outline. +pub fn soft_hyphen_outline_font() -> Option> { + use write_fonts::tables::cmap::Cmap; + + const BASE: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf"); + let font = skrifa::FontRef::new(BASE).ok()?; + let charmap = font.charmap(); + let mappings = [('A', 'A'), ('V', 'V'), ('-', '-'), ('\u{00AD}', '-')] + .into_iter() + .map(|(c, source)| Some((c, write_fonts::types::GlyphId::new(charmap.map(source)?.to_u32())))); + let cmap = Cmap::from_mappings(mappings.collect::>>()?).ok()?; + let family = SOFT_HYPHEN_OUTLINE_FAMILY; + let name = name_table(&[(1, family), (2, "Regular"), (4, "Soft Hyphen Profile Sans Regular"), (6, "SoftHyphenProfileSans-Regular")])?; + let mut builder = FontBuilder::new(); + builder.add_table(&cmap).ok()?; + builder.add_raw(Tag::new(b"name"), name); + for r in font.table_directory.table_records() { + let tag = Tag::new(&r.tag().to_be_bytes()); + if tag != Tag::new(b"GPOS") && tag != Tag::new(b"kern") && !builder.contains(tag) { + builder.add_raw(tag, font.table_data(r.tag())?.as_bytes()); + } + } + Some(builder.build()) +} + /// The bundled Source Sans 3 Regular as family [`PAIR_KERNING_FAMILY`], its `GPOS` replaced by two /// `kern` pairs, A followed by V and S followed by S: each takes 60 units off the first glyph's /// advance (the pair's first value record) and moves the second 100 units left (its second). diff --git a/crates/text/src/tests/typo.rs b/crates/text/src/tests/typo.rs index 345340b52..105c1b424 100644 --- a/crates/text/src/tests/typo.rs +++ b/crates/text/src/tests/typo.rs @@ -18,6 +18,267 @@ fn run(text: &str, size: f64) -> TextRun { TextRun { text: text.into(), style: style(size), inline: None } } +#[test] +fn optical_pairs_cross_paint_runs_and_manual_and_tracking_are_independent() { + let st = CharStyle { font_family: "JetBrains Mono".into(), size: 100.0, optical_kerning: true, ..CharStyle::default() }; + let mut t = TextObject::point(Point::ZERO, "III", st); + let advance = |t: &TextObject| layout(db(), t).glyphs.iter().map(|g| g.advance).collect::>(); + let optical = advance(&t); + let mut plain = t.clone(); + plain.runs[0].style.kerning = Some(0.0); + let unkerned = advance(&plain); + assert!((optical[0] - unkerned[0]).abs() > 0.1, "Optical changes the monospaced pair"); + edit::style_range(&mut t.runs, 1, 2, |s| s.fill = vectorcraft_doc::color::Paint::solid(vectorcraft_doc::color::Color::rgb(1.0, 0.0, 0.0))); + let painted = advance(&t); + assert!((painted[0] - optical[0]).abs() < 0.01, "paint does not interrupt Optical"); + edit::style_range(&mut t.runs, 0, 1, |s| s.kerning = Some(0.0)); + let manual = advance(&t); + assert!((manual[0] - unkerned[0]).abs() < 0.01); + assert!((manual[1] - optical[1]).abs() < 0.01, "the next pair remains Optical"); + edit::style_range(&mut t.runs, 0, 3, |s| s.tracking = 100.0); + let tracked = advance(&t); + assert!((tracked[0] - manual[0] - 10.0).abs() < 0.01); + assert!((tracked[1] - manual[1] - 10.0).abs() < 0.01); + + for text in ["I I", "I\u{301}"] { + let sample = TextObject::point(Point::ZERO, text, CharStyle { kerning: None, ..t.runs[0].style.clone() }); + let mut zero = sample.clone(); + zero.runs[0].style.kerning = Some(0.0); + assert_eq!(advance(&sample), advance(&zero), "spaces and marks inside a cluster have no Optical correction"); + } + let mut vertical = TextObject::point(Point::ZERO, "II", CharStyle { kerning: None, ..t.runs[0].style.clone() }); + vertical.vertical = true; + let mut vertical_zero = vertical.clone(); + vertical_zero.runs[0].style.kerning = Some(0.0); + assert_eq!(advance(&vertical), advance(&vertical_zero), "vertical type keeps a neutral correction"); + let rtl = TextObject::point(Point::ZERO, "אב", CharStyle { kerning: None, ..t.runs[0].style.clone() }); + let rtl_advances = advance(&rtl); + assert!(rtl_advances.len() >= 2 && rtl_advances.iter().all(|v| v.is_finite())); + let mut rtl_zero = rtl.clone(); + rtl_zero.runs[0].style.kerning = Some(0.0); + assert!((rtl_advances[1] - advance(&rtl_zero)[1]).abs() <= 25.0, "right-to-left Optical corrections remain bounded"); + let mut rtl_manual = rtl.clone(); + edit::style_range(&mut rtl_manual.runs, 0, 2, |s| s.kerning = Some(200.0)); + let mut rtl_pair_zero = rtl.clone(); + edit::style_range(&mut rtl_pair_zero.runs, 0, 2, |s| s.kerning = Some(0.0)); + let pair_advance = |t: &TextObject| layout(db(), t).glyphs.iter().find(|g| g.byte == 0).map(|g| g.advance).unwrap(); + assert!((pair_advance(&rtl_pair_zero) - pair_advance(&rtl_zero)).abs() < 0.01, "manual zero replaces the right-to-left Optical correction"); + assert!((pair_advance(&rtl_manual) - pair_advance(&rtl_pair_zero) - 20.0).abs() < 0.01, "manual spacing changes the adjacent right-to-left pair"); +} + +#[test] +fn optical_pair_skips_an_invisible_soft_hyphen_with_an_outline() { + use crate::test_fonts::{SOFT_HYPHEN_OUTLINE_FAMILY, soft_hyphen_outline_font}; + + let db = FontDb::with_font_dirs(vec![]); + assert_eq!(db.add_font(soft_hyphen_outline_font().unwrap()), 1); + let style = CharStyle { font_family: SOFT_HYPHEN_OUTLINE_FAMILY.into(), size: 100.0, optical_kerning: true, ..CharStyle::default() }; + let face = db.style_face(&style).unwrap(); + assert_ne!(face.glyph_for('\u{00AD}'), 0); + assert_eq!(face.glyph_for('\u{00AD}'), face.glyph_for('-')); + let paired = layout(&db, &TextObject::point(Point::ZERO, "AV", style.clone())); + let separated = layout(&db, &TextObject::point(Point::ZERO, "A\u{00AD}V", style.clone())); + let mut manual = style; + manual.kerning = Some(0.0); + let plain = layout(&db, &TextObject::point(Point::ZERO, "A\u{00AD}V", manual)); + + assert!((paired.glyphs[0].advance - plain.glyphs[0].advance).abs() > 0.1, "A-V has an Optical correction"); + // A keeps its uncorrected advance because neither pair across the invisible glyph is measured. + assert!((separated.glyphs[0].advance - plain.glyphs[0].advance).abs() < 0.01); + assert!(separated.glyphs.iter().filter(|g| g.byte == 1).all(|g| g.advance == 0.0), "the soft hyphen remains zero width if emitted"); + let v = |lay: &TextLayout| lay.glyphs.iter().find(|g| g.byte == 3).unwrap().origin.x; + assert!((v(&separated) - v(&plain)).abs() < 0.01, "the soft hyphen adds no spacing"); +} + +#[test] +fn optical_pair_after_separate_combining_mark_uses_base_outline() { + let st = CharStyle { font_family: "Source Sans 3".into(), size: 100.0, optical_kerning: true, ..CharStyle::default() }; + let advances = |text: &str, manual: bool| { + let mut style = st.clone(); + if manual { + style.kerning = Some(0.0); + } + layout(db(), &TextObject::point(Point::ZERO, text, style)).glyphs.iter().map(|g| (g.byte, g.advance)).collect::>() + }; + let plain = advances("II", false); + let plain_zero = advances("II", true); + let accented = advances("I\u{30d}I", false); + let accented_zero = advances("I\u{30d}I", true); + assert_eq!(accented.len(), 3, "the accent has its own glyph"); + assert_eq!(accented[0].0, accented[1].0, "the base and mark share a cluster"); + assert!((accented[0].1 - accented_zero[0].1).abs() < 0.01, "the base keeps its advance"); + let correction = accented[1].1 - accented_zero[1].1; + assert!((correction - (plain[0].1 - plain_zero[0].1)).abs() < 0.01, "the marked cluster uses the base outline"); + assert!(correction.abs() > 0.1, "Optical adjusts the pair after the accent"); +} + +#[test] +fn optical_pair_after_rtl_separate_mark_uses_base_outline() { + let st = CharStyle { font_family: "Noto Sans Arabic".into(), size: 100.0, optical_kerning: true, ..CharStyle::default() }; + let shaped = |text: &str, manual: bool| { + let mut style = st.clone(); + if manual { + style.kerning = Some(0.0); + } + layout(db(), &TextObject::point(Point::ZERO, text, style)).glyphs.iter().map(|g| (g.byte, g.advance)).collect::>() + }; + let plain = shaped("بب", false); + let plain_zero = shaped("بب", true); + let marked = shaped("بَب", false); + let marked_zero = shaped("بَب", true); + let marked_cluster = marked.iter().filter(|g| g.0 == 0).collect::>(); + assert_eq!(marked_cluster.len(), plain.iter().filter(|g| g.0 == 0).count() + 1, "the mark has its own glyph in the base cluster"); + assert_eq!(marked_cluster.first().unwrap().1, 0.0, "the cluster starts with a nonspacing glyph"); + assert!(marked_cluster.last().unwrap().1 > 0.0, "the base follows the mark"); + let correction = marked.iter().filter(|g| g.0 == 4).map(|g| g.1).sum::() - marked_zero.iter().filter(|g| g.0 == 4).map(|g| g.1).sum::(); + let plain_correction = + plain.iter().filter(|g| g.0 == 2).map(|g| g.1).sum::() - plain_zero.iter().filter(|g| g.0 == 2).map(|g| g.1).sum::(); + assert!(plain_correction.abs() > 0.1, "Optical adjusts the unmarked pair"); + assert!((correction - plain_correction).abs() < 0.01, "the marked cluster uses the base outline"); +} + +#[test] +fn recurring_optical_pairs_have_equal_corrections() { + let st = CharStyle { font_family: "JetBrains Mono".into(), size: 100.0, optical_kerning: true, ..CharStyle::default() }; + let text = "II".repeat(50); + let optical = layout(db(), &TextObject::point(Point::ZERO, &text, st.clone())).glyphs.iter().map(|g| g.advance).collect::>(); + let zero = layout(db(), &TextObject::point(Point::ZERO, &text, CharStyle { kerning: Some(0.0), ..st })) + .glyphs + .iter() + .map(|g| g.advance) + .collect::>(); + assert_eq!(optical.len(), zero.len()); + let correction = optical[0] - zero[0]; + assert!(correction.abs() > 0.1); + for i in 1..optical.len() - 1 { + assert_eq!(optical[i] - zero[i], correction, "pair {i} has the same correction"); + } +} + +#[test] +fn metrics_pair_survives_transition_to_optical() { + let st = CharStyle { font_family: "Source Sans 3".into(), size: 100.0, ..CharStyle::default() }; + let mut text = TextObject::point(Point::ZERO, "AVV", st); + let advances = |text: &TextObject| layout(db(), text).glyphs.iter().map(|g| g.advance).collect::>(); + let metrics = advances(&text); + let mut unkerned = text.clone(); + unkerned.runs[0].style.kerning = Some(0.0); + assert!((metrics[0] - advances(&unkerned)[0]).abs() > 0.1, "the font supplies an AV pair"); + edit::style_range(&mut text.runs, 1, 3, |s| s.optical_kerning = true); + let mixed = advances(&text); + assert!((mixed[0] - metrics[0]).abs() < 0.01, "the metrics AV pair crosses the mode change"); + let mut optical = TextObject::point(Point::ZERO, "VV", text.runs[1].style.clone()); + let optical_pair = advances(&optical)[0]; + optical.runs[0].style.kerning = Some(0.0); + assert!((mixed[1] - optical_pair).abs() < 0.01, "the first Optical V keeps its next pair"); + assert!((mixed[1] - advances(&optical)[0]).abs() > 0.1, "Optical corrects the next pair"); +} + +#[test] +fn manual_pair_before_optical_does_not_gain_font_kerning() { + let st = CharStyle { font_family: "Source Sans 3".into(), size: 100.0, ..CharStyle::default() }; + let mut text = TextObject::point(Point::ZERO, "AVA", st); + edit::style_range(&mut text.runs, 1, 2, |s| s.kerning = Some(0.0)); + edit::style_range(&mut text.runs, 2, 3, |s| s.optical_kerning = true); + let mixed = layout(db(), &text).glyphs[1].advance; + let mut unkerned = TextObject::point(Point::ZERO, "VA", text.runs[1].style.clone()); + unkerned.runs[0].style.kerning = Some(0.0); + let manual = layout(db(), &unkerned).glyphs[0].advance; + assert!((mixed - manual).abs() < 0.01, "manual V keeps its unkerned pair with Optical A"); +} + +#[test] +fn optical_pair_uses_drawn_baseline() { + let st = CharStyle { font_family: "JetBrains Mono".into(), size: 100.0, optical_kerning: true, ..CharStyle::default() }; + let mut text = TextObject::point(Point::ZERO, "II", st); + let first = |text: &TextObject| layout(db(), text).glyphs[0].advance; + let normal = first(&text); + edit::style_range(&mut text.runs, 1, 2, |s| s.baseline_shift = 80.0); + let shifted = first(&text); + assert!((shifted - normal).abs() > 0.1, "a displaced outline changes the Optical correction"); +} + +#[test] +fn optical_pair_skips_rotated_glyphs() { + let st = CharStyle { font_family: "JetBrains Mono".into(), size: 100.0, optical_kerning: true, ..CharStyle::default() }; + let mut text = TextObject::point(Point::ZERO, "II", st); + let first = |text: &TextObject| layout(db(), text).glyphs[0].advance; + let unrotated = first(&text); + let mut plain = text.clone(); + plain.runs[0].style.kerning = Some(0.0); + assert!((unrotated - first(&plain)).abs() > 0.1, "the unrotated pair has an Optical correction"); + edit::style_range(&mut text.runs, 1, 2, |s| s.rotation = 20.0); + let rotated = first(&text); + let mut manual_zero = text.clone(); + edit::style_range(&mut manual_zero.runs, 0, 1, |s| s.kerning = Some(0.0)); + assert!((rotated - first(&manual_zero)).abs() < 0.01, "rotated pairs keep their uncorrected advance"); +} + +#[test] +fn optical_shaping_keeps_manual_value_on_first_run() { + let st = CharStyle { font_family: "Source Sans 3".into(), size: 100.0, optical_kerning: true, kerning: Some(0.0), ..CharStyle::default() }; + let mut text = TextObject::point(Point::ZERO, "AVA", st); + let zero = layout(db(), &text).glyphs.iter().map(|g| g.advance).collect::>(); + edit::style_range(&mut text.runs, 0, 1, |s| s.kerning = Some(120.0)); + let manual = layout(db(), &text).glyphs.iter().map(|g| g.advance).collect::>(); + assert!((manual[0] - zero[0] - 12.0).abs() < 0.01, "the first run adds its manual value"); + assert!((manual[1] - zero[1]).abs() < 0.01, "the following run keeps its advance"); +} + +#[test] +fn ordinary_and_vertical_segments_keep_their_spacing() { + let st = CharStyle { font_family: "Source Sans 3".into(), size: 100.0, kerning: Some(100.0), ..CharStyle::default() }; + let plain = TextObject::point(Point::ZERO, "AVA", st); + let advances = |text: &TextObject| layout(db(), text).glyphs.iter().map(|g| g.advance).collect::>(); + let expected = advances(&plain); + let mut split = plain.clone(); + edit::style_range(&mut split.runs, 1, 2, |s| s.fill = vectorcraft_doc::color::Paint::solid(vectorcraft_doc::color::Color::rgb(1.0, 0.0, 0.0))); + assert_eq!(advances(&split), expected, "a non-Optical segment keeps the same spacing across runs"); + + let mut vertical = plain; + vertical.vertical = true; + let expected = advances(&vertical); + edit::style_range(&mut vertical.runs, 0, 1, |s| s.kerning = Some(120.0)); + let manual = advances(&vertical); + assert!((manual[0] - expected[0] - 2.0).abs() < 0.01, "vertical manual spacing changes only its own advance"); + edit::style_range(&mut vertical.runs, 0, 3, |s| s.optical_kerning = true); + assert_eq!(advances(&vertical), manual, "vertical type keeps its spacing with Optical selected"); +} + +#[test] +fn optical_spacing_works_without_font_pair_tables() { + use crate::test_fonts::{NO_PAIR_KERNING_FAMILY, no_pair_kerning_font}; + + let db = FontDb::with_font_dirs(vec![]); + assert_eq!(db.add_font(no_pair_kerning_font().unwrap()), 1); + let st = CharStyle { font_family: NO_PAIR_KERNING_FAMILY.into(), size: 100.0, ..CharStyle::default() }; + let mut text = TextObject::point(Point::ZERO, "AV", st); + let metrics = layout(&db, &text).glyphs[0].advance; + text.runs[0].style.kerning = Some(0.0); + let manual_zero = layout(&db, &text).glyphs[0].advance; + assert!((metrics - manual_zero).abs() < 0.01, "the font has no automatic pair correction"); + text.runs[0].style.kerning = None; + text.runs[0].style.optical_kerning = true; + let optical = layout(&db, &text).glyphs[0].advance; + assert!((optical - manual_zero).abs() > 0.1, "Optical uses outlines without a pair table"); +} + +#[test] +fn manual_pair_positions_match_under_auto_and_optical_in_both_directions() { + use crate::test_fonts::{RIGHT_TO_LEFT_OPTICAL_FAMILY, right_to_left_optical_font}; + + let db = FontDb::with_font_dirs(vec![]); + assert_eq!(db.add_font(right_to_left_optical_font().unwrap()), 1); + for (letters, family) in [("AV", "Source Sans 3"), ("אב", RIGHT_TO_LEFT_OPTICAL_FAMILY)] { + let mut text = TextObject::point(Point::ZERO, letters, CharStyle { font_family: family.into(), size: 100.0, ..CharStyle::default() }); + edit::style_range(&mut text.runs, 0, letters.chars().next().unwrap().len_utf8(), |s| s.kerning = Some(200.0)); + let positions = |text: &TextObject| layout(&db, text).glyphs.iter().map(|g| (g.byte, g.origin, g.advance)).collect::>(); + let auto = positions(&text); + edit::style_range(&mut text.runs, 0, letters.len(), |s| s.optical_kerning = true); + assert_eq!(positions(&text), auto, "{letters}: manual pair has the same positions in both modes"); + } +} + fn area_path(text: &str, st: CharStyle, frame: &BezPath, justify: Justify) -> TextObject { let mut t = TextObject::point(Point::ZERO, text, st); t.kind = TextKind::Area { frame: PathData::from_bezpath(frame) }; diff --git a/crates/text/src/tests/vertical.rs b/crates/text/src/tests/vertical.rs index d69b5df29..a2206df5e 100644 --- a/crates/text/src/tests/vertical.rs +++ b/crates/text/src/tests/vertical.rs @@ -719,5 +719,29 @@ fn proportional_metrics_kern_the_glyphs_they_respace() { }; let (auto, manual) = (lay(true, None), lay(true, Some(0.0))); assert!(auto.iter().zip(&manual).any(|(a, b)| (a - b).abs() > 0.01), "kerned: {auto:?} {manual:?}"); + let optical = { + let s = CharStyle { font_family: "Noto Sans CJK SC".into(), optical_kerning: true, ..st(true, None) }; + layout(&db, &vertical_styled("「カタカナ」とトップ、ヤッホー。", s)).glyphs.iter().map(|g| g.advance).collect::>() + }; + assert!(auto.iter().zip(&optical).all(|(a, b)| (a - b).abs() < 0.01), "vertical Optical uses font metrics: {auto:?} {optical:?}"); assert!(lay(false, None).iter().all(|a| (a - 20.0).abs() < 0.01), "off: an em each, as before"); } + +#[test] +fn vertical_optical_to_auto_keeps_the_fonts_pair() { + use crate::test_fonts::{PAIR_KERNING_FAMILY, pair_kerning_font}; + let db = FontDb::with_font_dirs(vec![]); + db.add_font(pair_kerning_font().unwrap()); + let style = CharStyle { font_family: PAIR_KERNING_FAMILY.into(), size: 100.0, ..CharStyle::default() }; + let positions = + |t: &TextObject| layout(&db, t).glyphs.iter().map(|g| (g.advance, g.origin, g.xf.as_coeffs()[4], g.xf.as_coeffs()[5])).collect::>(); + let auto = vertical_styled("AV", style.clone()); + let mut mixed = auto.clone(); + edit::style_range(&mut mixed.runs, 0, 1, |s| s.optical_kerning = true); + let mut unkerned = auto.clone(); + edit::style_range(&mut unkerned.runs, 0, 1, |s| s.kerning = Some(0.0)); + let expected = positions(&auto); + assert_eq!(expected.len(), 2); + assert_ne!(expected, positions(&unkerned), "the font's A-V pair changes vertical positions"); + assert_eq!(positions(&mixed), expected, "vertical Optical keeps the A-V font pair across the style boundary"); +} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 2104ac7af..fbb0019ec 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1587,7 +1587,7 @@ font-technology Variable Променливи Keep the pasted color as a new swatch with a numbered name. Запазване на поставения цвят като нов образец с номерирано име. Keep this row's colors (exclude) Запазване на цветовете на този ред (изключване) Keep: Запазване: - Kerning (0 = Auto) Кернинг (0 = Автоматично) + Kerning Кернинг Keyboard Increment Стъпка с клавиатурата Keyboard Shortcut Conflicts Конфликти на клавишни комбинации Keyboard Shortcut Set Набор клавишни комбинации @@ -2159,6 +2159,7 @@ font-technology Variable Променливи Open the URL in the web browser Отваряне на URL адреса в уеб браузъра Opened with {} note(s): {} Отворено с {} бележка(и): {} Open… Отваряне… + Optical Оптично Optical Margin Alignment Подравняване по оптично поле Optimize for fast web view Оптимизиране за бърз уеб преглед Optimized Оптимизирано @@ -3761,6 +3762,7 @@ axis Both И двете Float Tool Group Плаваща група инструменти @msg no library `{id}` няма библиотека `{id}` @msg select some type first първо изберете текст +@msg kerning must be a number, "auto", or "optical" Кернингът трябва да е число, „auto“ или „optical“ @msg {_1} has no {_2} style {_1} няма стил {_2} @msg a layer couldn't be read слой не можа да бъде прочетен @msg it has more than one artboard има повече от една работна област diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 2d42bf75d..311580ce5 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -1411,7 +1411,8 @@ lighting Light Svjetlo Fractional Widths Djelimične širine Hide Options Sakrij opcije Horizontal Scale % Vodoravna skala % - Kerning (0 = Auto) Podrezivanje (0 = automatski) + Kerning Podrezivanje + Optical Optičko Leading Prored No Break Bez preloma Proportional Metrics Proporcionalna metrika @@ -4114,6 +4115,7 @@ imageTrace Outline Obris @plural {n} stroke|{n} strokes {n} potez|{n} poteza|{n} poteza @msg no library `{id}` nema biblioteke `{id}` @msg select some type first prvo odaberite tekst +@msg kerning must be a number, "auto", or "optical" Podrezivanje mora biti broj, „auto” ili „optical” @msg the selection has no gradient fill odabir nema ispunu gradijentom @msg the selection has no gradient stroke odabir nema potez gradijentom @msg the selection has no pattern fill odabir nema ispunu uzorkom diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index b7a150666..442e051c0 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -2046,8 +2046,8 @@ Keep visible surfaces only when expanding Conserva només les superfícies visibles en expandir Keep: Conserva: Keeps the document editable: VectorCraft reopens the PDF with nothing lost. For files you'll edit again. Manté el document editable: VectorCraft reobre el PDF sense perdre res. Per a fitxers que editaràs de nou. - Kerning (0 = Auto) Kerning (0 = automàtic) Key Color: Color clau: + Kerning Kerning Keyboard Increment Increment de teclat Keyboard Shortcut Conflicts Conflictes de dreceres de teclat Keyboard Shortcut Set Conjunt de dreceres de teclat @@ -2740,6 +2740,7 @@ Opens files in other apps Obre fitxers en altres aplicacions Opens network connections Obre connexions de xarxa Open… Obre… + Optical Òptic Optical Margin Alignment Alineació òptica de marges Optimize for fast web view Optimitza per a la visualització web ràpida Optimized Optimitzat @@ -5354,6 +5355,7 @@ @msg select paths or anchor points selecciona traçats o punts d’ancoratge @msg select paths to join selecciona traçats per unir @msg select some type first selecciona text primer +@msg kerning must be a number, "auto", or "optical" El kerning ha de ser un nombre, «auto» o «optical» @msg Select text or click into text with the Type tool to insert glyphs Selecciona text o fes clic dins del text amb l’eina de text per inserir glifs @msg select the art to export first selecciona primer la il·lustració que vols exportar @msg select the art to repeat selecciona la il·lustració que vols repetir diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 75765ab75..aa88620f4 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1436,7 +1436,6 @@ lighting Light Světlo Fractional Widths Zlomkové šířky Hide Options Skrýt možnosti Horizontal Scale % Vodorovná změna velikosti % - Kerning (0 = Auto) Vyrovnání párů (0 = automaticky) Leading Proklad No Break Nezalamovat Proportional Metrics Proporcionální metrika @@ -3514,6 +3513,7 @@ theme Appearance Motiv Identify Objects By Identifikovat objekty podle Include SVG Code Zahrnout kód SVG Italian Italština + Kerning Vyrovnání párů Keyboard Increment Krok klávesnice Language Options Možnosti jazyka Large Velké @@ -3556,6 +3556,7 @@ theme Appearance Motiv On Paste Při vložení On Screen Na obrazovce Open Documents As Tabs Otevírat dokumenty jako karty + Optical Optické Options Možnosti Output All Blacks Accurately Ve výstupu všechny černé přesně Output All Blacks as Rich Black Ve výstupu všechny černé jako sytou černou @@ -4151,6 +4152,7 @@ imageTrace Outline Obrys # Status and error messages @msg no library `{id}` knihovna `{id}` neexistuje @msg select some type first nejprve vyberte text +@msg kerning must be a number, "auto", or "optical" Vyrovnání párů musí být číslo, „auto“ nebo „optical“ @msg the selection has no gradient fill výběr nemá přechodovou výplň @msg the selection has no gradient stroke výběr nemá přechodový tah @msg the selection has no pattern fill výběr nemá vzorkovou výplň diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 8140d38e1..b7c6e23fd 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -1351,7 +1351,6 @@ lighting Light Lys Fractional Widths Fraktionerede bredder Hide Options Skjul indstillinger Horizontal Scale % Vandret skalering % - Kerning (0 = Auto) Kerning (0 = automatisk) Leading Linjeafstand No Break Ingen ombrydning Proportional Metrics Proportionale metrikker @@ -3271,6 +3270,7 @@ theme Appearance Udseende Identify Objects By Identificér objekter ud fra Include SVG Code Medtag SVG-kode Italian Italiensk + Kerning Kerning Keyboard Increment Tastaturtrin Language Options Sprogindstillinger Large Stor @@ -3299,6 +3299,7 @@ theme Appearance Udseende On Paste Ved indsættelse On Screen På skærmen Open Documents As Tabs Åbn dokumenter som faner + Optical Optisk Options Indstillinger Output All Blacks Accurately Udskriv alle sorte nøjagtigt Output All Blacks as Rich Black Udskriv alle sorte som rich black @@ -3881,6 +3882,7 @@ imageTrace Outline Kontur @plural {n} stroke|{n} strokes {n} kontur|{n} konturer @msg no library `{id}` intet bibliotek `{id}` @msg select some type first markér først noget tekst +@msg kerning must be a number, "auto", or "optical" Kerning skal være et tal, „auto“ eller „optical“ @msg {_1} has no {_2} style {_1} har ingen {_2}-stil @msg a layer couldn't be read et lag kunne ikke læses @msg it has more than one artboard den har mere end én tegneflade diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index fa638758f..2d1e72845 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1625,7 +1625,7 @@ file-filter Templates Vorlagen Keep the pasted color as a new swatch with a numbered name. Die eingefügte Farbe als neues Farbfeld mit nummeriertem Namen behalten. Keep this row's colors (exclude) Farben dieser Zeile beibehalten (ausschließen) Keep: Beibehalten: - Kerning (0 = Auto) Kerning (0 = Auto) + Kerning Kerning Keyboard Increment Schritte per Tastatur Keyboard Shortcut Conflicts Konflikte bei Tastaturbefehlen Keyboard Shortcut Set Tastaturbefehlssatz @@ -2172,6 +2172,7 @@ file-filter Templates Vorlagen Open the URL in the web browser Die URL im Webbrowser öffnen Opened with {} note(s): {} Mit {} Hinweis(en) geöffnet: {} Open… Öffnen… + Optical Optisch Optical Margin Alignment Optischer Randausgleich Optimize for fast web view Für schnelle Webanzeige optimieren Optimized Optimiert @@ -3763,6 +3764,7 @@ theme Appearance Erscheinungsbild # names and values in backticks or code (`colorModel rgb`, `document.export`) stay as they are. @msg no library `{id}` keine Bibliothek `{id}` @msg select some type first wählen Sie zuerst Text aus +@msg kerning must be a number, "auto", or "optical" Kerning muss eine Zahl, „auto“ oder „optical“ sein @msg the selection has no gradient fill die Auswahl hat keinen Flächenverlauf @msg the selection has no gradient stroke die Auswahl hat keinen Konturverlauf @msg the selection has no pattern fill die Auswahl hat kein Flächenmuster diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index baf6f52ec..16fe9435e 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1543,7 +1543,7 @@ font-technology Variable Μεταβλητές Keep Plain Text Διατήρηση απλού κειμένου Keep this row's colors (exclude) Διατήρηση των χρωμάτων αυτής της γραμμής (εξαίρεση) Keep: Διατήρηση: - Kerning (0 = Auto) Διαγραμμάτωση (0 = Αυτόματο) + Kerning Διαγραμμάτωση Keyboard Increment Βήμα πληκτρολογίου Keyboard Shortcut Conflicts Διενέξεις συντομεύσεων πληκτρολογίου Keyboard Shortcut Set Σύνολο συντομεύσεων πληκτρολογίου @@ -2080,6 +2080,7 @@ axis Both Και τα δύο Dock Panel Αγκύρωση πάνελ @msg no library `{id}` Δεν υπάρχει βιβλιοθήκη με αναγνωριστικό `{id}` @msg select some type first Επιλέξτε πρώτα κάποιο κείμενο +@msg kerning must be a number, "auto", or "optical" Η διαγραμμάτωση πρέπει να είναι αριθμός, «auto» ή «optical» @msg the selection has no gradient fill η επιλογή δεν έχει ντεγκραντέ γεμίσματος @msg the selection has no gradient stroke η επιλογή δεν έχει ντεγκραντέ περιγράμματος @msg the selection has no pattern fill η επιλογή δεν έχει μοτίβο γεμίσματος @@ -2886,6 +2887,7 @@ theme Appearance Εμφάνιση Open Swatch Library Άνοιγμα βιβλιοθήκης δειγμάτων χρωμάτων Open the URL in the web browser Άνοιγμα της διεύθυνσης στο πρόγραμμα περιήγησης Opened with {} note(s): {} Άνοιξε με {} σημειώσεις: {} + Optical Οπτική Optical Margin Alignment Οπτική στοίχιση περιθωρίου Optimize for fast web view Βελτιστοποίηση για γρήγορη προβολή στον ιστό Options for Selection… Επιλογές για την επιλογή… diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 7b5b6aae3..d4d2d20eb 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1625,7 +1625,7 @@ font-technology Variable Variables Keep the pasted color as a new swatch with a numbered name. Conservar el color pegado como una muestra nueva con nombre numerado. Keep this row's colors (exclude) Conservar los colores de esta fila (excluir) Keep: Conservar: - Kerning (0 = Auto) Interletraje (0 = auto) + Kerning Interletraje Keyboard Increment Incremento de teclado Keyboard Shortcut Conflicts Conflictos de métodos abreviados de teclado Keyboard Shortcut Set Conjunto de métodos abreviados de teclado @@ -2214,6 +2214,7 @@ font-technology Variable Variables Open the URL in the web browser Abrir la URL en el navegador web Opened with {} note(s): {} Abierto con {} nota(s): {} Open… Abrir… + Optical Óptico Optical Margin Alignment Alineación óptica de márgenes Optimize for fast web view Optimizar para vista web rápida Optimized Optimizado @@ -3843,6 +3844,7 @@ axis Both Ambos # names and values in backticks or code (`colorModel rgb`, `document.export`) stay as they are. @msg no library `{id}` no hay ninguna biblioteca `{id}` @msg select some type first selecciona primero algún texto +@msg kerning must be a number, "auto", or "optical" El interletraje debe ser un número, «auto» u «optical» @msg the selection has no gradient fill la selección no tiene un degradado de relleno @msg the selection has no gradient stroke la selección no tiene un degradado de trazo @msg the selection has no pattern fill la selección no tiene un motivo de relleno diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 606118dba..e080c8f98 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -1411,7 +1411,6 @@ lighting Light Valo Fractional Widths Murtolukuleveydet Hide Options Piilota asetukset Horizontal Scale % Vaakaskaalaus % - Kerning (0 = Auto) Parivälistys (0 = automaattinen) Leading Riviväli No Break Ei katkoa Proportional Metrics Suhteelliset metriikat @@ -3453,6 +3452,7 @@ theme Appearance Ulkoasu Identify Objects By Tunnista objektit Include SVG Code Sisällytä SVG-koodi Italian Italia + Kerning Parivälistys Keyboard Increment Näppäimistön lisäys Language Options Kieliasetukset Large Suuri @@ -3481,6 +3481,7 @@ theme Appearance Ulkoasu On Paste Liitettäessä On Screen Näytöllä Open Documents As Tabs Avaa asiakirjat välilehtinä + Optical Optinen Options Asetukset Output All Blacks Accurately Tulosta kaikki mustat tarkasti Output All Blacks as Rich Black Tulosta kaikki mustat syvämustina @@ -4067,6 +4068,7 @@ imageTrace Outline Ääriviiva @plural {n} stroke|{n} strokes {n} viiva|{n} viivaa @msg no library `{id}` ei kirjastoa `{id}` @msg select some type first valitse ensin tekstiä +@msg kerning must be a number, "auto", or "optical" Parivälistyksen on oltava luku, ”auto” tai ”optical” @msg the selection has no gradient fill valinnalla ei ole liukuväritäyttöä @msg the selection has no gradient stroke valinnalla ei ole liukuväriviivaa @msg the selection has no pattern fill valinnalla ei ole kuviotäyttöä diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 5e8203d51..dd9cc259a 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1625,7 +1625,7 @@ font-technology Variable Variables Keep the pasted color as a new swatch with a numbered name. Conserver la couleur collée comme nouvelle nuance avec un nom numéroté. Keep this row's colors (exclude) Conserver les couleurs de cette ligne (exclure) Keep: Conserver : - Kerning (0 = Auto) Crénage (0 = auto) + Kerning Crénage Keyboard Increment Incrément clavier Keyboard Shortcut Conflicts Conflits de raccourcis clavier Keyboard Shortcut Set Jeu de raccourcis clavier @@ -2212,6 +2212,7 @@ font-technology Variable Variables Open the URL in the web browser Ouvrir l’URL dans le navigateur web Opened with {} note(s): {} Ouvert avec {} remarque(s) : {} Open… Ouvrir… + Optical Optique Optical Margin Alignment Alignement optique des marges Optimize for fast web view Optimiser pour un affichage web rapide Optimized Optimisé @@ -3839,6 +3840,7 @@ axis Both Les deux # names and values in backticks or code (`colorModel rgb`, `document.export`) stay as they are. @msg no library `{id}` aucune bibliothèque `{id}` @msg select some type first sélectionnez d’abord du texte +@msg kerning must be a number, "auto", or "optical" Le crénage doit être un nombre, « auto » ou « optical » @msg the selection has no gradient fill la sélection n’a pas de dégradé de fond @msg the selection has no gradient stroke la sélection n’a pas de dégradé de contour @msg the selection has no pattern fill la sélection n’a pas de motif de fond diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index aec44044d..671a569ca 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2044,8 +2044,8 @@ file-filter Templates Sablonok Keep visible surfaces only when expanding Kibontáskor csak a látható felületek megtartása Keep: Megtartás: Keeps the document editable: VectorCraft reopens the PDF with nothing lost. For files you'll edit again. Szerkeszthető marad a dokumentum: a VectorCraft veszteség nélkül nyitja meg újra a PDF-et. Azokhoz a fájlokhoz, amelyeket még szerkesztesz. - Kerning (0 = Auto) Alávágás (0 = automatikus) Key Color: Kulcsszín: + Kerning Alávágás Keyboard Increment Billentyűzetes lépésköz Keyboard Shortcut Conflicts Billentyűparancs-ütközések Keyboard Shortcut Set Billentyűparancs-készlet @@ -2688,6 +2688,7 @@ file-filter Templates Sablonok “{name}” already exists. Replace it? „{name}” már létezik. Felülírod? The name in the save panel was “{typed}”; VectorCraft added the file type's extension. Replacing the file overwrites its contents. A mentési panelen megadott név ez volt: „{typed}”, a VectorCraft pedig hozzáadta a fájltípus kiterjesztését. A fájl felülírásakor a tartalma elvész. Open… Megnyitás… + Optical Optikai Optical Margin Alignment Optikai margóigazítás Optimize for fast web view Optimalizálás gyors webes megtekintéshez Optimized Optimalizált @@ -5044,6 +5045,7 @@ imageTrace Outline Körvonal @msg select paths or anchor points jelölj ki útvonalakat vagy horgonypontokat @msg select paths to join jelöld ki az összekapcsolandó útvonalakat @msg select some type first előbb jelölj ki szöveget +@msg kerning must be a number, "auto", or "optical" Az alávágás szám, „auto” vagy „optical” lehet @msg the selection has no gradient fill a kijelölésnek nincs színátmenetes kitöltése @msg the selection has no gradient stroke a kijelölésnek nincs színátmenetes körvonala @msg the selection has no pattern fill a kijelölésnek nincs mintázatos kitöltése diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 5329eea88..664551a1f 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1351,7 +1351,6 @@ lighting Light Cahaya Fractional Widths Lebar fraksional Hide Options Sembunyikan opsi Horizontal Scale % Skala horizontal % - Kerning (0 = Auto) Kerning (0 = Otomatis) Leading Leading No Break Tanpa pemenggalan Proportional Metrics Metrik proporsional @@ -3271,6 +3270,7 @@ theme Appearance Tampilan Identify Objects By Identifikasi objek berdasarkan Include SVG Code Sertakan kode SVG Italian Italia + Kerning Kerning Keyboard Increment Inkremen papan ketik Language Options Opsi bahasa Large Besar @@ -3299,6 +3299,7 @@ theme Appearance Tampilan On Paste Saat menempel On Screen Di layar Open Documents As Tabs Buka dokumen sebagai tab + Optical Optis Options Opsi Output All Blacks Accurately Keluarkan semua warna hitam secara akurat Output All Blacks as Rich Black Keluarkan semua warna hitam sebagai rich black @@ -3881,6 +3882,7 @@ imageTrace Outline Kontur @plural {n} stroke|{n} strokes {n} goresan @msg no library `{id}` tidak ada pustaka `{id}` @msg select some type first pilih beberapa teks terlebih dahulu +@msg kerning must be a number, "auto", or "optical" Kerning harus berupa angka, “auto”, atau “optical” @msg {_1} has no {_2} style {_1} tidak memiliki gaya {_2} @msg a layer couldn't be read satu lapisan tidak dapat dibaca @msg it has more than one artboard memiliki lebih dari satu artboard diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 34c18549d..708378ba4 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1650,7 +1650,7 @@ file-filter Templates Modelli Keep the pasted color as a new swatch with a numbered name. Mantieni il colore incollato come nuovo campione con un nome numerato. Keep this row's colors (exclude) Mantieni i colori di questa riga (escludi) Keep: Mantieni: - Kerning (0 = Auto) Crenatura (0 = auto) + Kerning Crenatura Keyboard Increment Incremento da tastiera Keyboard Shortcut Conflicts Conflitti tra scelte rapide da tastiera Keyboard Shortcut Set Set di scelte rapide da tastiera @@ -2241,6 +2241,7 @@ file-filter Templates Modelli Open the URL in the web browser Apri l'URL nel browser web Opened with {} note(s): {} Aperto con note ({}): {} Open… Apri… + Optical Ottica Optical Margin Alignment Allineamento ottico dei margini Optimize for fast web view Ottimizza per visualizzazione web veloce Optimized Ottimizzato @@ -3883,6 +3884,7 @@ axis Both Entrambi # names and values in backticks or code (`colorModel rgb`, `document.export`) stay as they are. @msg no library `{id}` nessuna libreria `{id}` @msg select some type first seleziona prima del testo +@msg kerning must be a number, "auto", or "optical" La crenatura deve essere un numero, «auto» o «optical» @msg the selection has no gradient fill la selezione non ha una sfumatura di riempimento @msg the selection has no gradient stroke la selezione non ha una sfumatura di traccia @msg the selection has no pattern fill la selezione non ha un motivo di riempimento diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 5d8e12074..628f0ec03 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1880,7 +1880,7 @@ font-technology Variable バリアブル Keep Plain Text テキストのみを保持 Keep both sides the same width 両側を同じ幅に保つ Keep this row's colors (exclude) この行のカラーを保持 (除外) - Kerning (0 = Auto) カーニング (0 = 自動) + Kerning カーニング Keyboard Increment キー入力 Keyboard Shortcut Conflicts キーボードショートカットの競合 Keyboard Shortcut Set キーボードショートカットセット @@ -2346,6 +2346,7 @@ font-technology Variable バリアブル Open the URL in the web browser URL を Web ブラウザーで開く Optimize for fast web view Web ビュー用に最適化 Optimized 最適化 + Optical オプティカル Options オプション Options for Selection… 選択項目のオプション… Options: オプション: @@ -4573,6 +4574,7 @@ swatch Bright Violet ビビッドバイオレット @msg choose a folder to export to 書き出し先のフォルダーを選択してください @msg the selection has no URL (or they differ) 選択したオブジェクトに URL がありません (または URL がそれぞれ異なります) @msg select some type first 先にテキストを選択してください +@msg kerning must be a number, "auto", or "optical" カーニングは数値、「auto」または「optical」にしてください @msg the selection has no gradient fill 選択したオブジェクトに塗りのグラデーションがありません @msg the selection has no gradient stroke 選択したオブジェクトに線のグラデーションがありません @msg the selection has no pattern fill 選択したオブジェクトに塗りのパターンがありません diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index ab0f0704d..4d44093f7 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -1268,7 +1268,6 @@ lighting Light სინათლე Fractional Widths წილადი სიგანეები Hide Options პარამეტრების დამალვა Horizontal Scale % ჰორიზონტალური მასშტაბი % - Kerning (0 = Auto) კერნინგი (0 = ავტო) Leading სტრიქონთა დაშორება No Break გადატანის გარეშე Proportional Metrics პროპორციული მეტრიკა @@ -3082,6 +3081,7 @@ theme Appearance იერსახე Identify Objects By ობიექტების იდენტიფიკაცია: Include SVG Code SVG კოდის ჩართვა Italian იტალიური + Kerning კერნინგი Keyboard Increment კლავიატურის ბიჯი Language Options ენის პარამეტრები Large დიდი @@ -3110,6 +3110,7 @@ theme Appearance იერსახე On Paste ჩასმისას On Screen ეკრანზე Open Documents As Tabs დოკუმენტების ჩანართებად გახსნა + Optical ოპტიკური Options პარამეტრები Output All Blacks Accurately ყველა შავის ზუსტად გამოტანა Output All Blacks as Rich Black ყველა შავის მდიდარ შავად გამოტანა @@ -3647,6 +3648,7 @@ font-technology Variable ცვლადი @plural {n} stroke|{n} strokes კონტური: {n} @msg no library `{id}` ბიბლიოთეკა `{id}` არ არსებობს @msg select some type first ჯერ მონიშნეთ ტექსტი +@msg kerning must be a number, "auto", or "optical" კერნინგი უნდა იყოს რიცხვი, „auto“ ან „optical“ @msg the selection has no gradient fill მონიშნულს შევსების გრადიენტი არ აქვს @msg the selection has no gradient stroke მონიშნულს კონტურის გრადიენტი არ აქვს @msg the selection has no pattern fill მონიშნულს შევსების პატერნი არ აქვს diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 5da60d247..dea1f8f50 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1909,7 +1909,7 @@ font-technology Variable 가변 Keep Plain Text 일반 텍스트 유지 Keep both sides the same width 양쪽 너비를 같게 유지 Keep this row's colors (exclude) 이 행의 색상 유지 (제외) - Kerning (0 = Auto) 커닝 (0 = 자동) + Kerning 커닝 Keyboard Increment 키보드 증분 Keyboard Shortcut Conflicts 단축키 충돌 Keyboard Shortcut Set 단축키 세트 @@ -2337,6 +2337,7 @@ font-technology Variable 가변 Open the URL in the web browser 웹 브라우저에서 URL 열기 Optimize for fast web view 빠른 웹 보기용으로 최적화 Optimized 최적화됨 + Optical 광학 Options 옵션 Options for Selection… 선택 항목 옵션… Options: 옵션: @@ -4501,6 +4502,7 @@ theme Appearance 외관 @msg choose a folder to export to 내보낼 폴더를 선택하세요 @msg the selection has no URL (or they differ) 선택 항목에 URL이 없거나 서로 다릅니다 @msg select some type first 먼저 문자를 선택하세요 +@msg kerning must be a number, "auto", or "optical" 커닝은 숫자, “auto” 또는 “optical”이어야 합니다 @msg the selection has no gradient fill 선택 항목에 칠 그레이디언트가 없습니다 @msg the selection has no gradient stroke 선택 항목에 선 그레이디언트가 없습니다 @msg the selection has no pattern fill 선택 항목에 칠 패턴이 없습니다 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index ee4e09b61..19fdc8663 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -1365,7 +1365,6 @@ lighting Light Licht Fractional Widths Fractionele breedtes Hide Options Opties verbergen Horizontal Scale % Horizontale schaal % - Kerning (0 = Auto) Kerning (0 = Auto) Leading Regelafstand No Break Niet afbreken Proportional Metrics Proportionele metingen @@ -3285,6 +3284,7 @@ theme Appearance Vormgeving Identify Objects By Objecten identificeren op Include SVG Code SVG-code opnemen Italian Italiaans + Kerning Kerning Keyboard Increment Toetsenbordincrement Language Options Taalopties Large Groot @@ -3313,6 +3313,7 @@ theme Appearance Vormgeving On Paste Bij plakken On Screen Op scherm Open Documents As Tabs Documenten openen als tabbladen + Optical Optisch Options Opties Output All Blacks Accurately Alle zwarttinten nauwkeurig uitvoeren Output All Blacks as Rich Black Alle zwarttinten uitvoeren als rijk zwart @@ -3895,6 +3896,7 @@ imageTrace Outline Contour @plural {n} stroke|{n} strokes {n} lijn|{n} lijnen @msg no library `{id}` geen bibliotheek `{id}` @msg select some type first selecteer eerst wat tekst +@msg kerning must be a number, "auto", or "optical" Kerning moet een getal, ‘auto’ of ‘optical’ zijn @msg {_1} has no {_2} style {_1} heeft geen {_2}stijl @msg a layer couldn't be read een laag kon niet worden gelezen @msg it has more than one artboard het heeft meer dan één tekengebied diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 5c90b4d97..9debbef17 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1350,7 +1350,6 @@ lighting Light Światło Fractional Widths Szerokości ułamkowe Hide Options Ukryj opcje Horizontal Scale % Skala pozioma % - Kerning (0 = Auto) Kerning (0 = auto) Leading Interlinia No Break Bez podziału Proportional Metrics Metryka proporcjonalna @@ -3392,6 +3391,7 @@ theme Appearance Wygląd Identify Objects By Identyfikuj obiekty według Include SVG Code Dołącz kod SVG Italian Włoski + Kerning Kerning Keyboard Increment Przyrost klawiatury Language Options Opcje języka Large Duże @@ -3420,6 +3420,7 @@ theme Appearance Wygląd On Paste Przy wklejaniu On Screen Na ekranie Open Documents As Tabs Otwieraj dokumenty jako karty + Optical Optyczny Options Opcje Output All Blacks Accurately Wyprowadzaj wszystkie czernie dokładnie Output All Blacks as Rich Black Wyprowadzaj wszystkie czernie jako czerń głęboką @@ -3975,6 +3976,7 @@ imageTrace Outline Kontur @plural {n} stroke|{n} strokes {n} obrys|{n} obrysy|{n} obrysów @msg no library `{id}` brak biblioteki `{id}` @msg select some type first najpierw zaznacz tekst +@msg kerning must be a number, "auto", or "optical" Kerning musi być liczbą, „auto” lub „optical” @msg the selection has no gradient fill zaznaczenie nie ma gradientu wypełnienia @msg the selection has no gradient stroke zaznaczenie nie ma gradientu obrysu @msg the selection has no pattern fill zaznaczenie nie ma wzorka wypełnienia diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 0694d8f18..e40d32fb1 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1400,7 +1400,7 @@ Keep Plain Text Manter Texto Simples Keep both sides the same width Manter os dois lados com a mesma largura Keep this row's colors (exclude) Manter as cores desta linha (excluir) - Kerning (0 = Auto) Kerning (0 = Automático) + Kerning Kerning Keyboard Increment Incremento do Teclado Keyboard Shortcut Conflicts Conflitos de Atalhos de Teclado Keyboard Shortcut Set Conjunto de Atalhos de Teclado @@ -1810,6 +1810,7 @@ Open password: Senha de abertura: Open the URL in the web browser Abra a URL no navegador da web Open… Abrir… + Optical Óptico Optical Margin Alignment Alinhamento de Margem Óptica Optimize for fast web view Otimizar para visualização rápida na web Optimized Otimizado @@ -3430,6 +3431,7 @@ axis Both Ambos Drag to the window's left edge to dock Arraste até a borda esquerda da janela para encaixar @msg no library `{id}` nenhuma biblioteca `{id}` @msg select some type first selecione algum texto primeiro +@msg kerning must be a number, "auto", or "optical" O kerning deve ser um número, “auto” ou “optical” @msg the selection has no gradient fill a seleção não tem gradiente de preenchimento @msg the selection has no gradient stroke a seleção não tem gradiente de traçado @msg the selection has no pattern fill a seleção não tem padrão de preenchimento diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 105ba72d1..7bfca518c 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1648,7 +1648,7 @@ file-filter Templates Шаблоны Keep the pasted color as a new swatch with a numbered name. Сохранять вставленный цвет как новый образец с номерным именем. Keep this row's colors (exclude) Сохранить цвета этой строки (исключить) Keep: Сохранять: - Kerning (0 = Auto) Кернинг (0 = авто) + Kerning Кернинг Keyboard Increment Приращение с клавиатуры Keyboard Shortcut Conflicts Конфликты сочетаний клавиш Keyboard Shortcut Set Набор сочетаний клавиш @@ -2237,6 +2237,7 @@ file-filter Templates Шаблоны Open the URL in the web browser Открыть URL в веб-браузере Opened with {} note(s): {} Открыто с примечаниями ({}) : {} Open… Открыть… + Optical Оптический Optical Margin Alignment Оптическое выравнивание полей Optimize for fast web view Оптимизировать для быстрого просмотра в web Optimized Оптимизировано @@ -3879,6 +3880,7 @@ axis Both Оба # names and values in backticks or code (`colorModel rgb`, `document.export`) stay as they are. @msg no library `{id}` нет библиотеки `{id}` @msg select some type first сначала выделите текст +@msg kerning must be a number, "auto", or "optical" Кернинг должен быть числом, «auto» или «optical» @msg the selection has no gradient fill у выделения нет градиентной заливки @msg the selection has no gradient stroke у выделения нет градиентной обводки @msg the selection has no pattern fill у выделения нет узорной заливки diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 483111988..1bd22f672 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -1587,7 +1587,7 @@ font-technology Variable Variabla Keep the pasted color as a new swatch with a numbered name. Behåll den inklistrade färgen som en ny färgruta med numrerat namn. Keep this row's colors (exclude) Behåll radens färger (undanta) Keep: Behåll: - Kerning (0 = Auto) Kerning (0 = auto) + Kerning Kerning Keyboard Increment Tangentbordssteg Keyboard Shortcut Conflicts Konflikter mellan kortkommandon Keyboard Shortcut Set Uppsättning kortkommandon @@ -2159,6 +2159,7 @@ font-technology Variable Variabla Open the URL in the web browser Öppna URL:en i webbläsaren Opened with {} note(s): {} Öppnad med {} anteckning(ar): {} Open… Öppna… + Optical Optisk Optical Margin Alignment Optisk marginaljustering Optimize for fast web view Optimera för snabb webbvisning Optimized Optimerad @@ -3761,6 +3762,7 @@ axis Both Båda Float Tool Group Gör verktygsgruppen flytande @msg no library `{id}` inget bibliotek `{id}` @msg select some type first markera någon text först +@msg kerning must be a number, "auto", or "optical" Kerning måste vara ett tal, ”auto” eller ”optical” @msg the selection has no gradient fill markeringen har ingen fyllnadsgradient @msg the selection has no gradient stroke markeringen har ingen konturgradient @msg the selection has no pattern fill markeringen har inget fyllnadsmönster diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 5e53f4fef..41456bf05 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -576,7 +576,7 @@ fn character_panel_terms_agree_within_each_catalog() { for l in LANGUAGES.iter().filter(|l| !l.source.is_empty()) { let (entries, _) = parse_entries(l.source); let row = |src: &str| entries.iter().find(|(ctx, s, _)| ctx.is_empty() && s == src).map(|(_, _, tr)| tr.to_lowercase()); - let kerning = row("Kerning (0 = Auto)").map(|tr| tr.split(" (").next().unwrap_or_default().trim().to_string()); + let kerning = row("Kerning"); let leading = row("Leading"); let tracking = row("Tracking"); for (a, b) in [(&kerning, &leading), (&kerning, &tracking), (&leading, &tracking)] { diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 23bdb6839..deb3e95d7 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1474,7 +1474,7 @@ font-technology Variable Değişken Keep the pasted color as a new swatch with a numbered name. Yapıştırılan rengi numaralı bir adla yeni bir renk örneği olarak koruyun. Keep this row's colors (exclude) Bu satırın renklerini koru (hariç tut) Keep: Sakla: - Kerning (0 = Auto) Karakter Aralığı (0 = Otomatik) + Kerning Karakter aralığı Keyboard Increment Klavye Artışı Keyboard Shortcut Conflicts Klavye Kısayolu Çakışmaları Keyboard Shortcut Set Klavye Kısayol Seti @@ -2038,6 +2038,7 @@ shape Open Açık Open the URL in the web browser URL'yi web tarayıcısında açın Opened with {} note(s): {} {} notla açıldı: {} Open… Aç… + Optical Optik Optical Margin Alignment Optik Kenar Boşluğu Hizalaması Optimize for fast web view Hızlı web görünümü için optimize edin Optimized Optimize edilmiş @@ -3655,6 +3656,7 @@ axis Both Her İkisi Float Tool Group Araç Grubunu Yüzer Yap @msg no library `{id}` `{id}` kütüphanesi yok @msg select some type first önce bir tür seçin +@msg kerning must be a number, "auto", or "optical" Karakter aralığı sayı, “auto” veya “optical” olmalıdır @msg the selection has no gradient fill seçimin degrade dolgusu yok @msg the selection has no gradient stroke seçimin degrade konturu yok @msg the selection has no pattern fill seçimin desen dolgusu yok diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index dde23c742..d78f48b33 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1644,7 +1644,7 @@ file-filter Templates Шаблони Keep the pasted color as a new swatch with a numbered name. Зберігати вставлений колір як новий зразок із номерним ім’ям. Keep this row's colors (exclude) Зберегти кольори цього рядка (виключити) Keep: Зберігати: - Kerning (0 = Auto) Кернінг (0 = авто) + Kerning Кернінг Keyboard Increment Крок клавіатури Keyboard Shortcut Conflicts Конфлікти поєднань клавіш Keyboard Shortcut Set Набір клавіш @@ -2233,6 +2233,7 @@ file-filter Templates Шаблони Open the URL in the web browser Відкрити URL у веб-браузері Opened with {} note(s): {} Відкрито із примітками ({}) : {} Open… Відкрити… + Optical Оптичний Optical Margin Alignment Оптичне вирівнювання полів Optimize for fast web view Оптимізувати для швидкого перегляду web Optimized Оптимізовано @@ -3867,6 +3868,7 @@ axis Both Обидві Float Tool Group Від’єднати групу інструментів @msg no library `{id}` немає бібліотеки `{id}` @msg select some type first спочатку виділіть текст +@msg kerning must be a number, "auto", or "optical" Кернінг має бути числом, «auto» або «optical» @msg the selection has no gradient fill виділення не має градієнтної заливки @msg the selection has no gradient stroke виділення не має градієнтного обведення @msg the selection has no pattern fill виділення не має візерункової заливки diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index e060c0999..ade0a07aa 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1922,8 +1922,8 @@ file-filter Templates Mẫu Keep this row's colors (exclude) Giữ màu của hàng này (loại trừ) Keep visible surfaces only when expanding Chỉ giữ bề mặt hiển thị khi mở rộng Keep: Giữ: - Kerning (0 = Auto) Kerning (0 = Tự động) Key Color: Màu chủ: + Kerning Kerning Keyboard Increment Bước nhảy bàn phím Keyboard Shortcut Conflicts Xung đột phím tắt Keyboard Shortcut Set Bộ phím tắt @@ -2499,6 +2499,7 @@ file-filter Templates Mẫu “{name}” already exists. Replace it? “{name}” đã tồn tại. Thay thế tệp này? The name in the save panel was “{typed}”; VectorCraft added the file type's extension. Replacing the file overwrites its contents. Tên trong bảng lưu là “{typed}”, và VectorCraft đã thêm phần mở rộng của loại tệp. Thay thế tệp sẽ ghi đè nội dung của tệp. Open… Mở… + Optical Quang học Optical Margin Alignment Căn lề quang học Optimize for fast web view Tối ưu để xem web nhanh Optimized Đã tối ưu @@ -4696,6 +4697,7 @@ theme Appearance Giao diện @msg select paths or anchor points chọn đường dẫn hoặc điểm neo @msg select paths to join hãy chọn đường dẫn để nối @msg select some type first hãy chọn chữ trước +@msg kerning must be a number, "auto", or "optical" Kerning phải là số, “auto” hoặc “optical” @msg the selection has no gradient fill vùng chọn không có tô chuyển sắc @msg the selection has no gradient stroke vùng chọn không có nét chuyển sắc @msg the selection has no pattern fill vùng chọn không có tô họa tiết diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 65842950a..acffa1e4e 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1575,7 +1575,7 @@ file-filter Templates 模板 Keep the pasted color as a new swatch with a numbered name. 将粘贴的颜色保留为新色板,并以编号命名。 Keep this row's colors (exclude) 保留此行的颜色 (排除) Keep: 保留: - Kerning (0 = Auto) 字偶距 (0 = 自动) + Kerning 字偶距 Keyboard Increment 键盘增量 Keyboard Shortcut Conflicts 键盘快捷键冲突 Keyboard Shortcut Set 键盘快捷键组 @@ -2113,6 +2113,7 @@ file-filter Templates 模板 Opened with {} note(s): {} 已打开,附 {} 条附注:{} OpenType OpenType Open… 打开… + Optical 视觉 Optical Margin Alignment 视觉边界对齐 Optimize for fast web view 优化快速 Web 查看 Optimized 优化 @@ -4290,6 +4291,7 @@ imageTrace Outline 轮廓 @msg choose a folder to export to 请选择要导出到的文件夹 @msg the selection has no URL (or they differ) 所选对象没有 URL (或其 URL 各不相同) @msg select some type first 请先选择一些文字 +@msg kerning must be a number, "auto", or "optical" 字偶距必须是数字、“auto”或“optical” @msg the selection has no gradient fill 所选对象没有渐变填色 @msg the selection has no gradient stroke 所选对象没有渐变描边 @msg the selection has no pattern fill 所选对象没有图案填色 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index fb06daa66..3b560be95 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1614,7 +1614,7 @@ file-filter Templates 範本 Keep the pasted color as a new swatch with a numbered name. 將貼上的顏色保留為新色票,並以編號命名。 Keep this row's colors (exclude) 保留此列的顏色 (排除) Keep: 保留: - Kerning (0 = Auto) 字距微調 (0 = 自動) + Kerning 字距微調 Keyboard Increment 鍵盤增量 Keyboard Shortcut Conflicts 鍵盤快速鍵衝突 Keyboard Shortcut Set 鍵盤快速鍵組 @@ -2228,6 +2228,7 @@ file-filter Templates 範本 @msg choose a folder to export to 請選擇要匯出至的資料夾 @msg the selection has no URL (or they differ) 選取範圍沒有 URL (或其 URL 各不相同) @msg select some type first 請先選取一些文字 +@msg kerning must be a number, "auto", or "optical" 字距微調必須是數字、「auto」或「optical」 @msg the selection has no gradient fill 選取範圍沒有漸層填色 @msg the selection has no gradient stroke 選取範圍沒有漸層筆畫 @msg the selection has no pattern fill 選取範圍沒有圖樣填色 @@ -3047,6 +3048,7 @@ file-filter Templates 範本 Opened with {} note(s): {} 已開啟,附 {} 則附註:{} OpenType OpenType Open… 開啟… + Optical 視覺 Optical Margin Alignment 視覺邊界對齊 Optimize for fast web view 最佳化以便快速網頁檢視 Optimized 最佳化 diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index 91dc0a741..82445374b 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -222,7 +222,7 @@ fn char_cmd(app: &mut VectorcraftApp, cmd: &str, p: Value) { range_style(app, p); } -/// Set the Kerning field's `value` (a number or "auto"). At a caret in text the Type tool edits +/// Set the Kerning field's `value` (a number, "auto", or "optical"). At a caret in text the Type tool edits /// it is the pair kerning after the character before the caret, as Alt+←/→ steps it /// (`type.step`); nowhere to set at the start of the text. Else it is a character attribute like /// the others: the selected range, or all the text. @@ -244,6 +244,37 @@ fn set_kerning(app: &mut VectorcraftApp, value: Value) { app.run("text.setRangeStyle", json!({"id": id.0, "start": start, "end": a, "kerning": value})).ok(); } +/// The pair before the caret, in 1/1000 em, using the layout the canvas draws. +fn caret_kerning(app: &VectorcraftApp, ui: &Ui) -> Option<(f64, bool)> { + let (id, a, b) = text_editing(app)?; + if a != b { + return None; + } + let NodeKind::Text(t) = &app.session.active()?.doc.node(id)?.kind else { return None }; + let text = t.plain_text(); + let start = vectorcraft_text::edit::prev_char(&text, a); + if start >= a { + return None; + } + let style = vectorcraft_text::edit::style_at(&t.runs, start + 1); + if let Some(v) = style.kerning { + return Some((v, false)); + } + let state = app.session.active()?; + let key = (state.uid, state.revision, id, a); + let slot = ui.scope_id().with("caret-kerning"); + if let Some((cached_key, value)) = ui.ctx().data(|d| d.get_temp::<((u64, u64, NodeId, usize), f64)>(slot)) + && cached_key == key + { + return Some((value, true)); + } + let original = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + let value = vectorcraft_engine::cmd::effective_kerning(vectorcraft_text::FontDb::global(), t, &original, start, a); + let value = value.round(); + ui.ctx().data_mut(|d| d.insert_temp(slot, (key, value))); + Some((value, true)) +} + /// The faces Type › Bold and Type › Italic switch to. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Face { @@ -637,14 +668,33 @@ pub(crate) fn metrics_grid(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, s: & match widgets::spin_field_auto(ui, "ch-lead", leading, Some(auto), unit, fw, 1.0, 0.1, &SIZE_PRESETS) { Some(SpinPick::Value(v)) => style(app, json!({"leading": v})), Some(SpinPick::Auto) => style(app, json!({"leading": "auto"})), + Some(SpinPick::Optical) => {} None => {} } }); ui.end_row(); - cell(ui, "VA", tl!("Kerning (0 = Auto)"), |ui| { - let kerning = shared(app, |s| s.kerning.unwrap_or(0.0)); - if let Some(v) = widgets::spin_plain(ui, "ch-kern", kerning, "", 0, fw, 10.0, -1000.0, &TRACKING_PRESETS) { - set_kerning(app, if v == 0.0 { json!("auto") } else { json!(v) }); + cell(ui, "VA", tl!("Kerning"), |ui| { + let mode = shared(app, |s| (s.optical_kerning, s.kerning)); + let caret = text_editing(app).is_some_and(|(_, a, b)| a == b); + let at_caret = if caret { caret_kerning(app, ui) } else { None }; + let kerning = at_caret.map(|(v, _)| v).or_else(|| mode.map(|(_, manual)| manual.unwrap_or(0.0))); + let automatic = mode.map(|(optical, manual)| !optical && manual.is_none()); + let optical = mode.map(|(optical, _)| optical); + match widgets::spin_kerning( + ui, + "ch-kern", + kerning, + automatic, + optical, + tl!("Optical"), + at_caret.is_some_and(|(_, auto)| auto), + fw, + &TRACKING_PRESETS, + ) { + Some(SpinPick::Value(v)) => set_kerning(app, json!(v)), + Some(SpinPick::Auto) => set_kerning(app, json!("auto")), + Some(SpinPick::Optical) => set_kerning(app, json!("optical")), + None => {} } }); cell(ui, "VA↔", tl!("Tracking"), |ui| { diff --git a/crates/ui-egui/src/panels/character/tests.rs b/crates/ui-egui/src/panels/character/tests.rs index fe5e53b3e..61ec42a21 100644 --- a/crates/ui-egui/src/panels/character/tests.rs +++ b/crates/ui-egui/src/panels/character/tests.rs @@ -130,6 +130,38 @@ fn kerning_at_a_caret_sets_the_pair_not_the_whole_text() { assert_eq!((kerning(&app, 1), kerning(&app, 2), kerning(&app, 3)), (Some(-50.0), Some(30.0), Some(30.0))); } +#[test] +fn character_kerning_setter_and_automatic_pair_value() { + let german = crate::i18n::Lang::from_code("de").unwrap(); + assert!(crate::i18n::has(german, "Optical")); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + app.session.select_tool("type", Default::default()).unwrap(); + app.run("text.create", json!({"x": 20, "y": 50, "text": "III", "size": 100, "font": "JetBrains Mono"})).unwrap(); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + app.session.set_tool_option("select", &json!({"start": 1, "end": 1})); + set_kerning(&mut app, json!("optical")); + assert!(text_style(&app).unwrap().0.optical_kerning); + let ctx = egui::Context::default(); + let mut shown = None; + ctx.run_ui(Default::default(), |ui| { + shown = caret_kerning(&app, ui); + }) + .textures_delta + .clear(); + let (value, automatic) = shown.unwrap(); + assert!(automatic && value != 0.0); + set_kerning(&mut app, json!(50)); + ctx.run_ui(Default::default(), |ui| { + shown = caret_kerning(&app, ui); + }) + .textures_delta + .clear(); + assert_eq!(shown, Some((50.0, false))); + app.session.set_tool_option("select", &json!({"start": 0, "end": 2})); + assert_eq!(shared(&app, |s| (s.optical_kerning, s.kerning)), None, "a mixed range shows a blank Kerning field"); +} + /// Highlight Kerning and Highlight Tracking show the Character panel and give its field the /// keyboard focus with the value selected: what is typed replaces it, and Enter applies it. /// Highlight Font Name opens the font family menu with its search field focused. Without diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index c6b855ffd..30a6fa2b7 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -51,7 +51,7 @@ pub(crate) use numeric::{ANY, round_to, set_bare_numbers_are_points, typed_unit} pub use numeric::{ SpinPick, mixed_field, mixed_field_named, num_field, num_field_named, num_field_within, opt_field, opt_field_named, range_field, range_field_named, range_field_stepped, size_field, size_field_named, slider, spin_field, spin_field_auto, spin_field_auto_named, - spin_field_named, spin_field_stops_named, spin_plain, spin_plain_named, + spin_field_named, spin_field_stops_named, spin_kerning, spin_plain, spin_plain_named, }; #[cfg(test)] use numeric::{decimal_point, share_of, spinner_step, step_through, step_to_multiple, within_a_millionth}; diff --git a/crates/ui-egui/src/widgets/numeric.rs b/crates/ui-egui/src/widgets/numeric.rs index 1867eb271..9244c32bf 100644 --- a/crates/ui-egui/src/widgets/numeric.rs +++ b/crates/ui-egui/src/widgets/numeric.rs @@ -346,7 +346,7 @@ pub fn range_field_named( let places = decimals.min(6) as i32; let value = value.into().map(|v| within(v, &range)); let label = take_previous_label(ui); - bounded_field(ui, id, label, name, value, suffix, decimals, width, &range, None).map(|v| within(round_to(v, places), &range)) + bounded_field(ui, id, label, name, value, suffix, decimals, width, &range, None, false).map(|v| within(round_to(v, places), &range)) } /// [`range_field`] whose ↑/↓ and wheel take `step`'s steps instead of whole numbers (see @@ -366,7 +366,7 @@ pub fn range_field_stepped( let places = decimals.min(6) as i32; let value = value.into().map(|v| within(v, &range)); let label = take_previous_label(ui); - bounded_field(ui, id, label, None, value, suffix, decimals, width, &range, Some(step)).map(|v| within(round_to(v, places), &range)) + bounded_field(ui, id, label, None, value, suffix, decimals, width, &range, Some(step), false).map(|v| within(round_to(v, places), &range)) } /// [`plain_field`] for a value the selected objects may not share: `None` shows a blank field. @@ -385,7 +385,7 @@ pub fn mixed_field_named( width: f32, ) -> Option { let label = take_previous_label(ui); - bounded_field(ui, id, label, name, value, suffix, decimals, width, &ANY, None) + bounded_field(ui, id, label, name, value, suffix, decimals, width, &ANY, None, false) } /// [`mixed_field`] whose ↑/↓, wheel and scrub stop at `bounds`; ↑/↓ and the wheel take `step`'s steps @@ -403,6 +403,7 @@ fn bounded_field( width: f32, bounds: &RangeInclusive, step: Option f64>, + parentheses: bool, ) -> Option { let armed = take_armed_focus(ui, &id); let id = id_with(ui.scope_id(), id); @@ -412,9 +413,9 @@ fn bounded_field( let suffix = suffix.trim(); let buf = decimal_point(buf); let bare = if suffix.is_empty() { buf.to_string() } else { buf.replace(suffix, "") }; - vectorcraft_doc::parse_number(&bare.replace(['%', '°'], "")) + vectorcraft_doc::parse_number(&bare.replace(['%', '°', '(', ')'], "")) }; - let shown = value.map(show).unwrap_or_default(); + let shown = value.map(|v| if parentheses { format!("({})", show(v)) } else { show(v) }).unwrap_or_default(); take_dialog_focus(ui, id, &shown); if armed { focus_field(ui, id, &shown); @@ -508,7 +509,7 @@ pub fn spin_field_stops_named( presets: &[f64], ) -> Option { let label = take_previous_label(ui); - spin_generic(ui, label, value, None, width, step, min, presets, presets, &|v| unit.format(v), &mut |ui, fw, label| { + spin_generic(ui, label, value, None, None, None, width, step, min, presets, presets, &|v| unit.format(v), &mut |ui, fw, label| { unit_field(ui, id, label, name, value, unit, fw, false, min..=f64::INFINITY, presets) }) .and_then(SpinPick::value) @@ -519,6 +520,7 @@ pub fn spin_field_stops_named( pub enum SpinPick { Value(f64), Auto, + Optical, } impl SpinPick { @@ -527,6 +529,7 @@ impl SpinPick { match self { SpinPick::Value(v) => Some(v), SpinPick::Auto => None, + SpinPick::Optical => None, } } } @@ -563,7 +566,7 @@ pub fn spin_field_auto_named( presets: &[f64], ) -> Option { let label = take_previous_label(ui); - spin_generic(ui, label, value, auto, width, step, min, presets, &[], &|v| unit.format(v), &mut |ui, fw, label| { + spin_generic(ui, label, value, auto, None, None, width, step, min, presets, &[], &|v| unit.format(v), &mut |ui, fw, label| { unit_field(ui, id, label, name, value, unit, fw, false, min..=f64::INFINITY, &[]) }) } @@ -602,12 +605,43 @@ pub fn spin_plain_named( let label = take_previous_label(ui); let value = value.into(); let fmt = |v: f64| format!("{v}{suffix}"); - spin_generic(ui, label, value, None, width, step, min, presets, &[], &fmt, &mut |ui, fw, label| { - bounded_field(ui, id, label, name, value, suffix, decimals, fw, &(min..=f64::INFINITY), None) + spin_generic(ui, label, value, None, None, None, width, step, min, presets, &[], &fmt, &mut |ui, fw, label| { + bounded_field(ui, id, label, name, value, suffix, decimals, fw, &(min..=f64::INFINITY), None, false) }) .and_then(SpinPick::value) } +/// Kerning's numeric field and automatic method choices. +#[allow(clippy::too_many_arguments)] +pub fn spin_kerning( + ui: &mut Ui, + id: impl std::hash::Hash + Copy, + value: Option, + automatic: Option, + optical: Option, + optical_label: &str, + parentheses: bool, + width: f32, + presets: &[f64], +) -> Option { + let label = take_previous_label(ui); + spin_generic( + ui, + label, + value, + automatic, + optical, + Some(optical_label), + width, + 10.0, + -1000.0, + presets, + &[], + &|v| format!("{v}"), + &mut |ui, fw, label| bounded_field(ui, id, label, None, value, "", 0, fw, &(-1000.0..=f64::INFINITY), None, parentheses), + ) +} + type SpinField<'a> = dyn FnMut(&mut Ui, f32, Option) -> Option + 'a; #[allow(clippy::too_many_arguments)] @@ -616,6 +650,8 @@ fn spin_generic( label: Option, value: Option, auto: Option, + optical: Option, + optical_label: Option<&str>, width: f32, step: f64, min: f64, @@ -674,6 +710,11 @@ fn spin_generic( { out = Some(SpinPick::Auto); } + if let Some(on) = optical + && ui.selectable_label(on, optical_label.unwrap_or_default()).clicked() + { + out = Some(SpinPick::Optical); + } let current = value.filter(|_| auto != Some(true)); for p in presets { if ui.selectable_label(current.is_some_and(|v| (v - p).abs() < 1e-6), fmt(*p)).clicked() { diff --git a/docs/mcp.md b/docs/mcp.md index 0595340df..129d7346c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3217,7 +3217,8 @@ Pen's second anchor at (150, 431), in line with the square's centre. leading (↑/↓ in horizontal type, ←/→ in vertical type), and baseline shift (Shift+↑/↓ in horizontal type, Shift+→/← in vertical type). Mixed selected text objects use the horizontal mapping; Cmd/Ctrl makes five steps. `type.resetTracking` (Cmd+Alt+Q, `press_key {key: "Q", mods: {cmd: true, alt: true}}`) sets tracking to 0 and - kerning to Auto in the type objects in `ids`, the Type tool's selected text or the selected type objects; at a caret + clears manual kerning in the type objects in `ids`, the Type tool's selected text or the selected type objects, + keeping Optical; at a caret it resets only the kerning of the character before it. `type.resetHorizontalScale` (Cmd+Shift+X, `press_key {key: "X", mods: {cmd: true, shift: true}}`) sets the horizontal scale to 100%, as the Character panel shows it, in the same text; at a caret it leaves the text as it is. In the app, `type.highlightKerning` (Cmd+Alt+K), @@ -4918,6 +4919,16 @@ the paragraph's text now starts; when the text before `at` starts no list, nothi {"name":"run_command","arguments":{"command":"text.convertListToText","params":{}}} ``` +## Kerning + +`text.setRangeStyle {id, start, end, kerning}` (a range) and `text.setFormat {ids?, kerning}` +(the selected type) accept `kerning: "auto" | "optical" | number` (1/1000 em). Auto uses the font's +pair metrics; Optical spaces horizontal pairs from their glyph shapes. Vertical text uses font +metrics in either mode. A number sets a manual pair value, +replacing that pair's automatic correction while tracking remains additive. Choosing Optical keeps +existing manual pair values; choosing `"auto"` clears them. New text and files without an Optical setting use Auto. The Character +panel offers both automatic modes. + ## Small Caps, Superscript and Subscript `text.setFormat {smallCaps, position}` (the selected type, or `ids`) and `text.setRangeStyle {id, start, end, smallCaps, position}` (a range) set what the Character panel's Small Caps, Superscript and Subscript buttons and menu items set. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 3d706ba6b..e6645bbbc 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -633,7 +633,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - With the Artboard tool, Cmd/Ctrl+A after a click on a row of the Artboards panel selects every artboard, as the macOS menu bar's Select All does then; the active artboard stays active, and the art's selection does not change. After a press outside the panel's list, Cmd/Ctrl+A selects every object again. It used to select every object after a click in the panel too. Public reference: [Select artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/select-artboards.html). - Cmd/Ctrl+/ (`appearance.addFill`) and Cmd/Ctrl+Alt+/ (`appearance.addStroke`) add a new fill and a new stroke to the selected objects, as the Appearance panel's Add New Fill and Add New Stroke buttons do, on the keys the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists for adding a new fill and a new stroke. Keyboard Shortcuts lists both under Window › Appearance. - Cmd/Ctrl+Alt+Shift+. and , (`type.size.increaseByFive`, `type.size.decreaseByFive`) step the font size of the selected type, or of the text the Type tool has selected, by five times the Size/Leading increment (Preferences › Type); Cmd/Ctrl+Shift+. and , still step it by one increment. - - Cmd/Ctrl+Alt+Q (`type.resetTracking`) sets the tracking of the selected type, or of the text the Type tool has selected, to 0 and its kerning to Auto (0 in the Character panel), in one undo step; at a caret it resets the kerning of the character before it. + - Cmd/Ctrl+Alt+Q (`type.resetTracking`) sets the tracking of the selected type, or of the text the Type tool has selected, to 0 and clears manual kerning while keeping the automatic method, in one undo step; at a caret it resets only the kerning of the character before it. - Cmd/Ctrl+Alt+K (`type.highlightKerning`) and Cmd/Ctrl+Alt+Shift+F (`type.highlightFontName`) show the Character panel and give its Kerning field the keyboard focus with the value selected, or open its font family menu with the search field focused, on the keys the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists for highlighting kerning and the font name field. The page lists the same key for selecting the tracking value; `type.highlightTracking` focuses the Tracking field the same way and has no default key in Keyboard Shortcuts. - Cmd/Ctrl+Alt+L (`ui.newLayer`) opens Layer Options for a new layer, as an Alt-click on the Layers panel's New Layer button does, on the key the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists for adding a layer through the New Layer dialog box. OK adds the layer above the current layer, and Cancel adds none. Cmd/Ctrl+L still adds a layer without the dialog. - F8 (`ui.symbolOptions`) opens Symbol Options for a new symbol made from the selection, as the Symbols panel's New Symbol button does, on the key the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists for making a new symbol. OK makes the symbol and puts an instance of it in place of the art, and Cancel makes none. With nothing selected, F8 opens no dialog, and the status bar says that nothing is selected. Shift+F8 and Cmd/Ctrl+F8 still show the Transform and Info panels. @@ -840,6 +840,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Vertical type spacing shortcuts:** The [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page gives Alt+Down/Up for kerning at a caret or tracking of selected text, Alt+Left/Right for leading, and Alt+Shift+Right/Left for baseline shift. Alt+Shift+Down/Up also steps kerning or tracking. Down increases kerning or tracking, Left increases leading, and Shift+Right increases baseline shift; the opposite arrows decrease them. Cmd/Ctrl makes five steps. Horizontal type keeps its existing mapping; mixed horizontal and vertical text-object selections take the horizontal mapping for both objects in one undo step. Selected vertical point, area and path type use the same directions. Tests: `spacing_arrows_follow_each_text_objects_orientation`, `alt_arrows_rotate_for_selected_vertical_type`, `mixed_type_objects_take_horizontal_spacing_chords_in_both_orders`, and `selected_vertical_area_and_path_type_use_vertical_spacing_chords`. - **Vertical punctuation:** fullwidth `!` (U+FF01) and `?` (U+FF1F) use their nominal glyph upright when no vertical alternate is present, following the Tu fallback in [Unicode UAX #50 §3.1](https://www.unicode.org/reports/tr50/#vo). Code-built test fonts cover point and area type with and without `vert` alternates, retain the rotated fallback for a bracket and long vowel mark, and leave horizontal type unchanged. Tests: `crates/text/src/tests/vertical.rs`. - Free Distort's dialog (Effect › Distort & Transform › Free Distort…) shows a box in the proportions of the first selected object's bounds with a handle on each corner. Dragging a handle moves that corner. A handle focused by a click or Tab moves 1% of the box with each arrow key, and 10% with Shift. Reset puts the four corners back on the box. The preview and OK run `effect.apply`, or `effect.setParams` when the dialog edits an applied effect, and the Appearance panel's inline editor opens the dialog with Edit…. Behavior reference: Illustrator CC Help (2014, [PDF](https://help.adobe.com/archive/en/illustrator/cc/2014/illustrator_reference.pdf)), page 285 (PDF page 291). +- **Optical kerning:** Character and Properties offer Optical beside Auto for automatic pair spacing. The side-profile spacing method is VectorCraft's own. Optical keeps its mode across cosmetic style runs, and leaves manual pair values and additive tracking intact. Native documents retain the mode and pair overrides; old documents use Auto. Behavior reference: [Line and character spacing](https://web.archive.org/web/20260916050333/https://helpx.adobe.com/illustrator/using/line-character-spacing.html) (public documentation). Tests: `crates/text/src/tests/typo.rs`, `crates/engine/src/tests/textedit.rs`. - **Kerning across style runs:** characters that differ only in fill, stroke, underline, strikethrough, overprint, character style name, leading, baseline shift, rotation, Character Alignment, kerning, or tracking that keeps or drops ligatures alike are shaped together, so restyling one character keeps the font's kerning pairs with its neighbors. A character with manual kerning that follows one with automatic kerning keeps the font's kerning with it (unless All Caps or Small Caps sets it as two letters, as ß becomes SS), and its value replaces the font's kerning after it. A ligature does not form across a change of style, and a change of font, size or OpenType features still shapes the characters apart. Behavior reference: [Line and character spacing](https://web.archive.org/web/20260916050333/https://helpx.adobe.com/illustrator/using/line-character-spacing.html) (public documentation). Tests: `crates/text/src/tests/typo.rs`, `crates/text/src/tests/bidi.rs`. - **Recent font pickers (#3515):** The shared Character, Properties, Glyphs and Find Font family picker lists recently applied families first, once each, in most-recent order and limited by Preferences › Type › Number of Recent Fonts. Search and filters apply to both sections; unavailable families are omitted. Section headings cannot be selected or previewed. Confirmed character-range font edits update the same saved history; previews and failed edits do not. Requirement: [issue #3515](https://github.com/storytold/vectorcraft/issues/3515). - **Type:** Character size, leading, baseline shift and horizontal-scale controls account for object/group transforms (#1034): exact document-point sizes, mixed-value display and keyboard increments, range edits, resettable horizontal stretch, undo/redo and native save/reopen (`tests_typescale`, Character panel tests). Point size is the transformed em-height axis, independent of glyph ink bounds; affine rotation, reflection and shear are retained. Group scaling still scales text; preserving font sizes is a separate enhancement. Behaviour reference: [Transform text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/transform-text.html) (public documentation). Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`), and an anchor of the path of type on a path outside perspective, the type reflowing along it (`text.reshapePath`, #3980). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. The Character panel's Variable Font sliders set each axis (see Fonts). Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Arabic typefaces: builds made with a craft-fonts checkout that holds its Arabic families (Noto Sans Arabic, the Arabic fallback, plus Cairo, Tajawal, Almarai, IBM Plex Sans Arabic, Noto Kufi Arabic, Amiri, Alexandria, Fustat, Reem Kufi, Aref Ruqaa and Gulzar once craft-fonts adds them, storytold/craft-fonts#11; releases get them when their craft-fonts pin moves) list them in the font menus on desktop; the web build fetches them from beside the wasm (Noto Sans Arabic before start, so Arabic never shows as boxes, the rest in the background). Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458). Type › Bold and Italic (beyond Illustrator, as page layout apps have them; Cmd+Shift+B / Cmd+Shift+I while the Type tool edits text) switch the selected type to its family's own Bold or Italic face and back, keeping its width (a Narrow face stays Narrow); a family without that face says so (#724). The commands that make or restyle type (and the MCP `add_text` tool) say in their result when area type does not fit its frame, and `add_text` takes Auto Size and Shrink Text to Fit (#1500, fixes #1471). Cmd+Shift+C (Align center) works on Windows and Linux too (#1940). A double-click on area type's bottom middle handle turns on Auto Size (#1940). Print and Print Preview warn about the area type and type on a path that prints without the text that doesn't fit, and give their ids. A tab stop in the Tabs panel drags from where the press landed, a stop just added included (#1487, fixes #1458). Area type in a non-rectangular shape moves a word too wide for the top lines down to the first line that holds it instead of breaking it (#1617, fixes #1615). Stylistic sets (ss01–ss20), stylistic and titling alternates, proportional and lining figures, and superscript and subscript glyphs can be turned on as OpenType features of a character style (`text.setStyle {features}`), and the OpenType panel keeps them (#1611, fixes #1607). Escape while typing ends the edit, keeps the type selected and switches to the Selection tool (#1802, #1686, #1656). Select › Same matches type on its characters' fill and stroke (#1746, #1737). `text.create`, `text.createInPath` and the MCP `add_text` tool take `justify`, so point type can be centred on or end at its point (#1752, #1748). diff --git a/docs/typography-lettering.md b/docs/typography-lettering.md index 373f92b96..588a91e77 100644 --- a/docs/typography-lettering.md +++ b/docs/typography-lettering.md @@ -24,7 +24,7 @@ can use installed system fonts, while the web app uses the fonts supplied by its 6. Adjust **Font Size** (the field beside **T**) and **Tracking** (beside **VA↔**) in the Character panel. Hover a field's label to see its name. For example, enter `72 pt` for a large heading and try tracking `50` to spread its letters slightly. Positive tracking adds space; negative tracking - tightens it. **Leading** controls the spacing between lines; **Kerning (0 = Auto)** adjusts pairs. + tightens it. **Leading** controls the spacing between lines; **Kerning** adjusts pairs. 7. To change only part of the text, select **Type Tool**, click into the text and drag across the characters before using the Character panel. Press **Escape** when finished. Keep the text editable until its wording, font and spacing are settled.