mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 17:56:50 -06:00
G4: No Break keeps marked characters on one line
Squashed from #3398 onto main.
This commit is contained in:
@@ -113,6 +113,12 @@ pub struct CharStyle {
|
||||
/// these characters are drawn in, whatever digits were typed.
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub digits: Digits,
|
||||
/// No Break (Character panel menu): these characters are not broken across lines, including
|
||||
/// at a space or a hyphenation point inside the run. A run longer than the line stays together
|
||||
/// and can overflow it.
|
||||
/// <https://helpx.adobe.com/illustrator/using/hyphenation-line-breaks.html>
|
||||
#[serde(default, rename = "noBreak", skip_serializing_if = "crate::skip::is_default")]
|
||||
pub no_break: bool,
|
||||
}
|
||||
|
||||
/// The numeral system digits are drawn in ([`CharStyle::digits`]): as typed, or every digit of
|
||||
@@ -285,6 +291,7 @@ impl Default for CharStyle {
|
||||
proportional_metrics: false,
|
||||
small_caps: None,
|
||||
digits: Digits::Default,
|
||||
no_break: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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), 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), 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\", 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), 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), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), 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
|
||||
),
|
||||
@@ -308,6 +308,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"leadingModel",
|
||||
"charAlign",
|
||||
"proportionalMetrics",
|
||||
"noBreak",
|
||||
"digits",
|
||||
"fontAxes",
|
||||
"burasagari",
|
||||
@@ -444,6 +445,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(v) = flag("proportionalMetrics") {
|
||||
st.proportional_metrics = v;
|
||||
}
|
||||
if let Some(v) = flag("noBreak") {
|
||||
st.no_break = v;
|
||||
}
|
||||
if let Some(v) = &font_axes {
|
||||
super::textedit::change_font_axes(st, v);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Character",
|
||||
[],
|
||||
None,
|
||||
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), 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}",
|
||||
"{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), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), 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}",
|
||||
has_doc,
|
||||
set_range_style
|
||||
),
|
||||
@@ -254,6 +254,7 @@ pub(crate) struct CharChange {
|
||||
small_caps: Option<Option<f64>>,
|
||||
char_align: Option<vectorcraft_doc::CharAlign>,
|
||||
proportional_metrics: Option<bool>,
|
||||
no_break: Option<bool>,
|
||||
digits: Option<vectorcraft_doc::Digits>,
|
||||
font_axes: Option<AxisChange>,
|
||||
}
|
||||
@@ -412,6 +413,7 @@ impl CharChange {
|
||||
small_caps,
|
||||
char_align: char_align_param(p, cmd)?,
|
||||
proportional_metrics: flag("proportionalMetrics"),
|
||||
no_break: flag("noBreak"),
|
||||
digits: digits_param(p, cmd)?,
|
||||
font_axes: font_axes_param(p, cmd)?,
|
||||
};
|
||||
@@ -453,6 +455,7 @@ impl CharChange {
|
||||
&& self.small_caps.is_none()
|
||||
&& self.char_align.is_none()
|
||||
&& self.proportional_metrics.is_none()
|
||||
&& self.no_break.is_none()
|
||||
&& self.digits.is_none()
|
||||
&& self.font_axes.is_none()
|
||||
}
|
||||
@@ -548,6 +551,9 @@ impl CharChange {
|
||||
if let Some(v) = self.proportional_metrics {
|
||||
st.proportional_metrics = v;
|
||||
}
|
||||
if let Some(v) = self.no_break {
|
||||
st.no_break = v;
|
||||
}
|
||||
if let Some(v) = self.digits {
|
||||
st.digits = v;
|
||||
}
|
||||
|
||||
@@ -156,6 +156,7 @@ pub fn char_attrs(v: &CharStyle) -> Map<String, Value> {
|
||||
("small_caps", json!(v.small_caps)),
|
||||
("charAlign", json!(v.char_align)),
|
||||
("proportionalMetrics", json!(v.proportional_metrics)),
|
||||
("noBreak", json!(v.no_break)),
|
||||
("digits", json!(v.digits)),
|
||||
] {
|
||||
m.insert(k.into(), x);
|
||||
|
||||
@@ -1346,6 +1346,34 @@ mod area_tests {
|
||||
assert!(!serde_json::from_value::<vectorcraft_doc::CharStyle>(old).unwrap().proportional_metrics);
|
||||
}
|
||||
|
||||
/// No Break is a character attribute, saved only when on.
|
||||
#[test]
|
||||
fn no_break_is_a_character_attribute_saved_only_when_on() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab"})).unwrap()["id"].as_u64().unwrap();
|
||||
let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => t.runs.clone(),
|
||||
_ => panic!("text"),
|
||||
};
|
||||
assert!(!runs(&s)[0].style.no_break);
|
||||
assert!(serde_json::to_value(&runs(&s)[0].style).unwrap().get("noBreak").is_none());
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"noBreak": true})).unwrap();
|
||||
assert!(runs(&s).iter().all(|r| r.style.no_break));
|
||||
assert_eq!(serde_json::to_value(&runs(&s)[0].style).unwrap()["noBreak"], true);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(!runs(&s)[0].style.no_break);
|
||||
s.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": 1, "noBreak": true})).unwrap();
|
||||
let on: Vec<_> = runs(&s).iter().map(|r| (r.text.clone(), r.style.no_break)).collect();
|
||||
assert_eq!(on, [("A".to_string(), true), ("b".to_string(), false)]);
|
||||
let old = serde_json::to_value(&runs(&s)[1].style).unwrap();
|
||||
assert!(old.get("noBreak").is_none());
|
||||
assert!(!serde_json::from_value::<vectorcraft_doc::CharStyle>(old).unwrap().no_break);
|
||||
s.execute("text.setFormat", &json!({"noBreak": false})).unwrap();
|
||||
assert!(runs(&s).iter().all(|r| !r.style.no_break));
|
||||
}
|
||||
|
||||
/// Digits (Character panel menu, Middle Eastern options): a character attribute, saved only
|
||||
/// when set; the text keeps the digits that were typed.
|
||||
#[test]
|
||||
|
||||
@@ -1292,6 +1292,19 @@ fn break_line(
|
||||
while g.get(cluster_end).is_some_and(|next| next.byte == gl.byte) {
|
||||
cluster_end += 1;
|
||||
}
|
||||
// No Break: the marked characters stay on one line, including across a space inside the run.
|
||||
if gl.no_break {
|
||||
while let Some(n) = g.get(cluster_end) {
|
||||
if !n.no_break {
|
||||
break;
|
||||
}
|
||||
let b = n.byte;
|
||||
cluster_end += 1;
|
||||
while g.get(cluster_end).is_some_and(|next| next.byte == b) {
|
||||
cluster_end += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let cluster_width: f64 = if gl.ch == '\t' {
|
||||
tab_advance(tabs, 0.0, x_start + x, g.get(cluster_end..).unwrap_or_default()).0
|
||||
} else {
|
||||
@@ -1310,7 +1323,7 @@ fn break_line(
|
||||
}
|
||||
x += cluster_width;
|
||||
let last = &g[cluster_end - 1];
|
||||
if last.break_after() && kinsoku_allows(g, cluster_end - 1, kinsoku) {
|
||||
if last.break_after() && kinsoku_allows(g, cluster_end - 1, kinsoku) && !splits_no_break(g, cluster_end) {
|
||||
if last.is_soft_hyphen() {
|
||||
if x + hyphen_glyph(last).adv <= width + EPS {
|
||||
last_break = Some((cluster_end, true));
|
||||
@@ -1342,8 +1355,10 @@ fn break_line(
|
||||
we += 1;
|
||||
}
|
||||
let x_ws: f64 = g[i..word_start].iter().map(|g| g.adv).sum();
|
||||
let pts: Vec<usize> =
|
||||
hyphen_breaks(text, g, word_start, we, opts).into_iter().filter(|&k| k > 0 && kinsoku_allows(g, k - 1, kinsoku)).collect();
|
||||
let pts: Vec<usize> = hyphen_breaks(text, g, word_start, we, opts)
|
||||
.into_iter()
|
||||
.filter(|&k| k > 0 && kinsoku_allows(g, k - 1, kinsoku) && !splits_no_break(g, k))
|
||||
.collect();
|
||||
for &k in pts.iter().rev() {
|
||||
if k <= j && k > i {
|
||||
let w = x_ws + g[word_start..k].iter().map(|g| g.adv).sum::<f64>() + hyphen_glyph(&g[k - 1]).adv;
|
||||
@@ -1443,6 +1458,11 @@ fn optical_hangs_rtl(line: &[(&SGlyph, f64)], share: fn(&SGlyph) -> f64) -> (f64
|
||||
(left, right, left_glyphs, right_glyphs)
|
||||
}
|
||||
|
||||
/// Would a line ending at glyph index `end` split a No Break run?
|
||||
fn splits_no_break(g: &[SGlyph], end: usize) -> bool {
|
||||
end > 0 && g.get(end - 1).is_some_and(|a| a.no_break) && g.get(end).is_some_and(|b| b.no_break)
|
||||
}
|
||||
|
||||
/// Does kinsoku allow a line break after glyph `j`? Not after an opening bracket, nor before a
|
||||
/// closing one, a comma, a full stop, a small kana… (the character is pushed to the next line
|
||||
/// with the one before it).
|
||||
@@ -1500,6 +1520,7 @@ mod wrapping_tests {
|
||||
proportional: false,
|
||||
vpal: None,
|
||||
level: unicode_bidi::Level::ltr(),
|
||||
no_break: false,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -1580,6 +1601,38 @@ mod wrapping_tests {
|
||||
let next_cluster_byte = shaped.get(first_end).map(|g| g.byte);
|
||||
assert_eq!(result.lines.first().map(|line| line.end), next_cluster_byte);
|
||||
}
|
||||
|
||||
/// No Break keeps the marked characters on one line, including across a space, and moves a
|
||||
/// marked run that does not fit to the next line whole.
|
||||
#[test]
|
||||
fn no_break_keeps_marked_characters_on_one_line() {
|
||||
let text = "A B";
|
||||
let plain = synthetic_glyphs(&[(0, 1, 10.0, 'A'), (1, 1, 4.0, ' '), (2, 1, 10.0, 'B')]);
|
||||
let (end, hyphen) = break_line(text, &plain, 0, 16.0, &[], 0.0, None, Burasagari::None, Kinsoku::None);
|
||||
assert!(!hyphen);
|
||||
assert_eq!(end, 2, "without the mark the line ends after the space");
|
||||
|
||||
let mut marked = plain;
|
||||
for g in &mut marked {
|
||||
g.no_break = true;
|
||||
}
|
||||
let (end, hyphen) = break_line(text, &marked, 0, 16.0, &[], 0.0, None, Burasagari::None, Kinsoku::None);
|
||||
assert!(!hyphen);
|
||||
assert_eq!(end, 3, "the marked words stay together even though they are wider than the line");
|
||||
|
||||
// 日 and 本 may break between them. Marked, they move together when they do not fit after "A ".
|
||||
let text = "A 日本";
|
||||
let mut glyphs = synthetic_glyphs(&[(0, 1, 8.0, 'A'), (1, 1, 2.0, ' '), (2, 3, 10.0, '日'), (5, 3, 10.0, '本')]);
|
||||
glyphs[2].no_break = true;
|
||||
glyphs[3].no_break = true;
|
||||
let (end, _) = break_line(text, &glyphs, 0, 22.0, &[], 0.0, None, Burasagari::None, Kinsoku::None);
|
||||
assert_eq!(end, 2, "the marked pair moves to the next line together");
|
||||
let (end, _) = break_line(text, &glyphs, end, 15.0, &[], 0.0, None, Burasagari::None, Kinsoku::None);
|
||||
assert_eq!(end, 4, "on its own line the pair stays together even when it is wider than the line");
|
||||
|
||||
let breaks = candidates(text, &glyphs, None, Kinsoku::None);
|
||||
assert!(breaks.iter().all(|b| b.end != 3), "no break between the marked characters: {breaks:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1660,12 +1713,14 @@ fn candidates(text: &str, g: &[SGlyph], hyphenate: Option<&HyphenationOptions>,
|
||||
if let Some(opts) = hyphenate
|
||||
&& j > ws
|
||||
{
|
||||
for k in hyphen_breaks(text, g, ws, j, opts).into_iter().filter(|&k| k > 0 && kinsoku_allows(g, k - 1, kinsoku)) {
|
||||
for k in
|
||||
hyphen_breaks(text, g, ws, j, opts).into_iter().filter(|&k| k > 0 && kinsoku_allows(g, k - 1, kinsoku) && !splits_no_break(g, k))
|
||||
{
|
||||
v.push(Breakpoint { end: k, hyphen: hyphen_glyph(&g[k - 1]).adv });
|
||||
}
|
||||
}
|
||||
let hy = if gl.is_soft_hyphen() { hyphen_glyph(gl).adv } else { 0.0 };
|
||||
if j + 1 < g.len() && g[j + 1].byte != gl.byte && !g[j + 1].is_space() && kinsoku_allows(g, j, kinsoku) {
|
||||
if j + 1 < g.len() && g[j + 1].byte != gl.byte && !g[j + 1].is_space() && kinsoku_allows(g, j, kinsoku) && !splits_no_break(g, j + 1) {
|
||||
v.push(Breakpoint { end: j + 1, hyphen: hy });
|
||||
}
|
||||
ws = j + 1;
|
||||
@@ -1674,7 +1729,9 @@ fn candidates(text: &str, g: &[SGlyph], hyphenate: Option<&HyphenationOptions>,
|
||||
if let Some(opts) = hyphenate
|
||||
&& g.len() > ws
|
||||
{
|
||||
for k in hyphen_breaks(text, g, ws, g.len(), opts).into_iter().filter(|&k| k > 0 && kinsoku_allows(g, k - 1, kinsoku)) {
|
||||
for k in
|
||||
hyphen_breaks(text, g, ws, g.len(), opts).into_iter().filter(|&k| k > 0 && kinsoku_allows(g, k - 1, kinsoku) && !splits_no_break(g, k))
|
||||
{
|
||||
v.push(Breakpoint { end: k, hyphen: hyphen_glyph(&g[k - 1]).adv });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,9 @@ pub(crate) struct SGlyph {
|
||||
/// A word of a script written without spaces (Thai, Lao, Khmer, Myanmar) ends after the
|
||||
/// glyph's cluster, so a line may break there ([`mark_word_ends`]).
|
||||
pub word_end: bool,
|
||||
/// No Break (Character panel menu): keep this glyph on the same line as the neighbouring
|
||||
/// glyphs that have it.
|
||||
pub no_break: bool,
|
||||
}
|
||||
|
||||
/// How `vpal` and the vertical kerning with it move an upright glyph in vertical type and change
|
||||
@@ -468,6 +471,7 @@ fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool {
|
||||
char_align: _,
|
||||
proportional_metrics,
|
||||
digits,
|
||||
no_break,
|
||||
} = a;
|
||||
*font_family == b.font_family
|
||||
&& *font_style == b.font_style
|
||||
@@ -482,6 +486,7 @@ fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool {
|
||||
&& *small_caps == b.small_caps
|
||||
&& *proportional_metrics == b.proportional_metrics
|
||||
&& *digits == b.digits
|
||||
&& *no_break == b.no_break
|
||||
&& ligatures_suppressed_by(*tracking) == ligatures_suppressed_by(b.tracking)
|
||||
}
|
||||
|
||||
@@ -550,6 +555,7 @@ fn inline_glyph(face: &Arc<FontFace>, st: &CharStyle, art: &InlineArt, run: usiz
|
||||
level,
|
||||
inline: Some(InlineBox { art: drawn, xf }),
|
||||
word_end: false,
|
||||
no_break: st.no_break,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -809,6 +815,7 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
|
||||
level: *level,
|
||||
inline: None,
|
||||
word_end: false,
|
||||
no_break: gst.no_break,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1272,3 +1272,20 @@ fn thai_loanwords_stay_whole() {
|
||||
let word = text.find("โปรแกรม").unwrap();
|
||||
assert!(l.lines.iter().all(|ln| ln.end <= word || ln.end >= word + "โปรแกรม".len()), "{lines:?}");
|
||||
}
|
||||
|
||||
/// No Break (Character panel menu) keeps the marked words on one line, where the same text wraps
|
||||
/// at the space without the mark.
|
||||
#[test]
|
||||
fn no_break_keeps_marked_words_on_one_line() {
|
||||
let plain = style(20.0);
|
||||
let marked = CharStyle { no_break: true, ..plain.clone() };
|
||||
let hello: f64 = layout(db(), &point("hello ", plain.clone())).glyphs.iter().map(|g| g.advance).sum();
|
||||
let whole: f64 = layout(db(), &point("hello world", plain.clone())).glyphs.iter().map(|g| g.advance).sum();
|
||||
assert!(whole > hello + 1.0, "the second word takes room");
|
||||
let frame = Rect::new(0.0, 0.0, hello + 1.0, 200.0);
|
||||
let wrapped = layout(db(), &area("hello world", plain, frame, Justify::Left));
|
||||
assert!(wrapped.lines.len() > 1, "it wraps at the space");
|
||||
let kept = layout(db(), &area("hello world", marked, frame, Justify::Left));
|
||||
assert_eq!(kept.lines.len(), 1, "the marked words stay together");
|
||||
assert_eq!(kept.lines[0].end, "hello world".len());
|
||||
}
|
||||
|
||||
@@ -792,7 +792,12 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
});
|
||||
ui.separator();
|
||||
}
|
||||
for l in ["Standard Vertical Roman Alignment", "Tate-chu-yoko", "Fractional Widths", "System Layout", "No Break"] {
|
||||
// No Break: the selected characters stay on one line.
|
||||
let no_break = s.as_ref().is_some_and(|s| s.no_break);
|
||||
if menu_item(ui, tl!("No Break"), has, no_break) {
|
||||
format(app, json!({"noBreak": !no_break}));
|
||||
}
|
||||
for l in ["Standard Vertical Roman Alignment", "Tate-chu-yoko", "Fractional Widths", "System Layout"] {
|
||||
menu_item(ui, tl!(l), false, l == "Fractional Widths");
|
||||
}
|
||||
ui.separator();
|
||||
@@ -811,8 +816,8 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
/// Reset Panel: the characters the panel acts on ([`char_cmd`]) take tracking 0, Auto leading and
|
||||
/// kerning, no baseline shift, 100% scales, no rotation, no underline or strikethrough, All Caps
|
||||
/// and Small Caps off, the normal position (neither superscript nor subscript), Roman Baseline
|
||||
/// character alignment, Proportional Metrics off and the Default digits, whether or not Show
|
||||
/// East Asian Options and Show Indic Options are on.
|
||||
/// character alignment, Proportional Metrics and No Break off and the Default digits, whether
|
||||
/// or not Show East Asian Options and Show Indic Options are on.
|
||||
fn reset_panel(app: &mut VectorcraftApp) {
|
||||
let d = CharStyle::default();
|
||||
style(app, json!({"tracking": 0, "leading": "auto"}));
|
||||
@@ -821,7 +826,7 @@ fn reset_panel(app: &mut VectorcraftApp) {
|
||||
json!({
|
||||
"kerning": "auto", "baselineShift": 0, "hScale": 100, "vScale": 100, "rotation": 0, "underline": false, "strikethrough": false,
|
||||
"allCaps": false, "smallCaps": d.small_caps.is_some(), "position": d.position.id(), "charAlign": d.char_align,
|
||||
"proportionalMetrics": d.proportional_metrics, "digits": d.digits
|
||||
"proportionalMetrics": d.proportional_metrics, "noBreak": d.no_break, "digits": d.digits
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -1168,10 +1173,37 @@ mod tests {
|
||||
assert!(shown(&mut app));
|
||||
}
|
||||
|
||||
/// No Break is in the panel menu, and choosing it marks the selected characters.
|
||||
#[test]
|
||||
fn no_break_menu_item_toggles_the_selected_characters() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
|
||||
let id = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "Hello"})).unwrap()["id"].as_u64().unwrap();
|
||||
app.session.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
let painted = |app: &mut VectorcraftApp| crate::tests_labels::painted_text(app, menu);
|
||||
assert!(painted(&mut app).contains("No Break"));
|
||||
assert!(!painted(&mut app).contains("✓ No Break"));
|
||||
let on = |app: &VectorcraftApp| match &app.session.active().unwrap().doc.node(NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => t.runs.iter().all(|r| r.style.no_break),
|
||||
_ => panic!("text"),
|
||||
};
|
||||
assert!(!on(&app));
|
||||
let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], menu);
|
||||
let at = texts.iter().find(|(t, _)| t.trim_start_matches(['✓', ' ']) == "No Break").unwrap_or_else(|| panic!("{texts:?}")).1;
|
||||
super::super::tests_appearance::click(&ctx, &mut app, at.center(), menu);
|
||||
assert!(on(&app));
|
||||
assert!(painted(&mut app).contains("✓ No Break"));
|
||||
let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], menu);
|
||||
let at = texts.iter().find(|(t, _)| t.trim_start_matches(['✓', ' ']) == "No Break").unwrap().1;
|
||||
super::super::tests_appearance::click(&ctx, &mut app, at.center(), menu);
|
||||
assert!(!on(&app));
|
||||
}
|
||||
|
||||
/// Reset Panel sets every character attribute the panel shows, except the font family, style
|
||||
/// and size, back to its default (Small Caps, the position, Character Alignment, Proportional
|
||||
/// Metrics and Digits included), in the selected type and in the text the Type tool has
|
||||
/// selected while it edits.
|
||||
/// Metrics, No Break and Digits included), in the selected type and in the text the Type tool
|
||||
/// has selected while it edits.
|
||||
#[test]
|
||||
fn reset_panel_resets_every_attribute_but_the_font() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
@@ -1197,6 +1229,7 @@ mod tests {
|
||||
s.position != d.position,
|
||||
s.char_align != d.char_align,
|
||||
s.proportional_metrics != d.proportional_metrics,
|
||||
s.no_break != d.no_break,
|
||||
s.digits != d.digits,
|
||||
]
|
||||
};
|
||||
@@ -1206,16 +1239,16 @@ mod tests {
|
||||
json!({
|
||||
"id": id, "tracking": 50, "leading": 30, "kerning": 20, "baselineShift": 3, "hScale": 120, "vScale": 90, "rotation": 15,
|
||||
"underline": true, "strikethrough": true, "allCaps": true, "smallCaps": true, "position": "superscript",
|
||||
"charAlign": "emBoxCenter", "proportionalMetrics": true, "digits": "hindi"
|
||||
"charAlign": "emBoxCenter", "proportionalMetrics": true, "noBreak": true, "digits": "hindi"
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
};
|
||||
let plain = runs(&app)[0].1.clone();
|
||||
assert_eq!(differs(&plain, &CharStyle::default()), [false; 15]);
|
||||
assert_eq!(differs(&plain, &CharStyle::default()), [false; 16]);
|
||||
custom(&mut app);
|
||||
let styled = runs(&app)[0].1.clone();
|
||||
assert_eq!(differs(&styled, &CharStyle::default()), [true; 15]);
|
||||
assert_eq!(differs(&styled, &CharStyle::default()), [true; 16]);
|
||||
app.run("select.set", json!({"ids": [id]})).unwrap();
|
||||
reset_panel(&mut app);
|
||||
assert_eq!(runs(&app), [("One 12".to_string(), plain.clone())]);
|
||||
|
||||
File diff suppressed because one or more lines are too long
+3
-1
@@ -1,6 +1,6 @@
|
||||
# Type parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (a click on the out port threads the text into a closed path, a new frame at the click or a dragged rectangle, #2572 in part; scores unchanged; native family labels fall back to localized legacy names when typographic names are English-only, #2208; scores unchanged; Cmd+Shift+C aligns centre off the Mac, #1940; tab leaders are drawn in the tab's gap, #1877; type in symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back on opening a native file, #1738; score unchanged; Escape while typing ends the edit, #1686; Select › Same on type, #1737; `justify` when creating point type, #1748; scores unchanged; OpenType stylistic sets, alternates, figure styles, superscript and subscript as character features, #1607; a wide word in non-rectangular area type moves down, #1615; scores unchanged; Character Styles New/Redefine capture the first selected character at run boundaries and end prior typing before capture; boundary and typing-history input tests and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Linux font folders from fontconfig, #1529; commands and MCP `add_text` report area type that does not fit its frame, #1471; Tabs panel stop drags, #1458; Fit Headline in a frame one line tall, #1444; frames and paths beyond a billion points hold no text, #1451; scores unchanged; missing glyphs are looked for again after a font rescan, #1376; scores unchanged; Full-name font matching (#1258); Windows font-service QA pending; scores unchanged; Middle Eastern digit types: Character panel menu › Digits, `{digits}`; the font menu's script filter lists Thai fonts; Thai, Lao, Khmer and Myanmar lines break between words; the caret steps over whole clusters such as Thai consonants with their marks; Thai typed in a font without Thai falls back to a font made for Thai; earlier: Proportional Metrics in vertical type, `vpal`, #966; Cmd+Shift+C aligns centre off the Mac, a double-click on area type's bottom handle turns on Auto Size, #1940; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged. tab leaders are drawn in the tab's gap, #1877; type in symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back on opening a native file, #1738; score unchanged; Escape while typing ends the edit, #1686; Select › Same on type, #1737; `justify` when creating point type, #1748; scores unchanged; OpenType stylistic sets, alternates, figure styles, superscript and subscript as character features, #1607; a wide word in non-rectangular area type moves down, #1615; scores unchanged; Character Styles New/Redefine capture the first selected character at run boundaries and end prior typing before capture; boundary and typing-history input tests and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Linux font folders from fontconfig, #1529; commands and MCP `add_text` report area type that does not fit its frame, #1471; Tabs panel stop drags, #1458; Fit Headline in a frame one line tall, #1444; frames and paths beyond a billion points hold no text, #1451; scores unchanged; missing glyphs are looked for again after a font rescan, #1376; scores unchanged; Full-name font matching (#1258); Windows font-service QA pending; scores unchanged; Middle Eastern digit types: Character panel menu › Digits, `{digits}`; the font menu's script filter lists Thai fonts; Thai, Lao, Khmer and Myanmar lines break between words; the caret steps over whole clusters such as Thai consonants with their marks; Thai typed in a font without Thai falls back to a font made for Thai; earlier: Proportional Metrics in vertical type, `vpal`, #966) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (No Break keeps marked characters on one line; scores unchanged; a click on the out port threads the text into a closed path, a new frame at the click or a dragged rectangle, #2572 in part; scores unchanged; native family labels fall back to localized legacy names when typographic names are English-only, #2208; scores unchanged; Cmd+Shift+C aligns centre off the Mac, #1940; tab leaders are drawn in the tab's gap, #1877; type in symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back on opening a native file, #1738; score unchanged; Escape while typing ends the edit, #1686; Select › Same on type, #1737; `justify` when creating point type, #1748; scores unchanged; OpenType stylistic sets, alternates, figure styles, superscript and subscript as character features, #1607; a wide word in non-rectangular area type moves down, #1615; scores unchanged; Character Styles New/Redefine capture the first selected character at run boundaries and end prior typing before capture; boundary and typing-history input tests and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Linux font folders from fontconfig, #1529; commands and MCP `add_text` report area type that does not fit its frame, #1471; Tabs panel stop drags, #1458; Fit Headline in a frame one line tall, #1444; frames and paths beyond a billion points hold no text, #1451; scores unchanged; missing glyphs are looked for again after a font rescan, #1376; scores unchanged; Full-name font matching (#1258); Windows font-service QA pending; scores unchanged; Middle Eastern digit types: Character panel menu › Digits, `{digits}`; the font menu's script filter lists Thai fonts; Thai, Lao, Khmer and Myanmar lines break between words; the caret steps over whole clusters such as Thai consonants with their marks; Thai typed in a font without Thai falls back to a font made for Thai; earlier: Proportional Metrics in vertical type, `vpal`, #966; Cmd+Shift+C aligns centre off the Mac, a double-click on area type's bottom handle turns on Auto Size, #1940; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged. tab leaders are drawn in the tab's gap, #1877; type in symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back on opening a native file, #1738; score unchanged; Escape while typing ends the edit, #1686; Select › Same on type, #1737; `justify` when creating point type, #1748; scores unchanged; OpenType stylistic sets, alternates, figure styles, superscript and subscript as character features, #1607; a wide word in non-rectangular area type moves down, #1615; scores unchanged; Character Styles New/Redefine capture the first selected character at run boundaries and end prior typing before capture; boundary and typing-history input tests and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Linux font folders from fontconfig, #1529; commands and MCP `add_text` report area type that does not fit its frame, #1471; Tabs panel stop drags, #1458; Fit Headline in a frame one line tall, #1444; frames and paths beyond a billion points hold no text, #1451; scores unchanged; missing glyphs are looked for again after a font rescan, #1376; scores unchanged; Full-name font matching (#1258); Windows font-service QA pending; scores unchanged; Middle Eastern digit types: Character panel menu › Digits, `{digits}`; the font menu's script filter lists Thai fonts; Thai, Lao, Khmer and Myanmar lines break between words; the caret steps over whole clusters such as Thai consonants with their marks; Thai typed in a font without Thai falls back to a font made for Thai; earlier: Proportional Metrics in vertical type, `vpal`, #966) · **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
|
||||
@@ -53,6 +53,7 @@ against Illustrator's public documentation; **estimated** unless noted.
|
||||
| CJK: input methods (marked text, candidate window) | done | |
|
||||
| CJK: kinsoku (Hard, Soft, None), Line-end Punctuation Half Width, tate-chu-yoko (auto) | done / partial | custom kinsoku sets, the Mojikumi Settings dialog and the other mojikumi sets missing |
|
||||
| CJK: Proportional Metrics (`palt` in horizontal type, `vpal` in vertical type) | done | #966 |
|
||||
| No Break | done | Character panel menu (`text.setFormat` / `text.setRangeStyle {noBreak}`): the marked characters stay on one line, including across a space; a run that does not fit after earlier text moves together, and a run longer than the line stays together. Evidence: `layout::wrapping_tests::no_break_keeps_marked_characters_on_one_line`, `tests::no_break_keeps_marked_words_on_one_line`, `typecmd::no_break_is_a_character_attribute_saved_only_when_on` |
|
||||
| CJK: ruby, manual tate-chu-yoko | missing | #633 |
|
||||
| Inline symbols in text | beyond Illustrator | not scored |
|
||||
| Align Stroke (inside, outside) on live type's character stroke | beyond Illustrator | not scored; `stroke.set {align}` and `text.setRangeStyle {strokeOptions: {align}}`; saved natively, drawn around the glyphs on the canvas and in PDF, EPS and SVG (live text, stroke on the glyph outlines); visual bounds grow with it (#2294) |
|
||||
@@ -63,6 +64,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-21 | minor | No Break: marked characters stay on one line; scores unchanged |
|
||||
| 2026-10-17 | minor | Roman Hanging Punctuation (#2756): per-paragraph hanging of Latin punctuation outside the margin; scores unchanged |
|
||||
| 2026-10-17 | minor | Justification… (#2755): word and letter spacing, glyph scaling, Auto Leading and Single Word Justification per paragraph; the default word spacing range is 80/100/133%, so justified lines break differently than with the old fixed 80–150%; scores unchanged |
|
||||
| 2026-10-17 | minor | A click on area type's red out port loads the Selection tool with the overflow text; the next click threads it into a closed path or a new frame of the same shape there, a drag into a new rectangle (`text.thread.add`, #2572 in part). The out port moved up the frame's right side, clear of the corner handle. Scores and hours unchanged. |
|
||||
|
||||
Reference in New Issue
Block a user