Merge branch 'control-panel-lists-shading' into paragraph-panel-indesign

# Conflicts:
#	ROADMAP.md
This commit is contained in:
moppeeer
2026-10-22 17:01:04 +02:00
23 changed files with 1357 additions and 619 deletions
+1
View File
@@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
- **2026-10-16** · Edit › Keyboard Shortcuts imports a set saved by InDesign's Show Set (.txt, Windows or Mac key names, UTF-8 or UTF-16): matched commands take its Default shortcuts or lose theirs, other commands give up keys it takes, and a report lists what it changed and the items it could not match (`window.importShortcuts`, #1180). Tool keys stay fixed. No readiness percentage change.
- **2026-10-22** · Hyphenation Settings, as in InDesign: the Paragraph panel menu's Hyphenation… (`app.hyphenationDialog`) sets the selected paragraphs' Hyphenate, Words with at Least, After First, Before Last, Hyphen Limit, Hyphenation Zone, the Better Spacing ↔ Fewer Hyphens slider (InDesign's 0–10 steps), Hyphenate Capitalized Words, Last Word and Across Column, with Preview, as one undo step; Paragraph Style Options › Hyphenation has the same form. The panel menu lists its items in InDesign's order. Replicated from a hyphenation tutorial: the same text in Minion Pro breaks into the same lines as InDesign's with hyphenation on, off, and at Fewer Hyphens. Hyphenate Across Column is kept and round-trips through IDML, but composition doesn't apply it yet. No readiness percentage change.
- **2026-10-22** · The Paragraph panel is laid out as InDesign's: the nine alignment buttons in groups; indent, spacing and drop cap fields by their icons in two columns, with Space Between Paragraphs Using Same Style (Ignore or a space); Shading and Border with the Paragraph Borders and Shading button and colour; Hyphenate with Do Not Align / Align to Baseline Grid. Its panel menu follows InDesign's order: Paragraph and Single-line Composer, Only Align First Line to Grid, Balance Ragged Lines, Optical Margin Alignment, Justification…, Keep Options…, Span Columns…, Hyphenation…, Drop Caps and Nested Styles…, GREP Styles… (these three now open for the selected paragraphs), Paragraph Rules…, Paragraph Borders and Shading…, Bullets and Numbering…, Restart/Continue Numbering, Define Lists… and the paragraph direction. Convert Bullets and Numbering to Text is listed but not available yet. No readiness percentage change.
- **2026-10-22** · A ruler guide dragged with the Selection tools, out of a ruler, on its own or with other selected guides, snaps along its axis as objects do (#1221): to other ruler guides, margins, columns, bleed, page and object edges and centers (with the smart-guide mark) and the document grid, within the snap zone and following Snap to Guides and Smart Guides. Shift snaps to the ruler's ticks and Cmd suspends snapping. The preview line, the position readout and the drop use the same snapped position. No readiness percentage change.
+2
View File
@@ -395,6 +395,8 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
let ctx = ctx.clone();
let dialog = if request.purpose == "swatches" {
rfd::AsyncFileDialog::new().add_filter("Swatch Exchange (ASE)", &["ase"])
} else if request.purpose == "shortcuts" {
rfd::AsyncFileDialog::new().add_filter("Shortcut set (text)", &["txt"])
} else if request.purpose == "place" {
rfd::AsyncFileDialog::new().add_filter("Graphics", IMAGE_EXTS)
} else {
+1
View File
@@ -187,6 +187,7 @@ fn open_filters(purpose: &str) -> &'static [OpenFilter] {
match purpose {
"swatches" => &[OpenFilter { name: "Swatch Exchange (ASE)", extensions: &["ase"] }],
"script" => &[OpenFilter { name: "Script", extensions: &["dcscript", "txt", "json"] }],
"shortcuts" => &[OpenFilter { name: "Shortcut set (text)", extensions: &["txt"] }],
"icc" => &[OpenFilter { name: "ICC profile", extensions: &["icc", "icm"] }],
"book" => &[OpenFilter { name: "Book", extensions: &["dcbook"] }],
"xml" => &[OpenFilter { name: "XML", extensions: &["xml"] }],
+1 -1
View File
@@ -109,7 +109,7 @@ fn position_param(p: &Value, base: &AnchorPosition) -> Result<AnchorPosition> {
}
/// Text frames can't be anchored yet (their stories need frames on a spread).
fn check_item(it: &Item) -> Result<()> {
pub(crate) fn check_item(it: &Item) -> Result<()> {
let mut ok = true;
it.walk(&mut |i| ok &= !matches!(i.content, Content::Text(_)));
if ok { Ok(()) } else { Err(bad("anchored.insert", "text frames can't be anchored yet")) }
+103 -6
View File
@@ -43,24 +43,64 @@ fn copy_text(s: &mut Session) -> Result<Option<String>> {
Ok(Some(text))
}
/// Plain text for the system clipboard after copying objects: the copied text frames' stories,
/// or U+FFFC (object replacement character) when none has text. Paste reads it back as "the
/// copied objects", not as text.
pub fn copied_objects_text(s: &Session) -> String {
let Some(clip) = &s.clipboard else { return '\u{FFFC}'.to_string() };
let mut stories = Vec::new();
for it in clip.spreads.first().map(|sp| sp.items.as_slice()).unwrap_or_default() {
it.walk(&mut |i| {
if let Some(tf) = i.text_frame()
&& !stories.contains(&tf.story)
{
stories.push(tf.story);
}
});
}
let text = stories.iter().filter_map(|id| clip.story(*id)).map(plain).filter(|t| !t.is_empty()).collect::<Vec<_>>().join("\n");
if text.is_empty() { '\u{FFFC}'.to_string() } else { text }
}
/// `text` (the system clipboard's) is what the last copy put there: objects were copied last and
/// it is their placeholder, so Paste stands for the copied objects.
fn is_objects_placeholder(s: &Session, text: &str) -> bool {
s.text_clipboard.is_none() && s.clipboard.is_some() && text.replace("\r\n", "\n") == copied_objects_text(s).replace("\r\n", "\n")
}
/// Paste text at the insertion point: the formatted copy when the system clipboard still holds
/// what was copied here (or `formatted` is forced), else plain text in the insertion format.
/// With `formatted`, objects copied last paste as anchored objects when `text` is absent or their
/// placeholder; other text was copied in another app since. Objects that can't be anchored (text
/// frames, into a table cell) paste as their placeholder text.
fn paste_text(s: &mut Session, text: Option<String>, formatted: bool) -> Result<Value> {
let t = s.doc()?.selection.text.ok_or_else(|| super::bad("edit.paste", "no insertion point"))?;
let clip = s.text_clipboard.clone();
// Items copied last (no text copied since) paste into the text as anchored objects.
if formatted
&& clip.is_none()
let own = formatted && clip.is_none() && s.clipboard.is_some() && text.as_deref().is_none_or(|x| is_objects_placeholder(s, x));
if own
&& t.cell.is_none()
&& let Some(items) = s.clipboard.clone()
{
let list: Vec<designcraft_doc::Item> = items.spreads.first().map(|sp| sp.items.iter().map(|i| (**i).clone()).collect()).unwrap_or_default();
if !list.is_empty() && list.iter().all(|i| !i.is_text_frame()) {
if !list.is_empty() && list.iter().all(|i| super::anchored::check_item(i).is_ok()) {
return s.edit(|d, sel| {
let st = d.story_mut(t.story).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
let r = t.range();
st.delete(r.start.min(st.len())..r.end.min(st.len()));
let end = super::anchored::anchor_items(d, t.story, r.start, list.clone(), &Default::default())?;
// Fresh ids, story and asset copies for every paste, as on the page.
let mut stories = std::collections::HashMap::new();
let fresh: Vec<designcraft_doc::Item> = list
.iter()
.map(|i| {
let mut i = i.clone();
super::object::renumber(d, &items, &mut i, &mut stories);
if d.layer(i.layer).is_none() {
i.layer = d.default_layer();
}
i
})
.collect();
let end = super::anchored::anchor_items(d, t.story, r.start, fresh, &Default::default())?;
sel.text = Some(designcraft_doc::TextSel { anchor: end, focus: end, ..t });
Ok(json!({"pos": end, "anchored": list.len()}))
});
@@ -80,6 +120,8 @@ fn paste_text(s: &mut Session, text: Option<String>, formatted: bool) -> Result<
Ok(json!({"pos": end, "formatted": true}))
});
}
// U+FFFC stands for objects with no text.
let text = if own { Some(copied_objects_text(s)).filter(|m| m != "\u{FFFC}") } else { text };
let text = text.or_else(|| clip.map(|(_, p)| p)).ok_or_else(|| super::bad("edit.paste", "clipboard is empty"))?;
let text = outside_text(&text)?;
s.execute("text.insert", &json!({"text": text, "raw": true}))
@@ -327,7 +369,8 @@ pub fn specs() -> Vec<CommandSpec> {
if s.doc()?.selection.text.is_some() {
return paste_text(s, text.map(str::to_string), true);
}
if let Some(text) = text {
// The placeholder of an object copy pastes the copied objects.
if let Some(text) = text.filter(|t| !is_objects_placeholder(s, t)) {
// What our own text copy put on the clipboard keeps its formatting.
let slice = s.text_clipboard.clone().filter(|(_, plain)| text.replace("\r\n", "\n") == *plain).map(|(slice, _)| slice);
return paste_text_frame(s, p, text, slice.as_deref());
@@ -538,6 +581,60 @@ mod text_clipboard_tests {
s.doc().unwrap().doc.check().unwrap();
}
/// Copying a rectangle, then text in another app, pasted the rectangle into the text (#212).
#[test]
fn paste_at_a_caret_prefers_other_apps_text_to_copied_objects() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let story = frame(&mut s, "ab");
let rect = s.execute("frame.create", &json!({"rect": [72, 400, 200, 480]})).unwrap()["id"].clone();
s.execute("selection.set", &json!({"ids": [rect]})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
let marker = copied_objects_text(&s);
s.execute("text.select", &json!({"story": story.0, "anchor": 2, "focus": 2})).unwrap();
let r = s.execute("edit.paste", &json!({"text": "From elsewhere"})).unwrap();
assert!(r.get("anchored").is_none(), "{r}");
assert_eq!(s.doc().unwrap().doc.story(story).unwrap().text, "abFrom elsewhere");
// What the object copy put on the system clipboard still pastes the objects.
assert_eq!(s.execute("edit.paste", &json!({"text": marker})).unwrap()["anchored"], 1);
// With no insertion point too: the rectangle, not a text frame holding the marker.
s.execute("edit.deselectAll", &json!({})).unwrap();
let r = s.execute("edit.paste", &json!({"text": marker})).unwrap();
let id = ItemId(r["ids"][0].as_u64().unwrap());
assert!(s.doc().unwrap().doc.item(id).unwrap().text_frame().is_none());
s.doc().unwrap().doc.check().unwrap();
// Paste without Formatting inserts the text given; empty text is no text.
s.execute("text.select", &json!({"story": story.0, "anchor": 0, "focus": 0})).unwrap();
assert!(s.execute("edit.pasteWithoutFormatting", &json!({"text": ""})).is_err());
}
/// Text frames, and groups holding one, can't be anchored: at a caret their copy pastes as text.
#[test]
fn copied_text_frames_paste_at_a_caret_as_their_text() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let target = frame(&mut s, "ab");
let copy_and_paste = |s: &mut Session, ids: Value, text: Value| {
s.execute("selection.set", &json!({"ids": ids})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
s.execute("text.select", &json!({"story": target.0, "anchor": 0, "focus": 0})).unwrap();
s.execute("edit.paste", &json!({"text": text})).unwrap()
};
let chapter = s.execute("frame.create", &json!({"rect": [72, 400, 300, 480], "content": "text", "text": "Chapter 1"})).unwrap()["id"].clone();
// The same text copied in another app, and the system clipboard read back as absent.
let r = copy_and_paste(&mut s, json!([chapter]), json!("Chapter 1"));
assert!(r.get("anchored").is_none(), "{r}");
s.execute("edit.paste", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.story(target).unwrap().text, "Chapter 1Chapter 1ab");
// A group with a text frame inside, its own placeholder on the system clipboard.
let rect = s.execute("frame.create", &json!({"rect": [72, 500, 200, 560]})).unwrap()["id"].clone();
let group = s.execute("object.group", &json!({"ids": [chapter, rect]})).unwrap()["id"].clone();
let r = copy_and_paste(&mut s, json!([group]), Value::Null);
assert!(r.get("anchored").is_none(), "{r}");
assert_eq!(s.doc().unwrap().doc.story(target).unwrap().text, "Chapter 1Chapter 1Chapter 1ab");
s.doc().unwrap().doc.check().unwrap();
}
#[test]
fn outside_text_never_pastes_markers() {
let mut s = Session::new();
+5 -1
View File
@@ -679,7 +679,11 @@ fn file_place(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.active()?;
st.selection.items.iter().copied().find(|i| st.doc.item(*i).is_some_and(|it| matches!(it.content, Content::Unassigned | Content::Graphic(_))))
});
let spread = super::spread_param(p, "file.place")?;
// `"spread": null` counts as omitted, like the other placement parameters.
let spread = match p.get("spread") {
Some(Value::Null) => designcraft_doc::SpreadRef::Doc(0),
_ => super::spread_param(p, "file.place")?,
};
let lid = s.doc()?.active_layer;
let px = p.get("x").and_then(Value::as_f64);
let py = p.get("y").and_then(Value::as_f64);
+1
View File
@@ -17,6 +17,7 @@ mod endnotes;
mod export;
mod file;
pub(crate) mod find;
pub use edit::copied_objects_text;
pub use find::same_find_domain;
pub(crate) mod flex;
mod fonts;
+2 -1
View File
@@ -1074,7 +1074,8 @@ fn adopt_or_dangle(dst: &mut Document, src: &Document, id: AssetId) -> AssetId {
dst.adopt_asset(src, id).unwrap_or_else(|| AssetId(dst.alloc()))
}
fn renumber(dst: &mut Document, src: &Document, it: &mut Item, stories: &mut HashMap<StoryId, StoryId>) {
/// Give `it` (an item of `src`) fresh ids from `dst`, with copies of its stories and the assets it uses.
pub(crate) fn renumber(dst: &mut Document, src: &Document, it: &mut Item, stories: &mut HashMap<StoryId, StoryId>) {
it.id = ItemId(dst.alloc());
let my_id = it.id;
if let Some(m) = it.media.as_mut()
+1 -1
View File
@@ -118,7 +118,7 @@ fn select_font() -> usvg::FontSelectionFn<'static> {
return Some(id);
}
}
// A family (or generic family) that matches: usvg's own selection.
// A family (or generic family) that matches: the query below.
_ => break,
}
}
+128 -20
View File
@@ -1583,6 +1583,21 @@ fn field_focused_id(ctx: &egui::Context) -> egui::Id {
pub fn begin_frame(ctx: &egui::Context) {
let (id, focused) = (field_focused_id(ctx), ctx.text_edit_focused());
ctx.data_mut(|d| d.insert_temp(id, focused));
#[cfg(not(target_arch = "wasm32"))]
{
let now = ctx.input(|i| i.modifiers);
// The modifiers last frame ended with are the ones this frame's events began with.
ctx.data_mut(|d| {
let (_, end) = d.get_temp::<(egui::Modifiers, egui::Modifiers)>(modifiers_id()).unwrap_or((now, now));
d.insert_temp(modifiers_id(), (end, now));
});
}
}
/// (held when this frame's events began, held after them).
#[cfg(not(target_arch = "wasm32"))]
fn modifiers_id() -> egui::Id {
egui::Id::new("canvas-frame-modifiers")
}
/// Caret / text highlight inside a table cell (the cell's lines are offset by the cell origin).
@@ -2546,7 +2561,21 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r
// text must not delete the character before it).
let composing_at_start = app.session.tool_composing();
let evs = ui.input(|i| i.events.clone());
// Native only: egui-winit sends a ⌘+V chord as a paste without its key press, so the modifiers
// held at the paste tell Paste in Place from Paste. The web also gets the key press, and the
// shortcut table has already run the command. A chord's modifiers may be released in the same
// frame, hence the modifiers held at each event.
#[cfg(not(target_arch = "wasm32"))]
let mut held =
ui.data(|d| d.get_temp::<(egui::Modifiers, egui::Modifiers)>(modifiers_id())).map_or_else(|| ui.input(|i| i.modifiers), |(start, _)| start);
for e in evs {
#[cfg(not(target_arch = "wasm32"))]
let chord = {
if let egui::Event::ModifiersChanged(m) = &e {
held = *m;
}
if matches!(e, egui::Event::Paste(_)) { crate::menus::paste_chord_command(app, held) } else { None }
};
let composing = composing_at_start || app.session.tool_composing();
match e {
egui::Event::Ime(ime) if wants_text => ime_event(app, ime, vi),
@@ -2568,6 +2597,15 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r
let id = if matches!(e, egui::Event::Cut) { "edit.cut" } else { "edit.copy" };
let _ = app.run(id, json!({}));
}
// Paste in Place's chord (⌘⌥⇧V unless rebound) arrives as a paste too.
#[cfg(not(target_arch = "wasm32"))]
egui::Event::Paste(_) if chord.is_some() => {
if let Some(id) = chord
&& crate::menus::enabled(app, id)
{
crate::menus::activate(app, id, &serde_json::Value::Null);
}
}
// Without a native menu bar the copy and paste keys arrive only as these events.
egui::Event::Copy | egui::Event::Cut | egui::Event::Paste(_) if !app.native_menu => {
let (id, params) = match e {
@@ -2716,33 +2754,47 @@ fn draw_hidden_characters(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc
for l in &ft.lines {
let size = (l.ascent * 0.75 * xf.zoom).clamp(6.0, 40.0) as f32;
let font = egui::FontId::proportional(size);
for g in &l.glyphs {
if g.len == 0 {
continue;
}
let ch = story.text[g.byte.min(story.text.len())..].chars().next().unwrap_or(' ');
let mark = match ch {
' ' => "·",
'\t' => "»",
'\u{2028}' => "¬",
'\u{A0}' => "°",
_ => continue,
};
let p = xf.to_screen(m * Point::new(g.x + if ch == ' ' { g.adv / 2.0 } else { 0.0 }, l.baseline));
let align = if ch == ' ' { egui::Align2::CENTER_BOTTOM } else { egui::Align2::LEFT_BOTTOM };
for (x, mark, centered) in hidden_marks(&story.text, l) {
let p = xf.to_screen(m * Point::new(x, l.baseline));
let align = if centered { egui::Align2::CENTER_BOTTOM } else { egui::Align2::LEFT_BOTTOM };
painter.text(p, align, mark, font.clone(), col);
}
if l.last_in_para {
let end = l.range.end;
let mark = if end >= story.text.len() { "#" } else { "" };
let p = xf.to_screen(m * Point::new(l.end_x + 1.0, l.baseline));
painter.text(p, egui::Align2::LEFT_BOTTOM, mark, font.clone(), col);
}
}
}
}
}
/// The hidden-character marks on line `l` of a story with text `text`: frame-space x, mark, and
/// whether the mark is centered on x (spaces) or starts at it.
fn hidden_marks(text: &str, l: &compose::Line) -> Vec<(f64, &'static str, bool)> {
let mut out = Vec::new();
for g in &l.glyphs {
if g.len == 0 {
continue;
}
let Some(ch) = text.get(g.byte..).and_then(|t| t.chars().next()) else { continue };
let mark = match ch {
' ' => "·",
'\t' => "»",
'\u{2028}' => "¬",
'\u{A0}' => "°",
_ => continue,
};
if ch == ' ' {
out.push((g.x + g.adv / 2.0, mark, true));
} else {
out.push((g.x, mark, false));
}
}
if l.last_in_para {
// The spaces trimmed off the line's end sit past `end_x`; the paragraph mark follows them.
let end = l.glyphs.iter().filter(|g| g.len > 0 && !g.visible && g.x >= l.end_x - 1e-9).map(|g| g.x + g.adv).fold(l.end_x, f64::max);
let mark = if l.range.end >= text.len() { "#" } else { "" };
out.push((end + 1.0, mark, false));
}
out
}
/// View › Flattener Preview: red over every object that involves transparency (opacity, blend
/// modes, effects, knockout) — what flattening or export rasterising affects.
fn draw_flattener_preview(painter: &egui::Painter, xf: &Xf, doc: &Document, layout: &CanvasLayout) {
@@ -4264,6 +4316,39 @@ mod tests {
assert!((it.bounds().center() - centre).hypot() < 0.01, "{:?} vs {centre:?}", it.bounds());
}
#[test]
fn the_paste_in_place_shortcut_pastes_in_place_and_plain_paste_offsets() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
let id = app.run("frame.create", json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].as_u64().unwrap();
let original = app.session.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds();
app.run("edit.copy", json!({})).unwrap();
let clip = app.object_clip_text.clone().expect("the copy's placeholder text");
let mut h = harness(app);
let paste = |h: &mut Harness<'static, DesignApp>, modifiers: egui::Modifiers| {
// As egui-winit delivers ⌃⌥⇧V: the chord's paste and the V release, no V press; here the
// modifiers are released within the same frame.
let release = egui::Event::Key { key: egui::Key::V, physical_key: None, pressed: false, repeat: false, modifiers };
let none = egui::Event::ModifiersChanged(egui::Modifiers::NONE);
h.input_mut().events.extend([egui::Event::ModifiersChanged(modifiers), egui::Event::Paste(clip.clone()), release, none]);
h.run_steps(2);
let st = h.state().session.doc().unwrap();
let pasted = *st.selection.items.first().expect("the copy is selected");
assert_ne!(pasted.0, id);
st.doc.item(pasted).unwrap().bounds()
};
let linux = egui::Modifiers { alt: true, ctrl: true, shift: true, mac_cmd: false, command: true };
assert_eq!(paste(&mut h, linux), original, "Paste in Place");
let moved = paste(&mut h, egui::Modifiers::COMMAND);
assert!((moved.x0 - original.x0 - 12.0).abs() < 1e-9 && (moved.y0 - original.y0 - 12.0).abs() < 1e-9, "{moved:?}");
// The modifiers went down in an earlier frame than the paste.
h.event_modifiers(egui::Event::Paste(clip.clone()), linux);
h.run_steps(2);
let st = h.state().session.doc().unwrap();
let pasted = *st.selection.items.first().expect("the copy is selected");
assert_eq!(st.doc.item(pasted).unwrap().bounds(), original);
}
#[test]
fn edit_paste_from_the_menu_pastes_svg_on_the_system_clipboard_as_paths() {
let services = crate::Services { clipboard_text: Some(Box::new(|| Some(ART.to_string()))), ..Default::default() };
@@ -5560,6 +5645,29 @@ mod tests {
assert_eq!(size, 18.0, "{key:?} applies the typed size");
}
}
#[test]
fn trailing_spaces_show_dots_before_the_paragraph_mark() {
let text = "ab \ncd ";
let mut d = Document::new(&designcraft_doc::build::NewDocument::default());
let lid = d.default_layer();
let rect = DRect::new(0.0, 0.0, 300.0, 100.0);
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), rect, lid, text, Default::default()).unwrap();
let cs = compose::compose_story(&d, sid, &compose::ComposeOptions::default());
let lines: Vec<_> = cs.frames.iter().flat_map(|f| &f.lines).collect();
assert_eq!(lines.len(), 2);
for (l, end_mark) in lines.iter().zip(["", "#"]) {
let marks = hidden_marks(text, l);
let dots: Vec<f64> = marks.iter().filter(|m| m.1 == "·").map(|m| m.0).collect();
assert_eq!(dots.len(), 2, "a dot for each trailing space: {marks:?}");
let &(para_x, mark, _) = marks.last().unwrap();
assert_eq!(mark, end_mark);
// The dots sit past the last letter, and the mark comes after both spaces.
let space = l.glyphs.iter().rev().find(|g| g.len > 0).unwrap();
assert!(dots.iter().all(|&x| x > l.end_x && x < para_x), "{dots:?} then {mark} at {para_x}");
assert!(para_x >= space.x + space.adv, "{mark} at {para_x} overlaps the last space");
}
}
}
#[cfg(test)]
+8 -8
View File
@@ -1532,7 +1532,7 @@ mod tests {
/// The application bar's search field (German interface).
fn search_node<'h>(h: &'h egui_kittest::Harness<'static, test_window::Window>) -> egui_kittest::Node<'h> {
h.get_by(|n| n.role() == egui::accesskit::Role::TextInput && n.placeholder() == Some("Suchen"))
h.get_by(|n| n.role() == egui::accesskit::Role::TextInput && n.placeholder() == Some(crate::i18n::tr("de", "Search")))
}
/// A document with two frames, the Selection tool, German menus, and the search field clicked.
@@ -1564,12 +1564,12 @@ mod tests {
#[test]
fn the_search_field_finds_and_runs_menu_commands() {
let mut h = searching();
h.event(egui::Event::Text("gruppier".into()));
let group = crate::i18n::tr("de", "Group");
h.event(egui::Event::Text(group.to_lowercase()));
h.run_steps(4);
assert!(row(&h, "Gruppieren").is_some(), "the German label is listed");
assert!(row(&h, "Gruppierung aufheben").is_some());
assert!(row(&h, group).is_some(), "the German label is listed");
// Nothing is selected, so Group is off and Return doesn't run it.
assert!(row(&h, "Gruppieren").is_some_and(|n| n.accesskit_node().is_disabled()));
assert!(row(&h, group).is_some_and(|n| n.accesskit_node().is_disabled()));
h.key_press(egui::Key::Enter);
h.run_steps(4);
assert_eq!(items(&h), (2, 0), "a disabled command doesn't run");
@@ -1582,7 +1582,7 @@ mod tests {
h.key_press(egui::Key::Enter);
h.run_steps(4);
assert_eq!(items(&h).0, 1, "the two frames are grouped");
assert!(row(&h, "Gruppieren").is_none(), "the matches closed");
assert!(row(&h, group).is_none(), "the matches closed");
assert!(!h.ctx.memory(|m| m.has_focus(super::search_id())), "the field gave the keyboard back");
}
@@ -1604,11 +1604,11 @@ mod tests {
assert_eq!(items(&h), (2, 2), "nothing was deleted or deselected");
h.event(egui::Event::Text("zz-no-such-command".into()));
h.run_steps(4);
assert!(h.query_by_label("Keine passenden Befehle").is_some());
assert!(h.query_by_label(crate::i18n::tr("de", "No matching commands")).is_some());
// Escape closes the matches and clears the field without touching the document.
h.key_press(egui::Key::Escape);
h.run_steps(4);
assert!(h.query_by_label("Keine passenden Befehle").is_none());
assert!(h.query_by_label(crate::i18n::tr("de", "No matching commands")).is_none());
assert!(!h.ctx.text_edit_focused());
assert_eq!(h.state().app.session.tool_id(), "rectangleFrame");
assert_eq!(items(&h), (2, 2));
+11 -4
View File
@@ -315,9 +315,16 @@ fn keyboard_shortcuts(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
if !msg.is_empty() {
ui.label(egui::RichText::new(msg).color(egui::Color32::from_rgb(240, 180, 60)));
}
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Reset All to Defaults"))).clicked() {
let _ = app.run("window.resetShortcuts", json!({}));
}
ui.horizontal(|ui| {
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Reset All to Defaults"))).clicked() {
let _ = app.run("window.resetShortcuts", json!({}));
}
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Import InDesign Set…"))).clicked()
&& let Some(msg) = crate::shortcut_import::pick_and_import(app)
{
d.fields.insert("message".into(), json!(msg));
}
});
}
/// File › Print, with the printers the system knows.
@@ -9161,7 +9168,7 @@ mod tests {
assert_eq!(app.ui.language, "de");
assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("preferences"));
let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
let heading = visible_label(&labels, screen, "Sprache der Oberfläche");
let heading = visible_label(&labels, screen, crate::i18n::tr("de", "Interface Language"));
let combo = visible_label(&labels, screen, "Deutsch");
// The names stay endonyms in the German interface.
@@ -190,11 +190,6 @@ fn select_all_guides_is_in_the_edit_menu_on_the_spread_in_view() {
assert_eq!(crate::menus::default_shortcut("guide.selectAll"), Some("Cmd+Alt+G"));
crate::menus::activate(h.state_mut(), "guide.selectAll", &serde_json::Value::Null);
assert_eq!(selected(&h), [0, 1, 2]);
assert_eq!(crate::i18n::tr("de", "Select All Guides"), "Alle Hilfslinien auswählen");
for (code, _) in crate::i18n::LANGUAGES.iter().filter(|(c, _)| !c.is_empty()) {
assert_ne!(crate::i18n::tr(code, "Select All Guides"), "Select All Guides", "{code}");
assert_ne!(crate::i18n::tr(code, "Ruler Guide"), "Ruler Guide", "{code}");
}
h.state_mut().ui.guides = false;
assert!(!crate::menus::menu_enabled(h.state(), "guide.selectAll"));
}
+192 -382
View File
@@ -204,7 +204,7 @@ const TABLE: &[(&str, [&str; 5])] = &[
("Pages on Top", ["Seiten oben", "Pages en haut", "Páginas en la parte superior", "ページを上に表示", "页面在上"]),
(
"Parents on Top",
["Übergeordnete Seiten oben", "Parents en haut", "Páginas principales en la parte superior", "親ページを上に表示", "父页面在上"],
["Übergeordnete Seiten oben", "Gabarits en haut", "Páginas principales en la parte superior", "親ページを上に表示", "父页面在上"],
),
("Resize:", ["Größe ändern:", "Redimensionner :", "Cambiar tamaño:", "サイズ変更:", "调整大小:"]),
("Extra Small", ["Sehr klein", "Très petite", "Extra pequeño", "極小", "特小"]),
@@ -215,8 +215,8 @@ const TABLE: &[(&str, [&str; 5])] = &[
("Jumbo", ["Riesig", "Géante", "Gigante", "最大", "巨大"]),
("Proportional", ["Proportional", "Proportionnel", "Proporcional", "比例", "按比例"]),
("Pages Fixed", ["Seiten fixiert", "Pages fixes", "Páginas fijas", "ページを固定", "页面固定"]),
("Parents Fixed", ["Übergeordnete fixiert", "Parents fixes", "Páginas principales fijas", "親ページを固定", "父页面固定"]),
("Parents", ["Übergeordnete Seiten", "Parents", "Páginas principales", "親ページ", "父页面"]),
("Parents Fixed", ["Übergeordnete fixiert", "Gabarits fixes", "Páginas principales fijas", "親ページを固定", "父页面固定"]),
("Parents", ["Übergeordnete Seiten", "Gabarits", "Páginas principales", "親ページ", "父页面"]),
(
"Bulleted & Numbered Lists",
[
@@ -562,7 +562,7 @@ const TABLE: &[(&str, [&str; 5])] = &[
),
("ICF Top", ["ICF oben", "Haut ICF", "Parte superior ICF", "平均字面の上", "平均字面上"]),
("ICF Bottom", ["ICF unten", "Bas ICF", "Parte inferior ICF", "平均字面の下", "平均字面下"]),
("Gyoudori:", ["Gyoudori:", "Gyoudori:", "Gyoudori:", "行取り:", "行取:"]),
("Gyoudori:", ["Gyoudori:", "Gyoudori :", "Gyoudori:", "行取り:", "行取:"]),
(
"Use Paragraph Gyoudori",
["Absatz-Gyoudori verwenden", "Utiliser le gyoudori de paragraphe", "Usar gyoudori de párrafo", "段落行取りを使用", "使用段落行取"],
@@ -799,7 +799,7 @@ const TABLE: &[(&str, [&str; 5])] = &[
("Editorial", ["Redaktion", "Éditorial", "Editorial", "エディトリアル", "编辑"]),
("Object & Layout", ["Objekt und Layout", "Objet et mise en page", "Objeto y diseño", "オブジェクト&レイアウト", "对象和版面"]),
("Styles", ["Formate", "Styles", "Estilos", "スタイル", "样式"]),
("Essentials Classic", ["Essentials Classic", "Essentials Classic", "Essentials Classic", "Essentials Classic", "基本功能(经典)"]),
("Essentials Classic", ["Essentials Classic", "Les indispensables (classique)", "Essentials Classic", "Essentials Classic", "基本功能(经典)"]),
("Split Window", ["Fenster teilen", "Fractionner la fenêtre", "Dividir ventana", "ウィンドウを分割", "拆分窗口"]),
("New Window", ["Neues Fenster", "Nouvelle fenêtre", "Nueva ventana", "新規ウィンドウ", "新建窗口"]),
(
@@ -938,7 +938,7 @@ const TABLE: &[(&str, [&str; 5])] = &[
],
),
("Delete Spread", ["Druckbogen löschen", "Supprimer la planche", "Eliminar pliego", "スプレッドを削除", "删除跨页"]),
("Parent Pages", ["Übergeordnete Seiten", "Pages parents", "Páginas principales", "親ページ", "父页面"]),
("Parent Pages", ["Übergeordnete Seiten", "Gabarits", "Páginas principales", "親ページ", "父页面"]),
(
"Apply Parent to Pages",
[
@@ -1549,12 +1549,12 @@ const TABLE: &[(&str, [&str; 5])] = &[
("Apply None (/)", ["Apply None (/)", "Appliquer Aucun (/)", "Apply None (/)", "Apply None (/)", "应用无 (/)"]),
(
"Default Fill and Stroke",
["Default Fill and Stroke", "Default Fill and Stroke", "Default Fill and Stroke", "Default Fill and Stroke", "默认填色和描边"],
["Default Fill and Stroke", "Fond et contour par défaut", "Default Fill and Stroke", "Default Fill and Stroke", "默认填色和描边"],
),
("Swap Fill and Stroke", ["Swap Fill and Stroke", "Swap Fill and Stroke", "Swap Fill and Stroke", "Swap Fill and Stroke", "交换填色和描边"]),
("Swap Fill and Stroke", ["Swap Fill and Stroke", "Permuter fond et contour", "Swap Fill and Stroke", "Swap Fill and Stroke", "交换填色和描边"]),
(
"Toggle Fill and Stroke",
["Toggle Fill and Stroke", "Toggle Fill and Stroke", "Toggle Fill and Stroke", "Toggle Fill and Stroke", "切换填色和描边"],
["Toggle Fill and Stroke", "Basculer fond/contour", "Toggle Fill and Stroke", "Toggle Fill and Stroke", "切换填色和描边"],
),
("Reverse the gradient", ["Reverse the gradient", "Inverser le dégradé", "Reverse the gradient", "Reverse the gradient", "反转渐变"]),
("Style source", ["Style source", "Source du style", "Style source", "Style source", "样式源"]),
@@ -2224,7 +2224,13 @@ const TABLE: &[(&str, [&str; 5])] = &[
),
(
"Show/Hide Document Grid",
["Show/Hide Document Grid", "Show/Hide Document Grid", "Show/Hide Document Grid", "Show/Hide Document Grid", "显示/隐藏文档网格"],
[
"Show/Hide Document Grid",
"Afficher/Masquer la grille du document",
"Show/Hide Document Grid",
"Show/Hide Document Grid",
"显示/隐藏文档网格",
],
),
(
"Show/Hide Frame Edges",
@@ -2876,7 +2882,7 @@ const TABLE: &[(&str, [&str; 5])] = &[
"Select an object or text to color its fill or stroke.",
[
"Select an object or text to color its fill or stroke.",
"Select an object or text to color its fill or stroke.",
"Sélectionnez un objet ou du texte pour colorer son fond ou son contour.",
"Select an object or text to color its fill or stroke.",
"Select an object or text to color its fill or stroke.",
"选择一个对象或文本以设置其填色或描边颜色。",
@@ -3085,7 +3091,7 @@ const TABLE: &[(&str, [&str; 5])] = &[
("Cap Height", ["Cap Height", "Hauteur de capitale", "Cap Height", "Cap Height", "大写字母高度"]),
("Leading", ["Leading", "Interligne", "Leading", "Leading", "行距"]),
("x Height", ["x Height", "Hauteur d'x", "x Height", "x Height", "x 字高"]),
("Em Box Height", ["Em Box Height", "Em Box Height", "Em Box Height", "Em Box Height", "全角字框高度"]),
("Em Box Height", ["Em Box Height", "Hauteur du cadratin", "Em Box Height", "Em Box Height", "全角字框高度"]),
("Fixed", ["Fixed", "Fixe", "Fixed", "Fixed", "固定"]),
("Never (continuous)", ["Never (continuous)", "Jamais (continu)", "Never (continuous)", "Never (continuous)", "从不(连续编号)"]),
("Page", ["Page", "Page", "Page", "Page", "页面"]),
@@ -3668,8 +3674,8 @@ const TABLE: &[(&str, [&str; 5])] = &[
("Ukrainian", ["Ukrainian", "Ukrainien", "Ukrainian", "Ukrainian", "乌克兰语"]),
("Cell Style", ["Cell Style", "Style de cellule", "Cell Style", "Cell Style", "单元格样式"]),
("Table Style", ["Table Style", "Style de tableau", "Table Style", "Table Style", "表样式"]),
("Col ←", ["Col ←", "Col ←", "Col ←", "Col ←", "左侧插入列"]),
("Col →", ["Col →", "Col →", "Col →", "Col →", "右侧插入列"]),
("Col ←", ["Col ←", "Col. ←", "Col ←", "Col ←", "左侧插入列"]),
("Col →", ["Col →", "Col. →", "Col →", "Col →", "右侧插入列"]),
("Row ↑", ["Row ↑", "Ligne ↑", "Row ↑", "Row ↑", "上方插入行"]),
("Row ↓", ["Row ↓", "Ligne ↓", "Row ↓", "Row ↓", "下方插入行"]),
("Hide Others", ["Hide Others", "Masquer les autres", "Hide Others", "Hide Others", "隐藏其他图层"]),
@@ -3699,7 +3705,7 @@ const TABLE: &[(&str, [&str; 5])] = &[
"Show Parent Page Overlay",
[
"Überlagerung der übergeordneten Seite anzeigen",
"Afficher la superposition de la page parent",
"Afficher la superposition du gabarit",
"Mostrar superposición de página principal",
"親ページオーバーレイを表示",
"显示父页面叠加",
@@ -3707,7 +3713,7 @@ const TABLE: &[(&str, [&str; 5])] = &[
),
(
"Controlled by Parent",
["Durch übergeordnete Seite gesteuert", "Contrôlé par le parent", "Controlado por principal", "親ページで制御", "由父页面控制"],
["Durch übergeordnete Seite gesteuert", "Contrôlé par le gabarit", "Controlado por principal", "親ページで制御", "由父页面控制"],
),
("US Business Card", ["US-Visitenkarte", "Carte de visite US", "Tarjeta de visita EE. UU.", "US 名刺", "美国名片"]),
(
@@ -4392,8 +4398,8 @@ const TABLE: &[(&str, [&str; 5])] = &[
"Bullets and Numbering…",
["Aufzählungszeichen und Nummerierung…", "Puces et numérotation…", "Viñetas y numeración…", "箇条書き記号と番号付け…", "项目符号和编号…"],
),
("List:", ["Liste:", "Liste:", "Lista:", "リスト:", "列表:"]),
("Mode:", ["Modus:", "Mode:", "Modo:", "モード:", "模式:"]),
("List:", ["Liste:", "Liste :", "Lista:", "リスト:", "列表:"]),
("Mode:", ["Modus:", "Mode :", "Modo:", "モード:", "模式:"]),
(
"Continue from Previous Number",
[
@@ -4755,217 +4761,198 @@ pub fn workspace_name<'a>(lang: &str, name: &'a str) -> &'a str {
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
/// Every per-language table `tr` looks up, by interface language.
const LANGUAGE_TABLES: &[(&str, &[(&str, &str)])] = &[
("ar", ar::TABLE),
("cs", cs::TABLE),
("de", de::TABLE),
("fr", fr::TABLE),
("it", it::TABLE),
("ja", ja::TABLE),
("ka", ka::TABLE),
("pl", pl::TABLE),
("pt-br", pt_br::TABLE),
("ru", ru::TABLE),
("uk", uk::TABLE),
];
/// Every per-language table `tr_context` looks up.
const CONTEXT_TABLES: &[(&str, &[(&str, &str, &str)])] =
&[("ar", ar::CONTEXT), ("cs", cs::CONTEXT), ("ja", ja::CONTEXT), ("pl", pl::CONTEXT), ("ru", ru::CONTEXT), ("uk", uk::CONTEXT)];
/// The languages of the shared tables' columns, in column order.
const SHARED_COLUMNS: [&str; 5] = ["de", "fr", "es", "ja", "zh"];
/// The German and Japanese tables translate the whole interface and win over the shared columns.
const WHOLE_INTERFACE: [&str; 2] = ["de", "ja"];
#[test]
fn every_interface_language_has_a_catalog() {
for (lang, i) in SHARED_COLUMNS.iter().zip(0..) {
assert_eq!(column(lang), Some(i), "{lang}");
}
for (code, name) in LANGUAGES.iter().filter(|(code, _)| !code.is_empty()) {
assert!(!name.is_empty(), "{code}");
assert!(LANGUAGE_TABLES.iter().any(|(lang, _)| lang == code) || column(code).is_some(), "{code} has no catalog in LANGUAGE_TABLES");
}
}
#[test]
fn cached_lookup_preserves_every_translation_across_language_switches() {
let own = |lang: &str, key: &str| {
LANGUAGE_TABLES.iter().find(|(code, _)| *code == lang).and_then(|(_, table)| table.iter().find(|(k, _)| *k == key)).map(|(_, text)| *text)
};
for (key, translations) in TABLE {
for (lang, expected) in ["de", "fr", "es", "ja", "zh"].into_iter().zip(translations) {
// `de::TABLE` and `ja::TABLE` translate the whole interface and win over the shared columns.
if lang != "de" && lang != "ja" {
assert_eq!(tr(lang, key), *expected, "{lang}: {key}");
}
for (lang, shared) in SHARED_COLUMNS.into_iter().zip(translations) {
// A language's own table is looked up before the shared column.
assert_eq!(tr(lang, key), own(lang, key).unwrap_or(*shared), "{lang}: {key}");
}
assert_eq!(tr("", key), *key);
}
for (key, expected) in de::TABLE {
assert_eq!(tr("de", key), *expected, "de: {key}");
}
for (key, expected) in ar::TABLE {
assert_eq!(tr("ar", key), *expected, "ar: {key}");
}
for (key, expected) in pt_br::TABLE {
assert_eq!(tr("pt-br", key), *expected, "pt-br: {key}");
}
for (key, expected) in it::TABLE {
assert_eq!(tr("it", key), *expected, "it: {key}");
}
for (key, expected) in uk::TABLE {
assert_eq!(tr("uk", key), *expected, "uk: {key}");
}
for (key, expected) in ru::TABLE {
assert_eq!(tr("ru", key), *expected, "ru: {key}");
}
for (key, expected) in cs::TABLE {
assert_eq!(tr("cs", key), *expected, "cs: {key}");
}
for (key, expected) in fr::TABLE {
assert_eq!(tr("fr", key), *expected, "fr: {key}");
}
for (key, expected) in pl::TABLE {
assert_eq!(tr("pl", key), *expected, "pl: {key}");
}
for (key, expected) in ka::TABLE {
assert_eq!(tr("ka", key), *expected, "ka: {key}");
for (lang, table) in LANGUAGE_TABLES {
for (key, expected) in *table {
assert_eq!(tr(lang, key), *expected, "{lang}: {key}");
assert_eq!(tr("", key), *key);
}
}
// Strings no catalog has stay as they are, in every language.
let unknown = String::from("A user-defined untranslated label");
for lang in ["pl", "cs", "uk", "ru", "ar", "pt-br", "it", "ka", "zh", "ja", "de", "fr", "es", "", "unknown"] {
assert!(std::ptr::eq(tr(lang, &unknown), unknown.as_str()));
for lang in LANGUAGES.iter().map(|(code, _)| *code).chain(["en", "unknown"]) {
assert!(std::ptr::eq(tr(lang, &unknown), unknown.as_str()), "{lang}");
// Fixed standard names stay identical.
assert_eq!(tr(lang, "PDF/X-4"), "PDF/X-4", "{lang}");
}
}
#[test]
fn translates_known_strings_and_keeps_the_rest() {
assert_eq!(tr("de", "File"), "Datei");
assert_eq!(tr("ja", "Swatches"), "スウォッチ");
assert_eq!(tr("fr", "Something new"), "Something new");
assert_eq!(tr("", "File"), "File");
assert_eq!(tr("ar", "File"), "ملف");
assert_eq!(tr("ar", "Unknown label"), "Unknown label");
assert_eq!(tr("pt-br", "File"), "Arquivo");
assert_eq!(tr("pt-br", "New Document…"), "Novo documento…");
assert_eq!(tr("pt-br", "Unknown label"), "Unknown label");
assert_eq!(tr("it", "Edit"), "Modifica");
assert_eq!(tr("it", "New Document…"), "Nuovo documento…");
assert_eq!(tr("it", "Unknown label"), "Unknown label");
assert!(is_rtl("ar"));
for (i, (en, translation)) in ar::TABLE.iter().enumerate() {
assert!(ar::TABLE[..i].iter().all(|(key, _)| key != en), "duplicate {en}");
assert!(!translation.is_empty(), "{en}");
fn context_captions_come_from_their_context_or_the_plain_translation() {
for (lang, table) in CONTEXT_TABLES {
for (key, scope, text) in *table {
assert_eq!(tr_context(lang, key, scope), *text, "{lang}: {key} ({scope})");
}
}
for (i, (en, translation)) in pt_br::TABLE.iter().enumerate() {
assert!(pt_br::TABLE[..i].iter().all(|(key, _)| key != en), "duplicate {en}");
assert!(!translation.is_empty(), "{en}");
for (key, scope, row) in CONTEXT {
for (lang, shared) in SHARED_COLUMNS.into_iter().zip(row) {
// A language's own context table is looked up before the shared one.
let own = CONTEXT_TABLES
.iter()
.filter(|(code, _)| *code == lang)
.flat_map(|(_, table)| table.iter())
.find(|(k, s, _)| k == key && s == scope)
.map(|(_, _, text)| *text);
assert_eq!(tr_context(lang, key, scope), own.unwrap_or(*shared), "{lang}: {key} ({scope})");
}
}
for (i, (en, translation)) in it::TABLE.iter().enumerate() {
assert!(it::TABLE[..i].iter().all(|(key, _)| key != en), "duplicate {en}");
assert!(!translation.is_empty(), "{en}");
}
// Italian entries are for keys the shared or Arabic table knows. A new English string
// falls back to English until it is translated, so it doesn't fail here.
for (en, _) in it::TABLE {
assert!(TABLE.iter().any(|(key, _)| key == en) || ar::TABLE.iter().any(|(key, _)| key == en), "it: unknown key {en}");
}
// Every row is unique and complete.
for (i, (en, t)) in TABLE.iter().enumerate() {
assert!(TABLE[..i].iter().all(|(e, _)| e != en), "duplicate {en}");
assert!(t.iter().all(|x| !x.is_empty()), "{en}");
// An unknown context, or a language without one, keeps the plain translation.
let keys = CONTEXT.iter().map(|(key, _, _)| *key).chain(CONTEXT_TABLES.iter().flat_map(|(_, table)| table.iter().map(|(key, _, _)| *key)));
for key in keys {
for lang in LANGUAGES.iter().map(|(code, _)| *code).chain(["en"]) {
assert_eq!(tr_context(lang, key, "no such context"), tr(lang, key), "{lang}: {key}");
}
assert_eq!(tr_context("", key, "count"), key);
}
}
/// Every catalog: no duplicate or stale keys, no empty translations, and placeholders, padding,
/// ellipses and label colons as in English. A string a catalog doesn't translate yet falls back
/// to English, so missing translations don't fail here.
#[test]
fn a_column_count_and_a_list_number_read_differently() {
for (lang, count, numeral) in [
("de", "Anzahl:", "Zahl:"),
("ja", "数:", "番号:"),
("uk", "Кількість:", "Номер:"),
("ar", "العدد:", "الرقم:"),
("fr", "Nombre :", "Numéro:"),
("zh", "数量:", "编号:"),
] {
assert_eq!((tr_context(lang, "Number:", "count"), tr(lang, "Number:")), (count, numeral), "{lang}");
fn every_catalog_is_unique_current_and_keeps_placeholders_and_markers() {
// (language, key, translation) of every catalog, and every catalog's keys by name.
let mut entries: Vec<(&str, &str, &str)> = Vec::new();
let mut catalogs: Vec<(&str, HashSet<&str>)> = Vec::new();
for (lang, table) in LANGUAGE_TABLES {
let keys: HashSet<_> = table.iter().map(|(key, _)| *key).collect();
assert_eq!(keys.len(), table.len(), "{lang}: duplicate keys");
catalogs.push((*lang, keys));
entries.extend(table.iter().map(|(key, text)| (*lang, *key, *text)));
}
assert_eq!(tr_context("en", "Number:", "count"), "Number:");
assert_eq!(tr("de", "Gutter:"), "Spaltenabstand:");
// The Pages panel menu.
for key in [
"Insert Pages…",
"Move Pages…",
"New Parent…",
"Override All Parent Page Items",
"Remove All Local Overrides",
"Detach All Objects from Parent",
"Create Alternate Layout…",
"Allow Selected Spread to Shuffle",
] {
assert_ne!(tr("pt-br", key), key);
let shared: HashSet<_> = TABLE.iter().map(|(key, _)| *key).collect();
assert_eq!(shared.len(), TABLE.len(), "duplicate keys in the shared table");
catalogs.push(("shared", shared.clone()));
for (lang, i) in SHARED_COLUMNS.into_iter().zip(0..) {
entries.extend(TABLE.iter().filter_map(|(key, row)| row.get(i).map(|text| (lang, *key, *text))));
entries.extend(CONTEXT.iter().filter_map(|(key, _, row)| row.get(i).map(|text| (lang, *key, *text))));
}
let mut contexts = vec![("shared", CONTEXT.iter().map(|(key, scope, _)| (*key, *scope)).collect::<Vec<_>>())];
for (lang, table) in CONTEXT_TABLES {
contexts.push((*lang, table.iter().map(|(key, scope, _)| (*key, *scope)).collect()));
entries.extend(table.iter().map(|(key, _, text)| (*lang, *key, *text)));
}
assert_ne!(tr("ar", "Apply Parent to Pages"), "Apply Parent to Pages");
}
#[test]
fn french_catalog_has_no_duplicate_or_stale_keys_and_keeps_placeholders() {
let catalog: HashMap<_, _> = fr::TABLE.iter().copied().collect();
assert_eq!(catalog.len(), fr::TABLE.len(), "duplicate French keys");
let shared: std::collections::HashSet<_> = TABLE.iter().map(|(key, _)| *key).collect();
// `fr::TABLE` is looked up first: a key there would hide the shared table's French column.
let both: Vec<_> = catalog.keys().filter(|k| shared.contains(*k)).collect();
assert!(both.is_empty(), "French keys also in the shared table: {both:?}");
let mut keys = std::collections::HashSet::new();
for table in [ar::TABLE, de::TABLE, it::TABLE, ja::TABLE, pt_br::TABLE, uk::TABLE, ru::TABLE, cs::TABLE] {
keys.extend(table.iter().map(|(key, _)| *key));
// A per-language table is looked up before the shared table, so a key in both hides the
// shared column, except in the tables that translate the whole interface.
for (lang, table) in LANGUAGE_TABLES.iter().filter(|(lang, _)| column(lang).is_some() && !WHOLE_INTERFACE.contains(lang)) {
let both: Vec<_> = table.iter().map(|(key, _)| *key).filter(|key| shared.contains(key)).collect();
assert!(both.is_empty(), "{lang} keys also in the shared table: {both:?}");
}
let stale: Vec<_> = catalog.keys().filter(|k| !keys.contains(*k)).collect();
assert!(stale.is_empty(), "French entries for unknown keys: {stale:?}");
let placeholders = |s: &str| {
let mut names = s.split('{').skip(1).filter_map(|part| part.split_once('}').map(|(name, _)| name.to_owned())).collect::<Vec<_>>();
names.sort();
// A key is current while the interface or another catalog has it.
let interface = interface_strings();
let current = |key: &str, own: &str| interface.contains(key) || catalogs.iter().any(|(name, keys)| *name != own && keys.contains(key));
for (name, keys) in &catalogs {
let mut stale: Vec<_> = keys.iter().filter(|key| !current(key, name)).collect();
stale.sort();
assert!(stale.is_empty(), "{name}: entries for unknown keys: {stale:?}");
}
for (name, keys) in &contexts {
let unique: HashSet<_> = keys.iter().collect();
assert_eq!(unique.len(), keys.len(), "{name}: duplicate context keys");
let stale: Vec<_> = keys.iter().filter(|(key, _)| !current(key, "")).collect();
assert!(stale.is_empty(), "{name}: contexts for unknown keys: {stale:?}");
}
let placeholders = |s: &'static str| {
let mut names: Vec<_> = s.split('{').skip(1).filter_map(|part| part.split_once('}').map(|(name, _)| name)).collect();
names.sort_unstable();
names
};
let shared_french = TABLE.iter().filter_map(|(key, row)| row.get(1).map(|text| (*key, *text)));
for (key, translation) in fr::TABLE.iter().copied().chain(shared_french) {
assert!(!translation.trim().is_empty(), "empty {key}");
assert_eq!(key.starts_with(' '), translation.starts_with(' '), "leading space: {key}");
assert_eq!(key.ends_with(' '), translation.ends_with(' '), "trailing space: {key}");
assert_eq!(placeholders(key), placeholders(translation), "placeholders: {key}");
let ellipsis = |s: &str| s.ends_with('…') || s.ends_with("...");
let colon = |s: &str| s.ends_with(':') || s.ends_with(':');
// Full-width punctuation carries its own spacing.
let full_width = |c: char| ('\u{3000}'..='\u{303F}').contains(&c) || ('\u{FF00}'..='\u{FFEF}').contains(&c);
for (lang, key, text) in entries {
assert!(!text.trim().is_empty(), "{lang}: empty translation of {key:?}");
assert_eq!(placeholders(key), placeholders(text), "{lang}: placeholders of {key:?}");
// Dialog ellipses, label colons and padded captions stay as in English.
assert_eq!(ellipsis(key), ellipsis(text), "{lang}: ellipsis of {key:?}");
assert_eq!(colon(key), colon(text), "{lang}: colon of {key:?}");
if !text.starts_with(full_width) {
assert_eq!(key.starts_with(' '), text.starts_with(' '), "{lang}: leading space of {key:?}");
}
if !text.ends_with(full_width) {
assert_eq!(key.ends_with(' '), text.ends_with(' '), "{lang}: trailing space of {key:?}");
}
}
assert_eq!(tr("fr", "Group"), "Associer");
}
#[test]
fn ukrainian_catalog_covers_existing_keys_and_preserves_placeholders() {
let catalog: std::collections::HashMap<_, _> = uk::TABLE.iter().copied().collect();
assert_eq!(catalog.len(), uk::TABLE.len(), "duplicate Ukrainian keys");
let mut keys = std::collections::HashSet::new();
keys.extend(TABLE.iter().map(|(key, _)| *key));
keys.extend(ar::TABLE.iter().map(|(key, _)| *key));
keys.extend(pt_br::TABLE.iter().map(|(key, _)| *key));
// A new English string falls back to English until it is translated, so only stale
// entries (keys no catalog knows any more) fail.
let stale: Vec<_> = catalog.keys().filter(|k| !keys.contains(*k)).collect();
assert!(stale.is_empty(), "Ukrainian entries for unknown keys: {stale:?}");
let placeholders =
|s: &str| s.split('{').skip(1).filter_map(|part| part.split_once('}').map(|(name, _)| name.to_owned())).collect::<Vec<_>>();
for (key, translation) in uk::TABLE {
assert!(!translation.trim().is_empty(), "empty {key}");
let mut source = placeholders(key);
let mut translated = placeholders(translation);
source.sort();
translated.sort();
assert_eq!(source, translated, "placeholders: {key}");
fn only_arabic_lays_out_right_to_left() {
for (code, _) in LANGUAGES {
assert_eq!(is_rtl(code), *code == "ar", "{code}");
}
}
/// Built-in style and workspace names follow the interface language; names people give their
/// own styles and workspaces are document data and stay as typed.
#[test]
fn only_built_in_style_and_workspace_names_are_translated() {
for (code, _) in LANGUAGES {
for name in [designcraft_doc::NO_PARA_STYLE, designcraft_doc::BASIC_PARAGRAPH, designcraft_doc::NO_CHAR_STYLE] {
assert_eq!(style_name(code, name), tr(code, name), "{code}");
}
assert_eq!(workspace_name(code, "Essentials"), tr(code, "Essentials"), "{code}");
for custom in ["Group", "Columns", "My layout style"] {
assert_eq!(style_name(code, custom), custom, "{code}");
assert_eq!(workspace_name(code, custom), custom, "{code}");
}
}
assert_eq!(tr_context("uk", "Columns", "table"), "Стовпці");
assert_eq!(tr("uk", "Columns"), "Колонки");
assert_eq!(tr_context("uk", "Group", "selection"), "Група");
// Step and Repeat's grid columns are not a text frame's.
assert_eq!(tr_context("uk", "Columns:", "grid"), "Стовпці:");
assert_eq!(tr_context("ja", "Columns:", "grid"), "列:");
assert_eq!(tr("ja", "Columns:"), "段:");
assert_eq!(tr("uk", "Group"), "Згрупувати");
assert_eq!(tr_context("", "Columns", "table"), "Columns");
assert_eq!(tr_context("ja", "Span Columns", "paragraphLayout"), "段抜き");
assert_eq!(tr("ja", "Span Columns"), "段抜きと段分割");
assert_eq!(tr_context("uk", "Custom caption", "custom"), "Custom caption");
assert_eq!(tr("uk", "through"), "включно");
assert_eq!(tr("uk", "up to"), "не включно");
assert!(!is_rtl("uk"));
assert_eq!(tr("uk", "Type"), "Текст");
assert_eq!(tr("uk", "Spread"), "Розворот");
assert_eq!(tr("uk", "Story"), "Текстовий матеріал");
assert_eq!(tr("uk", "Footer Rows"), "Рядки нижнього колонтитула");
assert_eq!(style_name("uk", "[Basic Paragraph]"), "[Основний абзац]");
assert_eq!(style_name("uk", "My layout style"), "My layout style");
assert_eq!(workspace_name("uk", "Essentials"), "Основне");
assert_eq!(workspace_name("uk", "My workspace"), "My workspace");
}
#[test]
fn georgian_catalog_preserves_placeholders_and_counts() {
let catalog: HashMap<_, _> = ka::TABLE.iter().copied().collect();
assert_eq!(catalog.len(), ka::TABLE.len(), "duplicate Georgian keys");
let placeholders = |s: &str| {
s.split('{').skip(1).filter_map(|part| part.split_once('}').map(|(name, _)| name.to_owned())).collect::<std::collections::BTreeSet<_>>()
};
for (key, translation) in ka::TABLE {
assert!(!translation.trim().is_empty(), "empty {key}");
assert_eq!(placeholders(key), placeholders(translation), "placeholders: {key}");
assert_eq!(key.ends_with('…'), translation.ends_with('…'), "ellipsis: {key}");
// Georgian has no capitals in running text: Mtavruli never appears in the interface.
assert!(!translation.chars().any(|c| ('\u{1C90}'..='\u{1CBF}').contains(&c)), "Mtavruli: {key}");
}
assert_eq!(tr("ka", "File"), "ფაილი");
assert_eq!(tr("ka", "Save As…"), "შენახვა როგორც…");
assert_eq!(tr("ka", "Unknown label"), "Unknown label");
assert!(!is_rtl("ka"));
fn georgian_counts_use_category_captions_for_every_number() {
for n in [1, 2, 5, 21] {
assert_eq!(count_label("ka", "pages", n), format!("გვერდი: {n}"));
}
@@ -4983,62 +4970,6 @@ mod tests {
assert_eq!(count_label("", "objects", 2), "2 objects");
}
#[test]
fn russian_catalog_covers_existing_keys_and_preserves_placeholders() {
let catalog: std::collections::HashMap<_, _> = ru::TABLE.iter().copied().collect();
assert_eq!(catalog.len(), ru::TABLE.len(), "duplicate Russian keys");
let mut keys = std::collections::HashSet::new();
keys.extend(TABLE.iter().map(|(key, _)| *key));
keys.extend(ar::TABLE.iter().map(|(key, _)| *key));
keys.extend(pt_br::TABLE.iter().map(|(key, _)| *key));
keys.extend(it::TABLE.iter().map(|(key, _)| *key));
keys.extend(ja::TABLE.iter().map(|(key, _)| *key));
keys.extend(uk::TABLE.iter().map(|(key, _)| *key));
// A new English string falls back to English until it is translated, so only stale
// entries (keys no catalog knows any more) fail.
let stale: Vec<_> = catalog.keys().filter(|k| !keys.contains(*k)).collect();
assert!(stale.is_empty(), "Russian entries for unknown keys: {stale:?}");
let placeholders =
|s: &str| s.split('{').skip(1).filter_map(|part| part.split_once('}').map(|(name, _)| name.to_owned())).collect::<Vec<_>>();
for (key, translation) in ru::TABLE {
assert!(!translation.trim().is_empty(), "empty {key}");
assert_eq!(key.starts_with(' '), translation.starts_with(' '), "leading space: {key}");
assert_eq!(key.ends_with(' '), translation.ends_with(' '), "trailing space: {key}");
assert!(!translation.contains(['ґ', 'є', 'і', 'ї', 'Ґ', 'Є', 'І', 'Ї']), "Ukrainian letter in {key}");
let mut source = placeholders(key);
let mut translated = placeholders(translation);
source.sort();
translated.sort();
assert_eq!(source, translated, "placeholders: {key}");
}
}
#[test]
fn russian_is_selectable_ltr_and_resolves_contexts_styles_and_menu_paths() {
assert!(LANGUAGES.contains(&("ru", "Русский")));
assert!(!is_rtl("ru"));
assert_eq!(tr("ru", "File"), "Файл");
assert_eq!(tr("ru", "Type"), "Текст");
assert_eq!(tr("ru", "Spread"), "Разворот");
assert_eq!(tr("ru", "Story"), "Материал");
assert_eq!(tr("ru", "Unknown label"), "Unknown label");
assert_eq!(tr_context("ru", "Columns", "table"), "Столбцы");
assert_eq!(tr("ru", "Columns"), "Колонки");
assert_eq!(tr_context("ru", "Group", "selection"), "Группа");
assert_eq!(tr("ru", "Group"), "Сгруппировать");
assert_eq!(tr_context("ru", "Custom caption", "custom"), "Custom caption");
assert_eq!(tr("ru", "through"), "включительно");
assert_eq!(tr("ru", "up to"), "не включая");
assert_eq!(tr("ru", "Edit/Spelling"), "Редактирование/Орфография");
assert_eq!(tr("ru", "Type/Insert Special Character/Quotation Marks"), "Текст/Вставить специальный символ/Кавычки");
assert_eq!(style_name("ru", "[Basic Paragraph]"), "[Основной абзац]");
assert_eq!(style_name("ru", "My layout style"), "My layout style");
assert_eq!(workspace_name("ru", "Essentials"), "Основное");
assert_eq!(workspace_name("ru", "My workspace"), "My workspace");
// Fixed standard names stay identical.
assert_eq!(tr("ru", "PDF/X-4"), "PDF/X-4");
}
#[test]
fn russian_counts_use_category_captions_for_every_number() {
assert!(category_first("ru") && category_first("uk") && !category_first("de") && !category_first(""));
@@ -5051,45 +4982,6 @@ mod tests {
}
}
#[test]
fn polish_catalog_covers_existing_keys_and_preserves_placeholders() {
let catalog: HashMap<_, _> = pl::TABLE.iter().copied().collect();
assert_eq!(catalog.len(), pl::TABLE.len(), "duplicate Polish keys");
let mut keys = std::collections::HashSet::new();
keys.extend(TABLE.iter().map(|(key, _)| *key));
for table in [ar::TABLE, pt_br::TABLE, ja::TABLE, it::TABLE, uk::TABLE, de::TABLE, fr::TABLE, ru::TABLE, cs::TABLE] {
keys.extend(table.iter().map(|(key, _)| *key));
}
// A new English string falls back to English until it is translated; only stale entries fail.
let stale: Vec<_> = catalog.keys().filter(|k| !keys.contains(*k)).collect();
assert!(stale.is_empty(), "Polish entries for unknown keys: {stale:?}");
let placeholders =
|s: &str| s.split('{').skip(1).filter_map(|part| part.split_once('}').map(|(name, _)| name.to_owned())).collect::<Vec<_>>();
for (key, translation) in pl::TABLE {
assert!(!translation.trim().is_empty(), "empty {key}");
let mut source = placeholders(key);
let mut translated = placeholders(translation);
source.sort();
translated.sort();
assert_eq!(source, translated, "placeholders: {key}");
assert_eq!(key.ends_with('…'), translation.ends_with('…'), "ellipsis: {key}");
}
assert!(LANGUAGES.contains(&("pl", "Polski")));
assert!(!is_rtl("pl"));
assert_eq!(tr("pl", "File"), "Plik");
assert_eq!(tr("pl", "New Document…"), "Nowy dokument…");
assert_eq!(tr("pl", "Spread"), "Rozkładówka");
assert_eq!(tr("pl", "Unknown label"), "Unknown label");
assert_eq!(style_name("pl", "[Basic Paragraph]"), "[Podstawowy akapit]");
assert_eq!(workspace_name("pl", "Essentials"), "Podstawowe");
// A table's columns and grid columns aren't a text frame's; a selection's group isn't the action.
assert_eq!(tr("pl", "Columns"), "Łamy");
assert_eq!(tr_context("pl", "Columns", "table"), "Kolumny");
assert_eq!(tr_context("pl", "Columns:", "grid"), "Łamy:");
assert_eq!(tr("pl", "Group"), "Grupuj");
assert_eq!(tr_context("pl", "Group", "selection"), "Grupa");
}
#[test]
fn polish_counts_use_category_captions_for_every_number() {
for n in [0, 1, 2, 5, 12, 22, 25] {
@@ -5100,15 +4992,10 @@ mod tests {
assert_eq!(tr("pl", "{pages} Pages in {spreads} Spreads"), "Strony: {pages}, rozkładówki: {spreads}");
}
#[test]
fn czech_catalog_has_no_stale_keys_and_keeps_placeholders_and_markers() {
let catalog: HashMap<_, _> = cs::TABLE.iter().copied().collect();
assert_eq!(catalog.len(), cs::TABLE.len(), "duplicate Czech keys");
let mut keys = std::collections::HashSet::new();
keys.extend(TABLE.iter().map(|(key, _)| *key));
for table in [ar::TABLE, it::TABLE, ja::TABLE, pt_br::TABLE, uk::TABLE] {
keys.extend(table.iter().map(|(key, _)| *key));
}
/// Every English string the interface passes to `tr`, as far as the command and tool tables and
/// the list of UI-only labels know them.
fn interface_strings() -> HashSet<&'static str> {
let mut keys = HashSet::new();
// Command labels and menu names reach `tr` from the engine and the UI command table.
for spec in designcraft_engine::command_specs() {
keys.insert(spec.label);
@@ -5128,6 +5015,8 @@ mod tests {
"(unchanged)",
"[No Language]",
"Cell Number:",
"Differs from this document",
"Digits:",
"Change fill to:",
"Chinese: Simplified",
"Chinese: Traditional",
@@ -5163,6 +5052,7 @@ mod tests {
"Found",
"Grab bag",
"Key Object",
"Load Styles",
"layer",
"layers",
"Lines +/−",
@@ -5178,6 +5068,7 @@ mod tests {
"Redefine Document Style",
"Report an issue",
"Reverse the order",
"Sort Entries in Alphabetical Order",
"Story {story} ← story {parent}",
"Story Editor — {count} words",
"Text Anchor",
@@ -5196,41 +5087,7 @@ mod tests {
"{name}: import cancelled while a dialog is open; choose the file again",
"{name}: {count} step(s) done",
]);
// A new English string falls back to English until it is translated, so only stale
// entries (keys nothing passes to `tr` any more) fail.
let stale: Vec<_> = catalog.keys().filter(|k| !keys.contains(*k)).collect();
assert!(stale.is_empty(), "Czech entries for unknown keys: {stale:?}");
let placeholders =
|s: &str| s.split('{').skip(1).filter_map(|part| part.split_once('}').map(|(name, _)| name.to_owned())).collect::<Vec<_>>();
for (key, translation) in cs::TABLE {
assert!(!translation.trim().is_empty(), "empty {key}");
let mut source = placeholders(key);
let mut translated = placeholders(translation);
source.sort();
translated.sort();
assert_eq!(source, translated, "placeholders: {key}");
// Dialog ellipses, label colons and padded button captions stay as in English.
assert_eq!(key.ends_with('…'), translation.ends_with('…'), "ellipsis: {key}");
assert_eq!(key.ends_with(':'), translation.ends_with(':'), "colon: {key}");
assert_eq!(key.starts_with(' '), translation.starts_with(' '), "leading space: {key}");
assert_eq!(key.ends_with(' '), translation.ends_with(' '), "trailing space: {key}");
}
assert!(!is_rtl("cs"));
assert_eq!(tr("cs", "File"), "Soubor");
assert_eq!(tr("cs", "New Document…"), "Nový dokument…");
assert_eq!(tr("cs", "Spread"), "Dvoustrana");
assert_eq!(tr("cs", "Bleed"), "Spadávka");
assert_eq!(tr("cs", "Leading"), "Proklad");
assert_eq!(tr("cs", "Unknown label"), "Unknown label");
assert_eq!(tr_context("cs", "Group", "selection"), "Skupina");
assert_eq!(tr_context("cs", "Name", "contributors"), "Jméno");
assert_eq!(tr_context("cs", "Name", "unknown"), "Název");
assert_eq!(tr("cs", "Group"), "Seskupit");
assert_eq!(tr_context("cs", "Columns", "table"), tr("cs", "Columns"));
assert_eq!(style_name("cs", "[Basic Paragraph]"), "[Základní odstavec]");
assert_eq!(style_name("cs", "My layout style"), "My layout style");
assert_eq!(workspace_name("cs", "Essentials"), "Základní");
assert_eq!(workspace_name("cs", "My workspace"), "My workspace");
keys
}
#[test]
@@ -5257,51 +5114,4 @@ mod tests {
assert!(forms.contains(&tr("cs", noun)), "{noun}: the catalog row is one of the plural forms");
}
}
#[test]
fn czech_translates_every_tool_name() {
for tool in designcraft_tools::TOOL_GROUPS.iter().flat_map(|group| group.iter()) {
assert_ne!(tr("cs", tool.label), tool.label, "{}", tool.id);
assert_eq!(tr("", tool.label), tool.label);
}
}
#[test]
fn japanese_vertical_type_commands_are_translated() {
for (en, ja) in [
("Story Direction", "組み方向"),
("Horizontal", "横書き"),
("Vertical", "縦書き"),
("Tate-Chu-Yoko", "縦中横"),
("Ruby…", "ルビ…"),
("Kenten", "圏点"),
("Warichu", "割り注"),
] {
assert_eq!(tr("ja", en), ja);
}
}
#[test]
fn pdf_export_dialog_strings_are_translated() {
// Translated labels in every supported language.
assert_ne!(tr("zh", "Crop Marks"), "Crop Marks");
assert_ne!(tr("de", "Tagged PDF"), "Tagged PDF");
assert_ne!(tr("ar", "Include document bleed"), "Include document bleed");
assert_ne!(tr("pt-br", "Compress images"), "Compress images");
// English returns the key unchanged.
assert_eq!(tr("", "Export PDF"), "Export PDF");
// An unknown string still falls through unchanged.
assert_eq!(tr("de", "Low Quality Print"), "Low Quality Print");
// Fixed standard names stay identical in every language.
assert_eq!(tr("ja", "PDF/X-4"), "PDF/X-4");
}
#[test]
fn style_row_menu_strings_are_translated_in_every_language() {
for (code, _) in LANGUAGES.iter().filter(|(c, _)| !c.is_empty()) {
for key in ["Redefine Style", "Alt-click clears overrides", "Apply \"{name}\", Clear Overrides"] {
assert_ne!(tr(code, key), key, "{code}: {key}");
}
}
}
}
-1
View File
@@ -2060,7 +2060,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Print Layer", "Ebene drucken"),
("Suppress Text Wrap When Layer is Hidden", "Textumfluss bei ausgeblendeter Ebene unterdrücken"),
// Remaining interface strings.
("A custom name", "Ein eigener Name"),
("Close Panel", "Bedienfeld schließen"),
("Default Fill and Stroke", "Standardfläche und -kontur"),
("Distribute Rows Evenly", "Zeilen gleichmäßig verteilen"),
+220
View File
@@ -1,57 +1,118 @@
//! French translations of the strings the shared table in `i18n.rs` has no row for,
//! written from the English source using ordinary French desktop-publishing vocabulary.
pub(super) const TABLE: &[(&str, &str)] = &[
("% of all commits", "% de tous les commits"),
("(any)", "(quelconque)"),
("(unchanged)", "(inchangé)"),
("90° CCW", "90° sens antihoraire"),
("90° CW", "90° sens horaire"),
("About", "À propos"),
("Activate Panel", "Activer le panneau"),
("Add Anchor Point Tool", "Outil Ajout de point d'ancrage"),
("Add Tab", "Ajouter une tabulation"),
("Align To", "Aligner sur"),
("Alignment", "Alignement"),
("All Documents", "Tous les documents"),
("Alpha Channel", "Couche alpha"),
("Alternate layout", "Variante de mise en page"),
("Anchored Object Options", "Options d'objet ancré"),
("Apply Numbering & Section Options", "Appliquer les options de numérotation et de section"),
("Apply Text Attributes (Eyedropper)", "Appliquer les attributs de texte (Pipette)"),
("ArtCraft App Page", "Page de l'application ArtCraft"),
("ArtCraft Website", "Site web d'ArtCraft"),
("Auto Size", "Taille automatique"),
("Auto Tate-Chu-Yoko", "Tate-Chu-Yoko automatique"),
("Binary assets added", "Fichiers binaires ajoutés"),
("Binary assets removed", "Fichiers binaires supprimés"),
("Binding", "Reliure"),
("Break Thread", "Rompre le chaînage"),
("Bullet Character", "Caractère de puce"),
("CMYK:", "CMJN :"),
("Cancel Preview", "Annuler l'aperçu"),
("Cell Number:", "Numéro de mobile :"),
("Change Panel Layout", "Modifier la disposition des panneaux"),
("Change Tab", "Modifier la tabulation"),
("Change To", "Remplacer par"),
("Change fill to:", "Remplacer le fond par :"),
("Character Style", "Style de caractère"),
("Check Spelling", "Vérifier l'orthographe"),
("Chinese: Simplified", "Chinois : simplifié"),
("Chinese: Traditional", "Chinois : traditionnel"),
("Clear All Tabs", "Effacer toutes les tabulations"),
("Clear Object Style Overrides", "Effacer les remplacements du style d'objet"),
("Clear Rotation", "Effacer la rotation"),
("Clipping Path Options…", "Options de masque…"),
("Close Panel", "Fermer le panneau"),
("Close Window", "Fermer la fenêtre"),
("Closed Path", "Tracé fermé"),
("Color Blindness – Deuteranopia", "Daltonisme – Deutéranopie"),
("Color Blindness – Protanopia", "Daltonisme – Protanopie"),
("Color Theme Tool", "Outil Thème de couleur"),
("Color Theme…", "Thème de couleur…"),
("Colour", "Couleur"),
("Colour Picker", "Sélecteur de couleurs"),
("Column Break", "Saut de colonne"),
("Commit Preview", "Valider l'aperçu"),
("Community", "Communauté"),
("Company", "Entreprise"),
("Conditional Text", "Texte conditionnel"),
("Content Collector Tool", "Outil Collecteur de contenu"),
("Content Placer Tool", "Outil Placeur de contenu"),
("Contributors", "Contributeurs"),
("Convert Direction Point Tool", "Outil Conversion de point directeur"),
("Convert Point", "Convertir le point"),
("Convert Rows", "Convertir les lignes"),
("Copyright Symbol", "Symbole du copyright"),
("Corner Options", "Options d'arrondi"),
("Couldn't write recovery data: {error}", "Impossible d'écrire les données de récupération : {error}"),
("Create Guides", "Créer des repères"),
("Dark", "Foncé"),
("Data Fields", "Champs de données"),
("Data Merge Options", "Options de fusion des données"),
("Define Cross-Reference Format", "Définir un format de référence croisée"),
("Define Lists", "Définir des listes"),
("Define Text Variable", "Définir une variable de texte"),
("Delete Anchor Point Tool", "Outil Suppression de point d'ancrage"),
("Delete Character Style", "Supprimer le style de caractère"),
("Delete Document Preset", "Supprimer le paramètre prédéfini de document"),
("Delete Parent", "Supprimer le gabarit"),
("Delete Type from Path", "Supprimer le texte curviligne"),
("Deselect Guides", "Désélectionner les repères"),
("DesignCraft Website", "Site web de DesignCraft"),
("DesignCraft on GitHub", "DesignCraft sur GitHub"),
("DesignCraft page", "Page DesignCraft"),
("Detect Edges", "Détecter les contours"),
("Direct Selection Tool", "Outil Sélection directe"),
("Discretionary Hyphen", "Trait d'union conditionnel"),
("Display name", "Nom affiché"),
("Distribute Rows Evenly", "Répartir les lignes uniformément"),
("Document CMYK", "CMJN du document"),
("Document Endnote Options", "Options de notes de fin de document"),
("Document Footnote Options", "Options de notes de bas de page du document"),
("Document RGB", "RVB du document"),
("Don't Save", "Ne pas enregistrer"),
("Done", "Terminé"),
("Double Left Quotation Marks", "Guillemets doubles ouvrants"),
("Double Right Quotation Marks", "Guillemets doubles fermants"),
("Drag Rows or Columns", "Faire glisser des lignes ou des colonnes"),
("Edit/Interface Language", "Édition/Langue de l'interface"),
("Edit/Spelling", "Édition/Orthographe"),
("Edit/Transparency Blend Space", "Édition/Espace de fusion de la transparence"),
("Edit/Transparency Flattener Presets", "Édition/Préréglages d'aplatissement de la transparence"),
("Ellipse Frame Tool", "Outil Bloc elliptique"),
("Ellipse Tool", "Outil Ellipse"),
("Ellipsis", "Points de suspension"),
("Em Dash", "Tiret cadratin"),
("Em Space", "Espace cadratin"),
("Email Address:", "Adresse e-mail :"),
("Email:", "E-mail :"),
("En Dash", "Tiret demi-cadratin"),
("En Space", "Espace demi-cadratin"),
(
"Equal fixed heights for the selected rows; cell text may become overset.",
"Hauteurs fixes identiques pour les lignes sélectionnées ; le texte des cellules peut déborder.",
),
("Erase Tool", "Outil Gomme"),
("Export EPUB", "Exporter en EPUB"),
("Export EPUB (Fixed Layout)", "Exporter en EPUB (mise en page fixe)"),
("Export EPUB (Reflowable)", "Exporter en EPUB (redistribuable)"),
@@ -59,50 +120,99 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Export IDML", "Exporter en IDML"),
("Export PDF (Interactive)", "Exporter en PDF (interactif)"),
("Export Page as PNG", "Exporter la page en PNG"),
("Export PowerPoint…", "Exporter en PowerPoint…"),
("Export Text", "Exporter le texte"),
("Export XML", "Exporter en XML"),
(
"Export a PDF, IDML or package to share — no cloud account needed.",
"Exportez un PDF, un fichier IDML ou un assemblage à partager — aucun compte cloud requis.",
),
("Extract Color Theme", "Extraire le thème de couleur"),
("Eyedropper Tool", "Outil Pipette"),
("Figure Space", "Espace chiffre"),
("File/Export", "Fichier/Exporter"),
("Find What", "Rechercher"),
("Find fill:", "Rechercher le fond :"),
("First commit", "Premier commit"),
("Flush Space", "Espace sans alinéa"),
("Forced Line Break", "Saut de ligne forcé"),
("Formatting Affects Text", "La mise en forme agit sur le texte"),
("Found", "Trouvé"),
("Frame Break", "Saut de bloc"),
("Free Transform Tool", "Outil Transformation manuelle"),
("GREP", "GREP"),
("Gap Tool", "Outil Espace"),
("Generate Index", "Générer l'index"),
("Generate Live Caption", "Générer une légende dynamique"),
("Get Cell Stroke", "Obtenir le contour de cellule"),
("Get Tabs", "Obtenir les tabulations"),
("Global Light", "Lumière globale"),
("Grab bag", "Liste"),
("Gradient Feather Tool", "Outil Contour progressif dégradé"),
("Gradient Swatch Tool", "Outil Dégradé de nuance"),
("Graphic", "Graphique"),
("Group with", "Grouper avec"),
("Hair Space", "Espace ultrafine"),
("Hand Tool", "Outil Main"),
("Hide Guides", "Masquer les repères"),
("High Contrast", "Contraste élevé"),
("High Resolution", "Haute résolution"),
("I, II, III, IV...", "I, II, III, IV..."),
("Import InDesign Shortcut Set", "Importer un jeu de raccourcis InDesign"),
("Import XML", "Importer XML"),
("Import cancelled: the originating document has closed", "Importation annulée : le document d'origine a été fermé"),
("Indent", "Retrait"),
("Indent to Here", "Retrait jusqu'à ce point"),
("Ink Limit 300%", "Limite d'encre 300 %"),
("Insert Anchored Object", "Insérer un objet ancré"),
("Insert Cross-Reference", "Insérer une référence croisée"),
("Insert Field", "Insérer un champ"),
("Insert Math Expression", "Insérer une expression mathématique"),
("Insert Pages", "Insérer des pages"),
("Internet Standard RGB (sRGB)", "RVB standard Internet (sRGB)"),
("Invalid panel layout; restored the default dock", "Disposition des panneaux non valide ; disposition par défaut restaurée"),
("Join", "Joindre"),
("Join the ArtCraft Discord", "Rejoindre le Discord d'ArtCraft"),
("Join the community", "Rejoindre la communauté"),
("Kerning", "Crénage"),
("Kerning/Tracking Step", "Pas de crénage/d'approche"),
("Key Object", "Objet clé"),
("Last commit", "Dernier commit"),
("Layout/Index", "Page/Index"),
("Layout/Pages", "Page/Pages"),
("Legacy Macintosh RGB", "RVB Macintosh hérité"),
("Light", "Clair"),
("Line Tool", "Outil Trait"),
("Line delta", "Différence de lignes"),
("Lines +/−", "Lignes +/−"),
("Lines added", "Lignes ajoutées"),
("Lines deleted", "Lignes supprimées"),
("List Composite Fonts", "Lister les polices composites"),
("List Styles in File", "Lister les styles du fichier"),
("Load DTD", "Charger la DTD"),
("Load Styles…", "Charger les styles…"),
("Load Swatches", "Charger des nuances"),
("Low Resolution", "Basse résolution"),
("Margins:", "Marges :"),
("Maximize/Restore Window", "Agrandir/Restaurer la fenêtre"),
("Measure Tool", "Outil Mesure"),
("Medium Dark", "Moyennement foncé"),
("Medium Light", "Moyennement clair"),
("Medium Resolution", "Résolution moyenne"),
("Menus", "Menus"),
("Merged PRs", "PR fusionnées"),
("Message:", "Message :"),
("Minimize Window", "Réduire la fenêtre"),
("Models", "Modèles"),
("Monitor RGB", "RVB du moniteur"),
("Move Column Guide", "Déplacer le repère de colonne"),
("Move Guides", "Déplacer les repères"),
("Move Pages", "Déplacer des pages"),
("Move Panel", "Déplacer le panneau"),
("Move Tab", "Déplacer la tabulation"),
("Move Window", "Déplacer la fenêtre"),
("Name (A–Z)", "Nom (A–Z)"),
("New Cell Style", "Nouveau style de cellule"),
("New Condition", "Nouvelle condition"),
("New Hyperlink", "Nouvel hyperlien"),
("New Hyperlink Destination", "Nouvelle destination d'hyperlien"),
@@ -110,13 +220,20 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("New Page Reference", "Nouvelle référence de page"),
("New Parent", "Nouveau gabarit"),
("New Stroke Style", "Nouveau style de contour"),
("New Table Style", "Nouveau style de tableau"),
("Next Page Number", "Numéro de la page suivante"),
("No contributor data was built into this copy.", "Aucune donnée sur les contributeurs n'est intégrée à cette copie."),
("No matches", "Aucune correspondance"),
("No model credits were built into this copy.", "Aucun crédit de modèle n'est intégré à cette copie."),
("Nonbreaking Hyphen", "Trait d'union insécable"),
("Nonbreaking Space", "Espace insécable"),
("None (keep transparency)", "Aucun (conserver la transparence)"),
("Note Tool", "Outil Note"),
("Number of Pages", "Nombre de pages"),
("Numbering & Section Options", "Options de numérotation et de section"),
("OK", "OK"),
("Object Style", "Style d'objet"),
("Object Style Options…", "Options de style d'objet…"),
("Object Styles", "Styles d'objet"),
("Object/Anchored Object", "Objet/Objet ancré"),
("Object/Arrange", "Objet/Disposition"),
@@ -132,62 +249,128 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Object/Transform", "Objet/Transformation"),
("Object/Transform Again", "Objet/Répéter la transformation"),
("On", "Activé"),
("Organization:", "Organisation :"),
("Orientation", "Orientation"),
("PDF import cancelled: the originating document has closed", "Importation PDF annulée : le document d'origine a été fermé"),
("PRs", "PR"),
("Package", "Assemblage"),
("Packaged {count} files into {dir}", "{count} fichiers assemblés dans {dir}"),
("Page Break", "Saut de page"),
("Page Options", "Options de page"),
("Page Tool", "Outil Page"),
("Panels", "Panneaux"),
("Paragraph Return", "Retour de paragraphe"),
("Paragraph Style", "Style de paragraphe"),
("Paragraph Symbol", "Symbole de paragraphe"),
("Parent Options…", "Options de gabarit…"),
("Pen Tool", "Outil Plume"),
("Pencil Tool", "Outil Crayon"),
("Phone:", "Téléphone :"),
("Picas", "Picas"),
("Pin Bar Position", "Épingler la position de la barre"),
("Place with Import Options", "Importer avec les options d'importation"),
("Polygon Frame Tool", "Outil Bloc polygonal"),
("Polygon Tool", "Outil Polygone"),
("Preflight", "Contrôle en amont"),
("Preflight Panel…", "Panneau Contrôle en amont…"),
("Press keys…", "Appuyez sur des touches…"),
("Preview Change", "Prévisualiser la modification"),
("Preview Record", "Aperçu de l'enregistrement"),
("Previous Page Number", "Numéro de la page précédente"),
("Print Booklet", "Imprimer le cahier"),
("Punctuation Space", "Espace de ponctuation"),
("Quarter Space", "Quart d'espace"),
("Quick Apply", "Application rapide"),
("RGB:", "RVB :"),
("Range:", "Plage :"),
("Real name", "Nom réel"),
("Recent Files", "Fichiers récents"),
("Rectangle Frame Tool", "Outil Bloc rectangulaire"),
("Rectangle Tool", "Outil Rectangle"),
("Redefine Character Style", "Redéfinir le style de caractère"),
("Redefine Paragraph Style", "Redéfinir le style de paragraphe"),
("Registered Trademark Symbol", "Symbole de marque déposée"),
("Relink", "Rééditer le lien"),
("Relink to Folder", "Rééditer le lien au dossier"),
("Relinked {count}", "Liens rétablis : {count}"),
("Reload Fonts", "Recharger les polices"),
("Remove Data Source", "Supprimer la source de données"),
("Remove Field", "Supprimer le champ"),
("Remove Frame from Thread", "Retirer le bloc du chaînage"),
("Reorder Objects", "Réorganiser les objets"),
("Report an Issue", "Signaler un problème"),
("Report an issue", "Signaler un problème"),
("Reset Bar Position", "Réinitialiser la position de la barre"),
("Resize Row or Column", "Redimensionner la ligne ou la colonne"),
("Resize Window", "Redimensionner la fenêtre"),
("Resize panel window", "Redimensionner la fenêtre du panneau"),
("Resize panels", "Redimensionner les panneaux"),
("Reverse the order", "Inverser l'ordre"),
("Right Indent Tab", "Tabulation de retrait à droite"),
("Rotate Tool", "Outil Rotation"),
("Rotation", "Rotation"),
("Ruler Zero Point", "Point zéro des règles"),
("Save As", "Enregistrer sous"),
("Save Swatches for Exchange", "Enregistrer les nuances pour échange"),
("Save a Copy", "Enregistrer une copie"),
("Scale Tool", "Outil Mise à l'échelle"),
("Scissors Tool", "Outil Ciseaux"),
("Scripts", "Scripts"),
("Section Marker", "Marque de section"),
("Section Symbol", "Symbole de section"),
("Select Guide", "Sélectionner le repère"),
("Select Page", "Sélectionner la page"),
("Select Paragraph", "Sélectionner le paragraphe"),
("Select Sentence", "Sélectionner la phrase"),
("Select Story", "Sélectionner l'article"),
("Select Text Unit", "Sélectionner une unité de texte"),
("Selection", "Sélection"),
("Selection Tool", "Outil Sélection"),
("Sent {count} page(s) to {printer}", "{count} page(s) envoyée(s) à {printer}"),
("Sentence case", "Majuscule en début de phrase"),
("Set Composite Font", "Définir la police composite"),
("Set OpenType Features", "Définir les fonctions OpenType"),
("Shear Tool", "Outil Déformation"),
("Simulate Paper Color", "Simuler la couleur du papier"),
("Single Left Quotation Mark", "Guillemet simple ouvrant"),
("Single Right Quotation Mark", "Guillemet simple fermant"),
("Sixth Space", "Sixième d'espace"),
("Smart Guide Options", "Options des repères commentés"),
("Smooth Tool", "Outil Lissage"),
("Start Page #", "N° de la première page"),
("Step and Repeat", "Exécuter et répéter"),
("Stop Preview", "Arrêter l'aperçu"),
("Story Column Direction", "Sens des colonnes de l'article"),
("Story Editor — {count} words", "Mode éditeur — {count} mots"),
("Story {story} ← story {parent}", "Article {story} ← article {parent}"),
("Straight Double Quotation Marks", "Guillemets droits doubles"),
("Straight Single Quotation Mark (Apostrophe)", "Guillemet droit simple (apostrophe)"),
("Subject:", "Objet :"),
("Subscript:", "Indice :"),
("Superscript:", "Exposant :"),
("Swap Text Fill and Stroke", "Permuter le fond et le contour du texte"),
("Tab", "Tabulation"),
("Table of Contents", "Table des matières"),
("Table/Convert Cell Type", "Tableau/Convertir le type de cellule"),
("Table/Delete", "Tableau/Supprimer"),
("Table/Insert", "Tableau/Insérer"),
("Table/Select", "Tableau/Sélectionner"),
("Text Anchor", "Ancre de texte"),
("Text:", "Texte :"),
("Thin Space", "Espace fine"),
("Third Space", "Tiers d'espace"),
("Thread Text Frames", "Chaîner les blocs de texte"),
("Title Case", "Majuscule à chaque mot"),
("To Body", "En lignes de corps"),
("To Footer", "En lignes de pied"),
("To Header", "En lignes d'en-tête"),
("Trademark Symbol", "Symbole de marque"),
("Transform Again Individually", "Répéter la transformation individuellement"),
("Transform Sequence Again", "Répéter la séquence de transformations"),
("Transform Sequence Again Individually", "Répéter la séquence de transformations individuellement"),
("Type Tool", "Outil Texte"),
("Type on a Path Options", "Options de texte curviligne"),
("Type on a Path Tool", "Outil Texte curviligne"),
("Type/Bulleted and Numbered Lists", "Texte/Listes à puces et numérotées"),
("Type/Change Case", "Texte/Modifier la casse"),
("Type/Hyperlinks & Cross-References", "Texte/Hyperliens et références croisées"),
@@ -208,8 +391,20 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("URL:", "URL :"),
("Unassigned", "Non attribué"),
("Units", "Unités"),
("Unsaved Changes", "Modifications non enregistrées"),
("Update Data Source", "Mettre à jour la source de données"),
("Username", "Nom d'utilisateur"),
("Validate from Root Element", "Valider à partir de l'élément racine"),
("Vertical Type Tool", "Outil Texte vertical"),
("Vertical Type on a Path Tool", "Outil Texte curviligne vertical"),
("View/Display Performance", "Affichage/Performances d'affichage"),
("View/Extras", "Affichage/Extras"),
("View/Grids & Guides", "Affichage/Grilles et repères"),
("View/Proof Setup", "Affichage/Format d'épreuve"),
("View/Rotate Spread", "Affichage/Faire pivoter la planche"),
("View/Screen Mode", "Affichage/Mode de l'écran"),
("View/Separations Preview", "Affichage/Aperçu des séparations"),
("View/Structure", "Affichage/Structure"),
("Window/Arrange", "Fenêtre/Disposition"),
("Window/Interactive", "Fenêtre/Interactif"),
("Window/Interface Color Theme", "Fenêtre/Thème de couleur de l'interface"),
@@ -218,6 +413,31 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Window/Type & Tables", "Fenêtre/Texte et tableaux"),
("Window/Utilities", "Fenêtre/Utilitaires"),
("Working CMYK", "CMJN de travail"),
("Zoom Tool", "Outil Zoom"),
("[No Language]", "[Aucune langue]"),
("i, ii, iii, iv...", "i, ii, iii, iv..."),
("image editing", "retouche d'images"),
("layer", "calque"),
("layers", "calques"),
("lowercase", "minuscules"),
("motion graphics and visual effects", "animation graphique et effets visuels"),
("photo library and raw development", "photothèque et développement raw"),
("reading, organizing and protecting PDFs", "lecture, organisation et protection de PDF"),
("the default printer", "l'imprimante par défaut"),
("vector illustration", "illustration vectorielle"),
("video editing, color and sound", "montage vidéo, couleur et son"),
("{contributors} contributors · {commits} commits", "{contributors} contributeurs · {commits} commits"),
("{count} conflict(s): {names}", "{count} conflit(s) : {names}"),
("{count} match(es)", "{count} correspondance(s)"),
("{count} replacement(s) made", "{count} remplacement(s) effectué(s)"),
("{label}: {name} (double-click: Color Picker)", "{label} : {name} (double-clic : Sélecteur de couleurs)"),
(
"{name}: import cancelled while a dialog is open; choose the file again",
"{name} : importation annulée car une boîte de dialogue était ouverte ; choisissez à nouveau le fichier",
),
("{name}: {count} step(s) done", "{name} : {count} étape(s) effectuée(s)"),
(
"“{}” has changes that are not saved. Save them before closing?",
"« {} » contient des modifications non enregistrées. Les enregistrer avant de fermer ?",
),
];
+7 -31
View File
@@ -27,6 +27,7 @@ mod prefs;
pub mod render_worker;
mod rtl;
mod section_options;
mod shortcut_import;
pub mod story_editor;
pub mod taskbar;
pub mod theme;
@@ -810,7 +811,7 @@ impl DesignApp {
Some(t.to_string())
}
None if !self.native_menu => {
self.object_clip_text = Some(self.copied_objects_text());
self.object_clip_text = Some(designcraft_engine::cmd::copied_objects_text(&self.session));
self.object_clip_text.clone()
}
None => {
@@ -910,35 +911,6 @@ impl DesignApp {
params
}
/// Plain text for the system clipboard after copying objects: the copied text frames' stories,
/// or U+FFFC (object replacement character) when none has text.
fn copied_objects_text(&self) -> String {
let mut stories = Vec::new();
if let Some(clip) = &self.session.clipboard
&& let Some(sp) = clip.spreads.first()
{
for it in &sp.items {
it.walk(&mut |i| {
if let Some(tf) = i.text_frame()
&& !stories.contains(&tf.story)
{
stories.push(tf.story);
}
});
}
}
let text = stories
.iter()
.filter_map(|id| self.session.clipboard.as_ref()?.story(*id))
.map(|st| {
st.text.chars().filter(|c| !('\u{E000}'..='\u{E1FF}').contains(c)).map(|c| if c == '\u{2028}' { '\n' } else { c }).collect::<String>()
})
.filter(|t| !t.is_empty())
.collect::<Vec<_>>()
.join("\n");
if text.is_empty() { '\u{FFFC}'.to_string() } else { text }
}
/// Handle requests produced by tools/commands (dialogs, view changes, file pickers).
pub fn after_engine(&mut self) {
let reqs = std::mem::take(&mut self.session.ui_requests);
@@ -1062,6 +1034,10 @@ impl DesignApp {
let Some(inbox) = self.services.inbox.clone() else { return };
let files = std::mem::take(&mut *inbox.lock().unwrap_or_else(|e| e.into_inner()));
for file in files {
if file.request.purpose == "shortcuts" {
shortcut_import::import_delivered(self, &file.bytes);
continue;
}
if self.ui.dialog.is_some() {
self.status(
crate::i18n::tr(&self.ui.language, "{name}: import cancelled while a dialog is open; choose the file again")
@@ -2484,7 +2460,7 @@ mod tests {
assert_eq!(app.ui.status, "Table: No table selected");
app.ui.language = "cs".into();
app.status_error("Table", "No table selected");
assert_eq!(app.ui.status, "Tabulka: No table selected");
assert_eq!(app.ui.status, format!("{}: No table selected", crate::i18n::tr("cs", "Table")));
}
#[test]
+47 -153
View File
@@ -85,6 +85,12 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
("window.richBlack", "Appearance of Black", None, "{on: bool} — show 100% black as rich black on screen (off: accurately)"),
("window.setShortcut", "Set Shortcut", None, "{id, shortcut: \"Cmd+Alt+J\" | \"\" (none) | null (default)} → {shortcut, conflicts: [ids]}"),
("window.resetShortcuts", "Reset Shortcuts", None, "{} — every command back to its default shortcut"),
(
"window.importShortcuts",
"Import InDesign Shortcut Set",
None,
"{path | text} — a set saved by InDesign's Show Set (.txt): matched commands take its Default shortcuts (or none), other commands lose keys it takes → {set, imported, changed, conflictsCleared, skipped (an earlier item whose keys a later one took), unmatched, unmatchedWithoutShortcut, unreadable}",
),
("app.findChange", "Find/Change…", Some("Cmd+F"), "{}"),
("app.insertTableDialog", "Create Table…", None, "{} — Insert Table dialog (body/header/footer rows, columns)"),
(
@@ -1742,6 +1748,17 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value
app.ui.shortcuts.clear();
Ok(Value::Null)
}
"window.importShortcuts" => {
let text = match (p.get("text").and_then(Value::as_str), p.get("path").and_then(Value::as_str)) {
(Some(text), _) => text.to_string(),
(None, Some(path)) => match crate::shortcut_import::read_file(path) {
Ok(text) => text,
Err(e) => return Some(Err(e)),
},
(None, None) => return Some(Err("give the set's path or text".into())),
};
crate::shortcut_import::import(app, &text)
}
"view.snapPreferences" => {
let b = |k: &str| p.get(k).and_then(Value::as_bool);
let ui = &mut app.ui;
@@ -3051,7 +3068,7 @@ pub fn all_menu_items() -> Vec<(String, String)> {
out
}
fn parse_shortcut(sc: &str) -> Option<(egui::Modifiers, egui::Key)> {
pub(crate) fn parse_shortcut(sc: &str) -> Option<(egui::Modifiers, egui::Key)> {
let mut m = egui::Modifiers::NONE;
let mut key = None;
for part in sc.split('+').filter(|s| !s.is_empty()) {
@@ -3092,6 +3109,20 @@ fn key_matches(m: egui::Modifiers, k: egui::Key, key: egui::Key, modifiers: egui
matches.then_some(k == key && m.shift == modifiers.shift)
}
/// The command a paste event stands for besides plain Paste. egui-winit turns every ⌘+V chord into
/// `Event::Paste` with the clipboard's text and drops the key press, so the shortcut table never
/// sees Paste in Place's chord; the modifiers held when it went down tell them apart. `None` when
/// the chord is another one, or the native menu's accelerator takes it.
#[cfg(not(target_arch = "wasm32"))]
pub fn paste_chord_command(app: &DesignApp, modifiers: egui::Modifiers) -> Option<&'static str> {
let id = "edit.pasteInPlace";
if app.native_menu && !app.ui.shortcuts.contains_key(id) {
return None;
}
let (m, k) = shortcut_of(app, id).as_deref().and_then(parse_shortcut)?;
(k == egui::Key::V && key_matches(m, k, egui::Key::V, modifiers) == Some(true)).then_some(id)
}
/// Global keyboard shortcuts: menu commands and single-key tool shortcuts.
pub fn shortcuts(app: &mut DesignApp, ctx: &egui::Context) {
// Only a focused text field takes the keys: the canvas (or a button) having focus after a click
@@ -3378,16 +3409,12 @@ mod tests {
}
#[test]
fn screen_mode_shows_the_current_mode_and_view_submenus_are_translated() {
fn screen_mode_shows_the_current_mode() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
let mode = |app: &DesignApp, m: &str| checked(app, "view.screenMode", &json!({ "mode": m }));
assert_eq!((mode(&app, "normal"), mode(&app, "preview")), (Some(true), Some(false)));
app.ui.screen_mode = crate::ScreenMode::Preview;
assert_eq!((mode(&app, "normal"), mode(&app, "preview")), (Some(false), Some(true)));
// Every View submenu has its Japanese path (Edit › Menus shows them).
for path in submenu_paths("View") {
assert_ne!(crate::i18n::tr("ja", &path), path, "{path} has no Japanese row");
}
}
fn walk(items: &[Item], out: &mut Vec<(String, String, Value)>) {
@@ -3804,8 +3831,8 @@ mod tests {
run_ui(&mut app, "app.language", &json!({"lang": "de"})).unwrap().unwrap();
assert_eq!(app.ui.language, "de");
assert_eq!(checked(&app, "app.language", &json!({"lang": "de"})), Some(true));
assert_eq!(crate::i18n::tr(&app.ui.language, "Window"), "Fenster");
assert!(run_ui(&mut app, "app.language", &json!({"lang": "xx"})).unwrap().is_err());
assert_eq!(app.ui.language, "de");
// The language is set in Preferences, not from a menu; without `lang` the command opens it.
let mut items = Vec::new();
for (_, entries) in menu_tree() {
@@ -3815,153 +3842,23 @@ mod tests {
run_ui(&mut app, "app.language", &json!({})).unwrap().unwrap();
assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("preferences"));
assert_eq!(app.ui.language, "de");
app.ui.dialog = None;
run_ui(&mut app, "app.language", &json!({"lang": "zh"})).unwrap().unwrap();
assert_eq!(app.ui.language, "zh");
assert_eq!(crate::i18n::tr(&app.ui.language, "File"), "文件");
app.run("file.new", json!({"pages": 4, "facingPages": true})).unwrap();
let before = serde_json::to_value(&app.session.doc().unwrap().doc).unwrap();
run_ui(&mut app, "app.language", &json!({"lang": "ar"})).unwrap().unwrap();
assert_eq!(app.ui.language, "ar");
assert_eq!(checked(&app, "app.language", &json!({"lang": "ar"})), Some(true));
assert_eq!(before, serde_json::to_value(&app.session.doc().unwrap().doc).unwrap());
for (title, _) in menu_tree() {
assert_ne!(crate::i18n::tr("ar", title), title, "{title}");
}
// Every menu title has a translation (Japanese never matches the English).
for (title, _) in menu_tree() {
assert_ne!(crate::i18n::tr("ja", title), title, "{title}");
}
run_ui(&mut app, "app.language", &json!({"lang": "pt-br"})).unwrap().unwrap();
assert_eq!(app.ui.language, "pt-br");
assert_eq!(checked(&app, "app.language", &json!({"lang": "pt-br"})), Some(true));
for (title, _) in menu_tree() {
// "Layout" is the natural Brazilian Portuguese word, so it stays identical
// to English; every other title must be translated.
if title != "Layout" {
assert_ne!(crate::i18n::tr("pt-br", title), title, "{title}");
}
}
run_ui(&mut app, "app.language", &json!({"lang": "it"})).unwrap().unwrap();
assert_eq!(app.ui.language, "it");
assert_eq!(checked(&app, "app.language", &json!({"lang": "it"})), Some(true));
for (title, _) in menu_tree() {
// "File" and "Layout" are the usual Italian menu names, so they stay identical
// to English; every other title must be translated.
if title != "File" && title != "Layout" {
assert_ne!(crate::i18n::tr("it", title), title, "{title}");
}
}
run_ui(&mut app, "app.language", &json!({"lang": "ka"})).unwrap().unwrap();
assert_eq!(app.ui.language, "ka");
assert_eq!(checked(&app, "app.language", &json!({"lang": "ka"})), Some(true));
// Every menu title, item and submenu is translated into Georgian.
let mut untranslated = Vec::new();
for (title, entries) in menu_tree() {
let mut labels = vec![title.to_string()];
let mut stack: Vec<&Item> = entries.iter().collect();
while let Some(entry) = stack.pop() {
match entry {
Item::Sub(label, children) => {
labels.push(label.clone());
stack.extend(children.iter());
}
Item::Cmd { label, id, .. } if id != "app.language" => labels.push(label.clone()),
_ => {}
}
}
// Labels without letters ("180°") or point sizes ("72 pt") read the same in every language.
let size = |l: &str| l.strip_suffix(" pt").is_some_and(|n| !n.is_empty() && n.chars().all(|c| c.is_ascii_digit()));
untranslated
.extend(labels.into_iter().filter(|l| l.chars().any(char::is_alphabetic) && !size(l) && crate::i18n::tr("ka", l) == l.as_str()));
}
assert!(untranslated.is_empty(), "untranslated Georgian menu labels: {untranslated:?}");
}
#[test]
fn ukrainian_language_is_selectable_persisted_and_keeps_document_data() {
fn every_interface_language_is_selectable_persisted_and_keeps_document_data() {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"pages": 4, "facingPages": true})).unwrap();
app.run("frame.create", json!({"rect": [36, 36, 200, 200], "content": "text", "text": "My words · Мої слова"})).unwrap();
app.run("frame.create", json!({"rect": [36, 36, 200, 200], "content": "text", "text": "My words · Мої слова · Zażółć gęślą jaźń"})).unwrap();
let before = serde_json::to_value(&app.session.doc().unwrap().doc).unwrap();
run_ui(&mut app, "app.language", &json!({"lang": "uk"})).unwrap().unwrap();
assert_eq!(checked(&app, "app.language", &json!({"lang": "uk"})), Some(true));
assert_eq!(before, serde_json::to_value(&app.session.doc().unwrap().doc).unwrap());
for (title, _) in menu_tree() {
assert_ne!(crate::i18n::tr("uk", title), title, "{title}");
for (code, _) in crate::i18n::LANGUAGES {
run_ui(&mut app, "app.language", &json!({"lang": code})).unwrap().unwrap();
assert_eq!(app.ui.language, *code);
assert_eq!(checked(&app, "app.language", &json!({"lang": code})), Some(true), "{code}");
assert_eq!(before, serde_json::to_value(&app.session.doc().unwrap().doc).unwrap(), "{code}");
let bytes = serde_json::to_vec(&app.ui).unwrap();
let restored: crate::UiState = serde_json::from_slice(&bytes).unwrap();
assert_eq!(restored.language, *code);
}
let bytes = serde_json::to_vec(&app.ui).unwrap();
let restored: crate::UiState = serde_json::from_slice(&bytes).unwrap();
assert_eq!(restored.language, "uk");
assert_eq!(crate::i18n::tr(&restored.language, "File"), "Файл");
assert_eq!(crate::i18n::tr(&restored.language, "A custom name"), "A custom name");
assert!(crate::i18n::LANGUAGES.contains(&("uk", "Українська")), "Preferences lists Ukrainian by its own name");
}
#[test]
fn russian_language_is_selectable_persisted_and_keeps_document_data() {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"pages": 4, "facingPages": true})).unwrap();
app.run("frame.create", json!({"rect": [36, 36, 200, 200], "content": "text", "text": "My words · Мои слова"})).unwrap();
let before = serde_json::to_value(&app.session.doc().unwrap().doc).unwrap();
run_ui(&mut app, "app.language", &json!({"lang": "ru"})).unwrap().unwrap();
assert_eq!(checked(&app, "app.language", &json!({"lang": "ru"})), Some(true));
assert_eq!(before, serde_json::to_value(&app.session.doc().unwrap().doc).unwrap());
for (title, _) in menu_tree() {
assert_ne!(crate::i18n::tr("ru", title), title, "{title}");
}
let bytes = serde_json::to_vec(&app.ui).unwrap();
let restored: crate::UiState = serde_json::from_slice(&bytes).unwrap();
assert_eq!(restored.language, "ru");
assert_eq!(crate::i18n::tr(&restored.language, "File"), "Файл");
assert_eq!(crate::i18n::tr(&restored.language, "A custom name"), "A custom name");
assert!(crate::i18n::LANGUAGES.contains(&("ru", "Русский")), "Preferences lists Russian by its own name");
}
#[test]
fn czech_language_is_selectable_persisted_and_keeps_document_data() {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"pages": 4, "facingPages": true})).unwrap();
app.run("frame.create", json!({"rect": [36, 36, 200, 200], "content": "text", "text": "My words · Příliš žluťoučký kůň"})).unwrap();
let before = serde_json::to_value(&app.session.doc().unwrap().doc).unwrap();
run_ui(&mut app, "app.language", &json!({"lang": "cs"})).unwrap().unwrap();
assert_eq!(app.ui.language, "cs");
assert_eq!(checked(&app, "app.language", &json!({"lang": "cs"})), Some(true));
assert_eq!(before, serde_json::to_value(&app.session.doc().unwrap().doc).unwrap());
for (title, _) in menu_tree() {
assert_ne!(crate::i18n::tr("cs", title), title, "{title}");
}
let bytes = serde_json::to_vec(&app.ui).unwrap();
let restored: crate::UiState = serde_json::from_slice(&bytes).unwrap();
assert_eq!(restored.language, "cs");
assert_eq!(crate::i18n::tr(&restored.language, "File"), "Soubor");
assert_eq!(crate::i18n::tr(&restored.language, "A custom name"), "A custom name");
assert!(crate::i18n::LANGUAGES.contains(&("cs", "Čeština")), "Preferences lists Czech by its own name");
}
#[test]
fn polish_language_is_selectable_persisted_and_keeps_document_data() {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"pages": 2, "facingPages": true})).unwrap();
app.run("frame.create", json!({"rect": [36, 36, 200, 200], "content": "text", "text": "Zażółć gęślą jaźń"})).unwrap();
let before = serde_json::to_value(&app.session.doc().unwrap().doc).unwrap();
run_ui(&mut app, "app.language", &json!({"lang": "pl"})).unwrap().unwrap();
assert_eq!(app.ui.language, "pl");
assert_eq!(checked(&app, "app.language", &json!({"lang": "pl"})), Some(true));
assert_eq!(before, serde_json::to_value(&app.session.doc().unwrap().doc).unwrap());
for (title, _) in menu_tree() {
assert_ne!(crate::i18n::tr("pl", title), title, "{title}");
}
let bytes = serde_json::to_vec(&app.ui).unwrap();
let restored: crate::UiState = serde_json::from_slice(&bytes).unwrap();
assert_eq!(restored.language, "pl");
assert_eq!(crate::i18n::tr(&restored.language, "File"), "Plik");
assert!(crate::i18n::LANGUAGES.contains(&("pl", "Polski")), "Preferences lists Polish by its own name");
}
#[test]
@@ -5004,9 +4901,10 @@ mod tests {
fn command_search_matches_labels_in_the_interface_language() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.ui.language = "de".into();
let items = command_search(&app, "gruppieren");
let label = crate::i18n::tr("de", "Group");
let items = command_search(&app, &label.to_lowercase());
let group = items.iter().find(|i| i.id == "object.group").unwrap();
assert_eq!(group.label, "Gruppieren");
assert_eq!(group.label, label);
assert_eq!(group.shortcut.as_deref(), Some("Cmd+G"));
assert!(command_search(&app, "group").iter().any(|i| i.id == "object.group"), "English labels match too");
assert!(command_search(&app, " ").is_empty());
@@ -5663,9 +5561,6 @@ mod tests {
// Language follows Settings, as in the suite's other apps, titled in the interface language.
assert_eq!(MAC_APP_MENU.get(settings + 1), Some(&AppMenuItem::Language));
assert_eq!(MAC_APP_MENU.get(settings + 2), Some(&AppMenuItem::Sep));
for (code, _) in crate::i18n::LANGUAGES.iter().filter(|(c, _)| !c.is_empty()) {
assert_ne!(crate::i18n::tr(code, "Language"), "Language", "{code}");
}
let label = |command: &str| {
MAC_APP_MENU.iter().find_map(|m| match m {
AppMenuItem::Cmd { label, command: c, .. } if *c == command => Some(*label),
@@ -5673,7 +5568,6 @@ mod tests {
})
};
assert_eq!(label("app.preferences"), Some("Settings…"));
assert_ne!(crate::i18n::tr("de", "Settings…"), "Settings…");
let ids = MAC_APP_MENU.iter().filter_map(|m| if let AppMenuItem::Cmd { id, .. } = m { Some(*id) } else { None }).collect::<Vec<_>>();
assert!(ids.iter().enumerate().all(|(i, id)| !ids[..i].contains(id)), "native ids are unique: {ids:?}");
+13 -2
View File
@@ -3517,8 +3517,19 @@ mod tests {
/// are translated, with a sample of each script's figures.
#[test]
fn digits_choices_use_the_reference_names() {
let shown: Vec<String> = DIGIT_OPTIONS.iter().map(|o| digit_label("fr", *o)).collect();
assert_eq!(shown, ["Par défaut", "Arabe 0123", "Hindi ٠١٢٣", "Persan ۰۱۲۳", "Natif"].map(String::from));
assert_eq!(DIGIT_OPTIONS.map(|o| o.1), ["Default", "Arabic", "Hindi", "Farsi", "Native"]);
for (lang, _) in crate::i18n::LANGUAGES {
let tr = |s| crate::i18n::tr(lang, s);
let shown: Vec<String> = DIGIT_OPTIONS.iter().map(|o| digit_label(lang, *o)).collect();
let expected = [
tr("Default").to_string(),
format!("{} 0123", tr("Arabic")),
format!("{} ٠١٢٣", tr("Hindi")),
format!("{} ۰۱۲۳", tr("Farsi")),
tr("Native").to_string(),
];
assert_eq!(shown, expected, "{lang}");
}
}
#[test]
+611
View File
@@ -0,0 +1,611 @@
//! Importing a keyboard shortcut set that InDesign's Keyboard Shortcuts dialog saved as text
//! (Show Set): `window.importShortcuts`.
//!
//! The file lists items under `Product area : <area>` headers, one per line:
//! `<name> --- [none defined]` or `<name> --- <context>: <keys>, <context>: <keys>`. Item names
//! use `: ` between submenu levels. Only the Default context applies (DesignCraft has no
//! shortcut contexts); a type command with no Default entry takes its Text entry.
use std::collections::{BTreeSet, HashMap};
use serde_json::{Value, json};
use crate::DesignApp;
use crate::menus::{self, Item};
/// Largest set file read. InDesign's full set is a few hundred kilobytes (twice that as UTF-16).
pub const MAX_BYTES: usize = 8 << 20;
/// Entries read per item: a real item has one or two per context.
const MAX_ENTRIES: usize = 64;
/// One `<context>: <keys>` entry of an item.
struct Entry {
context: String,
keys: String,
}
struct SetItem {
area: String,
name: String,
entries: Vec<Entry>,
}
struct ParsedSet {
name: String,
items: Vec<SetItem>,
}
/// Text from the bytes of a set file: UTF-8 (with or without a BOM) or UTF-16 (by its BOM, or
/// by the zero bytes of ASCII text without one).
pub fn decode(bytes: &[u8]) -> Result<String, String> {
if bytes.len() > MAX_BYTES {
return Err(format!("the file is larger than {} MB", MAX_BYTES >> 20));
}
let utf16 = |rest: &[u8], le: bool| -> String {
let units = rest.as_chunks::<2>().0.iter().map(|&c| if le { u16::from_le_bytes(c) } else { u16::from_be_bytes(c) });
char::decode_utf16(units).map(|r| r.unwrap_or(char::REPLACEMENT_CHARACTER)).collect()
};
Ok(match bytes {
[0xFF, 0xFE, rest @ ..] => utf16(rest, true),
[0xFE, 0xFF, rest @ ..] => utf16(rest, false),
[0xEF, 0xBB, 0xBF, rest @ ..] => String::from_utf8_lossy(rest).into_owned(),
[a, 0, ..] if *a != 0 => utf16(bytes, true),
[0, b, ..] if *b != 0 => utf16(bytes, false),
_ => String::from_utf8_lossy(bytes).into_owned(),
})
}
/// Reads and decodes a set file, refusing one over [`MAX_BYTES`].
pub fn read_file(path: &str) -> Result<String, String> {
let cap = u64::try_from(MAX_BYTES).unwrap_or(u64::MAX);
let bytes = designcraft_engine::io::read_capped(path, cap).map_err(|e| format!("can't read {path}: {e}"))?;
decode(&bytes)
}
/// `Label : value` with any spacing around the colon, when the line starts with `label`.
fn header<'a>(line: &'a str, label: &str) -> Option<&'a str> {
let rest = line.get(..label.len()).filter(|h| h.eq_ignore_ascii_case(label)).and_then(|_| line.get(label.len()..))?;
rest.trim_start().strip_prefix(':').map(str::trim)
}
fn parse(text: &str) -> Result<ParsedSet, String> {
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
let mut set = ParsedSet { name: String::new(), items: Vec::new() };
let mut area = String::new();
let mut header_seen = false;
for line in text.lines() {
let line = line.trim_end_matches('\r');
let trimmed = line.trim();
if let Some(name) = header(trimmed, "Shortcut Set") {
set.name = name.to_string();
header_seen = true;
} else if let Some(a) = header(trimmed, "Product area") {
area = a.to_string();
header_seen = true;
} else if let Some((name, keys)) = trimmed.split_once(" --- ") {
let name = name.trim();
if name.is_empty() {
continue;
}
set.items.push(SetItem { area: area.clone(), name: name.to_string(), entries: entries(keys) });
}
}
if set.items.is_empty() && !header_seen {
return Err("not a shortcut set: no `Product area` or `<item> --- <shortcuts>` lines".into());
}
Ok(set)
}
/// The entries after ` --- `: none for `[none defined]`. A comma is also a key (`Ctrl+,`), so a
/// piece without `: ` belongs to the entry before it.
fn entries(keys: &str) -> Vec<Entry> {
let keys = keys.trim();
if keys.eq_ignore_ascii_case("[none defined]") || keys.is_empty() {
return Vec::new();
}
let mut pieces: Vec<String> = Vec::new();
for piece in keys.split(", ") {
match pieces.last_mut() {
Some(last) if !piece.contains(": ") => {
last.push_str(", ");
last.push_str(piece);
}
_ => pieces.push(piece.to_string()),
}
}
pieces
.into_iter()
.take(MAX_ENTRIES)
.filter_map(|p| {
let (context, keys) = p.split_once(": ")?;
Some(Entry { context: context.trim().to_string(), keys: keys.trim().to_string() })
})
.collect()
}
/// Whether the keys use Mac modifier names, where Ctrl is the Control key rather than the
/// command modifier.
fn is_mac_set(set: &ParsedSet) -> bool {
set.items.iter().flat_map(|i| &i.entries).any(|e| {
let k = e.keys.to_lowercase();
k.contains("cmd") || k.contains("command") || k.contains('⌘') || k.contains("opt") || k.contains('⌥')
})
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Mod {
Command,
Alt,
Shift,
Control,
}
/// Keys as InDesign writes them (`Shift+Ctrl+S`, `Ctrl+Num 5`, `Page Down`, `⇧⌘S`) as a
/// DesignCraft shortcut string (`Cmd+Shift+S`). Ctrl is the command modifier in a Windows set.
fn read_keys(keys: &str, mac: bool) -> Result<String, &'static str> {
const SYMBOLS: &[(&str, Mod)] = &[("⌘", Mod::Command), ("⌥", Mod::Alt), ("⇧", Mod::Shift), ("⌃", Mod::Control)];
const WORDS: &[(&str, Mod)] = &[
("command", Mod::Command),
("cmd", Mod::Command),
("option", Mod::Alt),
("opt", Mod::Alt),
("alt", Mod::Alt),
("shift", Mod::Shift),
("control", Mod::Control),
("ctrl", Mod::Control),
];
let mut m = egui::Modifiers::NONE;
let mut set = |md: Mod| match md {
Mod::Command => m.command = true,
Mod::Alt => m.alt = true,
Mod::Shift => m.shift = true,
Mod::Control if mac => m.ctrl = true,
Mod::Control => m.command = true,
};
let mut rest = keys.trim();
'strip: loop {
for (sym, md) in SYMBOLS {
if let Some(r) = rest.strip_prefix(sym) {
set(*md);
rest = r.strip_prefix('+').unwrap_or(r).trim_start();
continue 'strip;
}
}
for (word, md) in WORDS {
let Some(head) = rest.get(..word.len()) else { continue };
let Some(after) = rest.get(word.len()..) else { continue };
// The word must be followed by a separator and a key: "Alt" alone, or "Altx", is a key name.
let mut chars = after.chars();
if head.eq_ignore_ascii_case(word) && matches!(chars.next(), Some('+' | ' ' | '-')) && !chars.as_str().is_empty() {
set(*md);
rest = chars.as_str().trim_start();
continue 'strip;
}
}
break;
}
if m.ctrl {
return Err("Control-key shortcuts are not supported");
}
let key = key_named(rest.trim()).ok_or("unknown key")?;
let sc = menus::shortcut_string(m, key);
// Only a string the shortcut dispatcher reads back as these keys is imported.
match menus::parse_shortcut(&sc) {
Some((pm, pk)) if pk == key && same_mods(pm, m) => Ok(sc),
_ => Err("unknown key"),
}
}
fn same_mods(a: egui::Modifiers, b: egui::Modifiers) -> bool {
a.command == b.command && a.alt == b.alt && a.shift == b.shift && a.ctrl == b.ctrl
}
/// A key by the name InDesign gives it (Windows words, Mac symbols), case- and space-insensitive.
fn key_named(name: &str) -> Option<egui::Key> {
use egui::Key;
if name.is_empty() {
return None;
}
let squashed: String = name.chars().filter(|c| !c.is_whitespace()).collect::<String>().to_lowercase();
// The numeric keypad: egui doesn't tell its keys apart from the main ones.
let n = squashed.strip_prefix("numpad").or_else(|| squashed.strip_prefix("num").filter(|r| !r.is_empty() && *r != "lock")).unwrap_or(&squashed);
let key = match n {
"up" | "uparrow" | "arrowup" | "↑" => Key::ArrowUp,
"down" | "downarrow" | "arrowdown" | "↓" => Key::ArrowDown,
"left" | "leftarrow" | "arrowleft" | "←" => Key::ArrowLeft,
"right" | "rightarrow" | "arrowright" | "→" => Key::ArrowRight,
"pageup" | "pgup" | "⇞" => Key::PageUp,
"pagedown" | "pgdn" | "⇟" => Key::PageDown,
"home" | "↖" => Key::Home,
"end" | "↘" => Key::End,
"esc" | "escape" | "⎋" => Key::Escape,
"tab" | "⇥" => Key::Tab,
"space" | "spacebar" => Key::Space,
"enter" | "return" | "↩" | "⏎" | "⌤" => Key::Enter,
"backspace" | "⌫" => Key::Backspace,
"delete" | "del" | "forwarddelete" | "⌦" => Key::Delete,
"insert" | "ins" | "help" => Key::Insert,
_ => return Key::from_name(n).or_else(|| Key::from_name(&n.to_uppercase())).or_else(|| Key::from_name(name)),
};
Some(key)
}
/// Matching form of a label or menu name: no ellipsis, no `&`, single spaces, lower case, and
/// no Show/Hide in front (InDesign names a toggle by what choosing it does now).
fn norm(s: &str) -> String {
let s = s.replace('…', "").replace("...", "").replace('&', "");
let s = s.split_whitespace().collect::<Vec<_>>().join(" ").to_lowercase();
["show/hide ", "show ", "hide "].iter().find_map(|p| s.strip_prefix(p).filter(|r| !r.is_empty())).map_or(s.clone(), str::to_string)
}
/// DesignCraft's commands by menu path and label.
#[derive(Default)]
struct Index {
/// `menu\nsubmenu\nlabel` → ids, in the menu bar.
path: HashMap<String, BTreeSet<String>>,
/// The same from the engine commands' own menu placement, which a UI command (the one with
/// the dialog) may stand in for in the menu bar.
engine_path: HashMap<String, BTreeSet<String>>,
/// (menu, label) → ids, at any depth of that menu.
in_menu: HashMap<(String, String), BTreeSet<String>>,
/// label → ids, anywhere.
label: HashMap<String, BTreeSet<String>>,
}
impl Index {
fn build() -> Self {
fn walk(ix: &mut Index, items: &[Item], path: &mut Vec<String>) {
for it in items {
match it {
Item::Sub(name, children) => {
path.push(norm(name));
walk(ix, children, path);
path.pop();
}
// A shortcut runs the command without parameters; a fixed-parameter variant can't hold one.
Item::Cmd { label, id, params, .. } if params.is_null() => {
let label = norm(label);
let top = path.first().cloned().unwrap_or_default();
add_path(&mut ix.path, path, &label, id);
ix.in_menu.entry((top, label.clone())).or_default().insert(id.clone());
ix.label.entry(label).or_default().insert(id.clone());
}
_ => {}
}
}
}
let mut ix = Index::default();
for (menu, entries) in menus::menu_tree() {
walk(&mut ix, &entries, &mut vec![norm(menu)]);
}
for c in designcraft_engine::command_specs() {
let label = norm(c.label);
if !c.menu.is_empty() {
let path: Vec<String> = c.menu.iter().map(|m| norm(m)).collect();
add_path(&mut ix.engine_path, &path, &label, c.id);
}
ix.label.entry(label).or_default().insert(c.id.to_string());
}
for (id, label, _, _) in menus::UI_COMMANDS {
ix.label.entry(norm(label)).or_default().insert((*id).to_string());
}
ix
}
/// The command an item names, trying the most specific match first. Matches by label alone
/// only count when exactly one command has that label.
fn find(&self, area: &str, name: &str) -> Option<String> {
let one = |s: Option<&BTreeSet<String>>| s.filter(|s| s.len() == 1).and_then(|s| s.first().cloned());
let segments: Vec<String> = name.split(": ").map(norm).collect();
let last = segments.last().cloned().unwrap_or_default();
let area = norm(area);
if let Some(menu) = area.strip_suffix(" menu").filter(|m| !m.contains(':')) {
let mut key = menu.to_string();
for s in &segments {
key.push('\n');
key.push_str(s);
}
if let Some(id) = one(self.path.get(&key)) {
return Some(id);
}
if segments.len() > 1
&& let Some(id) = one(self.in_menu.get(&(menu.to_string(), segments.join(" "))))
{
return Some(id);
}
if let Some(id) = one(self.engine_path.get(&key)) {
return Some(id);
}
if let Some(id) = one(self.in_menu.get(&(menu.to_string(), last.clone()))) {
return Some(id);
}
// Menus are arranged differently: the item's own label, if no other command has it.
return one(self.label.get(&last));
}
if segments.len() == 1 { one(self.label.get(&last)) } else { None }
}
}
fn add_path(map: &mut HashMap<String, BTreeSet<String>>, path: &[String], label: &str, id: &str) {
let mut key = path.join("\n");
key.push('\n');
key.push_str(label);
map.entry(key).or_default().insert(id.to_string());
}
/// What the set says about one DesignCraft command, over every item matched to it.
struct Decision {
id: String,
item: String,
/// The first readable shortcut.
shortcut: Option<String>,
/// Every matched item said `[none defined]`.
all_none: bool,
}
fn command_label(id: &str) -> String {
menus::ui_label(id)
.map(|(l, _)| l.to_string())
.or_else(|| designcraft_engine::find_command(id).map(|c| c.label.to_string()))
.unwrap_or_else(|| id.to_string())
}
fn keys_of(sc: &str) -> Option<(bool, bool, bool, bool, egui::Key)> {
menus::parse_shortcut(sc).map(|(m, k)| (m.command, m.alt, m.shift, m.ctrl, k))
}
/// Imports a set: every matched command takes the set's shortcut (or none), another command
/// holding an imported shortcut loses it, and the rest keep theirs. Returns the report.
pub fn import(app: &mut DesignApp, text: &str) -> Result<Value, String> {
if text.len() > MAX_BYTES {
return Err(format!("the set is larger than {} MB", MAX_BYTES >> 20));
}
let set = parse(text)?;
let mac = is_mac_set(&set);
let ix = Index::build();
let mut decisions: Vec<Decision> = Vec::new();
let mut unmatched = Vec::new();
let mut unmatched_without_shortcut = 0usize;
let mut unreadable = Vec::new();
for item in &set.items {
let shown = if item.area.is_empty() { item.name.clone() } else { format!("{} › {}", item.area, item.name) };
let all_keys = item.entries.iter().map(|e| format!("{}: {}", e.context, e.keys)).collect::<Vec<_>>().join(", ");
let Some(id) = ix.find(&item.area, &item.name) else {
if item.entries.is_empty() {
unmatched_without_shortcut += 1;
} else {
let tool = norm(&item.area) == "tools"
&& designcraft_tools::TOOL_GROUPS.iter().flat_map(|g| g.iter()).any(|t| norm(t.label) == norm(&item.name));
let mut row = json!({"item": shown, "keys": all_keys});
if tool {
row["reason"] = json!("tool keys are fixed");
}
unmatched.push(row);
}
continue;
};
let in_context = |ctx: &'static str| item.entries.iter().filter(move |e| e.context.eq_ignore_ascii_case(ctx)).collect::<Vec<&Entry>>();
let text_command = id.starts_with("type.") || id.starts_with("text.");
let mut considered = in_context("Default");
if considered.is_empty() && text_command {
considered = in_context("Text");
}
let mut read = None;
let mut why = "";
for e in &considered {
match read_keys(&e.keys, mac) {
Ok(sc) => {
read = Some(sc);
break;
}
Err(w) => why = w,
}
}
if read.is_none() && !considered.is_empty() {
unreadable.push(json!({"item": shown, "keys": all_keys, "reason": why}));
}
let pos = match decisions.iter().position(|d| d.id == id) {
Some(p) => p,
None => {
decisions.push(Decision { id: id.clone(), item: shown.clone(), shortcut: None, all_none: true });
decisions.len() - 1
}
};
let Some(d) = decisions.get_mut(pos) else { continue };
d.all_none &= item.entries.is_empty();
if d.shortcut.is_none()
&& let Some(sc) = read
{
d.shortcut = Some(sc);
d.item = shown;
}
}
let before: HashMap<String, Option<String>> = menus::effective_shortcuts(app).into_iter().map(|(id, sc)| (id, Some(sc))).collect();
let mut imported = 0usize;
let mut taken: Vec<((bool, bool, bool, bool, egui::Key), String)> = Vec::new();
let mut skipped = Vec::new();
let mut decided: BTreeSet<String> = BTreeSet::new();
// When two items in the set want the same keys, the later one takes them (the rule shared by
// the Craft apps' importers).
let mut winner: HashMap<(bool, bool, bool, bool, egui::Key), usize> = HashMap::new();
for (i, d) in decisions.iter().enumerate() {
if let Some(keys) = d.shortcut.as_deref().and_then(keys_of) {
winner.insert(keys, i);
}
}
for (i, d) in decisions.iter().enumerate() {
match &d.shortcut {
Some(sc) => {
let Some(keys) = keys_of(sc) else { continue };
if let Some(w) = winner.get(&keys).filter(|&&w| w != i).and_then(|&w| decisions.get(w)) {
skipped.push(json!({"id": d.id, "item": d.item, "shortcut": sc, "takenBy": w.id}));
continue;
}
taken.push((keys, d.id.clone()));
if menus::default_shortcut(&d.id).and_then(keys_of) == Some(keys) {
app.ui.shortcuts.remove(&d.id);
} else {
app.ui.shortcuts.insert(d.id.clone(), sc.clone());
}
}
None if d.all_none => {
if menus::default_shortcut(&d.id).is_none() {
app.ui.shortcuts.remove(&d.id);
} else {
app.ui.shortcuts.insert(d.id.clone(), String::new());
}
}
None => continue,
}
imported += 1;
decided.insert(d.id.clone());
}
let mut conflicts = Vec::new();
for (id, sc) in menus::effective_shortcuts(app) {
if decided.contains(&id) {
continue;
}
// A command sharing its default keys with the one taking them (file.new and the New
// Document dialog) keeps them: the dispatcher already prefers the other.
let twin = |by: &str| {
let default = |id: &str| menus::default_shortcut(id).and_then(keys_of);
default(&id).is_some() && default(&id) == default(by)
};
if let Some(keys) = keys_of(&sc)
&& taken.iter().any(|(k, by)| *k == keys && !twin(by))
{
app.ui.shortcuts.insert(id.clone(), String::new());
conflicts.push(json!({"id": id, "label": command_label(&id), "shortcut": sc}));
}
}
let changed: Vec<Value> = decisions
.iter()
.filter(|d| decided.contains(&d.id))
.filter_map(|d| {
let from = before.get(&d.id).cloned().flatten();
let to = menus::shortcut_of(app, &d.id);
(from != to).then(|| json!({"id": d.id, "label": command_label(&d.id), "item": d.item, "from": from, "to": to}))
})
.collect();
Ok(json!({
"set": set.name,
"imported": imported,
"changed": changed,
"conflictsCleared": conflicts,
"skipped": skipped,
"unmatched": unmatched,
"unmatchedWithoutShortcut": unmatched_without_shortcut,
"unreadable": unreadable,
}))
}
/// One line for the Keyboard Shortcuts dialog about an import's report.
pub fn summary(lang: &str, report: &Value) -> String {
let n = |k: &str| report[k].as_u64().unwrap_or_default().to_string();
let len = |k: &str| report[k].as_array().map_or(0, Vec::len).to_string();
crate::i18n::tr(lang, "Imported {count} shortcuts from “{set}”; {unmatched} items not matched, {unreadable} unreadable.")
.replace("{count}", &n("imported"))
.replace("{set}", report["set"].as_str().unwrap_or_default())
.replace("{unmatched}", &len("unmatched"))
.replace("{unreadable}", &len("unreadable"))
}
/// The Keyboard Shortcuts dialog's Import button: a native file picker imports at once and
/// returns the summary; in a browser the chosen file arrives later through the import inbox.
pub fn pick_and_import(app: &mut DesignApp) -> Option<String> {
if let Some(pick) = app.services.pick_open.as_mut() {
let path = pick("shortcuts")?;
return Some(match app.run("window.importShortcuts", json!({"path": path})) {
Ok(report) => summary(&app.ui.language, &report),
Err(e) => e,
});
}
let request = app.import_request("shortcuts");
if let Some(open) = app.services.open_async.as_mut() {
open(request);
}
None
}
/// A set file chosen in the browser: import it and say how it went in the open Keyboard
/// Shortcuts dialog, or the status bar.
pub fn import_delivered(app: &mut DesignApp, bytes: &[u8]) {
let msg = match decode(bytes).and_then(|text| app.run("window.importShortcuts", json!({"text": text}))) {
Ok(report) => summary(&app.ui.language, &report),
Err(e) => e,
};
match app.ui.dialog.as_mut().filter(|d| d.id == "keyboardShortcuts") {
Some(d) => {
d.fields.insert("message".into(), json!(msg));
}
None => app.status(msg),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn app() -> DesignApp {
DesignApp::new(designcraft_engine::Session::new(), crate::Services::default())
}
#[test]
fn imports_a_windows_set_and_reports_what_it_could_not_use() {
let mut app = app();
// Something else holds Ctrl+Alt+K before the import; the set gives it to Check Spelling.
menus::run_ui(&mut app, "window.setShortcut", &json!({"id": "app.palette", "shortcut": "Cmd+Alt+K"})).unwrap().unwrap();
let set = "\u{feff}Shortcut Set : My Set\r\n\r\n\
Product area : Edit Menu\r\n\
\tSpelling: Check Spelling... --- Default: Ctrl+Alt+K, Text: F7\r\n\
\tDuplicate --- [none defined]\r\n\
\tDuplicate --- [none defined]\r\n\
\tFrobnicate Everything --- Default: Ctrl+F12\r\n\
\tNever Heard Of It --- [none defined]\r\n\
Product area : File Menu\r\n\
\tClose --- Default: Shift+Ctrl+Num 5, Default: Ctrl+F4\r\n\
\tSave --- Default: Ctrl+Hyperspace\r\n\
\tRevert --- Default: Shift+Ctrl+F9\r\n\
\tSave As... --- Default: Shift+Ctrl+F9\r\n\
garbage line without a separator\r\n";
let r = menus::run_ui(&mut app, "window.importShortcuts", &json!({"text": set})).unwrap().unwrap();
assert_eq!(r["set"], "My Set");
// A submenu item, with Ctrl as the command modifier.
assert_eq!(menus::shortcut_of(&app, "spelling.check").as_deref(), Some("Cmd+Alt+K"));
assert_eq!(menus::shortcut_of(&app, "file.close").as_deref(), Some("Cmd+Shift+5"));
// `[none defined]` in every occurrence clears a matched command.
assert_eq!(menus::shortcut_of(&app, "edit.duplicate"), None);
// The command that held the imported keys loses them.
assert_eq!(menus::shortcut_of(&app, "app.palette"), None);
assert!(r["conflictsCleared"].as_array().unwrap().iter().any(|c| c["id"] == "app.palette"), "{r}");
// Unknown items with shortcuts and unreadable keys are reported; Save keeps its shortcut.
assert_eq!(r["unmatched"], json!([{"item": "Edit Menu › Frobnicate Everything", "keys": "Default: Ctrl+F12"}]));
assert_eq!(r["unmatchedWithoutShortcut"], 1);
assert_eq!(r["unreadable"][0]["item"], "File Menu › Save");
assert_eq!(menus::shortcut_of(&app, "app.save").as_deref(), Some("Cmd+S"));
// Two items want the same keys: the later one takes them.
assert_eq!(menus::shortcut_of(&app, "app.saveDialog").as_deref(), Some("Cmd+Shift+F9"));
assert_eq!(r["skipped"][0]["takenBy"], "app.saveDialog", "{r}");
assert_eq!(r["imported"], 4);
// Reset Shortcuts is the way back.
menus::run_ui(&mut app, "window.resetShortcuts", &json!({})).unwrap().unwrap();
assert_eq!(menus::shortcut_of(&app, "app.palette").as_deref(), Some("Cmd+Return"));
}
#[test]
fn hostile_input_returns_a_report_or_an_error() {
let mut app = app();
for text in ["", "\0\0\0", "--- ---", " --- Default: Ctrl+", "Product area : \n\t: --- Default: +, Default: ,,,", "x --- Default: ⌘⇧"] {
let _ = menus::run_ui(&mut app, "window.importShortcuts", &json!({"text": text})).unwrap();
}
assert!(menus::run_ui(&mut app, "window.importShortcuts", &json!({})).unwrap().is_err());
assert!(menus::run_ui(&mut app, "window.importShortcuts", &json!({"path": "/nonexistent/set.txt"})).unwrap().is_err());
// UTF-16 with a BOM, and Mac modifier spellings.
let mut bytes = vec![0xFF, 0xFE];
bytes.extend("Shortcut Set : Mac\nProduct area : File Menu\n\tClose --- Default: ⇧⌘W\n".encode_utf16().flat_map(u16::to_le_bytes));
let r = menus::run_ui(&mut app, "window.importShortcuts", &json!({"text": decode(&bytes).unwrap()})).unwrap().unwrap();
assert_eq!(r["set"], "Mac");
assert_eq!(menus::shortcut_of(&app, "file.close").as_deref(), Some("Cmd+Shift+W"));
}
}
+1 -1
View File
@@ -85,7 +85,7 @@ layer survive interchange; literal XML and round-trip tests are in
| PDF presets | `.joboptions`, `.pdfs` | read/write | ✗ |
| Print / flattener / document presets | `.prst`, `.flst`, `.dcst` | read/write | ✗ |
| Preflight profiles | `.idpp` | read/write | ✗ (own profiles in the document) |
| Keyboard shortcut sets | `.indk` | read/write | ✗ (own JSON) |
| Keyboard shortcut sets | `.indk`; Show Set `.txt` | read/write; write | ~ (imports a Show Set `.txt`; own JSON) |
| User dictionary | `.udc` | read/write | ✗ (own, in the document and prefs) |
| Fonts | OTF, TTF, TTC, variable | ✓ | ✓ (Type 1 retired by both) |
+1 -1
View File
@@ -418,7 +418,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
- Spring-loaded Zoom: hold Cmd+Space (Ctrl+Space on Windows/Linux) for the Zoom cursor and click/scrubby zoom; Alt-click zooms out. Release returns to the previous tool without changing the document or undo history. Text-frame and text-field typing suppress the shortcut; Space remains Hand.
- Guides belong to the active layer: hidden with it (or with its Show Guides off) and locked with it.
- Pencil tool (N): freehand strokes, shown as you draw in the layer's colour, simplified to smooth paths (Alt closes); Smooth and Erase tools re-fit or remove the stretch you drag along (`path.smooth`, `path.erase`).
- Edit › Keyboard Shortcuts: every command, click and press the new keys, conflicts shown, per-command and global reset; `window.setShortcut`.
- Edit › Keyboard Shortcuts: every command, click and press the new keys, conflicts shown, per-command and global reset; `window.setShortcut`. Import InDesign Set… reads a set saved by InDesign's Show Set (.txt) and reports what it changed and what it could not match; `window.importShortcuts`.
- Primary Text Frame (New Document) and Smart Text Reflow: pages are added while the primary story oversets and empty ones at the end removed, in the same undo step (Preferences › Type).
- Glyphs panel: every character of any font (rendered by our renderer), search by character or U+code, click to insert, recently used.
- UI scaling (Preferences › Interface, 50–200%; `window.uiScale`); the window title steps aside when the bar is crowded.
+1 -1
View File
@@ -43,7 +43,7 @@ basics of editing fail for too many users on Windows and Linux.
| Context menus (canvas, panels, rulers) | ✓ everywhere | 9 context-menu sites, ruler units by right-click | ~ | **not opening for several users** (#238, #187, #160) |
| IME text input on the canvas | ✓ | ✓ | ~ | not checked by hand with a system IME (#322) |
| Font menu: search, preview, favourites, type-ahead, filters (class, recently added), styles per family, sample text and size, recently used, similar fonts | ✓ | ✓ | ~ | search field focus on macOS (#335, #86); Adobe Fonts (Find More, activated) out of scope |
| Keyboard shortcuts: InDesign defaults, editor, sets | ✓ | ✓ editor; defaults from 09-shortcuts.md | ✓ | |
| Keyboard shortcuts: InDesign defaults, editor, sets | ✓ | ✓ editor; defaults from 09-shortcuts.md; imports an InDesign Show Set `.txt` | ✓ | tool keys are fixed, so a set's Tools items are reported, not applied |
| Quick Apply | ✓ | ✓ | ✓ | |
| Numeric fields: units, arithmetic, scrubbing | ✓ | 21 pt spinner fields with arithmetic | ✓ | |
| Colour fields: hex, picker from any swatch field | ✓ | ✓ | ~ | hex entry and picker issues (#323, #190) |