mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 21:17:48 +00:00
Fix #5111: kerning at the caret before a line's first character moves it
At the Type tool's caret, kerning goes on the character before the caret. At the start of the text there is none, so Alt+arrows, the Kerning field and Reset Tracking and Kerning did nothing; at a later paragraph's start the kerning landed on the invisible line break. CharStyle::kerning_before (kerningBefore) is the space before a character that starts a line by itself: the first character of the text, of a paragraph, or after a forced line break. Shaping gives its cluster the space and draws it that much later along the line (right to left the space is at its right; in vertical type it moves down the column). The caret at such a line's start sets it (edit::caret_kerning_target), in type.step, type.resetTracking and the Character panel; text.setRangeStyle takes it. SVG export starts the line where the character is drawn. The Eyedropper and character styles leave it alone.
This commit is contained in:
@@ -28,6 +28,12 @@ pub struct CharStyle {
|
||||
/// Manual pair spacing in 1/1000 em, as [`Self::tracking`]; None uses the automatic mode.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub kerning: Option<f64>,
|
||||
/// Space before the character in 1/1000 em, as [`Self::kerning`], when it starts a line by
|
||||
/// itself: the first character of the text, of a paragraph, or after a forced line break. The
|
||||
/// Type tool's caret before such a character kerns with it (#5111), since there is no character
|
||||
/// before it whose [`Self::kerning`] could space it. Elsewhere it has no effect.
|
||||
#[serde(default, rename = "kerningBefore", skip_serializing_if = "crate::skip::is_default")]
|
||||
pub kerning_before: f64,
|
||||
/// 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,
|
||||
@@ -411,6 +417,7 @@ impl Default for CharStyle {
|
||||
leading: None,
|
||||
tracking: 0.0,
|
||||
kerning: None,
|
||||
kerning_before: 0.0,
|
||||
optical_kerning: false,
|
||||
baseline_shift: 0.0,
|
||||
h_scale: 100.0,
|
||||
|
||||
@@ -356,6 +356,7 @@ impl ParaList {
|
||||
position: CharPosition::Normal,
|
||||
tracking: 0.0,
|
||||
kerning: None,
|
||||
kerning_before: 0.0,
|
||||
optical_kerning: first.optical_kerning,
|
||||
tate_chu_yoko: false,
|
||||
underline: false,
|
||||
|
||||
@@ -1430,6 +1430,9 @@ impl Picked {
|
||||
stroke_width: st.stroke_width,
|
||||
overprint_fill: st.overprint_fill,
|
||||
overprint_stroke: st.overprint_stroke,
|
||||
// The space before a line's first character belongs to where the character is,
|
||||
// not to how it looks: a sample from one doesn't move the target's (#5111).
|
||||
kerning_before: st.kerning_before,
|
||||
..cs.clone()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Character",
|
||||
[],
|
||||
None,
|
||||
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), fill?: colour|\"none\", stroke?: colour|\"none\" (#rrggbbaa and #rgba are refused in both), 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\", …], featureChanges?: [\"tnum\", \"-onum\", …] (OpenType features turned on, or with - off, on top of each character's own; its other features are kept), 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), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; on a vertical path a positive Up/Down moves it toward the path's start and a positive Left/Right to the path's left; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), 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\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), 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), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\" (the space after each character), kerningBefore?: 1/1000 em -1000..10000 (the space before a character that starts a line by itself: the first character of the text, of a paragraph or after a forced line break; no effect elsewhere; 0 by default), baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), fill?: colour|\"none\", stroke?: colour|\"none\" (#rrggbbaa and #rgba are refused in both), 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\", …], featureChanges?: [\"tnum\", \"-onum\", …] (OpenType features turned on, or with - off, on top of each character's own; its other features are kept), 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), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; on a vertical path a positive Up/Down moves it toward the path's start and a positive Left/Right to the path's left; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), 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\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), 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
|
||||
),
|
||||
@@ -103,7 +103,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Step Type",
|
||||
[],
|
||||
None,
|
||||
"{attribute: \"size\"|\"leading\"|\"tracking\"|\"kerning\"|\"baselineShift\", by?: steps (1; negative steps down), id?, start?: byte, end?: byte, ids?: [id…] (instead of id)} step type by the Preferences › Type increments (size and leading: typeSizeIncrement, pt; tracking and kerning: trackingIncrement, 1/1000 em; baseline shift: baselineShiftIncrement, pt), what the Type tool's Alt+arrows do (Cmd/Ctrl too: five steps; horizontal: ←/→ kerning at the caret or tracking of the selection, ↑/↓ leading, Shift+↑/↓ baseline shift; vertical: ↓/↑ with or without Shift kerning or tracking, ←/→ leading, Shift+→/← baseline shift; mixed selected text objects use the horizontal mapping). The range of `id`, else the type objects in `ids`, else the text the Type tool has selected (at a caret: kerning steps the character before it, leading its paragraph), else every selected type object → {ids}",
|
||||
"{attribute: \"size\"|\"leading\"|\"tracking\"|\"kerning\"|\"baselineShift\", by?: steps (1; negative steps down), id?, start?: byte, end?: byte, ids?: [id…] (instead of id)} step type by the Preferences › Type increments (size and leading: typeSizeIncrement, pt; tracking and kerning: trackingIncrement, 1/1000 em; baseline shift: baselineShiftIncrement, pt), what the Type tool's Alt+arrows do (Cmd/Ctrl too: five steps; horizontal: ←/→ kerning at the caret or tracking of the selection, ↑/↓ leading, Shift+↑/↓ baseline shift; vertical: ↓/↑ with or without Shift kerning or tracking, ←/→ leading, Shift+→/← baseline shift; mixed selected text objects use the horizontal mapping). The range of `id`, else the type objects in `ids`, else the text the Type tool has selected (at a caret: kerning steps the space after the character before it, or at the start of a line (of the text, a paragraph, or after a forced line break) the space before the character after it (kerningBefore, as text.setRangeStyle), and nothing at an empty line's start; leading its paragraph), else every selected type object → {ids}",
|
||||
has_doc,
|
||||
type_step
|
||||
),
|
||||
@@ -148,7 +148,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Reset Tracking and Kerning",
|
||||
[],
|
||||
Some("Cmd+Alt+Q"),
|
||||
"{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}",
|
||||
"{ids?: [id…]} set tracking to 0 and clear manual kerning (kerning and kerningBefore) 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 there, as type.step steps it), else in every selected type object, as one undo step → {ids}",
|
||||
has_selection,
|
||||
reset_tracking
|
||||
),
|
||||
@@ -430,6 +430,8 @@ pub(crate) struct CharChange {
|
||||
leading: Option<Option<f64>>,
|
||||
tracking: Option<f64>,
|
||||
kerning: Option<Option<f64>>,
|
||||
/// The space before a character that starts a line by itself (`kerningBefore`).
|
||||
kerning_before: Option<f64>,
|
||||
optical_kerning: Option<bool>,
|
||||
baseline_shift: Option<f64>,
|
||||
h_scale: Option<f64>,
|
||||
@@ -691,7 +693,7 @@ fn auto_or_num(p: &Value, k: &str, cmd: &str) -> Result<Option<Option<f64>>> {
|
||||
impl CharChange {
|
||||
/// `setup` gives the superscript, subscript and small caps proportions.
|
||||
pub(crate) fn parse(p: &Value, cmd: &str, setup: &vectorcraft_doc::DocSetup) -> Result<Self> {
|
||||
check_numbers(p, &["size", "tracking", "baselineShift", "hScale", "vScale", "rotation", "strokeWidth"], cmd)?;
|
||||
check_numbers(p, &["size", "tracking", "kerningBefore", "baselineShift", "hScale", "vScale", "rotation", "strokeWidth"], cmd)?;
|
||||
check_switches(
|
||||
p,
|
||||
&["underline", "strikethrough", "allCaps", "proportionalMetrics", "tateChuYoko", "standardVerticalRoman", "noBreak", "kashidas"],
|
||||
@@ -720,6 +722,7 @@ impl CharChange {
|
||||
Some(Value::String(v)) if v.eq_ignore_ascii_case("optical") || 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\""))?)),
|
||||
},
|
||||
kerning_before: num("kerningBefore"),
|
||||
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),
|
||||
@@ -777,6 +780,7 @@ impl CharChange {
|
||||
&& self.leading.is_none()
|
||||
&& self.tracking.is_none()
|
||||
&& self.kerning.is_none()
|
||||
&& self.kerning_before.is_none()
|
||||
&& self.optical_kerning.is_none()
|
||||
&& self.baseline_shift.is_none()
|
||||
&& self.h_scale.is_none()
|
||||
@@ -883,6 +887,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.kerning_before {
|
||||
st.kerning_before = v.clamp(-1000.0, 10000.0);
|
||||
}
|
||||
if let Some(v) = self.optical_kerning {
|
||||
st.optical_kerning = v;
|
||||
}
|
||||
@@ -1694,17 +1701,16 @@ pub(crate) fn editing_story(s: &Session) -> Vec<NodeId> {
|
||||
s.active().map(|st| super::threads::story_of(&st.doc, id)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// A text range a type shortcut changes: its type object, its bytes, and the caret's byte when it
|
||||
/// came from a caret ([`type_ranges`]).
|
||||
pub(crate) type TypeRange = (NodeId, usize, usize, Option<usize>);
|
||||
|
||||
/// The text ranges a type shortcut changes: the range of `id`, else all the text of the type
|
||||
/// objects in `ids`, else the text the Type tool has selected (all the text of every frame of the
|
||||
/// story after Select All in threaded text, [`editing_story`]), else all the text of every selected
|
||||
/// type object. At a caret, `at_caret` gives the range from the plain text and the caret's byte
|
||||
/// (None: nothing changes there). Each range comes with whether it came from a caret.
|
||||
pub(crate) fn type_ranges(
|
||||
s: &mut Session,
|
||||
p: &Value,
|
||||
c: &str,
|
||||
at_caret: impl Fn(&str, usize) -> Option<(usize, usize)>,
|
||||
) -> Result<Vec<(NodeId, usize, usize, bool)>> {
|
||||
/// (None: nothing changes there). Each range comes with the caret's byte when it came from a caret.
|
||||
pub(crate) fn type_ranges(s: &mut Session, p: &Value, c: &str, at_caret: impl Fn(&str, usize) -> Option<(usize, usize)>) -> Result<Vec<TypeRange>> {
|
||||
// (text, its range; None: all of it).
|
||||
let targets: Vec<(NodeId, Option<(usize, usize)>)> = match super::object_param(p, "id", c)? {
|
||||
// An object that isn't type is refused as `ids` of it is.
|
||||
@@ -1728,10 +1734,10 @@ pub(crate) fn type_ranges(
|
||||
let text = text_ref(s, id)?.plain_text();
|
||||
let (a, b) = range.map_or((0, text.len()), |(a, b)| (a.min(text.len()), b.min(text.len())));
|
||||
let (a, b, caret) = if a < b {
|
||||
(a, b, false)
|
||||
(a, b, None)
|
||||
} else {
|
||||
match at_caret(&text, a) {
|
||||
Some((a, b)) => (a, b, true),
|
||||
Some((x, y)) => (x, y, Some(a)),
|
||||
None => continue,
|
||||
}
|
||||
};
|
||||
@@ -1763,24 +1769,31 @@ fn type_step(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Step::Tracking | Step::Kerning => prefs.tracking_increment,
|
||||
Step::BaselineShift => prefs.baseline_shift_increment,
|
||||
};
|
||||
// The ranges stepped. At a caret: kerning is the space after the character before it, leading
|
||||
// the paragraph's; the rest wait for a selection.
|
||||
// The ranges stepped. At a caret: kerning is the space after the character before it, or at
|
||||
// the start of a line the space before the character after it (#5111), leading the
|
||||
// paragraph's; the rest wait for a selection.
|
||||
let ranges = type_ranges(s, p, C, |text, a| match step {
|
||||
Step::Kerning => Some((edit::prev_char(text, a), a)),
|
||||
Step::Kerning => edit::caret_kerning_target(text, a).map(|(r, _)| (r.start, r.end)),
|
||||
Step::Leading => {
|
||||
let para = edit::paragraph_at(text, a);
|
||||
Some((para.start, para.end))
|
||||
}
|
||||
_ => None,
|
||||
})?;
|
||||
// At a caret, the kerning stepped: (whether it is the space before the character, its value).
|
||||
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
|
||||
let value = match caret {
|
||||
Some(c) if matches!(step, Step::Kerning) => {
|
||||
let t = text_ref(s, id)?;
|
||||
if edit::caret_kerning_target(&t.plain_text(), c).is_some_and(|(_, before)| before) {
|
||||
Some((true, edit::style_at(&t.runs, a + 1).kerning_before))
|
||||
} else {
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
|
||||
Some((false, effective_kerning(vectorcraft_text::FontDb::global(), &t, &lay, a, b)))
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
caret_kerning.push(value);
|
||||
}
|
||||
@@ -1829,10 +1842,13 @@ fn type_step(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
st.leading = Some(super::typecmd::local_type_value(t, leading * points + inc, (0.1, 5000.0), false, C)?);
|
||||
}
|
||||
Step::Tracking => st.tracking = (st.tracking + inc).clamp(-1000.0, 10000.0),
|
||||
Step::Kerning => {
|
||||
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::Kerning => match caret_kerning.get(range_index).copied().flatten() {
|
||||
Some((true, current)) => st.kerning_before = (current + inc).clamp(-1000.0, 10000.0),
|
||||
caret => {
|
||||
let current = caret.map_or_else(|| st.kerning.unwrap_or(0.0), |(_, v)| v);
|
||||
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)?;
|
||||
}
|
||||
@@ -1849,17 +1865,22 @@ fn type_step(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
/// 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.
|
||||
/// only the kerning there changes, as Alt+←/→ in horizontal type or Alt+↓/↑ in vertical type kerns
|
||||
/// there: of the character before it, or at the start of a line the space before the character
|
||||
/// after it ([`edit::caret_kerning_target`]). The automatic method remains selected.
|
||||
fn reset_tracking(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ranges = type_ranges(s, p, "type.resetTracking", |text, a| Some((edit::prev_char(text, a), a)))?;
|
||||
let ranges = type_ranges(s, p, "type.resetTracking", |text, a| edit::caret_kerning_target(text, a).map(|(r, _)| (r.start, r.end)))?;
|
||||
if !ranges.is_empty() {
|
||||
s.edit("Reset Tracking and Kerning", |d, _| {
|
||||
for &(id, a, b, caret) in &ranges {
|
||||
let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?;
|
||||
edit::style_range(&mut t.runs, a, b, |st| {
|
||||
st.kerning = None;
|
||||
if !caret {
|
||||
let before = caret.and_then(|c| edit::caret_kerning_target(&t.plain_text(), c)).map(|(_, before)| before);
|
||||
edit::style_range(&mut t.runs, a, b, |st| match before {
|
||||
Some(true) => st.kerning_before = 0.0,
|
||||
Some(false) => st.kerning = None,
|
||||
None => {
|
||||
st.kerning = None;
|
||||
st.kerning_before = 0.0;
|
||||
st.tracking = 0.0;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -174,8 +174,10 @@ fn to_attrs<T: Serialize>(v: &T) -> Map<String, Value> {
|
||||
/// from a style instead leaves the text's own value alone.
|
||||
pub fn char_attrs(v: &CharStyle) -> Map<String, Value> {
|
||||
let mut m = to_attrs(v);
|
||||
// A chosen glyph belongs to one character, not to a reusable character style.
|
||||
// A chosen glyph belongs to one character, and the space before a line's first character to
|
||||
// where it stands (#5111), not to a reusable character style.
|
||||
m.remove("glyphAlternate");
|
||||
m.remove("kerningBefore");
|
||||
for (k, x) in [
|
||||
("font_version", json!(v.font_version)),
|
||||
("fontAxes", json!(v.font_axes)),
|
||||
@@ -516,6 +518,12 @@ fn attrs_param(d: &Document, p: &Value, kind: Kind, verb: &str, kept: Option<&Ma
|
||||
if m.contains_key("glyphAlternate") {
|
||||
return Err(bad(&c, "`glyphAlternate` belongs to one character, not to a character style (text.setGlyphAlternate sets it)"));
|
||||
}
|
||||
if m.contains_key("kerningBefore") {
|
||||
return Err(bad(
|
||||
&c,
|
||||
"`kerningBefore` belongs to a line's first character, not to a character style (text.setRangeStyle sets it)",
|
||||
));
|
||||
}
|
||||
let mut st = with_attrs(&CharStyle::default(), m, &c)?;
|
||||
let changed = |key: &str| m.contains_key(key) && kept.and_then(|k| k.get(key)) != m.get(key);
|
||||
for (key, value, lo, hi) in
|
||||
|
||||
@@ -1336,7 +1336,8 @@ fn para_style_redefined_to_the_defaults_updates_its_paragraphs() {
|
||||
|
||||
/// The same for character styles: Auto kerning and the Roman baseline alignment, which the
|
||||
/// run's JSON leaves out, are captured and reach the style's users. A glyph choice belongs to
|
||||
/// one character, so it is not a reusable style attribute.
|
||||
/// one character, and the space before a line's first character to where it stands (#5111), so
|
||||
/// neither is a reusable style attribute.
|
||||
#[test]
|
||||
fn char_style_redefined_to_the_defaults_updates_its_text() {
|
||||
use vectorcraft_doc::CharAlign;
|
||||
@@ -1356,6 +1357,7 @@ fn char_style_redefined_to_the_defaults_updates_its_text() {
|
||||
assert_eq!((kerning(&s), aligns(&s)), ([None, None, Some(60.0)], [CharAlign::RomanBaseline; 3]));
|
||||
let mut reusable = settable::<CharStyle>();
|
||||
reusable.remove("glyphAlternate");
|
||||
reusable.remove("kerningBefore");
|
||||
assert_eq!(defined(&s, 0), reusable, "Redefine captures every reusable attribute");
|
||||
s.execute("charStyle.new", &json!({"name": "Copy", "id": local})).unwrap();
|
||||
assert_eq!(defined(&s, 1), reusable, "New captures every reusable attribute");
|
||||
|
||||
@@ -20,6 +20,7 @@ mod brushlib;
|
||||
mod brushsym;
|
||||
mod build;
|
||||
mod cancel;
|
||||
mod caret_kerning;
|
||||
mod charstroke;
|
||||
pub(super) mod clip;
|
||||
mod clipboard;
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
//! Kerning at the Type tool's caret at the start of a line (#5111): with no character on the line
|
||||
//! before the caret, `type.step`, `type.resetTracking` and the Kerning field set the space before
|
||||
//! the character after it (`kerningBefore`), as one undo step, and the character moves.
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::{CharStyle, NodeId, NodeKind, TextObject};
|
||||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
|
||||
|
||||
use crate::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn text(s: &mut Session, t: &str) -> NodeId {
|
||||
NodeId(s.execute("text.create", &json!({"x": 100, "y": 100, "text": t, "size": 20})).unwrap()["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn obj(s: &Session, id: NodeId) -> TextObject {
|
||||
let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(id).map(|n| &n.kind) else { panic!("not text") };
|
||||
(**t).clone()
|
||||
}
|
||||
|
||||
fn style_at(s: &Session, id: NodeId, byte: usize) -> CharStyle {
|
||||
vectorcraft_text::edit::style_at(&obj(s, id).runs, byte + 1)
|
||||
}
|
||||
|
||||
/// Where glyph `byte` is drawn along its line, in text space.
|
||||
fn drawn_x(s: &Session, id: NodeId, byte: usize) -> f64 {
|
||||
let l = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &obj(s, id));
|
||||
l.glyphs.iter().find(|g| g.byte == byte).unwrap().xf.as_coeffs()[4]
|
||||
}
|
||||
|
||||
/// The Type tool editing `id` (made by [`text`]) with its caret at byte `caret`.
|
||||
fn caret_in(s: &mut Session, id: NodeId, caret: usize) -> ViewInfo {
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("type", v).unwrap();
|
||||
for k in [PointerKind::Down, PointerKind::Up] {
|
||||
s.pointer(&PointerEvent::new(k, 102.0, 95.0), v).unwrap();
|
||||
}
|
||||
assert_eq!(s.tool_options()["editing"], json!(id.0), "editing the text");
|
||||
s.set_tool_option("select", &json!({"start": caret, "end": caret}));
|
||||
v
|
||||
}
|
||||
|
||||
fn step_at(s: &mut Session, id: NodeId, caret: usize, by: i32) -> serde_json::Value {
|
||||
s.execute("type.step", &json!({"id": id.0, "start": caret, "end": caret, "attribute": "kerning", "by": by})).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_step_at_the_start_of_the_text_moves_the_first_character() {
|
||||
let mut s = session();
|
||||
let id = text(&mut s, "AV");
|
||||
let inc = s.prefs.tracking_increment;
|
||||
let (a, v) = (drawn_x(&s, id, 0), drawn_x(&s, id, 1));
|
||||
assert_eq!(step_at(&mut s, id, 0, 1)["ids"], json!([id.0]));
|
||||
let st = style_at(&s, id, 0);
|
||||
assert_eq!((st.kerning_before, st.kerning), (inc, None), "the space before A, not after it");
|
||||
// A step of `inc` thousandths of the 20 pt em moves both letters along the line.
|
||||
let moved = inc / 1000.0 * 20.0;
|
||||
assert!((drawn_x(&s, id, 0) - a - moved).abs() < 1e-6 && (drawn_x(&s, id, 1) - v - moved).abs() < 1e-6);
|
||||
// A negative step goes back towards the line's start, past it.
|
||||
step_at(&mut s, id, 0, -3);
|
||||
assert_eq!(style_at(&s, id, 0).kerning_before, -2.0 * inc);
|
||||
assert!((drawn_x(&s, id, 0) - a + 2.0 * moved).abs() < 1e-6);
|
||||
// One undo step each.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(style_at(&s, id, 0).kerning_before, inc);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(style_at(&s, id, 0).kerning_before, 0.0);
|
||||
assert!((drawn_x(&s, id, 0) - a).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inside_a_line_a_step_still_kerns_the_character_before_the_caret() {
|
||||
let mut s = session();
|
||||
let id = text(&mut s, "AV");
|
||||
step_at(&mut s, id, 1, 1);
|
||||
let (a, v) = (style_at(&s, id, 0), style_at(&s, id, 1));
|
||||
assert_eq!((a.kerning_before, v.kerning_before, v.kerning), (0.0, 0.0, None));
|
||||
assert!(a.kerning.is_some(), "the pair after A");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_start_of_a_paragraph_or_after_a_forced_line_break_kerns_the_character_after_it() {
|
||||
let mut s = session();
|
||||
let inc = s.prefs.tracking_increment;
|
||||
let id = text(&mut s, "One\nTwo");
|
||||
let t = drawn_x(&s, id, 4);
|
||||
step_at(&mut s, id, 4, 1);
|
||||
assert_eq!((style_at(&s, id, 3).kerning, style_at(&s, id, 4).kerning_before), (None, inc), "not the line break's kerning");
|
||||
assert!(drawn_x(&s, id, 4) > t, "T moves");
|
||||
let forced = text(&mut s, "One\u{2028}Two");
|
||||
let after = "One\u{2028}".len();
|
||||
step_at(&mut s, forced, after, 1);
|
||||
assert_eq!((style_at(&s, forced, 3).kerning, style_at(&s, forced, after).kerning_before), (None, inc));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_line_has_nothing_to_kern() {
|
||||
let mut s = session();
|
||||
let id = text(&mut s, "One\n\nTwo");
|
||||
let before = obj(&s, id).runs;
|
||||
assert_eq!(step_at(&mut s, id, 4, 1)["ids"], json!([]));
|
||||
assert_eq!(obj(&s, id).runs, before);
|
||||
// Nor does the end of text after a line break.
|
||||
let end = text(&mut s, "One\n");
|
||||
assert_eq!(step_at(&mut s, end, 4, 1)["ids"], json!([]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_at_the_start_of_a_line_clears_the_space_before_and_a_full_reset_clears_it_too() {
|
||||
let mut s = session();
|
||||
let id = text(&mut s, "AV");
|
||||
s.execute("text.setRangeStyle", &json!({"id": id.0, "kerning": -40, "tracking": 100})).unwrap();
|
||||
step_at(&mut s, id, 0, 2);
|
||||
assert_ne!(style_at(&s, id, 0).kerning_before, 0.0);
|
||||
// At the caret before A only the space before A goes; A keeps its kerning after it.
|
||||
caret_in(&mut s, id, 0);
|
||||
s.execute("type.resetTracking", &json!({})).unwrap();
|
||||
let st = style_at(&s, id, 0);
|
||||
assert_eq!((st.kerning_before, st.kerning, st.tracking), (0.0, Some(-40.0), 100.0));
|
||||
step_at(&mut s, id, 0, 2);
|
||||
s.execute("type.resetTracking", &json!({"ids": [id.0]})).unwrap();
|
||||
let st = style_at(&s, id, 0);
|
||||
assert_eq!((st.kerning_before, st.kerning, st.tracking), (0.0, None, 0.0), "all manual kerning");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_range_style_sets_the_space_before() {
|
||||
let mut s = session();
|
||||
let id = text(&mut s, "AV");
|
||||
s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": 1, "kerningBefore": 50})).unwrap();
|
||||
assert_eq!(style_at(&s, id, 0).kerning_before, 50.0);
|
||||
s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": 1, "kerningBefore": 20000})).unwrap();
|
||||
assert_eq!(style_at(&s, id, 0).kerning_before, 10000.0, "kerning's limits");
|
||||
assert!(s.execute("text.setRangeStyle", &json!({"id": id.0, "kerningBefore": "auto"})).is_err());
|
||||
}
|
||||
|
||||
/// The Type tool: Alt+← at the caret before the first character kerns it, and Alt+→ back.
|
||||
#[test]
|
||||
fn alt_arrows_at_the_caret_before_the_first_character_kern_it() {
|
||||
let mut s = session();
|
||||
let inc = s.prefs.tracking_increment;
|
||||
let id = text(&mut s, "Hello");
|
||||
let v = caret_in(&mut s, id, 0);
|
||||
let alt = Mods { alt: true, ..Mods::default() };
|
||||
s.tool_key(ToolKey::Left, alt, v).unwrap();
|
||||
assert_eq!((style_at(&s, id, 0).kerning_before, style_at(&s, id, 0).kerning), (-inc, None));
|
||||
s.tool_key(ToolKey::Right, alt, v).unwrap();
|
||||
s.tool_key(ToolKey::Right, alt, v).unwrap();
|
||||
assert_eq!(style_at(&s, id, 0).kerning_before, inc);
|
||||
}
|
||||
|
||||
/// The Eyedropper copies how type looks, not where a line starts: a sample from a first character
|
||||
/// with a space before it leaves the target's first character where it was.
|
||||
#[test]
|
||||
fn the_eyedropper_keeps_the_targets_space_before() {
|
||||
let mut s = session();
|
||||
let src = text(&mut s, "Source");
|
||||
s.execute("text.setRangeStyle", &json!({"id": src.0, "start": 0, "end": 1, "kerningBefore": 300, "tracking": 50})).unwrap();
|
||||
let dst = text(&mut s, "Target");
|
||||
s.execute("appearance.copyFrom", &json!({"source": src.0})).unwrap();
|
||||
let st = style_at(&s, dst, 0);
|
||||
assert_eq!((st.tracking, st.kerning_before), (50.0, 0.0), "the tracking goes, the space before doesn't");
|
||||
}
|
||||
|
||||
/// A character style captured from a line's first character leaves its space before out, and a
|
||||
/// style can't be given one: it would space the first character of every paragraph set in it.
|
||||
#[test]
|
||||
fn a_character_style_leaves_the_space_before_out() {
|
||||
let mut s = session();
|
||||
let id = text(&mut s, "Source");
|
||||
s.execute("text.setRangeStyle", &json!({"id": id.0, "kerningBefore": 300, "tracking": 50})).unwrap();
|
||||
s.execute("charStyle.new", &json!({"name": "Body", "id": id.0})).unwrap();
|
||||
let attrs = s.doc().unwrap().doc.char_styles[0].attrs.clone();
|
||||
assert_eq!(attrs.get("tracking"), Some(&json!(50.0)));
|
||||
assert!(!attrs.contains_key("kerningBefore"), "{attrs:?}");
|
||||
let err = s.execute("charStyle.new", &json!({"name": "Spaced", "attrs": {"kerningBefore": 100}})).unwrap_err();
|
||||
assert!(err.to_string().contains("kerningBefore"), "{err}");
|
||||
}
|
||||
@@ -751,6 +751,17 @@ fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) -
|
||||
let x = g.xf.translation().x - offset;
|
||||
(advance, if (x - g.origin.x).abs() > PLACE_TOLERANCE { x } else { g.origin.x })
|
||||
}
|
||||
// The space before a line's first character (`kerningBefore`) is in its advance and
|
||||
// moves it along the line: a reader sets it from where it is drawn, without it.
|
||||
None if !lay.vertical
|
||||
&& !g.rtl
|
||||
&& g.len > 0
|
||||
&& run.style.kerning_before != 0.0
|
||||
&& vectorcraft_text::edit::starts_line(&plain, g.byte) =>
|
||||
{
|
||||
let before = Some(g.xf.translation().x - g.origin.x).filter(|b| b.is_finite()).unwrap_or(0.0);
|
||||
(g.advance - before, g.origin.x + before)
|
||||
}
|
||||
None => (g.advance, g.origin.x),
|
||||
};
|
||||
let src: String = source(line.glyph_start + k, g).chars().filter(|c| *c != '\u{ad}').map(|c| run.style.digits.map(c)).collect();
|
||||
|
||||
@@ -109,6 +109,30 @@ fn rich_text_writes_spacing_shift_and_scale() {
|
||||
assert_eq!(attr(tags[1], "y"), attr(tags[0], "y"));
|
||||
}
|
||||
|
||||
/// The space before a line's first character (`kerningBefore`, #5111) moves the line's first
|
||||
/// `<tspan>` to where the character is drawn, left aligned or anchored at a centred line's centre.
|
||||
#[test]
|
||||
fn the_space_before_a_lines_first_character_moves_its_tspan() {
|
||||
let before = CharStyle { kerning_before: 250.0, ..style(12.0) };
|
||||
// 250/1000 em of 12 pt: 3 pt. The second paragraph's first character takes it too; the "O" after
|
||||
// the first "H" is inside its line, so its value does nothing.
|
||||
let runs = vec![run("HO", before.clone()), run("H\n", style(12.0)), run("H", before.clone()), run("O", style(12.0))];
|
||||
let svg = export(&area_doc(runs, 200.0, Justify::Left), &ExportOptions::default());
|
||||
// The first <tspan> on each baseline.
|
||||
let mut starts: Vec<(f64, f64)> = vec![];
|
||||
for t in tspans(&svg) {
|
||||
let (x, y) = (attr(t, "x").unwrap(), attr(t, "y").unwrap());
|
||||
if starts.last().is_none_or(|&(_, last)| last != y) {
|
||||
starts.push((x, y));
|
||||
}
|
||||
}
|
||||
assert_eq!(starts.iter().map(|s| s.0).collect::<Vec<_>>(), vec![3.0, 3.0], "{svg}");
|
||||
// Centred, the line is 3 pt longer and the anchor sits at the middle of what a reader draws.
|
||||
let svg = export(&area_doc(vec![run("H", before.clone()), run("OH", style(12.0))], 200.0, Justify::Center), &ExportOptions::default());
|
||||
let x = tspans(&svg).iter().find_map(|t| attr(t, "x")).unwrap();
|
||||
assert!((x - 101.5).abs() < 0.01, "{svg}");
|
||||
}
|
||||
|
||||
/// Proportional Metrics (#966) is written as the `palt` feature, beside the character's own.
|
||||
#[test]
|
||||
fn proportional_metrics_write_palt() {
|
||||
|
||||
@@ -214,6 +214,28 @@ pub fn next_char(s: &str, i: usize) -> usize {
|
||||
s[i..].chars().next().map(|c| i + c.len_utf8()).unwrap_or(s.len())
|
||||
}
|
||||
|
||||
/// Does the character at byte `i` of `s` start a line by itself: the first character of the text,
|
||||
/// of a paragraph, or after a forced line break (U+2028)?
|
||||
pub fn starts_line(s: &str, i: usize) -> bool {
|
||||
i == 0 || s.get(..i).is_some_and(|before| before.ends_with(['\n', '\u{2028}']))
|
||||
}
|
||||
|
||||
/// The character whose kerning the Type tool's caret at byte `i` of `s` sets (the Kerning field,
|
||||
/// Alt+arrows, Reset Tracking and Kerning), and whether that kerning is the space before it
|
||||
/// ([`vectorcraft_doc::CharStyle::kerning_before`]) or after it
|
||||
/// ([`vectorcraft_doc::CharStyle::kerning`]). Inside a line it is the space after the character
|
||||
/// before the caret. At the start of a line ([`starts_line`]) no character on the line comes before
|
||||
/// the caret, so it is the space before the character after it (#5111). None at the start of an
|
||||
/// empty line, where no character follows either.
|
||||
pub fn caret_kerning_target(s: &str, i: usize) -> Option<(Range<usize>, bool)> {
|
||||
let i = floor_char(s, i);
|
||||
if !starts_line(s, i) {
|
||||
return Some((prev_char(s, i)..i, false));
|
||||
}
|
||||
let c = s.get(i..)?.chars().next()?;
|
||||
(c != '\n' && c != '\u{2028}').then(|| (i..i + c.len_utf8(), true))
|
||||
}
|
||||
|
||||
/// The start of the grapheme cluster before `i` (Left arrow): a Thai consonant with its vowel and
|
||||
/// tone marks, an accented letter or an emoji sequence is passed over whole.
|
||||
pub fn prev_cluster(s: &str, i: usize) -> usize {
|
||||
|
||||
@@ -51,6 +51,7 @@ pub(crate) fn paint_only(a: &CharStyle, b: &CharStyle) -> bool {
|
||||
leading,
|
||||
tracking,
|
||||
kerning,
|
||||
kerning_before,
|
||||
optical_kerning,
|
||||
baseline_shift,
|
||||
h_scale,
|
||||
@@ -100,6 +101,7 @@ pub(crate) fn paint_only(a: &CharStyle, b: &CharStyle) -> bool {
|
||||
&& leading == &b.leading
|
||||
&& tracking == &b.tracking
|
||||
&& kerning == &b.kerning
|
||||
&& kerning_before == &b.kerning_before
|
||||
&& optical_kerning == &b.optical_kerning
|
||||
&& baseline_shift == &b.baseline_shift
|
||||
&& h_scale == &b.h_scale
|
||||
|
||||
+26
-15
@@ -99,14 +99,17 @@ fn takes_cell(g: &SGlyph) -> bool {
|
||||
///
|
||||
/// It stands in glyph space as it stands in vertical point type's line space: a quarter turn
|
||||
/// counterclockwise from horizontal path type's pose about the center of its cell, a baseline shift
|
||||
/// kept across the column and a leading space taken off moving it along the path, then moved across
|
||||
/// kept across the column and a leading space taken off (or the space before a column's first
|
||||
/// character) moving it along the path, then moved across
|
||||
/// the path as a glyph lying on its side is.
|
||||
fn upright_path_turn(g: &SGlyph, em: f64, pre: Affine, local: Affine, unturned: Affine, angle: f64, advance: f64) -> (Affine, f64) {
|
||||
let cell = if takes_cell(g) { upright_cell(g) } else { advance };
|
||||
let (shift, unshift) = (Affine::translate((0.0, -g.bshift)), Affine::translate((0.0, g.bshift)));
|
||||
let about = Point::new(cell * 0.5, -upright_centre(g, cell, em));
|
||||
let mut turn =
|
||||
Affine::translate((-g.lead, g.face.ideographic_centre() * em)) * shift * Affine::rotate_about(-std::f64::consts::FRAC_PI_2, about) * unshift;
|
||||
let mut turn = Affine::translate((g.kern_shift - g.lead, g.face.ideographic_centre() * em))
|
||||
* shift
|
||||
* Affine::rotate_about(-std::f64::consts::FRAC_PI_2, about)
|
||||
* unshift;
|
||||
// The effects that keep the glyph's axes fixed (Stair Step) leave its top toward the path's end on
|
||||
// a path that runs to the left: it turns half a turn about its cell's center.
|
||||
let tangent = Vec2::new(angle.cos(), angle.sin());
|
||||
@@ -359,19 +362,20 @@ impl Ctx<'_> {
|
||||
} else {
|
||||
self.db.outline(&g.face, g.gid)
|
||||
};
|
||||
// A glyph whose leading space was taken off (mojikumi) is drawn that much earlier: an
|
||||
// upright one in vertical type by moving it up the column once it stands upright.
|
||||
// A glyph whose leading space was taken off (mojikumi) is drawn that much earlier, and one
|
||||
// moved along the line by manual kerning ([`SGlyph::kern_shift`]) that much later: an
|
||||
// upright one in vertical type by moving it along the column once it stands upright.
|
||||
let upright = self.vertical && g.tcy.is_none() && stands_upright(g) && !(self.on_path && g.inline.is_some());
|
||||
let lead = if upright { 0.0 } else { g.lead };
|
||||
let (lead, kern_shift) = if upright { (0.0, 0.0) } else { (g.lead, g.kern_shift) };
|
||||
let local = match &g.inline {
|
||||
// Inline graphics: art space to glyph space (no font outline), moved along the line by
|
||||
// right-to-left manual kerning as a glyph is.
|
||||
Some(ib) => Affine::translate((g.kern_shift - lead, -g.bshift)) * ib.xf,
|
||||
Some(ib) => Affine::translate((kern_shift - lead, -g.bshift)) * ib.xf,
|
||||
// Baseline shift moves the glyph across its line, and Character Rotation then turns it
|
||||
// about its shifted pen position, so the shift is across the line at any rotation.
|
||||
// Manual kerning and composition trim move that pen position along the line.
|
||||
None => {
|
||||
Affine::translate((g.kern_shift - lead, -g.bshift))
|
||||
Affine::translate((kern_shift - lead, -g.bshift))
|
||||
* Affine::rotate(-g.rotation.to_radians())
|
||||
* Affine::translate((g.dx, g.dy))
|
||||
* Affine::scale_non_uniform(g.sx, g.sy)
|
||||
@@ -379,7 +383,7 @@ impl Ctx<'_> {
|
||||
};
|
||||
// The turning point in font units, which later moves of the glyph's transform keep.
|
||||
let pivot = match local.determinant() {
|
||||
det if det != 0.0 && det.is_finite() => local.inverse() * Point::new(g.kern_shift - lead, -g.bshift),
|
||||
det if det != 0.0 && det.is_finite() => local.inverse() * Point::new(kern_shift - lead, -g.bshift),
|
||||
_ => Point::ZERO,
|
||||
};
|
||||
let unturned = if g.inline.is_none() && g.rotation != 0.0 && g.sx != 0.0 && g.sy != 0.0 {
|
||||
@@ -490,9 +494,10 @@ impl Ctx<'_> {
|
||||
// space after the cell, and must not push the glyph off the centre line).
|
||||
let em = self.style_at(g.byte).size;
|
||||
let cell = if takes_cell(g) { upright_cell(g) } else { advance };
|
||||
// A leading space taken off moves it up the column after the turn (moving the
|
||||
// turning point instead would move it across the column too).
|
||||
let turn = Affine::translate((-g.lead, 0.0))
|
||||
// A leading space taken off moves it up the column after the turn, and the space
|
||||
// before a column's first character down it (moving the turning point instead
|
||||
// would move it across the column too).
|
||||
let turn = Affine::translate((g.kern_shift - g.lead, 0.0))
|
||||
* Affine::rotate_about(-std::f64::consts::FRAC_PI_2, Point::new(origin.x + cell * 0.5, origin.y - upright_centre(g, cell, em)));
|
||||
m = shift * turn * unshift * m;
|
||||
// Its own advance across, from where the cell centres it: where the shaper set the
|
||||
@@ -527,10 +532,16 @@ impl Ctx<'_> {
|
||||
};
|
||||
// Where its own width starts along its advance: the shaper's placement, a leading space
|
||||
// taken off and right-to-left manual kerning move the glyph along the line. An upright or
|
||||
// tate-chu-yoko glyph in vertical type starts its cell at the start of its advance.
|
||||
let width_start = if g.inline.is_none() && !upright && !(self.vertical && g.tcy.is_some()) {
|
||||
// tate-chu-yoko glyph in vertical type starts its cell at the start of its advance, an
|
||||
// upright one after the space before a column's first character.
|
||||
let along = {
|
||||
let [a, b, ..] = pre.as_coeffs();
|
||||
Some((g.kern_shift + g.dx - g.lead) * a.hypot(b)).filter(|s| s.is_finite()).unwrap_or(0.0)
|
||||
a.hypot(b)
|
||||
};
|
||||
let width_start = if g.inline.is_none() && !upright && !(self.vertical && g.tcy.is_some()) {
|
||||
Some((g.kern_shift + g.dx - g.lead) * along).filter(|s| s.is_finite()).unwrap_or(0.0)
|
||||
} else if upright {
|
||||
Some(g.kern_shift * along).filter(|s| s.is_finite()).unwrap_or(0.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
@@ -39,7 +39,9 @@ pub(crate) struct SGlyph {
|
||||
pub dy: f64,
|
||||
/// Right to left: the run's manual kerning, which moves the glyph right along the baseline,
|
||||
/// outside its Character Rotation; with whole-point advances, the difference the kerning makes
|
||||
/// to the cluster's rounded advance. 0 otherwise.
|
||||
/// to the cluster's rounded advance. Left to right and in vertical type: the space before a
|
||||
/// line's first character ([`CharStyle::kerning_before`]), which moves it along the line the
|
||||
/// same way. 0 otherwise.
|
||||
pub kern_shift: f64,
|
||||
/// Outline scale (font units -> points).
|
||||
pub sx: f64,
|
||||
@@ -522,6 +524,20 @@ pub(crate) fn shape_runs(
|
||||
}
|
||||
}
|
||||
|
||||
/// [`CharStyle::kerning_before`] of the character at `byte` of `text` in style `st`, in points of
|
||||
/// `spacing_em`: the space before it when it starts a line by itself ([`crate::edit::starts_line`]), else 0. Its
|
||||
/// cluster takes the space in its advance, as manual kerning takes the space after a character,
|
||||
/// and left to right (and in vertical type) its glyphs are drawn that much later on the line
|
||||
/// (#5111). Right to left, the line starts at its right: the space the advance gains is already on
|
||||
/// that side.
|
||||
fn space_before(text: &str, byte: usize, st: &CharStyle, spacing_em: f64) -> f64 {
|
||||
if st.kerning_before == 0.0 || !crate::edit::starts_line(text, byte) || text.get(byte..).is_some_and(|rest| rest.starts_with(['\n', '\u{2028}']))
|
||||
{
|
||||
return 0.0;
|
||||
}
|
||||
Some(st.kerning_before / 1000.0 * spacing_em).filter(|s| s.is_finite()).unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// 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));
|
||||
@@ -836,7 +852,8 @@ fn drawn_alike(a: &CharStyle, b: &CharStyle, vertical: bool) -> bool {
|
||||
/// They do when they differ only in paint and stroke, underline and strikethrough, overprint, the
|
||||
/// character style's name, leading, baseline shift, rotation, Character Alignment, Character
|
||||
/// Direction (text is shaped apart where its bidi level changes), Kashidas, the kerning (each glyph takes its
|
||||
/// run's, see [`shape_segment`]) and the tracking, as long as the tracking keeps or drops standard
|
||||
/// run's, see [`shape_segment`]), the space before a line's first character ([`space_before`])
|
||||
/// and the tracking, as long as the tracking keeps or drops standard
|
||||
/// ligatures alike ([`crate::features::ligatures_suppressed_by`]).
|
||||
fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool {
|
||||
// Every field is named, so that a new attribute has to be sorted into one group or the other.
|
||||
@@ -849,6 +866,7 @@ fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool {
|
||||
leading: _,
|
||||
tracking,
|
||||
kerning: _,
|
||||
kerning_before: _,
|
||||
optical_kerning: _,
|
||||
baseline_shift: _,
|
||||
h_scale,
|
||||
@@ -948,8 +966,9 @@ fn shaping_script(c: char) -> Script {
|
||||
///
|
||||
/// The art is scaled uniformly to `scale` x the font size tall, its left edge on the pen and its
|
||||
/// vertical centre on the middle of the cap height raised by the inline's shift (see
|
||||
/// [`InlineArt`]). Its advance is the scaled art width plus tracking and its character's manual
|
||||
/// kerning, which right to left in horizontal type also moves the art right by the value, as it
|
||||
/// [`InlineArt`]). Its advance is the scaled art width plus tracking, its character's manual
|
||||
/// kerning and the space before it at a line's start ([`space_before`]). Right to left in
|
||||
/// horizontal type the manual kerning also moves the art right by the value, as it
|
||||
/// moves a letter's glyph. The art doesn't take the run's Horizontal Scale; the tracking and the
|
||||
/// kerning do, as after a letter ([`spacing_scale`]). The glyph's ascent and descent grow to the
|
||||
/// art's extent so a tall graphic opens up its line. A missing symbol takes a square of the same
|
||||
@@ -983,7 +1002,8 @@ fn inline_glyph(
|
||||
let spacing_em = size * spacing_scale(st, vertical);
|
||||
let tracking = Some(st.tracking / 1000.0 * spacing_em).filter(|t| t.is_finite()).unwrap_or(0.0);
|
||||
let manual_kern = st.kerning.map(|v| v / 1000.0 * spacing_em).filter(|k| k.is_finite()).unwrap_or(0.0);
|
||||
let adv = width + tracking + manual_kern;
|
||||
let before = space_before(text, range.start, st, spacing_em);
|
||||
let adv = width + tracking + manual_kern + before;
|
||||
SGlyph {
|
||||
face: face.clone(),
|
||||
gid: 0,
|
||||
@@ -994,7 +1014,7 @@ fn inline_glyph(
|
||||
tracking: if adv.is_finite() { tracking } else { 0.0 },
|
||||
dx: 0.0,
|
||||
dy: 0.0,
|
||||
kern_shift: if level.is_rtl() && !vertical { manual_kern } else { 0.0 },
|
||||
kern_shift: if level.is_rtl() && !vertical { manual_kern } else { before },
|
||||
// Not used to draw (the art has its own transform), but they make the glyph's em box
|
||||
// its run's em: Character Alignment and Top-to-Top leading treat an inline graphic as a
|
||||
// character of its run's size, whatever its scale (see `layout::glyph_em`).
|
||||
@@ -1407,15 +1427,19 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
|
||||
adv = 0.0;
|
||||
} else {
|
||||
let manual_kern = gst.kerning.map(|v| v / 1000.0 * spacing_em).unwrap_or(0.0);
|
||||
let before = space_before(text, cl, gst, spacing_em);
|
||||
// Manual kerning spaces a character from the next one in reading order. Right to left,
|
||||
// that one is drawn to its left: the cluster's glyphs move right along the baseline by
|
||||
// the value, so that the space its last glyph's advance gains is at the cluster's left.
|
||||
// The space before a line's first character moves them the other way.
|
||||
if level.is_rtl() && !feats.vertical {
|
||||
kern_shift = manual_kern;
|
||||
} else {
|
||||
kern_shift = before;
|
||||
}
|
||||
if last_in_cluster {
|
||||
tracked = gst.tracking / 1000.0 * spacing_em;
|
||||
adv += tracked + manual_kern;
|
||||
adv += tracked + manual_kern + before;
|
||||
}
|
||||
}
|
||||
out.push(SGlyph {
|
||||
|
||||
@@ -19,6 +19,7 @@ mod inline;
|
||||
mod joiners;
|
||||
mod justify;
|
||||
mod kept_layouts;
|
||||
mod kerning_before;
|
||||
mod ligature_carets;
|
||||
mod path_joins;
|
||||
mod ruby;
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
//! The space before a character that starts a line by itself (`CharStyle::kerning_before`, #5111):
|
||||
//! the first character of the text, of a paragraph or after a forced line break moves along its line
|
||||
//! by the value, the rest of the line follows, and the value does nothing anywhere else.
|
||||
use crate::*;
|
||||
use kurbo::Shape;
|
||||
use vectorcraft_doc::{CharStyle, Justify};
|
||||
|
||||
fn db() -> &'static FontDb {
|
||||
FontDb::global()
|
||||
}
|
||||
|
||||
fn style(size: f64) -> CharStyle {
|
||||
CharStyle { size, ..CharStyle::default() }
|
||||
}
|
||||
|
||||
/// Where glyph `byte` is drawn along a horizontal line.
|
||||
fn drawn_x(l: &TextLayout, byte: usize) -> f64 {
|
||||
l.glyphs.iter().find(|g| g.byte == byte).map(|g| g.xf.as_coeffs()[4]).unwrap()
|
||||
}
|
||||
|
||||
/// `t` with `kerning_before` set on the character at `byte`.
|
||||
fn spaced(t: &TextObject, byte: usize, value: f64) -> TextObject {
|
||||
let mut t = t.clone();
|
||||
let end = edit::next_char(&t.plain_text(), byte);
|
||||
edit::style_range(&mut t.runs, byte, end, |st| st.kerning_before = value);
|
||||
t
|
||||
}
|
||||
|
||||
/// How far each of `bytes` moved along the line from `a` to `b`.
|
||||
fn moves(a: &TextObject, b: &TextObject, bytes: &[usize]) -> Vec<f64> {
|
||||
let (la, lb) = (layout(db(), a), layout(db(), b));
|
||||
bytes.iter().map(|&i| drawn_x(&lb, i) - drawn_x(&la, i)).collect()
|
||||
}
|
||||
|
||||
fn near(values: &[f64], expected: &[f64]) -> bool {
|
||||
values.len() == expected.len() && values.iter().zip(expected).all(|(v, e)| (v - e).abs() < 1e-6)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_character_moves_along_its_line_and_the_rest_follow() {
|
||||
let plain = TextObject::point(Point::ZERO, "HOH", style(100.0));
|
||||
// 250/1000 em of 100 pt: 25 pt, away from the line's start; a negative value pulls it back.
|
||||
let away = moves(&plain, &spaced(&plain, 0, 250.0), &[0, 1, 2]);
|
||||
assert!(near(&away, &[25.0, 25.0, 25.0]), "{away:?}");
|
||||
let back = moves(&plain, &spaced(&plain, 0, -100.0), &[0, 1, 2]);
|
||||
assert!(near(&back, &[-10.0, -10.0, -10.0]), "{back:?}");
|
||||
// The line is that much longer, so a centred line moves its first character half as far.
|
||||
let mut centred = plain.clone();
|
||||
centred.para.justify = Justify::Center;
|
||||
let half = moves(¢red, &spaced(¢red, 0, 250.0), &[0, 2]);
|
||||
assert!(near(&half, &[12.5, 12.5]), "{half:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_does_nothing_on_a_character_inside_a_line() {
|
||||
let plain = TextObject::point(Point::ZERO, "HOH", style(100.0));
|
||||
let inside = moves(&plain, &spaced(&plain, 1, 250.0), &[0, 1, 2]);
|
||||
assert!(near(&inside, &[0.0, 0.0, 0.0]), "{inside:?}");
|
||||
// Nor on the first character of a line that wrapped in area type: the line break moves.
|
||||
let area = super::area("HOH HOH", style(50.0), Rect::new(0.0, 0.0, 180.0, 400.0), Justify::Left);
|
||||
let wrapped = layout(db(), &area);
|
||||
let second_line = wrapped.glyphs.iter().find(|g| g.byte == 4).unwrap().line;
|
||||
assert_eq!(second_line, 1, "the second HOH wraps");
|
||||
let moved = moves(&area, &spaced(&area, 4, 250.0), &[0, 4, 5]);
|
||||
assert!(near(&moved, &[0.0, 0.0, 0.0]), "{moved:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_paragraph_and_a_forced_line_break_start_lines_by_themselves() {
|
||||
let paragraphs = TextObject::point(Point::ZERO, "HO\nHO", style(100.0));
|
||||
let moved = moves(¶graphs, &spaced(¶graphs, 3, 250.0), &[0, 1, 3, 4]);
|
||||
assert!(near(&moved, &[0.0, 0.0, 25.0, 25.0]), "{moved:?}");
|
||||
let forced = TextObject::point(Point::ZERO, "HO\u{2028}HO", style(100.0));
|
||||
let after = "HO\u{2028}".len();
|
||||
let moved = moves(&forced, &spaced(&forced, after, 250.0), &[0, after, after + 1]);
|
||||
assert!(near(&moved, &[0.0, 25.0, 25.0]), "{moved:?}");
|
||||
// On a line break it stays: an empty line has nothing to move.
|
||||
let empty = TextObject::point(Point::ZERO, "HO\n\nHO", style(100.0));
|
||||
let moved = moves(&empty, &spaced(&empty, 3, 250.0), &[0, 4]);
|
||||
assert!(near(&moved, &[0.0, 0.0]), "{moved:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_takes_the_em_of_the_character_as_manual_kerning_does() {
|
||||
// Horizontal Scale 50%: half the space, as kerning after a character takes it.
|
||||
let narrow = TextObject::point(Point::ZERO, "HOH", CharStyle { h_scale: 50.0, ..style(100.0) });
|
||||
let moved = moves(&narrow, &spaced(&narrow, 0, 250.0), &[0, 2]);
|
||||
assert!(near(&moved, &[12.5, 12.5]), "{moved:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_to_left_the_space_is_at_the_right_of_the_first_character() {
|
||||
// The first character in reading order is drawn rightmost; the space before it is on its right,
|
||||
// so the two keep their distance, and the line is 25 pt longer.
|
||||
let plain = TextObject::point(Point::ZERO, "אב", style(100.0));
|
||||
let kerned = spaced(&plain, 0, 250.0);
|
||||
let (a, b) = (layout(db(), &plain), layout(db(), &kerned));
|
||||
assert!(drawn_x(&a, 0) > drawn_x(&a, 2), "right to left");
|
||||
let gap = |l: &TextLayout| drawn_x(l, 0) - drawn_x(l, 2);
|
||||
assert!((gap(&b) - gap(&a)).abs() < 1e-6, "{} {}", gap(&a), gap(&b));
|
||||
let length = |l: &TextLayout| l.lines[0].x1 - l.lines[0].x0;
|
||||
assert!((length(&b) - length(&a) - 25.0).abs() < 1e-6, "{} {}", length(&a), length(&b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_vertical_type_the_first_character_moves_down_its_column() {
|
||||
// An upright glyph (§) and letters lying on their side both move 5 pt (250/1000 em of 20 pt)
|
||||
// down the column, and stay in it.
|
||||
for text in ["§§", "ab"] {
|
||||
let mut plain = TextObject::point(Point::ZERO, text, style(20.0));
|
||||
plain.xf = Affine::IDENTITY;
|
||||
plain.vertical = true;
|
||||
let kerned = spaced(&plain, 0, 250.0);
|
||||
let (a, b) = (layout(db(), &plain), layout(db(), &kerned));
|
||||
for i in 0..2 {
|
||||
let (before, after) = (a.glyphs[i].outline.bounding_box(), b.glyphs[i].outline.bounding_box());
|
||||
assert!((after.center().y - before.center().y - 5.0).abs() < 1e-6, "{text} {i}: {before:?} {after:?}");
|
||||
assert!((after.center().x - before.center().x).abs() < 1e-6, "{text} {i}: {before:?} {after:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -266,8 +266,9 @@ fn char_cmd(app: &mut VectorcraftApp, cmd: &str, p: Value) {
|
||||
|
||||
/// 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.
|
||||
/// (`type.step`), or at the start of a line the space before the character after it, where Auto
|
||||
/// and Optical set no space (#5111); nowhere to set at an empty line's start. Else it is a
|
||||
/// character attribute like the others: the selected range, or all the text.
|
||||
fn set_kerning(app: &mut VectorcraftApp, value: Value) {
|
||||
let Some((id, a, b)) = text_editing(app) else {
|
||||
format(app, json!({"kerning": value}));
|
||||
@@ -278,15 +279,18 @@ fn set_kerning(app: &mut VectorcraftApp, value: Value) {
|
||||
return;
|
||||
}
|
||||
let Some(NodeKind::Text(t)) = app.session.active().and_then(|st| st.doc.node(id)).map(|n| &n.kind) else { return };
|
||||
let start = vectorcraft_text::edit::prev_char(&t.plain_text(), a);
|
||||
if start >= a {
|
||||
return;
|
||||
}
|
||||
let Some((target, before)) = vectorcraft_text::edit::caret_kerning_target(&t.plain_text(), a) else { return };
|
||||
end_typing(app);
|
||||
app.run("text.setRangeStyle", json!({"id": id.0, "start": start, "end": a, "kerning": value})).ok();
|
||||
let params = if before {
|
||||
json!({"id": id.0, "start": target.start, "end": target.end, "kerningBefore": value.as_f64().unwrap_or(0.0)})
|
||||
} else {
|
||||
json!({"id": id.0, "start": target.start, "end": target.end, "kerning": value})
|
||||
};
|
||||
app.run("text.setRangeStyle", params).ok();
|
||||
}
|
||||
|
||||
/// The pair before the caret, in 1/1000 em, using the layout the canvas draws.
|
||||
/// The kerning at the caret, in 1/1000 em, using the layout the canvas draws: the pair before it,
|
||||
/// or at the start of a line the space before the character after it.
|
||||
fn caret_kerning(app: &VectorcraftApp, ui: &Ui) -> Option<(f64, bool)> {
|
||||
let (id, a, b) = text_editing(app)?;
|
||||
if a != b {
|
||||
@@ -294,11 +298,12 @@ fn caret_kerning(app: &VectorcraftApp, ui: &Ui) -> Option<(f64, bool)> {
|
||||
}
|
||||
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 (target, before) = vectorcraft_text::edit::caret_kerning_target(&text, a)?;
|
||||
let start = target.start;
|
||||
let style = vectorcraft_text::edit::style_at(&t.runs, start + 1);
|
||||
if before {
|
||||
return Some((style.kerning_before, false));
|
||||
}
|
||||
if let Some(v) = style.kerning {
|
||||
return Some((v, false));
|
||||
}
|
||||
|
||||
+2
-1
@@ -4773,7 +4773,8 @@ Pen's second anchor at (150, 431), in line with the square's centre.
|
||||
`type.resetTracking` (Cmd+Alt+Q, `press_key {key: "Q", mods: {cmd: true, alt: true}}`) sets tracking to 0 and
|
||||
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,
|
||||
it resets only the kerning there: of the character before it, or at the start of a line (of the text, a paragraph,
|
||||
or after a forced line break) the space before the character after it (`kerningBefore`). `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),
|
||||
`type.highlightTracking` (no default key) and `type.highlightFontName` (Cmd+Alt+Shift+F) show the Character panel and
|
||||
|
||||
File diff suppressed because one or more lines are too long
+2
-1
@@ -1,6 +1,6 @@
|
||||
# Type parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (a centred or right-aligned line of tracked type is aligned without the tracking after its last character, as the page of users' `.ai` files aligns it, #6610; previously minor: tracking and manual kerning in horizontally scaled type are thousandths of the em as the Horizontal Scale draws it, as the page of users' `.ai` files spaces such type; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: Direct Selection shows and drags the direction handles of type on a path's path, #3565; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas base-letter paint; scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: right-aligned area type sets the text after a line's last tab against the right edge; a soft return in justified type no longer keeps its line from being justified; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (kerning at the Type tool's caret at the start of a line sets the space before the character after it, #5111; previously minor: a centred or right-aligned line of tracked type is aligned without the tracking after its last character, as the page of users' `.ai` files aligns it, #6610; previously minor: tracking and manual kerning in horizontally scaled type are thousandths of the em as the Horizontal Scale draws it, as the page of users' `.ai` files spaces such type; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: Direct Selection shows and drags the direction handles of type on a path's path, #3565; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas base-letter paint; scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: right-aligned area type sets the text after a line's last tab against the right edge; a soft return in justified type no longer keeps its line from being justified; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in
|
||||
[target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the
|
||||
@@ -81,6 +81,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-11 | minor | Kerning at the start of a line (#5111): at the Type tool's caret before the first character of the text, of a paragraph or after a forced line break, the Kerning field, Alt+arrows and Reset Tracking and Kerning set the space before the character after the caret (`CharStyle::kerning_before`, `kerningBefore` in `text.setRangeStyle`), which moves it and the rest of its line, in horizontal, right-to-left and vertical type and in SVG export. They did nothing at the text's start and kerned the invisible line break at a paragraph's. Scores and estimates unchanged. |
|
||||
| 2026-11-11 | minor | Aligned lines of tracked type: a centred or right-aligned line is as wide as its characters without the tracking after its last one (`LineInfo::trailing`), so a centred line sits half that tracking right of where it did and a right-aligned line's last glyph ends on its anchor or frame edge, the tracking after it reaching past. The page of users' `.ai` files (used locally, never committed) aligns tracked lines so: matched glyph by glyph with the page, 176 centred lines of point type in 51 files sat half their last character's tracking left of it (median 0.500 of it; 143 within 0.05 pt), 58 right-aligned lines in 5 files the whole tracking (median 1.001), and 182 left-aligned lines where the page has them; in a line whose last character is tracked 150 and the others −34, the offset is half the last character's tracking (0.498 of it). All those lines end a paragraph. The last character is the last the line draws before a paragraph's end, a forced line break or the spaces a wrap leaves (they hang past the line). Spaces or a tab typed before a paragraph's end or a forced line break are the line's last characters, and the letter before them keeps its tracking: in the 2 such lines in the files, the page counts the space and the letter's tracking and leaves out only the space's own (VectorCraft still leaves trailing spaces out of the width). Forced line breaks and wrapped lines of tracked type aren't in the files: they take the rule unverified, so that Convert To Point Type and Artwork Cleanup's Break Text, which turn wraps into paragraphs and forced line breaks, keep the lines where they were. A hyphenated line, one whose last character hangs, justified, vertical and right-to-left lines and type on a path keep the tracking, and so do right, centre and decimal tab stops, which still align the text after them with its last character's tracking. Manual kerning on the last character, line breaking and justification are unchanged (none in the files). The tracking stays in the last glyph's advance (the caret after it and a selection of it take it in), and Fit Headline still counts it, as line breaking does. Right-aligned area type sets the text after its last tab the same way. Convert To Area Type makes the frame end where the lines are aligned, with room for that tracking, so tracked type converted either way stays where it is (it moved by the tracking on each round trip). Affinity import aligns artistic text by the same rule, so centred or right-aligned tracked artistic text moves by half or all of its last character's tracking (unverified against Affinity). Centred and right-aligned tracked type lays out differently than before in every document. Tests `tests::an_aligned_line_leaves_its_last_characters_tracking_out_of_its_width`, `tests::which_lines_leave_the_tracking_after_their_last_character_out`, `tests::an_aligned_line_leaves_out_its_last_tracking_as_glyph_scaling_sets_it`, `tests::the_text_after_the_last_tab_of_right_aligned_type_leaves_its_last_tracking_out`, and the engine's `converting_tracked_type_between_point_and_area_keeps_it_in_place` and `break_tracked_area_type_keeps_its_aligned_lines_in_place`. Scores and estimates unchanged. (#6610) |
|
||||
| 2026-11-11 | minor | Tracking and manual kerning in horizontally scaled type: they are thousandths of the em as the Horizontal Scale draws it (`vectorcraft_text::spacing_scale`), so at 50% a tracking of 100 adds 0.05 em after each character, half as much as at 100%. They were added at the full em whatever the scale, so narrow tracked type drifted further right with each letter. Illustrator's own layout in users' `.ai` files (used locally, not committed) spaces such type so, and PDF scales its character spacing by its horizontal scaling alike (ISO 32000-1 §9.4.4). Inline graphics take it after them too; vertical type keeps the full em, its advances down the column not all taking the Horizontal Scale. The automatic kerning the Character panel reads, Touch Type's limits and Artwork Cleanup's Combine Point Text measure in the same em, and Scale Text Attributes now keeps tracked type where a horizontal stretch of its transform drew it. Type that has both a Horizontal Scale other than 100% and tracking or manual kerning lays out differently than before in every document. Tests `tests::tracking_and_manual_kerning_take_the_horizontal_scale`, `tests::typo::optical_pairs_in_horizontally_scaled_type_measure_without_the_scaled_tracking`, `tests::inline::shift_tracking_and_missing_symbols`, `tests::vertical::vertical_tracking_keeps_the_em_at_any_horizontal_scale` and the engine's `horizontally_scaled_pair_reads_as_the_kerning_that_keeps_it`, `scale_text_attributes_keeps_tracked_type_where_it_was` and `combine_puts_horizontally_scaled_pieces_back_where_they_were`. Scores and estimates unchanged. |
|
||||
| 2026-11-10 | minor | Edit Custom Dictionary (#4936): Edit › Edit Custom Dictionary… adds, deletes and changes the custom dictionary's words, applied on Done; the Check Spelling dialog remains. Scores and estimates unchanged. |
|
||||
|
||||
Reference in New Issue
Block a user