Files
vectorcraft/crates/text/src/hyphen.rs
T
dotCooCoo 24583effe0 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.
2026-10-16 15:27:55 +07:00

292 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Pattern-free English hyphenation: break long words between syllables at vowel–consonant
//! 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
//! [`set_hyphenation_exceptions`].
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;
pub const MIN_AFTER: usize = 3;
/// An exception for one word: never break it, or break only at the listed character indices.
#[derive(Clone, Debug, PartialEq, Eq)]
enum Exception {
Never,
Only(Vec<usize>),
}
#[derive(Default)]
struct Exceptions {
source: String,
map: HashMap<String, Exception>,
}
fn store() -> MutexGuard<'static, Exceptions> {
static LOCK: OnceLock<Mutex<Exceptions>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(Exceptions::default())).lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn parse_list(source: &str) -> Exceptions {
let mut map = HashMap::new();
for entry in source.split(',') {
let entry = entry.trim();
if entry.is_empty() {
continue;
}
let (word, ex) = parse_exception(entry);
if word.is_empty() {
continue;
}
map.insert(word, ex);
}
Exceptions { source: source.to_string(), map }
}
/// Preferences › Hyphenation › Exceptions: comma-separated words. A word with no hyphens is never
/// hyphenated; hyphens mark the only allowed break points (`ty-pog-ra-phy`). Matching is
/// case-insensitive. Empty clears the list.
pub fn set_hyphenation_exceptions(source: &str) {
*store() = parse_list(source);
}
/// The last string passed to [`set_hyphenation_exceptions`] (empty when none).
pub fn hyphenation_exceptions() -> String {
store().source.clone()
}
fn parse_exception(entry: &str) -> (String, Exception) {
// Illustrator: bare word = never break; hyphens mark the only allowed breaks. No other syntax.
if !entry.contains('-') {
return (normalize_word(entry), Exception::Never);
}
let mut word = String::new();
let mut points = Vec::new();
for ch in entry.chars() {
if ch == '-' {
let at = word.chars().count();
if at > 0 {
points.push(at);
}
} else {
for c in ch.to_lowercase() {
word.push(c);
}
}
}
let n = word.chars().count();
points.retain(|&p| p > 0 && p < n);
points.sort_unstable();
points.dedup();
(word, Exception::Only(points))
}
fn normalize_word(word: &str) -> String {
word.chars().flat_map(|c| c.to_lowercase()).collect()
}
/// Look the word up under the preferences lock. Only the matched [`Exception`] is cloned, never
/// the whole list.
#[cfg(not(test))]
fn exception_for(word: &str) -> Option<Exception> {
store().map.get(&normalize_word(word)).cloned()
}
/// Unit tests read only their own thread's list: tests running in parallel set the process-wide
/// one.
#[cfg(test)]
fn exception_for(word: &str) -> Option<Exception> {
TEST_OVERRIDE.with(|c| c.borrow().as_ref().and_then(|e| e.map.get(&normalize_word(word)).cloned()))
}
fn is_vowel(c: char) -> bool {
matches!(c.to_ascii_lowercase(), 'a' | 'e' | 'i' | 'o' | 'u' | 'y') || (c.is_alphabetic() && !c.is_ascii())
}
/// Consonant pairs that stay together at the start of a syllable ("gra-phy", "tea-cher").
fn onset_pair(a: char, b: char) -> bool {
let (a, b) = (a.to_ascii_lowercase(), b.to_ascii_lowercase());
matches!(
(a, b),
('c' | 's' | 't' | 'p' | 'w' | 'g', 'h')
| ('b' | 'c' | 'd' | 'f' | 'g' | 'k' | 'p' | 't', 'r')
| ('b' | 'c' | 'f' | 'g' | 'k' | 'p' | 's', 'l')
| ('q', 'u')
)
}
/// 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![],
Exception::Only(pts) => pts,
};
}
let c: Vec<char> = word.chars().collect();
let n = c.len();
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 before..=n.saturating_sub(after) {
if !c[i].is_alphabetic() || !c[i - 1].is_alphabetic() {
continue;
}
let ok = if v[i - 1] && !v[i] {
// V-CV: a single consonant (or an onset pair) followed by a vowel starts the syllable.
(i + 1 < n && v[i + 1]) || (i + 2 < n && onset_pair(c[i], c[i + 1]) && v[i + 2])
} else if !v[i - 1] && !v[i] {
// VC-CV: split a consonant cluster after the first consonant unless it's an onset pair.
i >= 2 && v[i - 2] && i + 1 < n && v[i + 1] && !onset_pair(c[i - 1], c[i])
} else {
false
};
if ok {
out.push(i);
}
}
out
}
/// `word` with `-` at every hyphenation point (for tests and diagnostics).
pub fn hyphenate_word(word: &str) -> String {
let pts = hyphen_points(word);
let mut s = String::with_capacity(word.len() + pts.len());
for (i, ch) in word.chars().enumerate() {
if pts.contains(&i) {
s.push('-');
}
s.push(ch);
}
s
}
#[cfg(test)]
thread_local! {
/// Per-thread list so unit tests don't race the process-wide preferences store.
static TEST_OVERRIDE: std::cell::RefCell<Option<Exceptions>> = const { std::cell::RefCell::new(None) };
}
/// Run `f` with Preferences › Hyphenation › Exceptions applied on this thread only (tests).
#[cfg(test)]
pub(crate) fn with_hyphenation_exceptions_for_test(source: &str, f: impl FnOnce()) {
TEST_OVERRIDE.with(|c| *c.borrow_mut() = Some(parse_list(source)));
f();
TEST_OVERRIDE.with(|c| *c.borrow_mut() = None);
}
#[cfg(test)]
mod tests {
use super::*;
fn with_exceptions(source: &str, f: impl FnOnce()) {
with_hyphenation_exceptions_for_test(source, f);
}
/// #394 Preferences › Hyphenation › Exceptions contract (parse + override).
#[test]
fn hyphenation_exceptions_override_the_pattern() {
let pattern = hyphen_points("typography");
assert_eq!(pattern, vec![4, 7], "pattern: typo-gra-phy");
// Bare word: never break (case-insensitive).
with_exceptions("typography", || {
assert!(hyphen_points("typography").is_empty());
assert!(hyphen_points("Typography").is_empty(), "case-insensitive");
});
// Hyphens mark the only allowed breaks (stricter than the pattern).
with_exceptions("typo-graphy", || {
assert_eq!(hyphen_points("typography"), vec![4], "only the marked break");
assert_eq!(hyphenate_word("typography"), "typo-graphy");
});
// Comma-separated list; empty / whitespace entries ignored.
with_exceptions(" , typography , hap-pen , ", || {
assert!(hyphen_points("typography").is_empty());
assert_eq!(hyphen_points("happen"), vec![3]);
assert!(!hyphen_points("hyphenation").is_empty(), "unlisted words still use the pattern");
});
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() {
set_hyphenation_exceptions("typography, hap-pen");
assert_eq!(hyphenation_exceptions(), "typography, hap-pen");
set_hyphenation_exceptions("");
assert!(hyphenation_exceptions().is_empty());
}
}