T: hyphenation exceptions in the user dictionary
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
ee89bf187f
commit
4f18835eb3
|
|
@ -57,6 +57,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
|
|||
- Appearance of Black complete: Printing/Exporting rich black for RGB output (PNG), Overprint [Black] Swatch at 100% (document, IDML OverprintBlack).
|
||||
- Hyperlinks and Bookmarks panels (Window ▸ Interactive): new from the selection, edit (`hyperlink.edit`), go to source (`hyperlink.goToSource`), rename bookmarks (`bookmark.rename`), go to page, delete.
|
||||
- Nested line styles (`nestedLineStyles` paragraph attribute, Paragraph Style Options › Drop Caps and Nested Styles, IDML AllNestedLineStyles): the first lines restyle until the line breaks settle.
|
||||
- User dictionary hyphenation exceptions (`hyphenation.addException/removeException/list`, Edit ▸ Spelling ▸ User Dictionary): `ex~am~ple` breaks only at `~`, a plain word never hyphenates.
|
||||
- Drag and drop text editing: drag selected text to move it (Alt copies) with its formatting (`text.release`).
|
||||
- Image Import Options: place any page of a multi-page PDF (`file.place {pdfPage}`; the app asks which page), shown on screen and embedded as that page in PDF export.
|
||||
- Define Lists: named numbered lists that continue across stories in page order (`list.define`, paragraph `listName` / `startAt`), round-tripped through IDML; `null` in `type.para` / `type.char` now removes an override.
|
||||
|
|
|
|||
|
|
@ -92,6 +92,13 @@ impl Cache {
|
|||
if !story.endnotes.is_empty() {
|
||||
sig.push(doc.endnote_start(sid) as usize);
|
||||
}
|
||||
// User hyphenation exceptions.
|
||||
if !doc.hyphenation_exceptions.is_empty() {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
doc.hyphenation_exceptions.hash(&mut h);
|
||||
sig.push(h.finish() as usize);
|
||||
}
|
||||
// Hidden conditions take their text out of the layout.
|
||||
for (i, c) in doc.conditions.iter().enumerate() {
|
||||
if !c.visible {
|
||||
|
|
|
|||
|
|
@ -369,6 +369,7 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo
|
|||
let mut cur = Cursor { fi: 0, col: 0, last_baseline: None, last_descent: 0.0, pending: 0.0 };
|
||||
let para_ranges = story.para_ranges();
|
||||
let np = para_ranges.len();
|
||||
let hyph_exceptions = doc.hyphenation_exception_map();
|
||||
let mut list_counter: u32 = 0;
|
||||
// Named lists number independently of layout: one pass up front.
|
||||
let named_numbers: Vec<Option<u32>> = {
|
||||
|
|
@ -512,7 +513,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);
|
||||
let hy = hyphenation_points(&story.text, &gl, &pp, &hyph_exceptions);
|
||||
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 {
|
||||
|
|
@ -580,7 +581,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);
|
||||
let hyph_after = hyphenation_points(&story.text, &glyphs, &pp, &hyph_exceptions);
|
||||
let base_size = base_chars.size;
|
||||
let base_leading = match base_chars.leading {
|
||||
designcraft_doc::Leading::Auto => base_size * pp.auto_leading,
|
||||
|
|
@ -1629,7 +1630,7 @@ 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) -> Vec<bool> {
|
||||
fn hyphenation_points(text: &str, glyphs: &[Glyph], pp: &ParaProps, exceptions: &HashMap<String, Vec<usize>>) -> Vec<bool> {
|
||||
let mut out = vec![false; glyphs.len()];
|
||||
if !pp.hyphenate {
|
||||
return out;
|
||||
|
|
@ -1676,7 +1677,12 @@ fn hyphenation_points(text: &str, glyphs: &[Glyph], pp: &ParaProps) -> Vec<bool>
|
|||
// 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 pts = cache.entry(word.into()).or_insert_with(|| hyphen::hyphen_points(word, &lim).into_boxed_slice());
|
||||
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.
|
||||
Some(v) => v,
|
||||
None => cache.entry(word.into()).or_insert_with(|| hyphen::hyphen_points(word, &lim).into_boxed_slice()),
|
||||
};
|
||||
for &p in pts.iter() {
|
||||
// char index p → byte → glyph whose cluster ends at that byte.
|
||||
let byte = a + word.char_indices().nth(p).map(|(bi, _)| bi).unwrap_or(word.len());
|
||||
|
|
|
|||
|
|
@ -144,6 +144,7 @@ impl Document {
|
|||
hyperlinks: vec![],
|
||||
bookmarks: vec![],
|
||||
user_words: vec![],
|
||||
hyphenation_exceptions: vec![],
|
||||
toc: None,
|
||||
text_variables: crate::vars::defaults(),
|
||||
footnote_options: Default::default(),
|
||||
|
|
|
|||
|
|
@ -153,6 +153,29 @@ pub struct Condition {
|
|||
}
|
||||
|
||||
impl Document {
|
||||
/// Hyphenation exceptions by lowercase word: the break positions (char indices; empty = never
|
||||
/// hyphenate).
|
||||
pub fn hyphenation_exception_map(&self) -> std::collections::HashMap<String, Vec<usize>> {
|
||||
self.hyphenation_exceptions
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let mut word = String::new();
|
||||
let mut breaks = Vec::new();
|
||||
for c in e.trim().chars() {
|
||||
if c == '~' {
|
||||
if breaks.last() != Some(&word.chars().count()) && !word.is_empty() {
|
||||
breaks.push(word.chars().count());
|
||||
}
|
||||
} else {
|
||||
word.extend(c.to_lowercase());
|
||||
}
|
||||
}
|
||||
breaks.retain(|b| *b < word.chars().count());
|
||||
(word, breaks)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Is text with these conditions hidden? (Conditioned text shows while any of its conditions
|
||||
/// is visible.)
|
||||
pub fn conditions_hide(&self, conds: &[String]) -> bool {
|
||||
|
|
@ -394,6 +417,10 @@ pub struct Document {
|
|||
/// Words added to the document's user dictionary (spelling).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub user_words: Vec<String>,
|
||||
/// User dictionary hyphenation exceptions: `ex~am~ple` (breaks only at `~`), or a word with
|
||||
/// no `~` that never hyphenates.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub hyphenation_exceptions: Vec<String>,
|
||||
/// The generated table of contents (Layout → Table of Contents), kept for Update.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub toc: Option<Toc>,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,35 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
vec![
|
||||
cmd!(query "spelling.check", "Check Spelling…", ["Edit", "Spelling"], Some("Cmd+I"),
|
||||
"{story?, suggestions?: true} → [{story, start, end, word, suggestions}]", has_doc, check),
|
||||
cmd!(
|
||||
"hyphenation.addException",
|
||||
"Add Hyphenation Exception",
|
||||
["Edit", "Spelling", "User Dictionary"],
|
||||
None,
|
||||
"{word: \"ex~am~ple\" (breaks only at ~) or a word with no ~ (never hyphenated)}",
|
||||
has_doc,
|
||||
|s, p| {
|
||||
let w = str_param(p, "word")
|
||||
.map(str::trim)
|
||||
.filter(|w| !w.replace('~', "").is_empty())
|
||||
.ok_or_else(|| bad("hyphenation.addException", "missing word"))?
|
||||
.to_string();
|
||||
let plain = w.replace('~', "").to_lowercase();
|
||||
s.edit(|d, _| {
|
||||
d.hyphenation_exceptions.retain(|e| e.replace('~', "").to_lowercase() != plain);
|
||||
d.hyphenation_exceptions.push(w.clone());
|
||||
Ok(Value::Null)
|
||||
})
|
||||
}
|
||||
),
|
||||
cmd!("hyphenation.removeException", "Remove Hyphenation Exception", [], None, "{word} (with or without ~)", has_doc, |s, p| {
|
||||
let plain = str_param(p, "word").unwrap_or("").replace('~', "").to_lowercase();
|
||||
s.edit(|d, _| {
|
||||
d.hyphenation_exceptions.retain(|e| e.replace('~', "").to_lowercase() != plain);
|
||||
Ok(Value::Null)
|
||||
})
|
||||
}),
|
||||
cmd!(query "hyphenation.list", "Hyphenation Exceptions", [], None, "{} → [word]", has_doc, |s, _| Ok(serde_json::to_value(&s.doc()?.doc.hyphenation_exceptions).unwrap_or_default())),
|
||||
cmd!("spelling.addWord", "Add to Dictionary", ["Edit", "Spelling"], None, "{word}", has_doc, |s, p| {
|
||||
let w = str_param(p, "word").ok_or_else(|| bad("spelling.addWord", "missing word"))?.to_lowercase();
|
||||
s.edit(|d, _| {
|
||||
|
|
@ -188,3 +217,36 @@ mod tests {
|
|||
assert_eq!(s.execute("spelling.check", &json!({})).unwrap().as_array().unwrap().len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod hyphenation_exception_tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn first_line(s: &Session, sid: designcraft_doc::StoryId) -> String {
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let cs = s.cache.get(d, sid, None);
|
||||
let l = &cs.frames[0].lines[0];
|
||||
let mut t = d.stories[&sid].text[l.range.clone()].to_string();
|
||||
if l.hyphenated {
|
||||
t.push('-');
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_exceptions_decide_where_words_break() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let r = s.execute("frame.create", &json!({"rect": [72, 72, 172, 400], "content": "text", "text": "a internationalization"})).unwrap();
|
||||
let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap());
|
||||
s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap();
|
||||
s.execute("type.para", &json!({"hyphenate": true, "hyphLastWord": true})).unwrap();
|
||||
s.execute("hyphenation.addException", &json!({"word": "in~ternationalization"})).unwrap();
|
||||
assert_eq!(first_line(&s, sid), "a in-", "the only break allowed");
|
||||
s.execute("hyphenation.addException", &json!({"word": "internationalization"})).unwrap();
|
||||
assert_eq!(s.execute("hyphenation.list", &json!({})).unwrap().as_array().unwrap().len(), 1, "replaces the earlier entry");
|
||||
assert!(!first_line(&s, sid).ends_with('-'), "never hyphenated");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2029,6 +2029,7 @@ impl<'r> Importer<'r> {
|
|||
hyperlinks: vec![],
|
||||
bookmarks: vec![],
|
||||
user_words: vec![],
|
||||
hyphenation_exceptions: vec![],
|
||||
toc: None,
|
||||
text_variables: designcraft_doc::vars::defaults(),
|
||||
footnote_options: std::mem::take(&mut self.footnote_options),
|
||||
|
|
|
|||
|
|
@ -105,6 +105,38 @@ fn text_field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, w: f32) {
|
|||
|
||||
/// Edit › Keyboard Shortcuts: every command and its shortcut; click a shortcut, then press the
|
||||
/// new keys (Esc cancels, Backspace clears).
|
||||
/// Edit › Spelling › User Dictionary: added words and hyphenation exceptions (applied at once).
|
||||
fn user_dictionary(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
ui.label(egui::RichText::new("Hyphenation Exceptions").font(semibold(12.0)));
|
||||
ui.label(egui::RichText::new("Type a word with ~ at each allowed break (ex~am~ple); without ~ the word is never hyphenated.").size(10.5));
|
||||
ui.horizontal(|ui| {
|
||||
text_field(ui, d, "word", 200.0);
|
||||
if ui.button("Add").clicked() {
|
||||
let w = d.s("word");
|
||||
if !w.trim().is_empty() {
|
||||
match app.run("hyphenation.addException", json!({"word": w.trim()})) {
|
||||
Ok(_) => {
|
||||
d.fields.insert("word".into(), json!(""));
|
||||
}
|
||||
Err(e) => app.status(format!("User Dictionary: {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let list = app.session.execute("hyphenation.list", &json!({})).ok().and_then(|v| v.as_array().cloned()).unwrap_or_default();
|
||||
egui::ScrollArea::vertical().max_height(180.0).show(ui, |ui| {
|
||||
for w in list {
|
||||
let w = w.as_str().unwrap_or("").to_string();
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(&w);
|
||||
if ui.small_button("Remove").clicked() {
|
||||
let _ = app.run("hyphenation.removeException", json!({"word": w}));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn keyboard_shortcuts(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
let mut q = d.s("query");
|
||||
ui.add(egui::TextEdit::singleline(&mut q).hint_text("Search commands").desired_width(f32::INFINITY));
|
||||
|
|
@ -608,6 +640,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
|
|||
"insertXref" => "New Cross-Reference",
|
||||
"findFont" => "Find/Replace Font",
|
||||
"polygonSettings" => "Polygon Settings",
|
||||
"userDictionary" => "User Dictionary",
|
||||
"qrCode" => "Generate QR Code",
|
||||
"preferences" => "Preferences",
|
||||
"print" => "Print",
|
||||
|
|
@ -759,6 +792,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
|
|||
});
|
||||
}
|
||||
"keyboardShortcuts" => keyboard_shortcuts(app, ui, &mut d),
|
||||
"userDictionary" => user_dictionary(app, ui, &mut d),
|
||||
"qrCode" => {
|
||||
let cur = d.s("type");
|
||||
ui.horizontal(|ui| {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
|
|||
("app.exportHtml", "Export HTML…", None, "{path?} — one self-contained web page"),
|
||||
("app.printBooklet", "Print Booklet…", None, "{path?} — saddle-stitched printer spreads as PDF"),
|
||||
("app.qrCode", "Generate QR Code…", None, "{} — the QR Code dialog (object.qrCode does the work)"),
|
||||
("app.userDictionary", "User Dictionary…", None, "{} — hyphenation exceptions (hyphenation.* commands)"),
|
||||
("app.exportText", "Export Text…", None, "{path?} — the story being edited, as Text Only (.txt) or Rich Text Format (.rtf)"),
|
||||
("app.exportPdf", "Export PDF…", None, "{path?, …file.exportPdf options} — asks for a path when none is given"),
|
||||
("app.palette", "Quick Apply…", Some("Cmd+Return"), "{} — search styles and commands"),
|
||||
|
|
@ -145,6 +146,7 @@ pub const MENUS: &[(&str, &[&str])] = &[
|
|||
">Spelling",
|
||||
"cmd:spelling.check",
|
||||
"cmd:spelling.addWord",
|
||||
"ui:app.userDictionary",
|
||||
"<",
|
||||
">Transparency Blend Space",
|
||||
"cmd:edit.transparencyBlendSpace|Document RGB|{\"space\": \"rgb\"}",
|
||||
|
|
@ -550,6 +552,10 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value,
|
|||
"app.exportEpub" => export_bytes(app, p, "epub", "file.exportEpub"),
|
||||
"app.exportHtml" => export_bytes(app, p, "html", "file.exportHtml"),
|
||||
"app.printBooklet" => export_bytes(app, p, "pdf", "file.printBooklet"),
|
||||
"app.userDictionary" => {
|
||||
app.ui.dialog = Some(crate::dialogs::Dialog::new("userDictionary", json!({"word": ""})));
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"app.qrCode" => {
|
||||
app.ui.dialog = Some(crate::dialogs::Dialog::new("qrCode", json!({})));
|
||||
Ok(Value::Null)
|
||||
|
|
|
|||
|
|
@ -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 | 99 | 3 | 0 | 99% |
|
||||
| P1 | 97 | 79 | 17 | 1 | 90% |
|
||||
| P1 | 97 | 80 | 16 | 1 | 91% |
|
||||
| P2 | 76 | 23 | 13 | 40 | 39% |
|
||||
|
||||
| Area | Parity |
|
||||
|
|
@ -24,7 +24,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia
|
|||
| Effects & transparency | 71% |
|
||||
| Placing & links | 82% |
|
||||
| Type & text frames | 92% |
|
||||
| Typography | 90% |
|
||||
| Typography | 92% |
|
||||
| Styles | 92% |
|
||||
| Tables | 92% |
|
||||
| Long documents | 88% |
|
||||
|
|
@ -234,7 +234,7 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t
|
|||
| Typography | Auto leading | P0 | D |
|
||||
| Typography | Text wrap | P0 | D |
|
||||
| Typography | Anchored objects | P1 | P |
|
||||
| Typography | Hyphenation exceptions | P1 | P |
|
||||
| Typography | Hyphenation exceptions | P1 | D |
|
||||
| Typography | Language support | P1 | P |
|
||||
| Styles | Paragraph styles | P0 | D |
|
||||
| Styles | Character styles | P0 | D |
|
||||
|
|
|
|||
Loading…
Reference in New Issue