T: text language — hyphenation, spelling and quotes follow it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-04 00:01:50 -04:00
parent 7b636fb9f5
commit 79f266c04c
8 changed files with 185 additions and 16 deletions

View File

@ -82,6 +82,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- Edit ▸ Menus (`window.hideMenuItem`): hide menu items; menus with hidden items end in Show All Menu Items.
- View ▸ Separations Preview (`view.separations`): one process plate as ink density, or areas over an ink limit in red (a GCR preview of the rendered view; spot plates and the flattener preview aren't there).
- Separations Preview plates now come from each object's own colour (CMYK as authored; RGB and images through the colour settings, images via a cached 17³ table), so pure black shows only on the black plate.
- Language (Character panel ▸ Language, `type.char {language}`): text in other languages isn't hyphenated or spell-checked with the English dictionaries, and typographer's quotes follow the language (German „…“, French and Russian «…», Swiss, Spanish, Dutch, Nordic, CJK 「…」). Hyphenation dictionaries for other languages aren't bundled yet.
- Books (`book.new/open/add/remove/list/styleSource/paginate/syncStyles/exportPdf`, Book panel): `.dcbook` files, continuous page numbering, style sync from a style source, one merged PDF.
- Track Changes (`changes.track/list/acceptAll/rejectAll`, Type ▸ Track Changes): typing is marked added (highlighted on screen), deletions are kept but hidden from layout and exports until accepted or rejected. Per-change accept/reject and authors aren't there yet.
- Track Changes: per-change Accept / Reject (`changes.accept`, `changes.reject`) and a Track Changes panel listing every change.

View File

@ -550,7 +550,7 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo
// The same spacing, hyphenation and breaker as the layout below.
let mut gl = sp.glyphs.clone();
apply_desired_spacing(&mut gl, &pp);
let hy = hyphenation_points(&story.text, &gl, &pp, &hyph_exceptions);
let hy = hyphenation_points(&story.text, &gl, &pp, &hyph_exceptions, &foreign_ranges(doc, story, prange.clone(), &base_chars));
let breaks = if pp.composer == Composer::SingleLine || gl.iter().any(|g| g.ch == '\t') || gl.len() > 4000 {
breaker::greedy(&gl, &hy, &spacing, &width)
} else if pp.balance_ragged && !spacing.justify {
@ -618,7 +618,7 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo
}
let mut glyphs = sp.glyphs;
apply_desired_spacing(&mut glyphs, &pp);
let hyph_after = hyphenation_points(&story.text, &glyphs, &pp, &hyph_exceptions);
let hyph_after = hyphenation_points(&story.text, &glyphs, &pp, &hyph_exceptions, &foreign_ranges(doc, story, prange.clone(), &base_chars));
let base_size = base_chars.size;
let base_leading = match base_chars.leading {
designcraft_doc::Leading::Auto => base_size * pp.auto_leading,
@ -1776,7 +1776,28 @@ type LimitsKey = (usize, usize, usize, bool);
/// Mark glyphs after which a hyphen may be inserted (dictionary/pattern points within the
/// paragraph's limits; words with discretionary hyphens break only there).
fn hyphenation_points(text: &str, glyphs: &[Glyph], pp: &ParaProps, exceptions: &HashMap<String, Vec<usize>>) -> Vec<bool> {
/// Whether a language uses the English hyphenation and spelling dictionaries.
pub fn is_english(language: &str) -> bool {
language.starts_with("English")
}
/// Byte ranges of paragraph `prange` set in a language other than English (no English
/// hyphenation there).
fn foreign_ranges(doc: &Document, story: &designcraft_doc::Story, prange: Range<usize>, base: &designcraft_doc::CharProps) -> Vec<Range<usize>> {
if is_english(&base.language)
&& story.runs().all(|(_, f)| f.over.language.as_deref().is_none_or(is_english) && f.style == designcraft_doc::story::NO_CHAR_STYLE)
{
return vec![];
}
story
.runs()
.filter(|(r, _)| r.start < prange.end && r.end > prange.start)
.filter(|(_, f)| !is_english(&doc.styles.resolve_char(base, f).language))
.map(|(r, _)| r)
.collect()
}
fn hyphenation_points(text: &str, glyphs: &[Glyph], pp: &ParaProps, exceptions: &HashMap<String, Vec<usize>>, foreign: &[Range<usize>]) -> Vec<bool> {
let mut out = vec![false; glyphs.len()];
if !pp.hyphenate {
return out;
@ -1822,7 +1843,8 @@ fn hyphenation_points(text: &str, glyphs: &[Glyph], pp: &ParaProps, exceptions:
let word = &text[a..b];
// Do not hyphenate the paragraph's last word unless allowed.
let is_last_word = !pp.hyph_last_word && glyphs[j..].iter().all(|g| !g.is_letter());
if !is_last_word {
let in_foreign = foreign.iter().any(|r| r.contains(&a));
if !is_last_word && !in_foreign {
let user = (!exceptions.is_empty()).then(|| exceptions.get(&word.to_lowercase())).flatten();
let pts: &[usize] = match user {
// User dictionary exceptions win over the patterns and the built-in list.

View File

@ -885,3 +885,17 @@ fn cjk_text_breaks_between_characters_with_kinsoku() {
assert!(!"、。".contains(first), "kinsoku: {:?}", &text[l.range.clone()]);
}
}
#[test]
fn only_english_text_gets_english_hyphenation() {
let text = "internationalization internationalization internationalization internationalization";
let narrow = Rect::new(36.0, 36.0, 120.0, 700.0);
let hyphenated = |d: &Document, sid| all_lines(&compose_story(d, sid, &ComposeOptions::default())).iter().filter(|l| l.hyphenated).count();
let (d, sid, _) = doc_with(text, narrow, ParaAttrs::default());
assert!(hyphenated(&d, sid) > 0, "English hyphenates");
let mut d2 = d.clone();
let st = d2.story_mut(sid).unwrap();
let n = st.len();
st.format_chars(0..n, |f| f.over.language = Some("French".into()));
assert_eq!(hyphenated(&d2, sid), 0, "French isn't hyphenated with English rules");
}

View File

@ -161,7 +161,19 @@ pub fn suggest(dict: &Dictionary, w: &str) -> Vec<String> {
}
/// Misspelled words of a story: byte ranges (acronyms skipped; the user dictionary counts).
pub fn misspellings(story: &designcraft_doc::Story, user: &[String]) -> Vec<std::ops::Range<usize>> {
pub fn misspellings(doc: &designcraft_doc::Document, story: &designcraft_doc::Story, user: &[String]) -> Vec<std::ops::Range<usize>> {
// Text in other languages isn't checked against the English dictionary.
let foreign: Vec<std::ops::Range<usize>> = {
story
.runs()
.filter(|(r, f)| {
let pi = story.para_at(r.start.min(story.len()));
let base = doc.styles.resolve_para(&story.paras[pi.min(story.paras.len().saturating_sub(1))]).1;
!designcraft_compose::is_english(&doc.styles.resolve_char(&base, f).language)
})
.map(|(r, _)| r)
.collect()
};
let dict = Dictionary::en_us();
let text = &story.text;
let bytes: Vec<(usize, char)> = text.char_indices().collect();
@ -182,7 +194,7 @@ pub fn misspellings(story: &designcraft_doc::Story, user: &[String]) -> Vec<std:
let end = bytes.get(j).map(|b| b.0).unwrap_or(text.len());
let word = &text[start..end];
let acronym = word.chars().all(|c| c.is_uppercase()) && word.chars().count() <= 5;
if !acronym && !known(dict, user, &word.to_lowercase()) {
if !acronym && !foreign.iter().any(|r| r.contains(&start)) && !known(dict, user, &word.to_lowercase()) {
out.push(start..end);
}
i = j.max(i + 1);
@ -202,7 +214,7 @@ fn check(s: &mut Session, p: &Value) -> Result<Value> {
let mut out = Vec::new();
for sid in stories {
let Some(story) = d.story(sid) else { continue };
for r in misspellings(story, &d.user_words) {
for r in misspellings(d, story, &d.user_words) {
let word = &story.text[r.clone()];
let sugg = if want_sugg { suggest(dict, &word.to_lowercase()) } else { vec![] };
out.push(json!({"story": sid.0, "start": r.start, "end": r.end, "word": word, "suggestions": sugg}));
@ -237,7 +249,7 @@ mod tests {
assert_eq!(s.execute("spelling.words", &serde_json::json!({})).unwrap(), serde_json::json!(["quux", "zorbo"]));
let mut story = designcraft_doc::Story::new(designcraft_doc::StoryId(1));
story.insert(0, "zorbo qwzx");
let bad = misspellings(&story, &s.doc().unwrap().doc.user_words);
let bad = misspellings(&s.doc().unwrap().doc, &story, &s.doc().unwrap().doc.user_words);
assert_eq!(bad, vec![6..10]);
}

View File

@ -360,6 +360,19 @@ fn smart_quotes(s: &Session, text: &str) -> String {
story.text[..t.range().start.min(story.len())].chars().last()
})
.unwrap_or(' ');
// The language at the insertion point picks the quote marks.
let language = s
.active()
.and_then(|st| {
let t = st.selection.text?;
let story = st.doc.text_story(t.story, t.cell)?;
let pos = t.range().start.min(story.len());
let pi = story.para_at(pos).min(story.paras.len().saturating_sub(1));
let base = st.doc.styles.resolve_para(story.paras.get(pi)?).1;
Some(st.doc.styles.resolve_char(&base, story.char_format_at(pos)).language)
})
.unwrap_or_default();
let [dq_open, dq_close, sq_open, sq_close] = quote_marks(&language);
let mut out = String::with_capacity(text.len());
let mut last = prev;
for c in text.chars() {
@ -367,16 +380,17 @@ fn smart_quotes(s: &Session, text: &str) -> String {
let r = match c {
'"' => {
if open {
'\u{201C}'
dq_open
} else {
'\u{201D}'
dq_close
}
}
'\'' => {
if open {
'\u{2018}'
sq_open
} else {
'\u{2019}'
// An apostrophe inside a word stays an apostrophe.
if last.is_alphanumeric() { '\u{2019}' } else { sq_close }
}
}
c => c,
@ -387,6 +401,45 @@ fn smart_quotes(s: &Session, text: &str) -> String {
out
}
/// Typographer's quotes of a language: [double open, double close, single open, single close].
pub fn quote_marks(language: &str) -> [char; 4] {
let l = language.to_ascii_lowercase();
if l.starts_with("german: swiss") || l.contains("swiss") {
['\u{00AB}', '\u{00BB}', '\u{2039}', '\u{203A}']
} else if l.starts_with("german")
|| l.starts_with("czech")
|| l.starts_with("slovak")
|| l.starts_with("bulgarian")
|| l.starts_with("lithuanian")
{
['\u{201E}', '\u{201C}', '\u{201A}', '\u{2018}']
} else if l.starts_with("french")
|| l.starts_with("russian")
|| l.starts_with("ukrainian")
|| l.starts_with("norwegian")
|| l.starts_with("greek")
{
['\u{00AB}', '\u{00BB}', '\u{2039}', '\u{203A}']
} else if l.starts_with("spanish") || l.starts_with("italian") || l.starts_with("portuguese") || l.starts_with("catalan") {
['\u{00AB}', '\u{00BB}', '\u{201C}', '\u{201D}']
} else if l.starts_with("dutch")
|| l.starts_with("polish")
|| l.starts_with("romanian")
|| l.starts_with("hungarian")
|| l.starts_with("croatian")
{
['\u{201E}', '\u{201D}', '\u{201A}', '\u{2019}']
} else if l.starts_with("swedish") || l.starts_with("finnish") {
['\u{201D}', '\u{201D}', '\u{2019}', '\u{2019}']
} else if l.starts_with("japanese") || l.starts_with("chinese") {
['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}']
} else if l.starts_with("danish") {
['\u{00BB}', '\u{00AB}', '\u{203A}', '\u{2039}']
} else {
['\u{201C}', '\u{201D}', '\u{2018}', '\u{2019}']
}
}
pub(crate) fn delete_selection(s: &mut Session) -> Result<Value> {
let tracking = s.doc()?.doc.settings.track_changes;
s.edit(|d, sel| {
@ -1088,3 +1141,25 @@ mod autocorrect_tests {
assert_eq!(s.doc().unwrap().selection.text.unwrap().focus, "teh The cat.".len());
}
}
#[cfg(test)]
mod language_tests {
use serde_json::json;
use crate::Session;
#[test]
fn language_sets_quotes_spelling_and_hyphenation() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": ""})).unwrap();
let sid = r["story"].as_u64().unwrap();
s.execute("type.char", &json!({"attrs": {"language": "German: 2006 Reform"}})).unwrap();
s.execute("text.insert", &json!({"text": "\"Wort\" ist's"})).unwrap();
let text = s.doc().unwrap().doc.stories[&designcraft_doc::StoryId(sid)].text.clone();
assert_eq!(text, "\u{201E}Wort\u{201C} ist\u{2019}s");
// German words aren't English misspellings.
let st = s.execute("spelling.check", &json!({"story": sid})).unwrap();
assert!(st.as_array().unwrap().is_empty(), "{st}");
}
}

View File

@ -1521,7 +1521,7 @@ fn draw_dynamic_spelling(app: &DesignApp, ctx: &egui::Context, painter: &egui::P
let bad: std::sync::Arc<Vec<std::ops::Range<usize>>> = match ctx.data(|d| d.get_temp(key)) {
Some(v) => v,
None => {
let v = std::sync::Arc::new(designcraft_engine::cmd::spelling::misspellings(story, &doc.user_words));
let v = std::sync::Arc::new(designcraft_engine::cmd::spelling::misspellings(doc, story, &doc.user_words));
ctx.data_mut(|d| d.insert_temp(key, v.clone()));
v
}

View File

@ -1139,6 +1139,39 @@ fn text_frame_section(app: &mut DesignApp, ui: &mut Ui) {
}
}
/// Languages offered for text (InDesign-style names).
const LANGUAGES: &[&str] = &[
"[No Language]",
"English: USA",
"English: UK",
"English: Canadian",
"German: 2006 Reform",
"German: Swiss 2006 Reform",
"French",
"French: Canadian",
"Spanish",
"Italian",
"Portuguese",
"Portuguese: Brazilian",
"Dutch: 2005 Reform",
"Danish",
"Swedish",
"Norwegian: Bokmål",
"Finnish",
"Polish",
"Czech",
"Hungarian",
"Greek",
"Russian",
"Ukrainian",
"Turkish",
"Japanese",
"Chinese",
"Korean",
"Arabic",
"Hebrew",
];
pub fn character_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
let Some(a) = text_attrs(app) else {
ui.label("Select text or a text frame.");
@ -1151,6 +1184,18 @@ pub fn character_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
super::font_family_picker(app, ui, &fam, 220.0);
super::font_style_picker(app, ui, &fam, &sty, 220.0);
variable_font_axes(app, ui, &fam, &sty);
// Language: hyphenation, spelling and typographer's quotes follow it.
let lang = c["language"].as_str().unwrap_or("English: USA").to_string();
ui.horizontal(|ui| {
caption(ui, "Language");
egui::ComboBox::from_id_salt("char_language").selected_text(&lang).width(170.0).show_ui(ui, |ui| {
for l in LANGUAGES {
if ui.selectable_label(*l == lang, *l).clicked() {
let _ = app.run("type.char", json!({"attrs": {"language": l}}));
}
}
});
});
egui::Grid::new("chargrid").num_columns(4).spacing(vec2(6.0, 4.0)).show(ui, |ui| {
caption(ui, "Size");
if let Some(v) = number(ui, "pcs", c["size"].as_f64(), " pt", 60.0, 2) {

View File

@ -10,7 +10,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia
| Priority | Features | Done | Partial | Missing | Parity |
|---|---|---|---|---|---|
| P0 | 102 | 100 | 2 | 0 | 99% |
| P1 | 97 | 94 | 3 | 0 | 98% |
| P1 | 97 | 95 | 2 | 0 | 99% |
| P2 | 76 | 65 | 11 | 0 | 93% |
| Area | Parity |
@ -24,7 +24,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia
| Effects & transparency | 92% |
| Placing & links | 93% |
| Type & text frames | 99% |
| Typography | 98% |
| Typography | 100% |
| Styles | 100% |
| Tables | 98% |
| Long documents | 100% |
@ -235,7 +235,7 @@ Open P0 items: Vector/page formats (PDF/AI/SVG/EPS place), Export to PDF (print)
| Typography | Text wrap | P0 | D |
| Typography | Anchored objects | P1 | D |
| Typography | Hyphenation exceptions | P1 | D |
| Typography | Language support | P1 | P |
| Typography | Language support | P1 | D |
| Styles | Paragraph styles | P0 | D |
| Styles | Character styles | P0 | D |
| Styles | Object styles | P1 | D |