From 24583effe0e4ec05eca7658ec7c1ec5b2af9d325 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 15 Oct 2026 14:51:26 -0700 Subject: [PATCH] Paragraph: the Hyphenation dialog sets each paragraph's hyphenation limits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/doc/src/lib.rs | 6 +- crates/doc/src/text.rs | 39 +++++ crates/engine/src/cmd/panelcmds.rs | 27 +++- crates/engine/src/cmd/typecmd.rs | 42 +++++ crates/text/src/composer.rs | 41 +++-- crates/text/src/hyphen.rs | 70 ++++++++- crates/text/src/layout.rs | 76 ++++++--- crates/text/src/tests_typo.rs | 136 ++++++++++++++++ crates/ui-egui/src/dialogs/hyphenation.rs | 182 ++++++++++++++++++++++ crates/ui-egui/src/dialogs/mod.rs | 2 + crates/ui-egui/src/i18n/bg.tsv | 8 + crates/ui-egui/src/i18n/cs.tsv | 8 + crates/ui-egui/src/i18n/de.tsv | 8 + crates/ui-egui/src/i18n/el.tsv | 8 + crates/ui-egui/src/i18n/es.tsv | 8 + crates/ui-egui/src/i18n/fi.tsv | 8 + crates/ui-egui/src/i18n/fr.tsv | 8 + crates/ui-egui/src/i18n/hu.tsv | 8 + crates/ui-egui/src/i18n/it.tsv | 8 + crates/ui-egui/src/i18n/ja.tsv | 8 + crates/ui-egui/src/i18n/ko.tsv | 8 + crates/ui-egui/src/i18n/pl.tsv | 8 + crates/ui-egui/src/i18n/pt-br.tsv | 8 + crates/ui-egui/src/i18n/ru.tsv | 8 + crates/ui-egui/src/i18n/sv.tsv | 8 + crates/ui-egui/src/i18n/uk.tsv | 8 + crates/ui-egui/src/i18n/vi.tsv | 8 + crates/ui-egui/src/i18n/zh-hans.tsv | 8 + crates/ui-egui/src/i18n/zh-hant.tsv | 8 + crates/ui-egui/src/menus.rs | 8 + crates/ui-egui/src/panels/paragraph.rs | 18 ++- docs/mcp.md | 26 ++++ docs/target-app-parity.md | 1 + 33 files changed, 779 insertions(+), 47 deletions(-) create mode 100644 crates/ui-egui/src/dialogs/hyphenation.rs diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index b190f4c1e..a11f04725 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -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}; diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 1fcad8a35..c0adafba7 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -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 for ParaStyleFile { space_before, space_after, hyphenate, + hyphenation, composer, tabs, style_name, @@ -494,6 +530,7 @@ impl From for ParaStyleFile { space_before, space_after, hyphenate, + hyphenation, composer, tabs, style_name, @@ -516,6 +553,7 @@ impl From for ParaStyle { space_before, space_after, hyphenate, + hyphenation, composer, tabs, style_name, @@ -534,6 +572,7 @@ impl From for ParaStyle { space_before, space_after, hyphenate, + hyphenation, composer, tabs, style_name, diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 2be5c1a93..968444277 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -43,7 +43,7 @@ pub fn specs() -> Vec { "Character / Paragraph", [], None, - "{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), 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 { "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 { 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; } diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index d1ffde26a..9a1b6bd11 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -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 diff --git a/crates/text/src/composer.rs b/crates/text/src/composer.rs index cea1c5d46..ed5e3de2f 100644 --- a/crates/text/src/composer.rs +++ b/crates/text/src/composer.rs @@ -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, } @@ -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 = 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 { diff --git a/crates/text/src/hyphen.rs b/crates/text/src/hyphen.rs index 120d7e9c6..9d1cc358c 100644 --- a/crates/text/src/hyphen.rs +++ b/crates/text/src/hyphen.rs @@ -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 { + 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 { if let Some(ex) = exception_for(word) { return match ex { Exception::Never => vec![], @@ -132,16 +145,22 @@ pub fn hyphen_points(word: &str) -> Vec { } let c: Vec = 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 = 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() { diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 70b75dc67..c56e0480d 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -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::() <= 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 = hyphen_breaks(text, g, word_start, we).into_iter().filter(|&k| k > 0 && kinsoku_allows(g, k - 1, kinsoku)).collect(); + let pts: Vec = + 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::() + 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 { +/// 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 { // 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 { 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 = 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 { .collect() } -/// Break candidates for the every-line composer. -fn candidates(text: &str, g: &[SGlyph], hyphenate: bool, kinsoku: Kinsoku) -> Vec { +/// 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 { 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(¶.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], 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], 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((¶.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], 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; diff --git a/crates/text/src/tests_typo.rs b/crates/text/src/tests_typo.rs index 27694a19e..3ba3d1e71 100644 --- a/crates/text/src/tests_typo.rs +++ b/crates/text/src/tests_typo.rs @@ -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(©.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))); diff --git a/crates/ui-egui/src/dialogs/hyphenation.rs b/crates/ui-egui/src/dialogs/hyphenation.rs new file mode 100644 index 000000000..47e1c5863 --- /dev/null +++ b/crates/ui-egui/src/dialogs/hyphenation.rs @@ -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 { + let (_, para) = crate::panels::character::text_style(app).ok_or("select some type first")?; + let h = ¶.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 { + 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 { + 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()); + } +} diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 27e16e45a..124d6d25a 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -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, diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index e43d4389c..9f46a281b 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -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 Игнориране diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 8fbbfa69b..b6e18d9e2 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 1d159e202..43881d2a7 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index c68062acb..d6a9a9d4f 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -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 Παράβλεψη diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index d9b82f297..cc2f84e73 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 2a2081342..75fc8956d 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 044b8f2ca..e6e93a202 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 0d5082767..ca36e2c7d 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index a05b6977e..12a995f3b 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index a9e683407..6c87de8ac 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -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 無視 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index e76037fd5..28ede709c 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -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 무시 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 63e6aee6b..5d0bfbb9f 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index fbacf718e..d6ec3e7d7 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 03585bba4..79a7f64b6 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -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 Игнорировать diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index dc6218d18..829beb5f8 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 6178f462a..1e844d4c6 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -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 Ігнорувати diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 863fba059..e03c59d32 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index e1e300c9b..1dc981f93 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -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 忽略 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 8ba894a26..e7f641c4f 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -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 略過 diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 8350960e6..6b322a891 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -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 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(), diff --git a/crates/ui-egui/src/panels/paragraph.rs b/crates/ui-egui/src/panels/paragraph.rs index 067cb7e32..0f56fe7a0 100644 --- a/crates/ui-egui/src/panels/paragraph.rs +++ b/crates/ui-egui/src/panels/paragraph.rs @@ -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 Result { 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 { diff --git a/docs/mcp.md b/docs/mcp.md index afa8c836d..0c370de35 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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`): diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index a5818300e..e34ff21d8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -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 `` 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.