Part of #394: each run hyphenates in its own language; Default Language sets new type's

Squashed from #2818 onto main.
This commit is contained in:
jo-quast
2026-11-05 12:17:33 +07:00
committed by bflatastic
parent 1943c7272c
commit bddb834231
14 changed files with 204 additions and 24 deletions
+5 -2
View File
@@ -143,8 +143,11 @@ pub struct CharStyle {
pub no_break: bool,
/// Language (Character panel): the BCP 47 tag of one of [`crate::setup::LANGUAGES`] these
/// characters are shaped in, so that the font uses that language's forms (OpenType `locl`).
/// `None`: the default language, English: USA, shaped without a language. New type takes the
/// document's language (Document Setup). Saved only when set.
/// `None`: the default language, English: USA, shaped without a language, which is what text
/// saved without one reads as. A tag the list doesn't have, from a file, is kept and has no
/// hyphenation. New type takes the document's language (Document Setup), else the default
/// language of Preferences › Hyphenation. Saved only when set. Each run hyphenates in its own
/// language (`vectorcraft_text::hyphen`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub language: Option<String>,
/// Another glyph of the font for the run's first character (Glyphs panel › Show › Alternates
+5 -2
View File
@@ -833,7 +833,10 @@ pub(crate) fn new_type_style(s: &Session, p: &Value) -> CharStyle {
None if !s.paint.fill.is_none() && s.paint.fill != vectorcraft_color::Paint::solid(vectorcraft_color::Color::WHITE) => s.paint.fill.clone(),
None => vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK),
};
// In the document's language (Document Setup), none for the default.
style.language = s.doc().ok().and_then(|d| vectorcraft_doc::setup::char_language(&d.doc.setup.language).ok().flatten()).map(str::to_string);
// In the language Document Setup chose, else Preferences › Hyphenation › Default Language; none
// for the default. Type already in a document keeps its own.
let chosen = |name: &str| vectorcraft_doc::setup::char_language(name).ok().flatten();
let document = s.doc().ok().and_then(|d| chosen(&d.doc.setup.language));
style.language = document.or_else(|| chosen(&s.prefs.hyphenation_language)).map(str::to_string);
style
}
+34 -8
View File
@@ -260,14 +260,40 @@ pub const PREF_SPECS: &[PrefSpec] = &[
"",
"Default Language",
choice(&[
("English: USA", "English: USA"),
("English: UK", "English: UK"),
("French", "French"),
("German", "German"),
("Spanish", "Spanish"),
("Italian", "Italian"),
("Dutch", "Dutch"),
("Portuguese", "Portuguese")
("en-US", "English: USA"),
("en-GB", "English: UK"),
("de", "German"),
("de-CH", "German: Swiss"),
("fr", "French"),
("it", "Italian"),
("es", "Spanish"),
("ca", "Catalan"),
("pt", "Portuguese"),
("nl", "Dutch"),
("da", "Danish"),
("sv", "Swedish"),
("fi", "Finnish"),
("nb", "Norwegian"),
("pl", "Polish"),
("cs", "Czech"),
("ru", "Russian"),
("sr-Cyrl", "Serbian: Cyrillic"),
("sr-Latn", "Serbian: Latin"),
("hr", "Croatian"),
("tr", "Turkish"),
("ja", "Japanese"),
("zh", "Chinese"),
("hi", "Hindi"),
("mr", "Marathi"),
("gu", "Gujarati"),
("bn", "Bengali"),
("pa", "Punjabi"),
("ta", "Tamil"),
("te", "Telugu"),
("kn", "Kannada"),
("ml", "Malayalam"),
("or", "Odia"),
("ur", "Urdu")
])
),
p!("hyphenationExceptions", "Hyphenation", "", "Exceptions (comma separated)", text),
+12 -1
View File
@@ -10,6 +10,14 @@ pub enum DrawMode {
Inside,
}
/// A saved Default Language as its tag: older preferences hold the language's name, and one the list
/// no longer has reads as the default.
fn language_tag<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<String, D::Error> {
let saved = <String as serde::Deserialize>::deserialize(d)?;
let tag = vectorcraft_doc::setup::char_language(&saved).ok().flatten();
Ok(tag.or_else(|| vectorcraft_doc::setup::LANGUAGES.first().map(|(_, t, _)| *t)).unwrap_or_default().to_string())
}
/// Application preferences (Edit → Preferences). Every field is reachable through `prefs.get` /
/// `prefs.set {key, value}` (camelCase keys, validated against [`cmd::prefscmds::PREF_SPECS`]).
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
@@ -114,6 +122,9 @@ pub struct Prefs {
pub show_slice_numbers: bool,
pub slice_line_color: String,
// Hyphenation
/// Default Language: the BCP 47 tag of the language new type takes when its document's language
/// is the default ([`vectorcraft_doc::setup::LANGUAGES`]).
#[serde(deserialize_with = "language_tag")]
pub hyphenation_language: String,
pub hyphenation_exceptions: String,
/// Type › Options › Additional Fonts Folder: a folder (read with its subfolders) whose fonts
@@ -404,7 +415,7 @@ impl Default for Prefs {
snap_perpendicular: true,
show_slice_numbers: true,
slice_line_color: s("#ff3f3f"),
hyphenation_language: s("English: USA"),
hyphenation_language: s("en-US"),
hyphenation_exceptions: String::new(),
fonts_folder: String::new(),
plugins_folder: String::new(),
+54
View File
@@ -582,6 +582,60 @@ fn set_pref(s: &mut Session, key: &str, value: Value) {
s.execute("prefs.set", &json!({"key": key, "value": value})).unwrap();
}
/// New type with the session's defaults: its id and language.
fn new_type(s: &mut Session) -> (NodeId, Option<String>) {
let id = NodeId(s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Hello"})).unwrap()["id"].as_u64().unwrap());
(id, type_language(s, id))
}
fn type_language(s: &Session, id: NodeId) -> Option<String> {
let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(id).map(|n| &n.kind) else { panic!("not text") };
t.runs[0].style.language.clone()
}
/// Preferences › Hyphenation › Default Language (#394) is the language of new type, stored as its
/// tag. Type already there keeps its language, and a language chosen in Document Setup comes first.
#[test]
fn default_language_is_the_language_of_new_type() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
let (before, language) = new_type(&mut s);
assert_eq!(language, None, "the default language isn't stored");
set_pref(&mut s, "hyphenationLanguage", json!("German"));
assert_eq!(s.prefs.hyphenation_language, "de", "a name is stored as its tag");
assert_eq!(new_type(&mut s).1.as_deref(), Some("de"));
assert_eq!(type_language(&s, before), None, "type already there stays as it is");
s.execute("document.setup", &json!({"language": "French"})).unwrap();
assert_eq!(new_type(&mut s).1.as_deref(), Some("fr"), "the document's own language comes first");
set_pref(&mut s, "hyphenationLanguage", json!("en-US"));
s.execute("document.setup", &json!({"language": "English: USA"})).unwrap();
assert_eq!(new_type(&mut s).1, None);
}
/// Preferences saved before the language was a tag hold its name; a language no longer in the list
/// reads as the default.
#[test]
fn a_saved_default_language_reads_as_its_tag() {
let read = |v: &str| serde_json::from_value::<Prefs>(json!({"hyphenationLanguage": v})).unwrap().hyphenation_language;
assert_eq!(read("German"), "de");
assert_eq!(read("English: USA"), "en-US");
assert_eq!(read("de"), "de");
assert_eq!(read("Klingon"), "en-US");
assert_eq!(Prefs::default().hyphenation_language, "en-US");
}
/// The preference offers every language the Character panel and Document Setup do.
#[test]
fn default_language_offers_every_language() {
let spec = PREF_SPECS.iter().find(|p| p.key == "hyphenationLanguage").unwrap();
let crate::cmd::prefscmds::PrefKind::Choice(options) = spec.kind else { panic!("a choice") };
let languages: Vec<(&str, &str)> = vectorcraft_doc::setup::LANGUAGES.iter().map(|(name, tag, _)| (*tag, *name)).collect();
assert_eq!(options, languages.as_slice());
}
fn gesture(s: &mut Session, v: ViewInfo, events: &[(vectorcraft_tools::PointerKind, f64, f64)]) {
for (k, x, y) in events {
s.pointer(&vectorcraft_tools::PointerEvent::new(*k, *x, *y), v).unwrap();
+45
View File
@@ -7,6 +7,11 @@
//! 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`].
//!
//! Each run hyphenates in its own language ([`vectorcraft_doc::CharStyle::language`]). English is
//! the only language with break points so far: text in any other language, or in a language a
//! file names that isn't known, doesn't hyphenate, apart from the words in the Exceptions list
//! ([`language_has_patterns`]).
use std::collections::HashMap;
use std::sync::{Mutex, MutexGuard, OnceLock};
@@ -144,6 +149,12 @@ fn onset_pair(a: char, b: char) -> bool {
)
}
/// Whether text in `language` (a [`vectorcraft_doc::CharStyle::language`]: a BCP 47 tag, `None` for
/// the default, English: USA) has break points of its own.
pub fn language_has_patterns(language: Option<&str>) -> bool {
language.is_none_or(|l| l.get(..2).is_some_and(|p| p.eq_ignore_ascii_case("en")) && matches!(l.as_bytes().get(2), None | Some(b'-')))
}
/// 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> {
@@ -157,12 +168,21 @@ pub fn hyphen_points(word: &str) -> Vec<usize> {
/// (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> {
hyphen_points_in(word, opts, true)
}
/// [`hyphen_points_with`] for a word in a language with or without break points of its own
/// ([`language_has_patterns`]): without them, only a word in the Exceptions list breaks.
pub fn hyphen_points_in(word: &str, opts: &HyphenationOptions, patterns: bool) -> Vec<usize> {
if let Some(ex) = exception_for(word) {
return match ex {
Exception::Never => vec![],
Exception::Only(pts) => lowercase_points_to_word(word, &pts),
};
}
if !patterns {
return vec![];
}
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);
@@ -235,6 +255,31 @@ mod tests {
with_hyphenation_exceptions_for_test(source, f);
}
/// #394: English in any region has break points; other languages, and a tag from a file that
/// isn't a known language, have none.
#[test]
fn only_english_has_break_points() {
for l in [None, Some("en-US"), Some("en-GB"), Some("EN-gb"), Some("en")] {
assert!(language_has_patterns(l), "{l:?}");
}
for l in [Some("de"), Some("fr"), Some("pt"), Some("eng"), Some("x-unknown"), Some(""), Some("é")] {
assert!(!language_has_patterns(l), "{l:?}");
}
}
/// #394: a language without break points leaves words whole, but the Exceptions list is the
/// user's own and applies in every language.
#[test]
fn a_language_without_break_points_keeps_only_the_exceptions() {
let opts = HyphenationOptions::default();
assert_eq!(hyphen_points_in("typography", &opts, true), vec![4, 7]);
assert!(hyphen_points_in("typography", &opts, false).is_empty());
with_exceptions("ty-pog-ra-phy, happen", || {
assert_eq!(hyphen_points_in("typography", &opts, false), vec![2, 5, 7]);
assert!(hyphen_points_in("happen", &opts, false).is_empty());
});
}
/// #394 Preferences › Hyphenation › Exceptions contract (parse + override).
#[test]
fn hyphenation_exceptions_override_the_pattern() {
+1 -1
View File
@@ -14,7 +14,7 @@ use vectorcraft_geom::{ArcPath, PathData};
use crate::GlyphBend;
use crate::composer::{Breakpoint, Params, compose};
use crate::fontdb::FontDb;
use crate::hyphen::hyphen_points_with;
use crate::hyphen::hyphen_points_in;
use crate::justify::{Flex, LineFit, apply_desired, fit_line, is_word_space};
use crate::kashida;
use crate::shape::{
+4 -3
View File
@@ -1,7 +1,7 @@
use super::region::Region;
use super::{
Affine, BezPath, Breakpoint, Burasagari, EPS, FirstBaseline, FontDb, HyphenationOptions, Kinsoku, LeadingModel, SGlyph, hyphen_glyph,
hyphen_points_with, no_line_end, no_line_start, soft_line_start,
hyphen_points_in, no_line_end, no_line_start, soft_line_start,
};
/// Vertical metrics of a line: (ascent, descent, leading, cap height, x height).
@@ -518,7 +518,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 under `opts`.
/// Glyph indices inside `g[ws..we]` (a word) where a hyphenated break may go under `opts`, in the
/// language of the word's first letter.
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);
@@ -542,7 +543,7 @@ fn hyphen_breaks(text: &str, g: &[SGlyph], ws: usize, we: usize, opts: &Hyphenat
starts.entry(g[k].byte).or_insert(k);
}
}
hyphen_points_with(word, opts).into_iter().filter_map(|ci| starts.get(offs.get(ci)?).copied()).collect()
hyphen_points_in(word, opts, g[a].hyphenates).into_iter().filter_map(|ci| starts.get(offs.get(ci)?).copied()).collect()
}
/// Break candidates for the every-line composer: hyphenation points under `hyphenate` (the
+1
View File
@@ -39,6 +39,7 @@ fn synthetic_glyphs(specs: &[(usize, usize, f64, char)]) -> Vec<SGlyph> {
no_break: false,
vertical_roman: false,
cursive: false,
hyphenates: true,
})
.collect()
}
+4
View File
@@ -80,6 +80,8 @@ pub(crate) struct SGlyph {
/// decided for the whole segment it was shaped in ([`joins`]): on a curved path it is bent
/// along the path so its joins meet.
pub cursive: bool,
/// The glyph's language has hyphenation break points ([`crate::hyphen::language_has_patterns`]).
pub hyphenates: bool,
}
/// Does `script` join its letters (a cursive script, whose neighbouring glyphs touch)?
@@ -947,6 +949,7 @@ fn inline_glyph(
no_break: st.no_break,
vertical_roman: false,
cursive: false,
hyphenates: false,
}
}
@@ -1327,6 +1330,7 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
no_break: gst.no_break,
vertical_roman: gst.standard_vertical_roman,
cursive,
hyphenates: crate::hyphen::language_has_patterns(gst.language.as_deref()),
});
}
}
+27
View File
@@ -1013,6 +1013,33 @@ fn hyphenation_rules() {
}
}
/// #394: each run hyphenates in its own language. English breaks words, a language without break
/// points doesn't, and text saved without a language breaks as it always did.
#[test]
fn each_run_hyphenates_in_its_own_language() {
let text = "Extraordinarily comprehensive typographical considerations notwithstanding everything.";
let frame = Rect::new(0.0, 0.0, 120.0, 600.0);
let hyphens = |language: Option<&str>| {
let st = CharStyle { language: language.map(str::to_string), ..style(14.0) };
let mut t = area(text, st, frame, Justify::Left);
t.para.hyphenate = true;
layout(db(), &t).glyphs.iter().filter(|g| g.len == 0).count()
};
let saved_without_one = hyphens(None);
assert!(saved_without_one > 0, "some words hyphenated");
assert_eq!(hyphens(Some("en-GB")), saved_without_one);
assert_eq!(hyphens(Some("de")), 0, "German has no break points yet");
assert_eq!(hyphens(Some("x-from-a-file")), 0, "a tag that isn't known has no hyphenation");
// both in one paragraph: the English run breaks and the German one doesn't
let mut t = area(&format!("{text} {text}"), style(14.0), frame, Justify::Left);
t.para.hyphenate = true;
edit::style_range(&mut t.runs, text.len(), 2 * text.len() + 1, |s| s.language = Some("de".into()));
let l = layout(db(), &t);
let at: Vec<usize> = l.glyphs.iter().filter(|g| g.len == 0).map(|g| g.byte).collect();
assert!(!at.is_empty() && at.iter().all(|b| *b < text.len()), "hyphens only in the English run: {at:?}");
}
#[test]
fn hyphenated_layout_adds_hyphens() {
let text = "Extraordinarily comprehensive typographical considerations notwithstanding everything.";
+7 -4
View File
@@ -1,6 +1,6 @@
# Gaps
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (document tabs are reordered by dragging them along the tab strip; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
## Feature submission evidence
@@ -28,6 +28,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard
| Date | Change | Summary |
|---|---|---|
| 2026-11-05 | minor | G12: Hyphenation › Default Language sets the language of new type unless Document Setup chose one, stored as a tag; each run hyphenates in its own language, English only so far, so 2 Preferences options stay unused (#394) |
| 2026-11-03 | minor | New gradients use CMYK default stops in CMYK documents, including Gradient tool creation. Scores and estimates unchanged. |
| 2026-11-02 | minor | Font-family fields step selected text with the mouse wheel (#5253); no scored gap or estimate reassessed. |
| 2026-10-24 | minor | G12: General › Corner Radius is the Rounded Rectangle tool's radius, a change while the tool is active reaches it, and the radius ↑/↓ set while drawing (or a Tool Options value) becomes the preference, within 0–1296 pt (#394, `crates/engine/src/tests/constrain_tools.rs`); no entry closed, estimate unchanged. |
@@ -437,9 +438,11 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
- **Done:** User Interface › Open Documents As Tabs: with the box cleared, each document opened or created
afterwards opens in a floating window of its own (G7's Float in Window); documents already open stay where they
are. Estimate unchanged.
- **Missing:** 3 Preferences options stored but not acted on (#394), each waiting on a subsystem of its own:
Hyphenation › Default Language (hyphenation is English-only, with no per-language
dictionaries), Plug-ins & Scratch Disks › Primary and Secondary (documents are kept in memory; nothing pages to
- **Done:** Hyphenation › Default Language is the language of new type, unless Document Setup chose one, and each
run hyphenates in its own language (Character panel). Only English has break points so far: text in another
language stays whole, apart from the Exceptions list (#394).
- **Missing:** 2 Preferences options stored but not acted on (#394), each waiting on a subsystem of its own:
Plug-ins & Scratch Disks › Primary and Secondary (documents are kept in memory; nothing pages to
disk), and Clipboard Handling › Legacy vector clipboard with its Preserve Paths / Preserve Appearance and
Overprints choice (Illustrator's own clipboard flavour, which has no public specification). Also Properties
panel per context, context menus beyond the canvas, Layers panel row menus and Collect for Export, SVG
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -30,6 +30,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard
| Date | Change | Summary |
|---|---|---|
| 2026-11-05 | minor | G12: Hyphenation › Default Language sets the language of new type unless Document Setup chose one, stored as a tag; each run hyphenates in its own language, English only so far, so 2 Preferences options stay unused (#394) |
| 2026-11-05 | minor | Gradient panel: Delete Stop sits at the right end of the ramp, so it shows whenever the ramp does instead of under the stop fields, where a short panel hid it (#5403); scores and estimates unchanged |
| 2026-11-04 | minor | Artboards panel: a Shift- or Ctrl/Cmd-click on a row selects several artboards with any tool, switching to the Artboard tool first, so a preset, orientation, size or Fill set in Properties or the Control bar goes to every selected artboard; Fill used to set the active artboard only (#5698); scores and estimates unchanged |
| 2026-11-04 | minor | Isolation mode: once the layer, or a group, around the isolated object is locked or hidden, a click or a marquee with the Selection tool no longer selects the isolated art (part of #5309); scores and estimates unchanged |
@@ -367,7 +368,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po
| Swatch, graphic style and symbol library panels: each library opens in a panel of its own, several at once, that floats, groups, docks and closes like any panel; opening an open library brings its panel forward; Previous / Next Library change the library in place; the panel menu's Persistent reopens the panel at startup with its view and find field (a library loaded from a file is read again; when its file is missing the panel reopens showing the file's path with Locate Library…, and the status bar names the file) | done | #2603; panel ids `library:<kind>:<id>`, `ui.libraryPanel {panel, library?, view?, showFind?, persistent?}`, `ui.libraryPanel.locate {panel, path?}`; the library panels have no sort option, as before |
| DXF export/import preserves intended curve closure | done | #1299; R13–2018 closed SPLINE bit set/read; an open curve ending at its start stays open; knots and control points unchanged; native CIRCLE/ARC export and CNC preset remain pending |
| macOS save sheet: choose the format from a menu | done | #866; the save panel is VectorCraft's own `NSSavePanel` with its Format menu set before the sheet opens (`apps/vectorcraft/src/mac_save_format.rs`); #1855 had withdrawn the menu that was added after the sheet began, which made Save As, Save a Copy and the first Save show no sheet |
| Preferences: every option acts | partial | 3 stored but unused (#394, audited 2026-10-30): Hyphenation › Default Language, Scratch Disks, Legacy vector clipboard; the rest act (Units › East Asian Type since 2026-10-31; Open Documents As Tabs: cleared, new documents open in floating windows). General › Corner Radius drives the Rounded Rectangle tool and follows its ↑/↓ (2026-10-24) |
| Preferences: every option acts | partial | 2 stored but unused (#394, audited 2026-10-30): Scratch Disks, Legacy vector clipboard; the rest act (Units › East Asian Type since 2026-10-31; Open Documents As Tabs: cleared, new documents open in floating windows). General › Corner Radius drives the Rounded Rectangle tool and follows its ↑/↓ (2026-10-24) |
| Preferences › General › Constrain Angle | done | arrow keys and Shift drags along the turned axes; the Rectangle, Rounded Rectangle, Ellipse and grid tools, point and area type and the shape size dialogs draw along them with the bounding box turned; Shift-drawn polygons and stars keep the turned base orientation; the Line Segment tool's Shift steps count from it (#394, <https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/rotate-objects-by-specific-angles.html>); arcs, spirals, flares and Pen paths are not turned |
| Tabs panel: a stop drags from where the press landed, a stop just added included | done | #1458 |
| Outline view keeps template layers in Preview, dimmed | done | #1486; a picture placed to be traced stays visible under the outlines |