Paragraph: the Hyphenation dialog sets each paragraph's hyphenation limits

The Paragraph panel menu's Hyphenation… was always disabled, and
automatic hyphenation used fixed limits: words of 6 letters or more,
3 letters on each side of the hyphen, any number of hyphenated lines
in a row, and capitalized words hyphenated.

Each paragraph now has hyphenation options: Words Longer Than, After
First, Before Last, Hyphen Limit, Hyphenation Zone and Hyphenate
Capitalized Words. The defaults are the fixed limits, and documents
keep their line breaks. The options are saved only when they differ
from the defaults. text.setFormat takes wordsLongerThan, afterFirst,
beforeLast, hyphenLimit, hyphenationZone and hyphenateCapitalized, and
paragraph styles carry the options. Hyphen Limit applies with both
composers. Hyphenation Zone applies with the Single-line Composer: a
line whose text before the next word already ends within the zone
breaks before that word instead of hyphenating it.

Hyphenation… is enabled with type selected and opens a Hyphenation
dialog (ui.hyphenation) on the values the Paragraph panel shows. OK
runs text.setFormat with the values that changed, as one undo step.

Docs: docs/mcp.md, docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-10-16 15:27:55 +07:00
committed by bflatastic
parent b795d15d69
commit 24583effe0
33 changed files with 779 additions and 47 deletions
+3 -3
View File
@@ -112,9 +112,9 @@ pub use setup::{Background, DocSetup, ExportText, GridSize, Quotes};
pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOptions, SliceSource};
pub use style_libs::StyleLibrary;
pub use text::{
AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, InlineArt, Justify, Kinsoku, LeadingModel,
Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, ScriptMetrics, TabAlign, TabStop, TextKind, TextObject, TextRun, TextStyleDef,
TextWrap, VerticalAlign, WrapShape,
AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, HyphenationOptions, InlineArt, Justify,
Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, ScriptMetrics, TabAlign, TabStop, TextKind, TextObject,
TextRun, TextStyleDef, TextWrap, VerticalAlign, WrapShape,
};
pub use trace::TraceView;
pub use variables::{DataSet, DataValue, Variable, VariableKind, Variables};
+39
View File
@@ -381,6 +381,37 @@ impl Composer {
}
}
/// A paragraph's hyphenation options (Paragraph panel menu › Hyphenation…): which words automatic
/// hyphenation may break, where, and on how many lines in a row. Saved only when they differ from
/// the defaults: words longer than 5 letters, 3 letters on each side of the hyphen, no hyphen
/// limit, no hyphenation zone, and capitalized words hyphenated.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct HyphenationOptions {
/// Words Longer Than: only words with more letters than this are hyphenated.
pub words_longer_than: u32,
/// After First: the fewest letters a word keeps before the hyphen.
pub after_first: u32,
/// Before Last: the fewest letters a word keeps after the hyphen.
pub before_last: u32,
/// Hyphen Limit: the most consecutive lines that automatic hyphenation may end with a hyphen;
/// 0 sets no limit.
pub hyphen_limit: u32,
/// Hyphenation Zone (pt): with the Single-line Composer, a line whose text before the next
/// word already ends within this distance of its right edge breaks before that word instead
/// of hyphenating it. 0 allows every hyphenation.
pub zone: f64,
/// Hyphenate Capitalized Words: when it is off, words that start with a capital letter are not
/// hyphenated. Words in capitals only are never hyphenated.
pub capitalized: bool,
}
impl Default for HyphenationOptions {
fn default() -> Self {
Self { words_longer_than: 5, after_first: 3, before_last: 3, hyphen_limit: 0, zone: 0.0, capitalized: true }
}
}
/// Paragraph attributes (the Paragraph panel).
///
/// Saved through [`ParaStyleFile`], which keeps files with text openable by builds from before
@@ -395,6 +426,8 @@ pub struct ParaStyle {
pub space_before: f64,
pub space_after: f64,
pub hyphenate: bool,
/// The limits automatic hyphenation keeps to (Paragraph panel menu › Hyphenation…).
pub hyphenation: HyphenationOptions,
/// Line breaking: Single-line or Every-line Composer.
pub composer: Composer,
/// Tab stops (Tabs panel), sorted by position.
@@ -442,6 +475,8 @@ struct ParaStyleFile {
space_after: f64,
#[serde(default)]
hyphenate: bool,
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
hyphenation: HyphenationOptions,
#[serde(default, skip_serializing_if = "Composer::is_default")]
composer: Composer,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
@@ -475,6 +510,7 @@ impl From<ParaStyle> for ParaStyleFile {
space_before,
space_after,
hyphenate,
hyphenation,
composer,
tabs,
style_name,
@@ -494,6 +530,7 @@ impl From<ParaStyle> for ParaStyleFile {
space_before,
space_after,
hyphenate,
hyphenation,
composer,
tabs,
style_name,
@@ -516,6 +553,7 @@ impl From<ParaStyleFile> for ParaStyle {
space_before,
space_after,
hyphenate,
hyphenation,
composer,
tabs,
style_name,
@@ -534,6 +572,7 @@ impl From<ParaStyleFile> for ParaStyle {
space_before,
space_after,
hyphenate,
hyphenation,
composer,
tabs,
style_name,
+26 -1
View File
@@ -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, 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), 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\"), 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), 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), 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\"), 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
),
@@ -210,6 +210,12 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
"spaceBefore",
"spaceAfter",
"hyphenate",
"wordsLongerThan",
"afterFirst",
"beforeLast",
"hyphenLimit",
"hyphenationZone",
"hyphenateCapitalized",
"mojikumi",
"kinsoku",
"direction",
@@ -346,6 +352,25 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(v) = flag("hyphenate") {
para.hyphenate = v;
}
let letters = |k: &str, lo: f64, hi: f64| num(k).map(|v| v.round().clamp(lo, hi) as u32);
if let Some(v) = letters("wordsLongerThan", 1.0, 25.0) {
para.hyphenation.words_longer_than = v;
}
if let Some(v) = letters("afterFirst", 1.0, 15.0) {
para.hyphenation.after_first = v;
}
if let Some(v) = letters("beforeLast", 1.0, 15.0) {
para.hyphenation.before_last = v;
}
if let Some(v) = letters("hyphenLimit", 0.0, 25.0) {
para.hyphenation.hyphen_limit = v;
}
if let Some(v) = num("hyphenationZone") {
para.hyphenation.zone = v.clamp(0.0, MAX_PARA);
}
if let Some(v) = flag("hyphenateCapitalized") {
para.hyphenation.capitalized = v;
}
if let Some(v) = mojikumi {
para.mojikumi = v;
}
+42
View File
@@ -888,6 +888,48 @@ mod area_tests {
assert_eq!(old.burasagari, Burasagari::None);
}
/// Paragraph panel menu › Hyphenation…: `text.setFormat` sets each paragraph's hyphenation
/// options, rounded and clamped, in one undo step; they are saved only when not the defaults,
/// and paragraph styles carry them.
#[test]
fn hyphenation_options_are_set_per_paragraph_and_saved_only_when_not_default() {
use vectorcraft_doc::HyphenationOptions;
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": 10, "text": "One\nTwo", "area": {"width": 200, "height": 100}})).unwrap()["id"]
.as_u64()
.unwrap();
let paras = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
NodeKind::Text(t) => (t.para_at(0).clone(), t.para_at(1).clone()),
_ => panic!("text"),
};
assert_eq!(paras(&s).0.hyphenation, HyphenationOptions::default());
assert!(serde_json::to_value(paras(&s).0).unwrap().get("hyphenation").is_none());
s.execute("select.set", &json!({"ids": [id]})).unwrap();
s.execute(
"text.setFormat",
&json!({"wordsLongerThan": 7.6, "afterFirst": 0, "beforeLast": 40, "hyphenLimit": 2, "hyphenationZone": 18, "hyphenateCapitalized": false, "start": 5, "end": 5}),
)
.unwrap();
let want = HyphenationOptions { words_longer_than: 8, after_first: 1, before_last: 15, hyphen_limit: 2, zone: 18.0, capitalized: false };
let (first, second) = paras(&s);
assert_eq!((first.hyphenation, &second.hyphenation), (HyphenationOptions::default(), &want), "only the paragraph the caret is in");
assert_eq!(serde_json::to_value(&second).unwrap()["hyphenation"]["hyphen_limit"], 2);
let back: vectorcraft_doc::ParaStyle = serde_json::from_value(serde_json::to_value(&second).unwrap()).unwrap();
assert_eq!(back.hyphenation, want);
s.execute("text.setFormat", &json!({"hyphenLimit": -3, "hyphenationZone": 5000})).unwrap();
assert_eq!((paras(&s).0.hyphenation.hyphen_limit, paras(&s).1.hyphenation.zone), (0, 1296.0));
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(paras(&s).1.hyphenation, HyphenationOptions::default());
// A paragraph style carries them; an older file's paragraph reads the defaults.
s.execute("paraStyle.new", &json!({"name": "Loose", "attrs": {"hyphenation": {"words_longer_than": 9}}})).unwrap();
s.execute("paraStyle.apply", &json!({"name": "Loose", "id": id})).unwrap();
assert_eq!(paras(&s).0.hyphenation, HyphenationOptions { words_longer_than: 9, ..HyphenationOptions::default() });
let old: vectorcraft_doc::ParaStyle = serde_json::from_value(json!({"justify": "Left", "hyphenate": true})).unwrap();
assert_eq!(old.hyphenation, HyphenationOptions::default());
}
/// #432: while the interface is in Japanese, new type (text.create, text.createInPath) starts
/// with em box top-to-top leading and em box centre alignment, and new styles made from nothing
/// carry them; params override; the values are journaled; imported text keeps the Roman
+29 -12
View File
@@ -56,6 +56,9 @@ pub(crate) struct Params {
/// Lines from this index on all have the same width, so their line numbers are interchangeable
/// (keeps the number of active breakpoints small).
pub uniform_from: usize,
/// Hyphen Limit: the most consecutive lines that may end with a hyphen before a hyphenation
/// point is no longer a break; 0 sets no limit. A soft hyphen stays a break and counts.
pub hyphen_limit: usize,
}
struct Node {
@@ -64,7 +67,9 @@ struct Node {
line: usize,
fitness: u8,
demerits: f64,
hyphenated: bool,
/// Lines in a row ending with a hyphen up to this break (at most `hyphen_limit`, or 1 with no
/// limit); 0 when this break has no hyphen.
hyphens: usize,
prev: Option<usize>,
}
@@ -120,11 +125,14 @@ pub(crate) fn compose(
all.push(Breakpoint { end: n, hyphen: 0.0 });
let (adj_demerits, hyphen_penalty) =
if p.ragged { (ADJ_DEMERITS_RAGGED, HYPHEN_PENALTY_RAGGED) } else { (ADJ_DEMERITS_JUSTIFIED, HYPHEN_PENALTY) };
let mut nodes = vec![Node { pos: 0, line: 0, fitness: 1, demerits: 0.0, hyphenated: false, prev: None }];
let mut nodes = vec![Node { pos: 0, line: 0, fitness: 1, demerits: 0.0, hyphens: 0, prev: None }];
let mut active: Vec<usize> = vec![0];
for bp in &all {
let last = bp.end == n;
let mut best: Vec<(usize, u8, f64, usize)> = vec![]; // (line, fitness, demerits, from)
let hyphen = bp.hyphen > 0.0;
// `automatic` is true for a hyphenation point and false for a soft hyphen typed in the text.
let automatic = hyphen && !bp.end.checked_sub(1).and_then(|k| g.get(k)).is_some_and(SGlyph::is_soft_hyphen);
let mut best: Vec<(usize, u8, usize, f64, usize)> = vec![]; // (line, fitness, hyphens, demerits, from)
let mut keep = Vec::with_capacity(active.len());
for &ai in &active {
let Some(a) = nodes.get(ai) else { continue };
@@ -164,14 +172,17 @@ pub(crate) fn compose(
continue;
}
keep.push(ai);
if automatic && p.hyphen_limit > 0 && a.hyphens >= p.hyphen_limit {
continue;
}
let free_last = last && (p.ragged || !p.justify_last);
if ratio > p.tolerance && !(last && (p.ragged || (ratio.is_infinite() && p.justify_last))) {
continue;
}
let badness = if ratio.is_finite() { (100.0 * ratio.abs().powi(3)).min(10_000.0) } else { 10_000.0 };
let penalty = if bp.hyphen > 0.0 { hyphen_penalty } else { 0.0 };
let penalty = if hyphen { hyphen_penalty } else { 0.0 };
let mut d = (LINE_PENALTY + badness).powi(2) + penalty * penalty;
if bp.hyphen > 0.0 && a.hyphenated {
if hyphen && a.hyphens > 0 {
d += DOUBLE_HYPHEN_DEMERITS;
}
// A ragged last line's length says nothing about the rag: it keeps its neighbour's class.
@@ -181,15 +192,21 @@ pub(crate) fn compose(
}
let total = a.demerits + d;
let line = (a.line + 1).min(p.uniform_from);
match best.iter_mut().find(|b| b.0 == line && b.1 == fit) {
Some(b) if b.2 <= total => {}
Some(b) => *b = (line, fit, total, ai),
None => best.push((line, fit, total, ai)),
// With a limit, breaks reached with different runs of hyphenated lines stay apart.
let hyphens = match (hyphen, p.hyphen_limit) {
(false, _) => 0,
(true, 0) => 1,
(true, limit) => (a.hyphens + 1).min(limit),
};
match best.iter_mut().find(|b| b.0 == line && b.1 == fit && b.2 == hyphens) {
Some(b) if b.3 <= total => {}
Some(b) => *b = (line, fit, hyphens, total, ai),
None => best.push((line, fit, hyphens, total, ai)),
}
}
active = keep;
for (line, fitness, demerits, from) in best {
nodes.push(Node { pos: bp.end, line, fitness, demerits, hyphenated: bp.hyphen > 0.0, prev: Some(from) });
for (line, fitness, hyphens, demerits, from) in best {
nodes.push(Node { pos: bp.end, line, fitness, demerits, hyphens, prev: Some(from) });
active.push(nodes.len() - 1);
}
if active.is_empty() || active.len() > MAX_ACTIVE {
@@ -202,7 +219,7 @@ pub(crate) fn compose(
while let Some(i) = cur {
let nd = nodes.get(i)?;
if nd.prev.is_some() {
out.push((nd.pos, nd.hyphenated));
out.push((nd.pos, nd.hyphens > 0));
}
cur = nd.prev;
if out.len() > n {
+63 -7
View File
@@ -1,6 +1,8 @@
//! Pattern-free English hyphenation: break long words between syllables at vowel–consonant
//! boundaries (V-CV: "ty-po", and VC-CV: "hap-pen"), never leaving fewer than [`MIN_BEFORE`]
//! letters before or [`MIN_AFTER`] after the hyphen.
//! boundaries (V-CV: "ty-po", and VC-CV: "hap-pen"). A paragraph's Hyphenation options
//! ([`HyphenationOptions`]) set the shortest word that breaks, the fewest letters left before and
//! after the hyphen ([`MIN_BEFORE`] and [`MIN_AFTER`] by default), and whether capitalized words
//! break.
//!
//! Preferences › Hyphenation › Exceptions (comma separated) override the points for named words:
//! `typography` never breaks, `ty-pog-ra-phy` breaks only where the hyphens mark. See
@@ -9,6 +11,8 @@
use std::collections::HashMap;
use std::sync::{Mutex, MutexGuard, OnceLock};
use vectorcraft_doc::HyphenationOptions;
/// Only words at least this long are hyphenated (Illustrator's default: longer than 5 letters).
pub const MIN_WORD: usize = 6;
pub const MIN_BEFORE: usize = 3;
@@ -120,10 +124,19 @@ fn onset_pair(a: char, b: char) -> bool {
)
}
/// Allowed hyphenation points in `word`, as char indices (a hyphen goes *before* the char).
/// Words containing non-letters (other than a trailing apostrophe) are not hyphenated.
/// Preferences exceptions override the pattern for a matching word.
/// Allowed hyphenation points in `word`, as char indices (a hyphen goes *before* the char), with
/// the default Hyphenation options.
pub fn hyphen_points(word: &str) -> Vec<usize> {
hyphen_points_with(word, &HyphenationOptions::default())
}
/// Allowed hyphenation points in `word`, as char indices (a hyphen goes *before* the char), within
/// a paragraph's Hyphenation options: words with more letters than Words Longer Than, at least
/// After First letters before the hyphen and Before Last after it (at least 1 each), and no word
/// that starts with a capital when Hyphenate Capitalized Words is off. Words containing non-letters
/// (other than a trailing apostrophe) are not hyphenated. Preferences exceptions override the
/// pattern and these options for a matching word.
pub fn hyphen_points_with(word: &str, opts: &HyphenationOptions) -> Vec<usize> {
if let Some(ex) = exception_for(word) {
return match ex {
Exception::Never => vec![],
@@ -132,16 +145,22 @@ pub fn hyphen_points(word: &str) -> Vec<usize> {
}
let c: Vec<char> = word.chars().collect();
let n = c.len();
if n < MIN_WORD || !c.iter().all(|c| c.is_alphabetic() || matches!(c, '\'' | '’')) {
let longer_than = usize::try_from(opts.words_longer_than).unwrap_or(usize::MAX);
if n <= longer_than || !c.iter().all(|c| c.is_alphabetic() || matches!(c, '\'' | '’')) {
return vec![];
}
// A capitalised run like "NASA" or a proper name stays whole only when all caps.
if c.iter().all(|c| c.is_uppercase()) {
return vec![];
}
if !opts.capitalized && c.first().is_some_and(|c| c.is_uppercase()) {
return vec![];
}
let before = usize::try_from(opts.after_first).unwrap_or(usize::MAX).max(1);
let after = usize::try_from(opts.before_last).unwrap_or(usize::MAX).max(1);
let v: Vec<bool> = c.iter().map(|&x| is_vowel(x)).collect();
let mut out = vec![];
for i in MIN_BEFORE..=n.saturating_sub(MIN_AFTER) {
for i in before..=n.saturating_sub(after) {
if !c[i].is_alphabetic() || !c[i - 1].is_alphabetic() {
continue;
}
@@ -224,6 +243,43 @@ mod tests {
assert_eq!(hyphen_points("typography"), pattern, "override cleared");
}
/// The default Hyphenation options are the limits of [`hyphen_points`]: words of 6 letters or
/// more, 3 letters on each side of the hyphen.
#[test]
fn default_hyphenation_options_keep_the_default_limits() {
let d = HyphenationOptions::default();
assert_eq!((d.words_longer_than as usize + 1, d.after_first as usize, d.before_last as usize), (MIN_WORD, MIN_BEFORE, MIN_AFTER));
for w in ["typography", "happen", "hyphenation", "language", "aromatic", "Language", "short"] {
assert_eq!(hyphen_points_with(w, &d), hyphen_points(w), "{w}");
}
}
/// Paragraph panel menu › Hyphenation…: Words Longer Than, After First, Before Last and
/// Hyphenate Capitalized Words limit where a word breaks.
#[test]
fn hyphenation_options_limit_the_points() {
let opts = |f: &dyn Fn(&mut HyphenationOptions)| {
let mut o = HyphenationOptions::default();
f(&mut o);
o
};
// An 8-letter word breaks by default, and not when only longer words may.
assert_eq!(hyphen_points("language"), vec![3]);
assert!(hyphen_points_with("language", &opts(&|o| o.words_longer_than = 10)).is_empty());
assert!(hyphen_points_with("language", &opts(&|o| o.words_longer_than = 8)).is_empty());
assert_eq!(hyphen_points_with("language", &opts(&|o| o.words_longer_than = 7)), vec![3]);
// After First and Before Last: "aro-ma-tic" by default, nothing with 4 and 4, "a-ro-ma-tic" with 1 and 1.
assert_eq!(hyphen_points("aromatic"), vec![3, 5]);
assert!(hyphen_points_with("aromatic", &opts(&|o| (o.after_first, o.before_last) = (4, 4))).is_empty());
assert_eq!(hyphen_points_with("aromatic", &opts(&|o| (o.after_first, o.before_last) = (1, 1))), vec![1, 3, 5]);
// 0 letters on either side still leaves the hyphen inside the word.
assert_eq!(hyphen_points_with("aromatic", &opts(&|o| (o.after_first, o.before_last) = (0, 0))), vec![1, 3, 5]);
// Capitalized words.
assert_eq!(hyphen_points("Language"), vec![3]);
assert!(hyphen_points_with("Language", &opts(&|o| o.capitalized = false)).is_empty());
assert_eq!(hyphen_points_with("language", &opts(&|o| o.capitalized = false)), vec![3]);
}
/// Process-wide store used by `prefs.set` / restore.
#[test]
fn set_hyphenation_exceptions_round_trips_the_source_string() {
+56 -20
View File
@@ -5,13 +5,14 @@ use std::ops::Range;
use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape, Vec2};
use unicode_bidi::{BidiInfo, Level};
use vectorcraft_doc::{
Burasagari, CharStyle, InlineArt, Justify, Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, TextKind, TextObject,
Burasagari, CharStyle, HyphenationOptions, InlineArt, Justify, Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect,
TextKind, TextObject,
};
use vectorcraft_geom::{ArcPath, PathData};
use crate::composer::{Breakpoint, Params, compose};
use crate::fontdb::FontDb;
use crate::hyphen::hyphen_points;
use crate::hyphen::hyphen_points_with;
use crate::shape::{
Punct, SGlyph, Tcy, cap_x_heights, hyphen_glyph, is_cjk, mark_word_ends, no_line_end, no_line_start, punct, shape_range, soft_line_start,
style_metrics,
@@ -1220,7 +1221,18 @@ fn word_width(g: &[SGlyph], i: usize) -> f64 {
/// Greedy break: returns (end glyph index, hyphenated) for a line starting at `i` of `width`.
/// With burasagari, a comma or full stop that doesn't fit ends the line, hanging outside it.
fn break_line(text: &str, g: &[SGlyph], i: usize, width: f64, hyphenate: bool, burasagari: Burasagari, kinsoku: Kinsoku) -> (usize, bool) {
/// `hyphenate` holds the paragraph's Hyphenation options and the Hyphenation Zone in force when
/// the line may hyphenate a word; a line whose text before the overflowing word already ends
/// within the zone breaks before that word.
fn break_line(
text: &str,
g: &[SGlyph],
i: usize,
width: f64,
hyphenate: Option<(&HyphenationOptions, f64)>,
burasagari: Burasagari,
kinsoku: Kinsoku,
) -> (usize, bool) {
if !width.is_finite() {
return (g.len(), false);
}
@@ -1263,13 +1275,24 @@ fn break_line(text: &str, g: &[SGlyph], i: usize, width: f64, hyphenate: bool, b
}
// Hyphenate the word that overflows.
let word_start = last_break.map_or(i, |b| b.0);
if hyphenate && g[j].is_letter() {
let in_zone = |zone: f64| {
let mut te = word_start;
while te > i && g[te - 1].is_space() {
te -= 1;
}
te > i && zone > 0.0 && width - g[i..te].iter().map(|g| g.adv).sum::<f64>() <= zone + EPS
};
if let Some((opts, zone)) = hyphenate
&& g[j].is_letter()
&& !in_zone(zone)
{
let mut we = j;
while we < g.len() && !g[we].is_space() && !g[we].break_after() {
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).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)).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;
@@ -1367,7 +1390,7 @@ mod wrapping_tests {
let text = "بَت";
let glyphs = synthetic_glyphs(&[(0, 4, 2.0, 'ب'), (0, 4, 2.0, 'ب'), (4, 2, 1.0, 'ت')]);
let (end, hyphenated) = break_line(text, &glyphs, 0, 3.0, false, Burasagari::None, Kinsoku::Hard);
let (end, hyphenated) = break_line(text, &glyphs, 0, 3.0, None, Burasagari::None, Kinsoku::Hard);
assert!(!hyphenated);
assert_eq!(end, 2, "oversized base-plus-mark cluster must stay together");
@@ -1380,11 +1403,11 @@ mod wrapping_tests {
let text = "بَتَث";
let glyphs = synthetic_glyphs(&[(0, 4, 1.0, 'ب'), (4, 4, 2.0, 'ت'), (4, 4, 2.0, 'ت'), (8, 2, 1.0, 'ث')]);
let (first_end, _) = break_line(text, &glyphs, 0, 3.0, false, Burasagari::None, Kinsoku::Hard);
let (first_end, _) = break_line(text, &glyphs, 0, 3.0, None, Burasagari::None, Kinsoku::Hard);
assert_eq!(first_end, 1);
assert_eq!(source_range(&glyphs, 0, first_end), 0..4);
let (second_end, hyphenated) = break_line(text, &glyphs, first_end, 3.0, false, Burasagari::None, Kinsoku::Hard);
let (second_end, hyphenated) = break_line(text, &glyphs, first_end, 3.0, None, Burasagari::None, Kinsoku::Hard);
assert!(!hyphenated);
assert_eq!(second_end, 3, "oversized cluster at the next line start must stay together");
@@ -1473,8 +1496,8 @@ fn soft_kinsoku_lets_small_kana_and_the_prolonged_sound_mark_start_a_line() {
}
}
/// Glyph indices inside `g[ws..we]` (a word) where a hyphenated break may go.
fn hyphen_breaks(text: &str, g: &[SGlyph], ws: usize, we: usize) -> Vec<usize> {
/// Glyph indices inside `g[ws..we]` (a word) where a hyphenated break may go under `opts`.
fn hyphen_breaks(text: &str, g: &[SGlyph], ws: usize, we: usize, opts: &HyphenationOptions) -> Vec<usize> {
// Strip leading/trailing punctuation (quotes, commas).
let (mut a, mut b) = (ws, we);
while a < b && !g[a].is_letter() {
@@ -1489,7 +1512,7 @@ fn hyphen_breaks(text: &str, g: &[SGlyph], ws: usize, we: usize) -> Vec<usize> {
let (s, e) = (g[a].byte, g[b - 1].byte + g[b - 1].len);
let Some(word) = text.get(s..e) else { return vec![] };
let offs: Vec<usize> = word.char_indices().map(|(o, _)| s + o).collect();
hyphen_points(word)
hyphen_points_with(word, opts)
.into_iter()
.filter_map(|ci| {
let byte = *offs.get(ci)?;
@@ -1499,15 +1522,18 @@ fn hyphen_breaks(text: &str, g: &[SGlyph], ws: usize, we: usize) -> Vec<usize> {
.collect()
}
/// Break candidates for the every-line composer.
fn candidates(text: &str, g: &[SGlyph], hyphenate: bool, kinsoku: Kinsoku) -> Vec<Breakpoint> {
/// Break candidates for the every-line composer: hyphenation points under `hyphenate` (the
/// paragraph's Hyphenation options) when it is given.
fn candidates(text: &str, g: &[SGlyph], hyphenate: Option<&HyphenationOptions>, kinsoku: Kinsoku) -> Vec<Breakpoint> {
let mut v = vec![];
let mut ws = 0;
for (j, gl) in g.iter().enumerate() {
let end_of_word = gl.is_space() || gl.break_after();
if end_of_word {
if hyphenate && j > ws {
for k in hyphen_breaks(text, g, ws, j).into_iter().filter(|&k| k > 0 && kinsoku_allows(g, k - 1, kinsoku)) {
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)) {
v.push(Breakpoint { end: k, hyphen: hyphen_glyph(&g[k - 1]).adv });
}
}
@@ -1518,8 +1544,10 @@ fn candidates(text: &str, g: &[SGlyph], hyphenate: bool, kinsoku: Kinsoku) -> Ve
ws = j + 1;
}
}
if hyphenate && g.len() > ws {
for k in hyphen_breaks(text, g, ws, g.len()).into_iter().filter(|&k| k > 0 && kinsoku_allows(g, k - 1, kinsoku)) {
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)) {
v.push(Breakpoint { end: k, hyphen: hyphen_glyph(&g[k - 1]).adv });
}
}
@@ -1571,9 +1599,10 @@ fn compose_para(cx: &Ctx<'_>, sg: &[SGlyph], para: &ParaStyle, pen: &Pen<'_>, rt
let width = |k: usize| widths.get(k).copied().unwrap_or(last);
// Lines from `uniform_from` on are interchangeable (same width).
let uniform_from = widths.iter().rposition(|&w| (w - last).abs() > EPS).map_or(0, |k| k + 1);
let cands = candidates(cx.text, sg, para.hyphenate, para.kinsoku);
let cands = candidates(cx.text, sg, para.hyphenate.then_some(&para.hyphenation), para.kinsoku);
let justify_last = para.justify == Justify::JustifyAll;
let params = |tolerance| Params { justify_last, ragged, tolerance, uniform_from };
let hyphen_limit = usize::try_from(para.hyphenation.hyphen_limit).unwrap_or(usize::MAX);
let params = |tolerance| Params { justify_last, ragged, tolerance, uniform_from, hyphen_limit };
// Ragged: first look for breaks that leave at most one rag zone (a sixth of the width) on
// each line, then accept any lines that fit.
let (strict, loose) = if ragged { (1.0, f64::INFINITY) } else { (1.0, 4.0) };
@@ -1629,6 +1658,11 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
}
let n = sg.len();
let composed = compose_para(cx, &sg, para, &pen, rtl);
// Hyphenation options: the Hyphenation Zone applies with the Single-line Composer, and the
// Hyphen Limit counts the lines in a row that end with a hyphen.
let zone = if cx.opts.composer.unwrap_or(para.composer) == Composer::SingleLine { para.hyphenation.zone } else { 0.0 };
let hyphen_limit = usize::try_from(para.hyphenation.hyphen_limit).unwrap_or(usize::MAX);
let mut hyphen_run = 0;
let mut li_para = 0;
let mut i = 0;
loop {
@@ -1658,9 +1692,10 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
break 'paras;
};
let width = x1 - x0 - ind_l - para.right_indent;
let hyphenate = (para.hyphenate && (hyphen_limit == 0 || hyphen_run < hyphen_limit)).then_some((&para.hyphenation, zone));
let (end, hyph) = match composed.as_ref().and_then(|c| c.get(li_para)) {
Some(&(e, h)) if e > i => (e, h),
_ if i < n => break_line(cx.text, &sg, i, width, para.hyphenate, para.burasagari, para.kinsoku),
_ if i < n => break_line(cx.text, &sg, i, width, hyphenate, para.burasagari, para.kinsoku),
_ => (n, false),
};
let m = Metrics::max(&sg[i..end]).unwrap_or(pm);
@@ -1685,6 +1720,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
}
break (baseline, x0, x1, end, hyph, m);
};
hyphen_run = if hyph && end > i { hyphen_run + 1 } else { 0 };
let (ax0, ax1) = if regions.is_some() { (x0 + ind_l, x1 - para.right_indent) } else { (x0, x1) };
let width = ax1 - ax0;
pen.pending = 0.0;
+136
View File
@@ -585,6 +585,142 @@ fn hyphenation_exceptions_keep_listed_words_whole_in_layout() {
);
}
/// The width of the frame [`the_language`] makes: "The lan-" and "language" fit, and "The
/// language" does not.
fn the_language_width() -> f64 {
natural_width("The lan-", 12.0).max(natural_width("Language", 12.0)) + 1.0
}
/// "The language" (or "The Language") in a frame that holds "The lan-" and not the whole of it,
/// hyphenated.
fn the_language(word: &str, composer: Composer) -> TextObject {
let text = format!("The {word}");
let w = the_language_width();
assert!(w < natural_width(&text, 12.0));
let mut t = area(&text, style(12.0), Rect::new(0.0, 0.0, w, 400.0), Justify::Left);
t.para.hyphenate = true;
t.para.composer = composer;
t
}
/// Paragraph panel menu › Hyphenation… › Words Longer Than: an 8-letter word that breaks with
/// the default (longer than 5 letters) stays whole when only words longer than 10 letters may
/// break.
#[test]
fn hyphenation_words_longer_than_keeps_shorter_words_whole() {
let mut t = the_language("language", Composer::SingleLine);
assert_eq!(line_texts(&t, &layout(db(), &t)), ["The lan", "guage"]);
t.para.hyphenation.words_longer_than = 10;
assert_eq!(line_texts(&t, &layout(db(), &t)), ["The", "language"]);
let mut t = the_language("language", Composer::EveryLine);
t.para.hyphenation.words_longer_than = 10;
assert_eq!(line_texts(&t, &layout(db(), &t)), ["The", "language"]);
}
/// Hyphenation… › After First and Before Last: the fewest letters on each side of the hyphen.
#[test]
fn hyphenation_after_first_and_before_last_limit_the_break() {
let mut t = the_language("language", Composer::SingleLine);
t.para.hyphenation.after_first = 4;
assert_eq!(line_texts(&t, &layout(db(), &t)), ["The", "language"]);
t.para.hyphenation.after_first = 3;
t.para.hyphenation.before_last = 6;
assert_eq!(line_texts(&t, &layout(db(), &t)), ["The", "language"]);
}
/// Hyphenation… › Hyphenate Capitalized Words: when it is off, a word that starts with a capital stays whole.
#[test]
fn hyphenation_leaves_capitalized_words_whole_when_off() {
for composer in [Composer::SingleLine, Composer::EveryLine] {
let mut t = the_language("Language", composer);
if composer == Composer::SingleLine {
assert_eq!(line_texts(&t, &layout(db(), &t)), ["The Lan", "guage"]);
}
t.para.hyphenation.capitalized = false;
assert_eq!(line_texts(&t, &layout(db(), &t)), ["The", "Language"], "{composer:?}");
}
}
/// Hyphenation… › Hyphenation Zone: with the Single-line Composer, a line whose text before the
/// next word already ends within the zone breaks before that word; the Every-line Composer
/// ignores the zone.
#[test]
fn hyphenation_zone_breaks_before_the_word_with_the_single_line_composer() {
let mut t = the_language("language", Composer::SingleLine);
let gap = the_language_width() - natural_width("The", 12.0);
t.para.hyphenation.zone = gap - 1.0;
assert_eq!(line_texts(&t, &layout(db(), &t)), ["The lan", "guage"]);
t.para.hyphenation.zone = gap + 1.0;
assert_eq!(line_texts(&t, &layout(db(), &t)), ["The", "language"]);
let mut every = the_language("language", Composer::EveryLine);
let without = line_texts(&every, &layout(db(), &every));
every.para.hyphenation.zone = gap + 1.0;
assert_eq!(line_texts(&every, &layout(db(), &every)), without);
}
/// The default Hyphenation options keep the line breaks of hyphenated text: the copy twice over
/// at three widths, ragged and justified, with either composer. The constant is an FNV-1a hash of
/// each line's end and whether it ends with a hyphen.
#[test]
fn default_hyphenation_options_keep_the_line_breaks() {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
let mut mix = |v: u64| {
for b in v.to_le_bytes() {
hash = (hash ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3);
}
};
for width in [150.0, 190.0, 240.0] {
for justify in [Justify::Left, Justify::JustifyLeft] {
for composer in [Composer::SingleLine, Composer::EveryLine] {
let mut t = area(&COPY.repeat(2), style(12.0), Rect::new(0.0, 0.0, width, 4000.0), justify);
t.para.hyphenate = true;
t.para.composer = composer;
let l = layout(db(), &t);
for li in &l.lines {
let hyphenated = li.glyph_end > li.glyph_start && l.glyphs.get(li.glyph_end - 1).is_some_and(|g| g.len == 0);
mix(li.end as u64 * 2 + u64::from(hyphenated));
}
}
}
}
assert_eq!(hash, 8_558_443_895_699_068_077);
}
/// The longest run of consecutive lines of `l` that end with a hyphen.
fn hyphen_run(l: &TextLayout) -> usize {
let mut run = 0;
let mut longest = 0;
for li in &l.lines {
let hyphenated = li.glyph_end > li.glyph_start && l.glyphs.get(li.glyph_end - 1).is_some_and(|g| g.len == 0);
run = if hyphenated { run + 1 } else { 0 };
longest = longest.max(run);
}
longest
}
/// Hyphenation… › Hyphen Limit: at most that many lines in a row end with a hyphen, with either
/// composer; 0 sets no limit.
#[test]
fn hyphen_limit_caps_consecutive_hyphenated_lines() {
let text = "Extraordinarily comprehensive typographical considerations notwithstanding everything. ".repeat(3);
for composer in [Composer::SingleLine, Composer::EveryLine] {
for justify in [Justify::Left, Justify::JustifyLeft] {
let mut t = area(text.trim_end(), style(14.0), Rect::new(0.0, 0.0, 120.0, 2000.0), justify);
t.para.hyphenate = true;
t.para.composer = composer;
let free = layout(db(), &t);
assert!(hyphen_run(&free) >= 2, "{composer:?} {justify:?}: lines in a row end with hyphens without a limit");
for limit in [1, 2] {
t.para.hyphenation.hyphen_limit = limit;
let l = layout(db(), &t);
assert!(!l.overflow);
assert!(hyphen_run(&l) >= 1, "{composer:?} {justify:?} {limit}: still hyphenated");
assert!(hyphen_run(&l) <= limit as usize, "{composer:?} {justify:?} {limit}: {}", hyphen_run(&l));
}
}
}
}
#[test]
fn soft_hyphen_is_invisible_mid_line() {
let l = layout(db(), &TextObject::point(Point::ZERO, "co\u{00AD}operate", style(20.0)));
+182
View File
@@ -0,0 +1,182 @@
//! Paragraph panel menu › Hyphenation… (or `ui.hyphenation`): the Hyphenation checkbox and the
//! limits automatic hyphenation keeps to in the paragraphs the Paragraph panel acts on: Words
//! Longer Than, After First, Before Last, Hyphen Limit, Hyphenation Zone and Hyphenate Capitalized
//! Words. The dialog opens on the values the Paragraph panel shows. OK runs `text.setFormat` with
//! the values that changed, as one undo step, and paragraphs that differ keep the values left alone.
//!
//! Fields: `hyphenate`, `wordsLongerThan` (letters, 1..25), `afterFirst` and `beforeLast`
//! (letters, 1..15), `hyphenLimit` (hyphens, 0..25, 0: no limit), `hyphenationZone` (pt) and
//! `hyphenateCapitalized`, as `text.setFormat` takes them, and `__initial` (the values it opened
//! with).
use serde_json::{Map, Value, json};
use super::swatch_options::{grid, label};
use super::{DialogSpec, form};
use crate::state::Dialog;
use crate::{VectorcraftApp, widgets};
/// The dialog kind of Hyphenation.
pub const KIND: &str = "hyphenation";
/// The field holding the values the dialog opened with.
const INITIAL: &str = "__initial";
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Hyphenation").into(), body, confirm, min_width: 300.0, ..DialogSpec::FORM };
/// Open Hyphenation on the paragraph attributes the Paragraph panel shows.
pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
let (_, para) = crate::panels::character::text_style(app).ok_or("select some type first")?;
let h = &para.hyphenation;
let values = json!({
"hyphenate": para.hyphenate,
"wordsLongerThan": h.words_longer_than,
"afterFirst": h.after_first,
"beforeLast": h.before_last,
"hyphenLimit": h.hyphen_limit,
"hyphenationZone": h.zone,
"hyphenateCapitalized": h.capitalized,
});
let mut fields = values.clone();
fields[INITIAL] = values;
app.ui.dialog = Some(Dialog::new(KIND, fields));
Ok(Value::Null)
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let unit = app.session.general_unit();
form::check(ui, d, "hyphenate", tl!("Hyphenation"));
ui.add_space(8.0);
// The whole-number fields: (field, label, unit, range).
let counts = [
("wordsLongerThan", tl!("Words Longer Than:"), tl!("letters"), (1.0, 25.0)),
("afterFirst", tl!("After First:"), tl!("letters"), (1.0, 15.0)),
("beforeLast", tl!("Before Last:"), tl!("letters"), (1.0, 15.0)),
("hyphenLimit", tl!("Hyphen Limit:"), tl!("hyphens"), (0.0, 25.0)),
];
grid(ui, |ui| {
for (key, text, suffix, (lo, hi)) in counts {
label(ui, text);
ui.horizontal(|ui| {
if let Some(v) = widgets::range_field(ui, ("hyphenation", key), d.f64(key, lo), lo..=hi, "", 0, 60.0) {
d.fields.insert(key.into(), json!(v));
}
ui.label(suffix);
});
ui.end_row();
}
label(ui, tl!("Hyphenation Zone:"));
form::length(ui, d, "hyphenationZone", unit, 90.0);
ui.end_row();
});
ui.add_space(8.0);
form::check(ui, d, "hyphenateCapitalized", tl!("Hyphenate Capitalized Words"));
false
}
/// `text.setFormat` parameters: the fields that differ from the values the dialog opened with.
fn params(d: &Dialog) -> Map<String, Value> {
let initial = d.fields.get(INITIAL).and_then(Value::as_object);
let mut p = Map::new();
for key in ["hyphenate", "wordsLongerThan", "afterFirst", "beforeLast", "hyphenLimit", "hyphenationZone", "hyphenateCapitalized"] {
let Some(v) = d.fields.get(key) else { continue };
// Numbers are compared as numbers (a field stores 3 as 3.0), and a length typed with its
// unit ("6 mm") is sent in points.
let v = if v.is_boolean() { v.clone() } else { json!(d.f64(key, 0.0)) };
let same = match (initial.and_then(|m| m.get(key)), &v) {
(Some(Value::Bool(was)), Value::Bool(now)) => was == now,
(Some(was), now) => was.as_f64().zip(now.as_f64()).is_some_and(|(a, b)| a == b),
(None, _) => false,
};
if !same {
p.insert(key.into(), v);
}
}
p
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let p = params(d);
app.ui.dialog = None;
if p.is_empty() {
return Ok(Value::Null);
}
crate::panels::paragraph::run_on_paragraphs(app, "text.setFormat", Value::Object(p))
}
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_engine::Session;
use vectorcraft_engine::doc::{HyphenationOptions, NodeId, NodeKind, ParaStyle};
/// A selected area type object with two paragraphs.
fn app() -> (VectorcraftApp, u64) {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 400})).unwrap();
let id = app.run("text.create", json!({"x": 10, "y": 10, "text": "One\nTwo", "area": {"width": 200, "height": 100}})).unwrap()["id"]
.as_u64()
.unwrap();
app.run("select.set", json!({"ids": [id]})).unwrap();
(app, id)
}
fn paras(app: &VectorcraftApp, id: u64) -> (ParaStyle, ParaStyle) {
match &app.session.active().unwrap().doc.node(NodeId(id)).unwrap().kind {
NodeKind::Text(t) => (t.para_at(0).clone(), t.para_at(1).clone()),
_ => panic!("not text"),
}
}
/// `ui.hyphenation` opens the dialog on the selected type; OK sets the paragraphs' hyphenation
/// options in one undo step.
#[test]
fn hyphenation_dialog_sets_the_paragraphs_options() {
let (mut app, id) = app();
app.run("ui.hyphenation", json!({})).unwrap();
let d = app.ui.dialog.clone().expect("Hyphenation opened");
assert_eq!(d.kind, KIND);
assert_eq!((d.bool("hyphenate"), d.f64("wordsLongerThan", 0.0), d.f64("hyphenLimit", -1.0)), (false, 5.0, 0.0));
assert!(d.bool("hyphenateCapitalized"));
let d = app.ui.dialog.as_mut().unwrap();
d.fields.insert("hyphenate".into(), json!(true));
d.fields.insert("wordsLongerThan".into(), json!(8));
d.fields.insert("hyphenLimit".into(), json!(2));
d.fields.insert("hyphenationZone".into(), json!("1 in"));
d.fields.insert("hyphenateCapitalized".into(), json!(false));
let undo = app.session.active().unwrap().history.undo.len();
super::super::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
let want = HyphenationOptions { words_longer_than: 8, hyphen_limit: 2, zone: 72.0, capitalized: false, ..HyphenationOptions::default() };
for p in [paras(&app, id).0, paras(&app, id).1] {
assert!(p.hyphenate);
assert_eq!(p.hyphenation, want);
}
assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1);
}
/// OK sends only the values that changed: paragraphs whose other options differ keep them.
#[test]
fn hyphenation_dialog_leaves_unchanged_values_alone() {
let (mut app, id) = app();
app.run("text.setFormat", json!({"ids": [id], "afterFirst": 2, "start": 5, "end": 5})).unwrap();
app.run("ui.hyphenation", json!({})).unwrap();
let d = app.ui.dialog.as_mut().unwrap();
d.fields.insert("beforeLast".into(), json!(4));
// Edited and set back to the value it opened with, as a field stores it.
d.fields.insert("afterFirst".into(), json!(3.0));
super::super::confirm(&mut app).unwrap();
let (first, second) = paras(&app, id);
assert_eq!((first.hyphenation.after_first, first.hyphenation.before_last), (3, 4));
assert_eq!((second.hyphenation.after_first, second.hyphenation.before_last), (2, 4));
// Nothing changed: no undo step.
let undo = app.session.active().unwrap().history.undo.len();
app.run("ui.hyphenation", json!({})).unwrap();
super::super::confirm(&mut app).unwrap();
assert_eq!(app.session.active().unwrap().history.undo.len(), undo);
// With no type selected the dialog does not open.
app.run("select.set", json!({"ids": []})).unwrap();
assert!(app.run("ui.hyphenation", json!({})).is_err());
assert!(app.ui.dialog.is_none());
}
}
+2
View File
@@ -43,6 +43,7 @@ pub mod graph_data;
pub mod graph_design;
pub mod graphic_style_options;
pub mod halftone;
pub mod hyphenation;
pub mod import_pdf;
pub mod layer_options;
pub mod layers_panel_options;
@@ -262,6 +263,7 @@ registry! {
TransformEach: [transform_each::KIND] => transform_each::SPEC,
WidthPoint: [width_point::KIND] => width_point::SPEC,
Corners: [corners::KIND] => corners::SPEC,
Hyphenation: [hyphenation::KIND] => hyphenation::SPEC,
SavePdf: [save_pdf::KIND] => save_pdf::SPEC,
SvgOptions: [svg_options::KIND] => svg_options::SPEC,
NewDocumentMore: [new_document::MORE] => new_document::MORE_SPEC,
+8
View File
@@ -1289,6 +1289,14 @@
Hyphenate Пренасяне на думи
Hyphenation Пренасяне
Hyphenation… Пренасяне…
Words Longer Than: Думи, по-дълги от:
After First: След първите:
Before Last: Преди последните:
Hyphen Limit: Лимит на тиретата:
Hyphenation Zone: Зона за пренасяне:
letters букви
hyphens тирета
Hyphenate Capitalized Words Пренасяне на думи с главна буква
ICC Profile ICC профил
Identify Objects By Идентифициране на обектите по
Ignore Игнориране
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -1610,6 +1610,14 @@ lighting Light Světlo
Every-line Composer Sazba všech řádků
First-line Left Indent Odsazení prvního řádku zleva
Hyphenation… Dělení slov…
Words Longer Than: Slova delší než:
After First: Po prvních:
Before Last: Před posledními:
Hyphen Limit: Limit spojovníků:
Hyphenation Zone: Zóna dělení slov:
letters písmen
hyphens spojovníků
Hyphenate Capitalized Words Dělit slova s velkým počátečním písmenem
Justification… Zarovnání do bloku…
Justify with last line aligned center Do bloku, poslední řádek na střed
Justify with last line aligned right Do bloku, poslední řádek vpravo
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -1297,6 +1297,14 @@
Hyphenate Silbentrennung
Hyphenation Silbentrennung
Hyphenation… Silbentrennung…
Words Longer Than: Wörter länger als:
After First: Nach den ersten:
Before Last: Vor den letzten:
Hyphen Limit: Trennstrichgrenze:
Hyphenation Zone: Trennzone:
letters Buchstaben
hyphens Trennstriche
Hyphenate Capitalized Words Großgeschriebene Wörter trennen
ICC Profile ICC-Profil
Identify Objects By Objekte identifizieren nach
Ignore Ignorieren
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -1251,6 +1251,14 @@
Hyphenate Συλλαβισμός
Hyphenation Συλλαβισμός
Hyphenation… Συλλαβισμός…
Words Longer Than: Λέξεις μεγαλύτερες από:
After First: Μετά τα πρώτα:
Before Last: Πριν από τα τελευταία:
Hyphen Limit: Όριο ενωτικών:
Hyphenation Zone: Ζώνη συλλαβισμού:
letters γράμματα
hyphens ενωτικά
Hyphenate Capitalized Words Συλλαβισμός λέξεων με κεφαλαίο αρχικό
ICC Profile Προφίλ ICC
Identify Objects By Αναγνώριση αντικειμένων κατά
Ignore Παράβλεψη
1 # Ελληνικά (el) — VectorCraft
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
+8
View File
@@ -1315,6 +1315,14 @@
Hyphenate Separar con guiones
Hyphenation Separación con guiones
Hyphenation… Separación con guiones…
Words Longer Than: Palabras de más de:
After First: Después de las primeras:
Before Last: Antes de las últimas:
Hyphen Limit: Límite de guiones:
Hyphenation Zone: Zona de separación:
letters letras
hyphens guiones
Hyphenate Capitalized Words Separar palabras con mayúscula inicial
ICC Profile Perfil ICC
Identify Objects By Identificar objetos por
Ignore Ignorar
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -1573,6 +1573,14 @@ lighting Light Valo
Every-line Composer Kaikkien rivien asettelu
First-line Left Indent Ensimmäisen rivin vasen sisennys
Hyphenation… Tavutus…
Words Longer Than: Sanat, jotka ovat pidempiä kuin:
After First: Ensimmäisten jälkeen:
Before Last: Ennen viimeisiä:
Hyphen Limit: Tavuviivaraja:
Hyphenation Zone: Tavutusalue:
letters kirjainta
hyphens tavuviivaa
Hyphenate Capitalized Words Tavuta isolla alkukirjaimella alkavat sanat
Justification… Tasaus…
Justify with last line aligned center Tasaa molemmat reunat, viimeinen rivi keskelle
Justify with last line aligned right Tasaa molemmat reunat, viimeinen rivi oikealle
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -1314,6 +1314,14 @@
Hyphenate Césure
Hyphenation Césure
Hyphenation… Césure…
Words Longer Than: Mots de plus de :
After First: Après les premières :
Before Last: Avant les dernières :
Hyphen Limit: Limite de tirets :
Hyphenation Zone: Zone de césure :
letters lettres
hyphens tirets
Hyphenate Capitalized Words Césure des mots commençant par une majuscule
ICC Profile Profil ICC
Identify Objects By Identifier les objets par
Ignore Ignorer
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -1669,6 +1669,14 @@
Hyphenate Elválasztás
Hyphenation Elválasztás
Hyphenation… Elválasztás…
Words Longer Than: Ennél hosszabb szavak:
After First: Az első után:
Before Last: Az utolsó előtt:
Hyphen Limit: Kötőjelek korlátja:
Hyphenation Zone: Elválasztási zóna:
letters betű
hyphens kötőjel
Hyphenate Capitalized Words Nagybetűs szavak elválasztása
ICC Profile ICC-profil
ICF Bottom/Left ICF alja/bal oldala
ICF Top/Right ICF teteje/jobb oldala
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -1314,6 +1314,14 @@
Hyphenate Sillaba
Hyphenation Sillabazione
Hyphenation… Sillabazione…
Words Longer Than: Parole con più di:
After First: Dopo le prime:
Before Last: Prima delle ultime:
Hyphen Limit: Limite trattini:
Hyphenation Zone: Zona di sillabazione:
letters lettere
hyphens trattini
Hyphenate Capitalized Words Sillaba parole con iniziale maiuscola
ICC Profile Profilo ICC
Identify Objects By Identifica oggetti per
Ignore Ignora
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -1557,6 +1557,14 @@
Hyphenate ハイフネーション
Hyphenation ハイフネーション
Hyphenation… ハイフネーション…
Words Longer Than: 次の文字数を超える単語:
After First: 語頭の文字数:
Before Last: 語尾の文字数:
Hyphen Limit: 連続ハイフンの上限:
Hyphenation Zone: ハイフネーションゾーン:
letters 文字
hyphens 個
Hyphenate Capitalized Words 大文字で始まる単語をハイフネーション
ICC Profile ICC プロファイル
Identify Objects By オブジェクトの識別方法
Ignore 無視
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -1552,6 +1552,14 @@
Hyphenate 하이픈 넣기
Hyphenation 하이픈 넣기
Hyphenation… 하이픈 넣기…
Words Longer Than: 다음보다 긴 단어:
After First: 앞부분 최소:
Before Last: 뒷부분 최소:
Hyphen Limit: 연속 하이픈 제한:
Hyphenation Zone: 하이픈 넣기 영역:
letters 글자
hyphens 개
Hyphenate Capitalized Words 대문자로 시작하는 단어에 하이픈 넣기
ICC Profile ICC 프로파일
Identify Objects By 개체 식별 기준
Ignore 무시
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -1523,6 +1523,14 @@ lighting Light Światło
Every-line Composer Kompozytor wielowierszowy
First-line Left Indent Wcięcie pierwszego wiersza
Hyphenation… Dzielenie wyrazów…
Words Longer Than: Wyrazy dłuższe niż:
After First: Po pierwszych:
Before Last: Przed ostatnimi:
Hyphen Limit: Limit łączników:
Hyphenation Zone: Strefa dzielenia:
letters liter
hyphens łączników
Hyphenate Capitalized Words Dziel wyrazy pisane wielką literą
Justification… Justowanie…
Justify with last line aligned center Justuj z ostatnim wierszem do środka
Justify with last line aligned right Justuj z ostatnim wierszem do prawej
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -1136,6 +1136,14 @@
Hyphenate Hifenizar
Hyphenation Hifenização
Hyphenation… Hifenização…
Words Longer Than: Palavras com Mais de:
After First: Após as Primeiras:
Before Last: Antes das Últimas:
Hyphen Limit: Limite de Hifens:
Hyphenation Zone: Zona de Hifenização:
letters letras
hyphens hifens
Hyphenate Capitalized Words Hifenizar Palavras com Inicial Maiúscula
ICC Profile Perfil ICC
Identify Objects By Identificar Objetos Por
Ignore Ignorar
Can't render this file because it contains an unexpected character in line 6 and column 47.
+8
View File
@@ -1312,6 +1312,14 @@
Hyphenate Расставлять переносы
Hyphenation Расстановка переносов
Hyphenation… Расстановка переносов…
Words Longer Than: Слова длиннее:
After First: После первых:
Before Last: Перед последними:
Hyphen Limit: Предел переносов:
Hyphenation Zone: Зона переноса:
letters букв
hyphens переносов
Hyphenate Capitalized Words Переносить слова с прописной буквы
ICC Profile Профиль ICC
Identify Objects By Определять объекты по
Ignore Игнорировать
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -1291,6 +1291,14 @@
Hyphenate Avstava
Hyphenation Avstavning
Hyphenation… Avstavning…
Words Longer Than: Ord längre än:
After First: Efter första:
Before Last: Före sista:
Hyphen Limit: Avstavningsgräns:
Hyphenation Zone: Avstavningszon:
letters bokstäver
hyphens bindestreck
Hyphenate Capitalized Words Avstava ord med inledande versal
ICC Profile ICC-profil
Identify Objects By Identifiera objekt efter
Ignore Ignorera
Can't render this file because it contains an unexpected character in line 5 and column 25.
+8
View File
@@ -1308,6 +1308,14 @@
Hyphenate Розставляти переноси
Hyphenation Розміщення переносів
Hyphenation… Розміщення переносів…
Words Longer Than: Слова, довші за:
After First: Після перших:
Before Last: Перед останніми:
Hyphen Limit: Межа переносів:
Hyphenation Zone: Зона переносу:
letters літер
hyphens переносів
Hyphenate Capitalized Words Переносити слова з великої літери
ICC Profile Профіль ICC
Identify Objects By Визначати об’єкти по
Ignore Ігнорувати
Can't render this file because it contains an unexpected character in line 720 and column 40.
+8
View File
@@ -1498,6 +1498,14 @@
Hyphenate Gạch nối
Hyphenation Gạch nối từ
Hyphenation… Gạch nối từ…
Words Longer Than: Từ dài hơn:
After First: Sau số chữ đầu:
Before Last: Trước số chữ cuối:
Hyphen Limit: Giới hạn gạch nối:
Hyphenation Zone: Vùng gạch nối:
letters chữ cái
hyphens gạch nối
Hyphenate Capitalized Words Gạch nối từ viết hoa chữ đầu
ICC Profile Hồ sơ ICC
ICF Bottom/Left ICF Dưới/Trái
ICF Top/Right ICF Trên/Phải
1 # Vietnamese (vi) catalog. Same format as zh-hant.tsv: context <TAB> source <TAB> translation
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
+8
View File
@@ -1256,6 +1256,14 @@
Hyphenate 连字
Hyphenation 连字符
Hyphenation… 连字符设置…
Words Longer Than: 单词长度超过:
After First: 断开前至少:
Before Last: 断开后至少:
Hyphen Limit: 连字符限制:
Hyphenation Zone: 连字区:
letters 个字母
hyphens 个连字符
Hyphenate Capitalized Words 大写单词使用连字符
ICC Profile ICC 配置文件
Identify Objects By 对象识别方式
Ignore 忽略
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -1286,6 +1286,14 @@
Hyphenate 使用連字號
Hyphenation 連字號
Hyphenation… 連字號…
Words Longer Than: 單字長度超過:
After First: 斷開前至少:
Before Last: 斷開後至少:
Hyphen Limit: 連字號限制:
Hyphenation Zone: 連字區:
letters 個字母
hyphens 個連字號
Hyphenate Capitalized Words 大寫單字使用連字號
ICC Profile ICC 描述檔
Identify Objects By 識別物件依據
Ignore 略過
Can't render this file because it contains an unexpected character in line 6 and column 47.
+8
View File
@@ -648,6 +648,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"",
"{id?, corners?: [i…]} open Corners for path `id` (default: the selected one) and its corners (anchor indices of the path with its corners uncut, as object.setLiveShape takes them: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; default: the Direct-Selected corners, else every corner) (dialog `corners`: kind (round|invertedRound|chamfer), radius (pt); double-clicking a corner widget opens it too): OK runs object.setLiveShape",
),
(
"ui.hyphenation",
"Hyphenation…",
"",
"{} open Hyphenation (the Paragraph panel menu's Hyphenation…) on the paragraphs the Paragraph panel acts on, filled in with the values it shows (dialog `hyphenation`: hyphenate, wordsLongerThan 1..25, afterFirst 1..15, beforeLast 1..15, hyphenLimit 0..25 (0: no limit), hyphenationZone (pt), hyphenateCapitalized): OK runs text.setFormat with the values that changed, as one undo step; an error with no type selected",
),
(
"ui.colorGuideLimit",
"Limit Color Guide to Library",
@@ -1537,6 +1543,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
},
"ui.widthPointEdit" => crate::dialogs::width_point::open(app, p),
"ui.corners" => crate::dialogs::corners::open(app, p),
"ui.hyphenation" => crate::dialogs::hyphenation::open(app),
"ui.colorGuideLimit" => crate::panels::color_guide::set_limit(app, p),
"ui.colorPanel" => crate::panels::color::command(app, p),
"ui.savePdfDialog" => crate::dialogs::open_save_pdf(app, p),
@@ -2186,6 +2193,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool {
"ui.graphicStyleOptions" => app.session.active().is_some(),
"ui.sampleScreenColor" => crate::screen_color::available(app),
"ui.brushOptions" => crate::dialogs::brush_options::available(app),
"ui.hyphenation" => crate::panels::character::text_style(app).is_some(),
"ui.colorBalanceDialog" | "ui.saturateDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()),
"ui.saveSwatchLibrary" => app.session.active().is_some(),
id if id.starts_with(crate::panels::swatches::USER_SLOT) => crate::panels::swatches::user_library(app, id).is_some(),
+14 -4
View File
@@ -1,6 +1,6 @@
//! Paragraph panel: seven alignment buttons, Paragraph Direction (with the Indic options), indents,
//! space before/after, Hyphenate, Mojikumi Set and Kinsoku Set (with the East Asian options); the panel menu
//! picks the Single-line or Every-line Composer.
//! opens Hyphenation… and picks the Single-line or Every-line Composer.
use egui::{Ui, vec2};
use serde_json::{Value, json};
@@ -46,14 +46,19 @@ pub(crate) fn align(app: &mut VectorcraftApp, justify: &str) -> Result<Value, St
Ok(Value::Null)
}
fn para_cmd(app: &mut VectorcraftApp, cmd: &str, mut p: Value) {
fn para_cmd(app: &mut VectorcraftApp, cmd: &str, p: Value) {
run_on_paragraphs(app, cmd, p).ok();
}
/// Run `cmd` on the paragraphs the Paragraph panel acts on ([`format`]) and return its result.
pub(crate) fn run_on_paragraphs(app: &mut VectorcraftApp, cmd: &str, mut p: Value) -> Result<Value, String> {
if let Some((id, a, b)) = super::character::text_editing(app) {
super::character::end_typing(app);
p["ids"] = json!([id.0]);
p["start"] = json!(a);
p["end"] = json!(b);
}
app.run(cmd, p).ok();
app.run(cmd, p)
}
/// Does the paragraph being edited (at the caret), else the selected text's first paragraph, run
@@ -277,9 +282,14 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
set_pstate(ui.ctx(), "pa-hide-options", !hidden);
}
ui.separator();
for l in [tl!("Roman Hanging Punctuation"), tl!("Justification…"), tl!("Hyphenation…")] {
for l in [tl!("Roman Hanging Punctuation"), tl!("Justification…")] {
menu_item(ui, l, false, false);
}
if menu_item(ui, tl!("Hyphenation…"), has, false)
&& let Err(e) = app.run("ui.hyphenation", json!({}))
{
app.status(e);
}
// How leading is measured (Japanese layout measures it from em box top to em box top), with
// the East Asian options.
if app.session.prefs.show_east_asian_options {
+26
View File
@@ -3148,6 +3148,32 @@ Type and Convert To Point Type. Either keeps the text and its styles and is one
{"name":"run_command","arguments":{"command":"type.convertToAreaType","params":{"ids":[42]}}}
```
## Hyphenation options
`text.setFormat` sets the Paragraph panel menu's Hyphenation… options of the selected type (or `ids`, or the
paragraphs a `start` / `end` range touches), which apply while `hyphenate` is on:
- `wordsLongerThan` (letters, 1 to 25, default 5): only words with more letters than this are hyphenated.
- `afterFirst` and `beforeLast` (letters, 1 to 15, default 3): the fewest letters a word keeps before and after the
hyphen.
- `hyphenLimit` (lines, 0 to 25, default 0): the most consecutive lines that may end with a hyphen; 0 sets no limit. A
line that ends at a soft hyphen counts, and a soft hyphen still breaks a line past the limit.
- `hyphenationZone` (pt, 0 to 1296, default 0): with the Single-line Composer, a line whose text before the next word
already ends within this distance of the right edge breaks before that word instead of hyphenating it. The Every-line
Composer does not use it.
- `hyphenateCapitalized` (default `true`): `false` leaves words that start with a capital letter whole. Words in
capitals only are never hyphenated.
The letter and line counts are rounded, and every value is clamped to its range. Preferences › Hyphenation › Exceptions still decide the breaks of a
listed word. The options are saved with the paragraph (`hyphenation` in `para`) only when they differ from the
defaults, and paragraph styles carry them (`paraStyle.new {attrs: {hyphenation: {words_longer_than: 8}}}`). One undo
step. `ui.hyphenation` opens the Hyphenation dialog on the values the Paragraph panel shows; OK sends the values that
changed.
```json
{"name":"run_command","arguments":{"command":"text.setFormat","params":{"hyphenate":true,"wordsLongerThan":8,"hyphenLimit":2}}}
```
## Kinsoku Set
`text.setFormat {kinsoku: "hard"|"soft"|"none"}` sets the Paragraph panel's Kinsoku Set of the selected type (or `ids`):
+1
View File
@@ -554,6 +554,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Paragraph alignment:** the shared Paragraph/Properties alignment row leaves every button unselected when the paragraphs it acts on differ. Auto resolves per paragraph; choosing an alignment uses the existing command. Headless paragraph_mixed_alignment_* regressions exercise mixed → Center → Undo, caret/uniform states, Unicode range boundaries, Auto/direction, split runs and grouped text. These regressions and full integration CI pass. Original captures confirm mixed → Center → Undo in the existing alignment-button layout. The [public Paragraph panel guide](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/paragraph-panel-overview.html) lists the seven controls, but does not specify mixed-state rendering. Other paragraph fields remain outside this change; scores and hours unchanged.
For Auto Size area type in columns, the bisection for the frame's height runs on the lines of the one-column layout, and its result is checked with at most two layouts of the columns. When the text wraps around objects or does not fit the one-column layout, or when the check fails, the columns are laid out at every step of the bisection, as before. Typing at the start of a story of 50,000 characters in 2 columns took 1,670 ms a keystroke and now takes 292 ms (release build, `auto_height_in_columns_typing_timing`).
An edit copies a thread's story only when it changes one of the thread's frames: with a story of 50,000 characters threaded through 8 frames with a style change at every word, moving another object took 7.8 ms and now takes 0.013 ms, and a frame of a drag took 7.2 ms and now takes 0.012 ms (release build, `threaded_story_edit_timing`).
The Paragraph panel menu's Hyphenation… (`ui.hyphenation`, `text.setFormat {wordsLongerThan, afterFirst, beforeLast, hyphenLimit, hyphenationZone, hyphenateCapitalized}`) sets each paragraph's hyphenation limits: the shortest word that breaks, the fewest letters on each side of the hyphen, the most hyphenated lines in a row, a zone at the right edge where the Single-line Composer breaks before a word instead of hyphenating it, and whether capitalized words break. The defaults are words longer than 5 letters, 3 letters on each side, no limit or zone, and capitalized words hyphenated; paragraph styles and the native file keep the options.
- **Interface languages, vertical type:** Preferences ▸ User Interface (or VectorCraft ▸ Language on macOS) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, German (every `de-*` locale), Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale) Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale) cover the whole interface, Czech every menu label; German, Spanish, French, Italian, Japanese, Korean, Vietnamese, Polish, Brazilian Portuguese, Russian and Ukrainian also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, German, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Proportional Metrics (the same menu; `{proportionalMetrics}`) sets full-width glyphs on the font's proportional widths (OpenType `palt`) in horizontal type and upright glyphs on its proportional heights (`vpal`, shaped top to bottom; fonts without vertical metrics keep their cells) in vertical type, and Line-end Punctuation Half Width takes nothing more off the punctuation they re-spaced; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines. The interface is available in Polish (`pl`): menus, panels, dialogs and messages, with one/few/many plurals; AI-drafted, native review pending (#1498).
- **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `<mask>` and PDF soft masks. The constant alpha soft mask that PDF and `.ai` export draw a CMYK page with transparency through is white, so applications that read every soft mask by its luminosity show the page too (#1359, part of #1286).
Disable, Enable, Unlink and Link Opacity Mask and Opacity Mask Options (`transparency.setOpacityMask`) record no undo step when every mask they act on already has the flags they set.