From 5c1aea75330540a99ffa37c4c97a26461420770d Mon Sep 17 00:00:00 2001 From: KnottyDyes <47539659+KnottyDyes@users.noreply.github.com> Date: Sat, 10 Oct 2026 09:17:30 -0600 Subject: [PATCH 1/9] Place: an explicit x, y, spread or width puts the image in a new frame Explicit image placement leaves selected frames untouched; an explicit frame still wins. Null placement parameters behave like omitted values. Reject nonnumeric, nonfinite and nonpositive image widths before editing. Cover rejection without document changes, valid sizing and null selection behavior with regression tests. --- crates/engine/src/cmd/file.rs | 291 +++++++++++++++++++++++++++++++++- 1 file changed, 288 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index f63f0d6e9..f18f1d7b9 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -103,7 +103,7 @@ pub fn specs() -> Vec { "Place…", ["File"], Some("Cmd+D"), - "{path?|base64?, name?, frame?: id (place into), spread?, x?, y?, width?, pdfPage?: n (1-based, Image Import Options), pdfCrop?: crop|trim|bleed|art|media, loadCursor?: with no frame to fill and no x/y, an image loads into the place cursor (File › Place), layoutPage?: n (IDML / .designcraft: that page's objects as a group)} — places an image (into the selected empty frame if any); text files (.txt, .docx, .rtf, .md) and Excel workbooks (.xlsx, as a table) go into the insertion point, the selected frame or a new frame on `page`/`rect` — {autoflow?: adds pages with threaded frames until the text fits, removeStyles?, styleMap?: {imported name: document style}, styleConflicts?: useExisting|redefine|autoRename, link?: keep the file linked (Links panel; default: the linkTextFiles preference)}", + "{path?|base64?, name?, frame?: id (place into), spread?, x?, y?, width?: finite number > 0 (null: absent), pdfPage?: n (1-based, Image Import Options), pdfCrop?: crop|trim|bleed|art|media, loadCursor?: with no frame to fill and no x/y, an image loads into the place cursor (File › Place), layoutPage?: n (IDML / .designcraft: that page's objects as a group)} — places an image into `frame` when given; otherwise any explicit x/y/spread/width creates a new frame; otherwise uses the selected empty or graphic frame, or creates a new frame; text files (.txt, .docx, .rtf, .md) and Excel workbooks (.xlsx, as a table) go into the insertion point, the selected frame or a new frame on `page`/`rect` — {autoflow?: adds pages with threaded frames until the text fits, removeStyles?, styleMap?: {imported name: document style}, styleConflicts?: useExisting|redefine|autoRename, link?: keep the file linked (Links panel; default: the linkTextFiles preference)}", has_doc, file_place ), @@ -327,6 +327,16 @@ fn file_place(s: &mut Session, p: &Value) -> Result { if designcraft_textimport::is_text_file(&name) { return super::place_text::place_text(s, p, &name, &bytes, link, before); } + let want_w = p + .get("width") + .filter(|value| !value.is_null()) + .map(|value| { + value + .as_f64() + .filter(|width| width.is_finite() && *width > 0.0) + .ok_or_else(|| bad("file.place", "`width` must be a finite number greater than zero")) + }) + .transpose()?; // Image Import Options: which page of a PDF (1-based). let pdf_page = match p.get("pdfPage").and_then(Value::as_u64) { Some(n) if designcraft_render::is_pdf(&bytes) => { @@ -362,14 +372,20 @@ fn file_place(s: &mut Session, p: &Value) -> Result { None => None, }; let target_frame = super::id_param(p, "frame").or_else(|| { + if ["x", "y", "spread", "width"].iter().any(|key| p.get(*key).is_some_and(|value| !value.is_null())) { + return None; + } 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); - let want_w = p.get("width").and_then(Value::as_f64); if target_frame.is_none() && px.is_none() && py.is_none() && p.get("loadCursor").and_then(Value::as_bool) == Some(true) { let mime = designcraft_render::image_mime(&bytes).to_string(); let asset = Asset { page: pdf_page, id: AssetId(0), name, mime, link, data: Arc::new(bytes), pixels: Some((pw, ph)) }; @@ -734,6 +750,275 @@ fn file_revert(s: &mut Session, _: &Value) -> Result { } } +#[cfg(all(test, not(target_arch = "wasm32")))] +mod image_place_tests { + use designcraft_doc::{Content, ItemId}; + use designcraft_geom::Rect; + use serde_json::{Value, json}; + + use crate::Session; + + fn image_file() -> std::io::Result { + image_file_with_color(30) + } + + fn image_file_with_color(red: u8) -> std::io::Result { + let file = tempfile::Builder::new().suffix(".png").tempfile()?; + let png = designcraft_render::Rendered { width: 40, height: 20, pixels: [red, 90, 180, 255].repeat(800) }.to_png(); + std::fs::write(file.path(), png)?; + Ok(file) + } + + #[test] + fn consecutive_path_placements_create_distinct_graphics() { + let images = [image_file_with_color(30).unwrap(), image_file_with_color(31).unwrap(), image_file_with_color(32).unwrap()]; + let mut session = Session::new(); + session.execute("file.new", &json!({"pages": 3, "facingPages": false})).unwrap(); + let mut placements = Vec::new(); + let mut originals = Vec::new(); + + for ((spread, x, y), image) in [(0, 0.0, 0.0), (1, 70.0, 90.0), (2, 150.0, 210.0)].into_iter().zip(&images) { + let placed = session.execute("file.place", &json!({"path": image.path(), "spread": spread, "x": x, "y": y})).unwrap(); + let id = ItemId(placed["id"].as_u64().unwrap()); + assert!( + placements.iter().all(|previous: &Value| previous["id"] != placed["id"]), + "each explicit placement needs a distinct frame: {placed}" + ); + let document = &session.doc().unwrap().doc; + let item = document.item(id).unwrap(); + assert_eq!(item.bounds(), Rect::new(x, y, x + 40.0, y + 20.0)); + assert!(document.spreads[spread].items.iter().any(|item| item.id == id)); + originals.push(serde_json::to_value(item).unwrap()); + placements.push(placed); + } + + let document = &session.doc().unwrap().doc; + assert_eq!(document.spreads.iter().flat_map(|spread| &spread.items).filter(|item| matches!(item.content, Content::Graphic(_))).count(), 3); + assert_eq!(document.assets.len(), 3); + for (placed, original) in placements.iter().zip(originals) { + assert_eq!(serde_json::to_value(document.item(ItemId(placed["id"].as_u64().unwrap())).unwrap()).unwrap(), original); + } + let links = session.execute("links.list", &json!({})).unwrap(); + assert_eq!(links.as_array().unwrap().len(), 3); + for (placed, image) in placements.into_iter().zip(&images) { + let row = links.as_array().unwrap().iter().find(|row| row["asset"] == placed["asset"]).unwrap(); + assert_eq!(row["path"], json!(image.path())); + assert_eq!(row["uses"].as_array().unwrap().len(), 1); + assert_eq!(row["uses"][0]["id"], placed["id"]); + } + } + + #[test] + fn individual_placement_parameters_bypass_selected_frames() { + let image = image_file().unwrap(); + for graphic in [false, true] { + for (key, value, expected) in [ + ("x", 0, Rect::new(0.0, 36.0, 40.0, 56.0)), + ("y", 0, Rect::new(36.0, 0.0, 76.0, 20.0)), + ("spread", 0, Rect::new(36.0, 36.0, 76.0, 56.0)), + ("width", 0, Rect::ZERO), + ("width", 80, Rect::new(36.0, 36.0, 116.0, 76.0)), + ] { + let mut session = Session::new(); + session.execute("file.new", &json!({})).unwrap(); + let selected = session.execute("frame.create", &json!({"rect": [100, 120, 200, 220]})).unwrap(); + if graphic { + session.execute("file.place", &json!({"path": image.path()})).unwrap(); + } + let id = ItemId(selected["id"].as_u64().unwrap()); + let original = serde_json::to_value(session.doc().unwrap().doc.item(id).unwrap()).unwrap(); + let mut params = json!({"path": image.path()}); + params[key] = json!(value); + + let result = session.execute("file.place", ¶ms); + if key == "width" && value == 0 { + assert!(result.is_err(), "zero width must be rejected, graphic={graphic}"); + continue; + } + let placed = result.unwrap(); + + assert_ne!(placed["id"], selected["id"], "{key}={value}, graphic={graphic}"); + let document = &session.doc().unwrap().doc; + assert_eq!(serde_json::to_value(document.item(id).unwrap()).unwrap(), original); + let item = document.item(ItemId(placed["id"].as_u64().unwrap())).unwrap(); + assert_eq!(item.bounds(), expected); + let Content::Graphic(content) = &item.content else { panic!("placed frame must be graphic") }; + let [scale_x, _, _, scale_y, _, _] = content.xf.as_coeffs(); + assert!(scale_x > 0.0 && scale_y > 0.0); + } + } + } + + #[test] + fn explicit_frame_wins_over_placement_and_selection() { + let image = image_file().unwrap(); + let mut session = Session::new(); + session.execute("file.new", &json!({})).unwrap(); + let target = session.execute("frame.create", &json!({"rect": [100, 120, 200, 220]})).unwrap(); + let selected = session.execute("frame.create", &json!({"rect": [300, 320, 400, 420]})).unwrap(); + + let placed = + session.execute("file.place", &json!({"path": image.path(), "frame": target["id"], "x": 0, "y": 0, "spread": 99, "width": 80})).unwrap(); + + assert_eq!(placed["id"], target["id"]); + let document = &session.doc().unwrap().doc; + let item = document.item(ItemId(target["id"].as_u64().unwrap())).unwrap(); + assert_eq!(item.bounds(), Rect::new(100.0, 120.0, 200.0, 220.0)); + assert!(matches!(item.content, Content::Graphic(_))); + assert!(matches!(document.item(ItemId(selected["id"].as_u64().unwrap())).unwrap().content, Content::Unassigned)); + assert_eq!(document.spreads[0].items.len(), 2); + } + + fn assert_width_rejected(width: Value) -> std::result::Result<(), Box> { + let image = image_file()?; + for graphic in [false, true] { + for explicit_frame in [false, true] { + let mut session = Session::new(); + session.execute("file.new", &json!({}))?; + let target = session.execute("frame.create", &json!({"rect": [100, 120, 200, 220]}))?; + if graphic { + session.execute("file.place", &json!({"path": image.path()}))?; + } + let mut params = json!({"path": image.path(), "width": width}); + if explicit_frame { + params["frame"] = target["id"].clone(); + session.execute("frame.create", &json!({"rect": [300, 320, 400, 420]}))?; + } + let before = session.doc()?; + let document = before.doc.clone(); + let selection = before.selection.clone(); + let revision = before.revision; + let assets = document.assets.len(); + + let result = session.execute("file.place", ¶ms); + + assert!( + matches!(&result, Err(crate::EngineError::BadParams { .. })), + "width={width}, graphic={graphic}, explicit_frame={explicit_frame}: expected BadParams, got {result:?}" + ); + let error = result.err().ok_or_else(|| crate::EngineError::Other("expected placement error".into()))?; + let crate::EngineError::BadParams { cmd, msg } = error else { return Err(error.into()) }; + assert_eq!(cmd, "file.place"); + assert!(msg.contains("width") && msg.contains("finite") && msg.contains("greater than zero"), "{msg}"); + let after = session.doc()?; + assert!(std::sync::Arc::ptr_eq(&document, &after.doc)); + assert_eq!(after.selection, selection); + assert_eq!(after.revision, revision); + assert_eq!(after.doc.assets.len(), assets); + } + } + Ok(()) + } + + #[test] + fn width_rejected_when_zero() { + assert_width_rejected(json!(0)).unwrap(); + } + + #[test] + fn width_rejected_when_negative() { + assert_width_rejected(json!(-80)).unwrap(); + } + + #[test] + fn width_rejected_when_non_numeric() { + for width in [json!("80"), json!(true), json!([80]), json!({"value": 80})] { + assert_width_rejected(width).unwrap(); + } + } + + #[test] + fn numeric_overflow_rejected_when_parsing_json() { + let result = serde_json::from_str::(r#"{"width":1e400}"#); + let error = result.unwrap_err(); + assert!(error.is_data() || error.is_syntax(), "{error}"); + eprintln!("overflow parser rejected width=1e400: {error}"); + } + + fn assert_null_placement_uses_selection(keys: &[&str]) -> std::result::Result<(), Box> { + let image = image_file()?; + let previous_image = image_file_with_color(31)?; + for graphic in [false, true] { + let mut session = Session::new(); + session.execute("file.new", &json!({}))?; + let selected = session.execute("frame.create", &json!({"rect": [100, 120, 200, 220]}))?; + let previous = graphic.then(|| session.execute("file.place", &json!({"path": previous_image.path()}))).transpose()?; + let mut params = json!({"path": image.path()}); + for key in keys { + params[*key] = Value::Null; + } + + let placed = session.execute("file.place", ¶ms)?; + + assert_eq!(placed["id"], selected["id"], "keys={keys:?}, graphic={graphic}"); + if let Some(previous) = previous { + assert_ne!(placed["asset"], previous["asset"]); + } + let document = &session.doc()?.doc; + let id = ItemId(selected["id"].as_u64().ok_or_else(|| crate::EngineError::Other("frame.create returned no numeric id".into()))?); + let item = document.item(id).ok_or(designcraft_doc::DocError::NoItem(id))?; + assert_eq!(item.bounds(), Rect::new(100.0, 120.0, 200.0, 220.0)); + assert!(matches!(&item.content, Content::Graphic(_)), "placed frame must be graphic"); + let Content::Graphic(content) = &item.content else { + return Err(crate::EngineError::Other("placed frame must be graphic".into()).into()); + }; + assert_eq!(json!(content.asset.0), placed["asset"]); + assert_eq!(document.spreads[0].items.len(), 1); + } + Ok(()) + } + + #[test] + fn selection_used_when_x_null() { + assert_null_placement_uses_selection(&["x"]).unwrap(); + } + + #[test] + fn selection_used_when_y_null() { + assert_null_placement_uses_selection(&["y"]).unwrap(); + } + + #[test] + fn selection_used_when_spread_null() { + assert_null_placement_uses_selection(&["spread"]).unwrap(); + } + + #[test] + fn selection_used_when_width_null() { + assert_null_placement_uses_selection(&["width"]).unwrap(); + } + + #[test] + fn selection_used_when_all_placement_parameters_null() { + assert_null_placement_uses_selection(&["x", "y", "spread", "width"]).unwrap(); + } + + #[test] + fn implicit_placement_fills_empty_and_replaces_graphic_selection() { + let image = image_file().unwrap(); + let previous_image = image_file_with_color(31).unwrap(); + for graphic in [false, true] { + let mut session = Session::new(); + session.execute("file.new", &json!({})).unwrap(); + let selected = session.execute("frame.create", &json!({"rect": [100, 120, 200, 220]})).unwrap(); + let previous = graphic.then(|| session.execute("file.place", &json!({"path": previous_image.path()})).unwrap()); + + let placed = session.execute("file.place", &json!({"path": image.path()})).unwrap(); + + assert_eq!(placed["id"], selected["id"]); + if let Some(previous) = previous { + assert_ne!(placed["asset"], previous["asset"]); + } + let document = &session.doc().unwrap().doc; + let item = document.item(ItemId(placed["id"].as_u64().unwrap())).unwrap(); + assert_eq!(item.bounds(), Rect::new(100.0, 120.0, 200.0, 220.0)); + let Content::Graphic(content) = &item.content else { panic!("placed frame must be graphic") }; + assert_eq!(json!(content.asset.0), placed["asset"]); + assert_eq!(document.spreads[0].items.len(), 1); + } + } +} + #[cfg(test)] mod pdf_crop_tests { use serde_json::json; From 0613948d3d1da1d968a3333747eb890a62a21dc1 Mon Sep 17 00:00:00 2001 From: KnottyDyes <47539659+KnottyDyes@users.noreply.github.com> Date: Sat, 10 Oct 2026 09:53:25 -0600 Subject: [PATCH 2/9] SVG: a font family named in any case finds the installed font DesignCraft matched an SVG font-family case-insensitively when importing the installed faces, but the final fontdb query compares names exactly, so "DeJaVu SaNs" loaded DejaVu Sans and then set the text in the serif fallback (Source Serif 4) without a word. Text frames already accept any case. The SVG font selector now runs usvg default query with each named family spelled the way the font database spells it; family order, generics, weight, stretch and style are unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BSB7fMQUccqFQekksDGWpo --- crates/images/src/lib.rs | 79 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 72 insertions(+), 7 deletions(-) diff --git a/crates/images/src/lib.rs b/crates/images/src/lib.rs index 28a42522d..ece1697f1 100644 --- a/crates/images/src/lib.rs +++ b/crates/images/src/lib.rs @@ -73,14 +73,18 @@ fn svg_options() -> &'static usvg::Options<'static> { } /// A family the SVG names that isn't bundled comes from the shared fonts (installed ones too), -/// as it would in a text frame. A name that is no family's but a face's PostScript name (as -/// Illustrator's SVG export writes them: `font-family="'MyriadPro-Regular'"`) sets the text in -/// that face, whatever font-weight and font-style say: the name already picks the weight and -/// style, and a family's other faces would contradict it. Families listed before it come first. +/// as it would in a text frame, and matches in any case, as it does there. A name that is no +/// family's but a face's PostScript name (as Illustrator's SVG export writes them: +/// `font-family="'MyriadPro-Regular'"`) sets the text in that face, whatever font-weight and +/// font-style say: the name already picks the weight and style, and a family's other faces would +/// contradict it. Families listed before it come first. fn select_font() -> usvg::FontSelectionFn<'static> { - let bundled = usvg::FontResolver::default_font_selector(); + use usvg::fontdb::{Family, Query, Stretch, Style, Weight}; Box::new(move |font, db| { let fonts = designcraft_fonts::FontDb::global(); + let spelled = |db: &usvg::fontdb::Database, name: &str| { + db.faces().flat_map(|f| &f.families).find(|(n, _)| n.eq_ignore_ascii_case(name)).map(|(n, _)| n.clone()) + }; for family in font.families() { if let usvg::FontFamily::Named(name) = family && !has_family(db, name) @@ -103,11 +107,51 @@ 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, } } - bundled(font, db) + // usvg's default query, but with each named family spelled as the database spells it: + // fontdb compares names exactly, so "DeJaVu SaNs" would quietly fall to the serif. + let names: Vec> = font + .families() + .iter() + .map(|f| match f { + usvg::FontFamily::Named(name) => Some(spelled(db, name).unwrap_or_else(|| name.clone())), + _ => None, + }) + .collect(); + let mut families: Vec = font + .families() + .iter() + .zip(&names) + .map(|(f, name)| match f { + usvg::FontFamily::Serif => Family::Serif, + usvg::FontFamily::SansSerif => Family::SansSerif, + usvg::FontFamily::Cursive => Family::Cursive, + usvg::FontFamily::Fantasy => Family::Fantasy, + usvg::FontFamily::Monospace => Family::Monospace, + usvg::FontFamily::Named(_) => Family::Name(name.as_deref().unwrap_or_default()), + }) + .collect(); + families.push(Family::Serif); + let stretch = match font.stretch() { + usvg::FontStretch::UltraCondensed => Stretch::UltraCondensed, + usvg::FontStretch::ExtraCondensed => Stretch::ExtraCondensed, + usvg::FontStretch::Condensed => Stretch::Condensed, + usvg::FontStretch::SemiCondensed => Stretch::SemiCondensed, + usvg::FontStretch::Normal => Stretch::Normal, + usvg::FontStretch::SemiExpanded => Stretch::SemiExpanded, + usvg::FontStretch::Expanded => Stretch::Expanded, + usvg::FontStretch::ExtraExpanded => Stretch::ExtraExpanded, + usvg::FontStretch::UltraExpanded => Stretch::UltraExpanded, + }; + let style = match font.style() { + usvg::FontStyle::Normal => Style::Normal, + usvg::FontStyle::Italic => Style::Italic, + usvg::FontStyle::Oblique => Style::Oblique, + }; + db.query(&Query { families: &families, weight: Weight(font.weight()), stretch, style }) }) } @@ -518,6 +562,27 @@ mod tests { assert_eq!(text_face(r#" font-family="NoSuch-Font""#), "SourceSerif4-Regular"); } + #[test] + fn svg_family_names_match_in_any_case() { + let font = designcraft_fonts::testing::font_with("DC Test SVG Casing", &['A', 'b']).unwrap(); + designcraft_fonts::FontDb::global().add_font(font); + let text = |family: &str, attrs: &str| { + format!( + r#"Ab"# + ) + }; + for family in ["DC Test SVG Casing", "dc test svg casing", "DC TEST svg Casing", "Nope, dc test SVG casing, serif"] { + let set = glyph_families(&text(family, "")); + assert!(set.len() == 2 && set.iter().all(|(f, gid)| f == "DC Test SVG Casing" && *gid != 0), "{family}: {set:?}"); + } + // Weight and style still reach the query: there's only a regular face, so it sets the text. + let set = glyph_families(&text("dc test svg casing", r#" font-weight="700" font-style="italic""#)); + assert!(set.len() == 2 && set.iter().all(|(f, _)| f == "DC Test SVG Casing"), "{set:?}"); + // A family nobody has still falls to the bundled serif. + let set = glyph_families(&text("DC Test SVG Nobody", "")); + assert!(set.len() == 2 && set.iter().all(|(f, _)| f == "Source Serif 4"), "{set:?}"); + } + /// A minimal Photoshop file: 2×1 RGB, no layers, raw merged image. fn tiny_psd() -> Vec { let mut b = Vec::new(); From 4a2f6037710a5722e0a7ecac05f68628d40233a9 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 16 Oct 2026 14:14:34 +0000 Subject: [PATCH 3/9] Integration: #404's case test is already on main --- crates/images/src/lib.rs | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/crates/images/src/lib.rs b/crates/images/src/lib.rs index 382f8e245..25d042c43 100644 --- a/crates/images/src/lib.rs +++ b/crates/images/src/lib.rs @@ -607,27 +607,6 @@ mod tests { assert_eq!(text_face(r#" font-family="NoSuch-Font""#), "SourceSerif4-Regular"); } - #[test] - fn svg_family_names_match_in_any_case() { - let font = designcraft_fonts::testing::font_with("DC Test SVG Casing", &['A', 'b']).unwrap(); - designcraft_fonts::FontDb::global().add_font(font); - let text = |family: &str, attrs: &str| { - format!( - r#"Ab"# - ) - }; - for family in ["DC Test SVG Casing", "dc test svg casing", "DC TEST svg Casing", "Nope, dc test SVG casing, serif"] { - let set = glyph_families(&text(family, "")); - assert!(set.len() == 2 && set.iter().all(|(f, gid)| f == "DC Test SVG Casing" && *gid != 0), "{family}: {set:?}"); - } - // Weight and style still reach the query: there's only a regular face, so it sets the text. - let set = glyph_families(&text("dc test svg casing", r#" font-weight="700" font-style="italic""#)); - assert!(set.len() == 2 && set.iter().all(|(f, _)| f == "DC Test SVG Casing"), "{set:?}"); - // A family nobody has still falls to the bundled serif. - let set = glyph_families(&text("DC Test SVG Nobody", "")); - assert!(set.len() == 2 && set.iter().all(|(f, _)| f == "Source Serif 4"), "{set:?}"); - } - /// A minimal Photoshop file: 2×1 RGB, no layers, raw merged image. fn tiny_psd() -> Vec { let mut b = Vec::new(); From d2bdb0866fb73cf9aac012dc4c08a8854ae2b92e Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Thu, 22 Oct 2026 09:26:43 -0400 Subject: [PATCH 4/9] Paste: other apps' text goes into a text caret after copying objects (#1344) * Paste: other apps' text goes into a text caret even after copying objects (#212) * Paste: text that matches a copied frame still pastes; groups with text frames aren't anchored (#212) --- crates/engine/src/cmd/anchored.rs | 2 +- crates/engine/src/cmd/edit.rs | 109 ++++++++++++++++++++++++++++-- crates/engine/src/cmd/mod.rs | 1 + crates/engine/src/cmd/object.rs | 3 +- crates/ui-egui/src/lib.rs | 31 +-------- 5 files changed, 108 insertions(+), 38 deletions(-) diff --git a/crates/engine/src/cmd/anchored.rs b/crates/engine/src/cmd/anchored.rs index dc7127459..6d09f89dd 100644 --- a/crates/engine/src/cmd/anchored.rs +++ b/crates/engine/src/cmd/anchored.rs @@ -109,7 +109,7 @@ fn position_param(p: &Value, base: &AnchorPosition) -> Result { } /// 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")) } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index e5a2d2296..fb8848a90 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -43,24 +43,64 @@ fn copy_text(s: &mut Session) -> Result> { 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::>().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, formatted: bool) -> Result { 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 = 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 = 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, 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 { 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(); diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index a4d5413bb..1d6294ea6 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -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; diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 82f33a6d8..4ccb5c823 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -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) { +/// 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) { it.id = ItemId(dst.alloc()); let my_id = it.id; if let Some(m) = it.media.as_mut() diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 3674b3e81..0c155851f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -810,7 +810,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 +910,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::() - }) - .filter(|t| !t.is_empty()) - .collect::>() - .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); From 1500b444b57a20d7d3fa7194faab94f853788bee Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Thu, 22 Oct 2026 09:26:51 -0400 Subject: [PATCH 5/9] Hidden characters: trailing spaces show their dots before the paragraph mark (#1345) * Hidden characters: trailing spaces show their dots before the paragraph mark (#594) * Hidden characters: skip glyphs whose offset isn't a character boundary --- crates/ui-egui/src/canvas.rs | 77 ++++++++++++++++++++++++++---------- 1 file changed, 57 insertions(+), 20 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 9868d4d8b..ae6394bbd 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2716,33 +2716,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) { @@ -5560,6 +5574,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 = 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)] From 291b39a1f30ee7b2df53dab6c18cc78e141468ac Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Thu, 22 Oct 2026 09:27:00 -0400 Subject: [PATCH 6/9] Edit: the Paste in Place shortcut pastes in place (#1346) * Edit: the Paste in Place shortcut pastes in place * Edit: the Paste in Place chord handling is native-only (web gets the key press) --- crates/ui-egui/src/canvas.rs | 71 ++++++++++++++++++++++++++++++++++++ crates/ui-egui/src/menus.rs | 14 +++++++ 2 files changed, 85 insertions(+) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ae6394bbd..318f82388 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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 { @@ -4278,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() }; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index fb43f8cb9..af2a91fc3 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -3050,6 +3050,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 From 8f98f3dc8bfac09d69f949c39bf97f0861621b6c Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Thu, 22 Oct 2026 09:28:11 -0400 Subject: [PATCH 7/9] Keyboard Shortcuts: import an InDesign shortcut set (#1296) * #1180: import an InDesign keyboard shortcut set Edit > Keyboard Shortcuts gets Import InDesign Set..., which reads the .txt that InDesign's Show Set writes (window.importShortcuts {path|text}). Matched commands take the set's Default shortcut, or lose theirs when every occurrence says [none defined]; other commands give up keys the set takes. The report lists changes, cleared conflicts, and the items that could not be matched or read. Tool keys stay fixed. * #1180: read the set file through the checked reader * #1180: when two items want the same keys, the later one takes them --- ROADMAP.md | 1 + apps/designcraft-web/src/web.rs | 2 + apps/designcraft/src/main.rs | 1 + crates/ui-egui/src/dialogs.rs | 13 +- crates/ui-egui/src/lib.rs | 5 + crates/ui-egui/src/menus.rs | 19 +- crates/ui-egui/src/shortcut_import.rs | 611 ++++++++++++++++++++++++++ docs/file-format-parity.md | 2 +- docs/target-app-parity.md | 2 +- docs/ui-parity.md | 2 +- 10 files changed, 651 insertions(+), 7 deletions(-) create mode 100644 crates/ui-egui/src/shortcut_import.rs diff --git a/ROADMAP.md b/ROADMAP.md index 8a2352e86..d3ef17e47 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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** · 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. - **2026-10-22** · macOS: the app menu has a Language submenu after Settings… with each interface language in its own name and the current one checked, as in the suite's other apps; Preferences › General › Interface Language stays. No readiness percentage change. - **2026-10-22** · macOS menu bar: the menus keep working after the interface language or Show CJK Features changes (a rebuilt menu bar lost every click, Quit included), and its key equivalents are the shortcuts in effect, so a shortcut moved in the Keyboard Shortcuts editor leaves its old menu item. No readiness percentage change. diff --git a/apps/designcraft-web/src/web.rs b/apps/designcraft-web/src/web.rs index fe0f4a7fa..27d236a2f 100644 --- a/apps/designcraft-web/src/web.rs +++ b/apps/designcraft-web/src/web.rs @@ -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 { diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index b56878176..13416e4e0 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -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"] }], diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 8b4043787..6b60b1296 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0c155851f..18fa402a5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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; @@ -1033,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") diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index af2a91fc3..2a271324a 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -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)"), ( @@ -1705,6 +1711,17 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option { + 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; @@ -3009,7 +3026,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()) { diff --git a/crates/ui-egui/src/shortcut_import.rs b/crates/ui-egui/src/shortcut_import.rs new file mode 100644 index 000000000..9ccbef1b9 --- /dev/null +++ b/crates/ui-egui/src/shortcut_import.rs @@ -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 : ` headers, one per line: +//! ` --- [none defined]` or ` --- : , : `. 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 `: ` entry of an item. +struct Entry { + context: String, + keys: String, +} + +struct SetItem { + area: String, + name: String, + entries: Vec, +} + +struct ParsedSet { + name: String, + items: Vec, +} + +/// 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 { + 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 { + 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 { + 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 ` --- ` 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 { + let keys = keys.trim(); + if keys.eq_ignore_ascii_case("[none defined]") || keys.is_empty() { + return Vec::new(); + } + let mut pieces: Vec = 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 { + 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 { + use egui::Key; + if name.is_empty() { + return None; + } + let squashed: String = name.chars().filter(|c| !c.is_whitespace()).collect::().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::>().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>, + /// 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>, + /// (menu, label) → ids, at any depth of that menu. + in_menu: HashMap<(String, String), BTreeSet>, + /// label → ids, anywhere. + label: HashMap>, +} + +impl Index { + fn build() -> Self { + fn walk(ix: &mut Index, items: &[Item], path: &mut Vec) { + 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 = 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 { + let one = |s: Option<&BTreeSet>| s.filter(|s| s.len() == 1).and_then(|s| s.first().cloned()); + let segments: Vec = 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>, 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, + /// 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 { + 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 = 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::>().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::>(); + 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> = 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 = 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 = 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 { + 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")); + } +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 507358dec..381498fb6 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -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) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c7e0678d7..bb7c5eb80 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -417,7 +417,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. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 2dcf4687a..b66fb585c 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -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) | From 9bd85a14b2857113259da4950f30736a1166791a Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Thu, 22 Oct 2026 09:30:03 -0400 Subject: [PATCH 8/9] i18n: complete the French interface (#1347) * i18n: complete the French interface (#644) * i18n: French uses gabarit for parent pages; fixes * i18n: French for the guide commands and the shortcut-set import --- crates/ui-egui/src/i18n.rs | 174 +++++++++++++++++++++++---- crates/ui-egui/src/i18n/fr.rs | 220 ++++++++++++++++++++++++++++++++++ 2 files changed, 371 insertions(+), 23 deletions(-) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index acf70ff32..fec123a33 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -139,7 +139,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", "極小", "特小"]), @@ -150,8 +150,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", [ @@ -497,7 +497,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", "段落行取りを使用", "使用段落行取"], @@ -734,7 +734,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", "新規ウィンドウ", "新建窗口"]), ( @@ -873,7 +873,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", [ @@ -1388,12 +1388,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", "样式源"]), @@ -2061,7 +2061,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", @@ -2713,7 +2719,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.", "选择一个对象或文本以设置其填色或描边颜色。", @@ -2922,7 +2928,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", "页面"]), @@ -3505,8 +3511,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", "隐藏其他图层"]), @@ -3536,7 +3542,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", "親ページオーバーレイを表示", "显示父页面叠加", @@ -3544,7 +3550,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 名刺", "美国名片"]), ( @@ -4229,8 +4235,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", [ @@ -4717,7 +4723,7 @@ mod tests { // `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(); + let mut keys = interface_strings(); 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)); } @@ -4738,6 +4744,121 @@ mod tests { assert_eq!(tr("fr", "Group"), "Associer"); } + /// Interface strings that read the same in French: names, abbreviations, numbering samples, key + /// symbols and words French shares with English. + const SAME_IN_FRENCH: &[&str] = &[ + " OK ", + " (auto)", + "+Bin", + "+LOC", + "A, B, C, D...", + "Angle", + "Articles", + "Bunri-Kinshi", + "Commits", + "Composition", + "Compression", + "Conditions", + "Cyan", + "DesignCraft", + "Direction", + "Distance", + "Document", + "Document…", + "Ellipse", + "Extras", + "Fractions", + "GREP", + "Hindi", + "Horizontal", + "I, II, III, IV...", + "Image", + "Index", + "Interface", + "Justification", + "Justification…", + "Kanji", + "Kenten", + "Lab", + "Ligatures", + "Magenta", + "Maximum", + "Menus", + "Menus…", + "Minimum", + "Mobile", + "Normal", + "Normal (W)", + "Notes", + "OK", + "OpenType", + "Options", + "Orange", + "Orientation", + "PDF/A-2b", + "PDF/X-4", + "Page", + "Pages", + "Pathfinder", + "Picas", + "Portrait", + "Position", + "Radial", + "Rectangle", + "Rensuuji", + "Rotation", + "Ruby", + "Ruby…", + "Satin", + "Saturation", + "Script", + "Scripts", + "Section", + "Serif", + "Shatai", + "Signature", + "Simple", + "Structure", + "Style", + "Styles", + "Tate-Chu-Yoko", + "Triangle", + "Version", + "Vertical", + "Violet", + "Warichu", + "Web", + "a, b, c, d...", + "document", + "i, ii, iii, iv...", + "pages", + "ΔLOC", + "⇧", + "−Bin", + "−LOC", + "⌃", + "⌘", + "⌥", + ]; + + #[test] + fn french_translates_every_interface_string() { + let keys = interface_strings(); + // A new English string needs a French entry (or a place on `SAME_IN_FRENCH`) before it ships. + let mut untranslated: Vec<_> = keys.iter().filter(|key| tr("fr", key) == **key && !SAME_IN_FRENCH.contains(key)).collect(); + untranslated.sort(); + assert!(untranslated.is_empty(), "French falls back to English for: {untranslated:?}"); + for key in SAME_IN_FRENCH { + assert!(keys.contains(key), "SAME_IN_FRENCH lists a string the interface no longer has: {key}"); + assert_eq!(tr("fr", key), *key, "SAME_IN_FRENCH lists a string French translates: {key}"); + } + for tool in designcraft_tools::TOOL_GROUPS.iter().flat_map(|group| group.iter()) { + assert!(tr("fr", tool.label).starts_with("Outil "), "{}", tool.id); + } + assert_eq!(tr("fr", "Selection Tool"), "Outil Sélection"); + assert_eq!(tr("fr", "Don't Save"), "Ne pas enregistrer"); + } + #[test] fn ukrainian_catalog_covers_existing_keys_and_preserves_placeholders() { let catalog: std::collections::HashMap<_, _> = uk::TABLE.iter().copied().collect(); @@ -4937,10 +5058,9 @@ 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"); + /// Every English string the interface passes to `tr`, as far as the catalogs, command and tool + /// tables and the list of UI-only labels know them. + fn interface_strings() -> std::collections::HashSet<&'static str> { 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] { @@ -5033,6 +5153,14 @@ mod tests { "{name}: import cancelled while a dialog is open; choose the file again", "{name}: {count} step(s) done", ]); + keys + } + + #[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 keys = interface_strings(); // 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(); diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index 78c5d5948..681230d84 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -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,64 +249,130 @@ 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"), ("Space After", "Espace après"), ("Space Before", "Espace avant"), ("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"), @@ -210,8 +393,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"), @@ -220,6 +415,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 ?", + ), ]; From e44662305f2367189e0fd11bac9d010e37e3f73b Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:25:46 -0400 Subject: [PATCH 9/9] i18n tests: the same checks for every language, none fail on a missing translation (#1576) * i18n: missing French strings don't fail tests * i18n: drop the Czech tool-name translation check * i18n tests: one catalog check for every language, no translated literals One test checks every catalog (each language's table and context table, and the shared tables' columns) for duplicate and stale keys, empty translations, placeholders, padding, ellipses and label colons. One test selects, persists and checks document data for every interface language. Context lookup, fallback, built-in names and RTL are checked over every language against the catalogs. Asserts on particular translated text, and checks that particular strings are translated, are gone. * i18n: drop a stale German entry --- crates/ui-egui/src/chrome.rs | 16 +- crates/ui-egui/src/dialogs.rs | 2 +- crates/ui-egui/src/guide_selection_tests.rs | 5 - crates/ui-egui/src/i18n.rs | 652 +++++--------------- crates/ui-egui/src/i18n/de.rs | 1 - crates/ui-egui/src/lib.rs | 2 +- crates/ui-egui/src/menus.rs | 167 +---- crates/ui-egui/src/panels/properties.rs | 15 +- 8 files changed, 205 insertions(+), 655 deletions(-) diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 0df5361f9..e853e6793 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -1155,7 +1155,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. @@ -1187,12 +1187,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"); @@ -1205,7 +1205,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"); } @@ -1227,11 +1227,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)); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 6b60b1296..40787954f 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -8963,7 +8963,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. diff --git a/crates/ui-egui/src/guide_selection_tests.rs b/crates/ui-egui/src/guide_selection_tests.rs index 6c41d0122..d69026712 100644 --- a/crates/ui-egui/src/guide_selection_tests.rs +++ b/crates/ui-egui/src/guide_selection_tests.rs @@ -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")); } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index fec123a33..6057abc97 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -4598,332 +4598,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::>())]; + 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 = interface_strings(); - 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::>(); - 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"); - } - - /// Interface strings that read the same in French: names, abbreviations, numbering samples, key - /// symbols and words French shares with English. - const SAME_IN_FRENCH: &[&str] = &[ - " OK ", - " (auto)", - "+Bin", - "+LOC", - "A, B, C, D...", - "Angle", - "Articles", - "Bunri-Kinshi", - "Commits", - "Composition", - "Compression", - "Conditions", - "Cyan", - "DesignCraft", - "Direction", - "Distance", - "Document", - "Document…", - "Ellipse", - "Extras", - "Fractions", - "GREP", - "Hindi", - "Horizontal", - "I, II, III, IV...", - "Image", - "Index", - "Interface", - "Justification", - "Justification…", - "Kanji", - "Kenten", - "Lab", - "Ligatures", - "Magenta", - "Maximum", - "Menus", - "Menus…", - "Minimum", - "Mobile", - "Normal", - "Normal (W)", - "Notes", - "OK", - "OpenType", - "Options", - "Orange", - "Orientation", - "PDF/A-2b", - "PDF/X-4", - "Page", - "Pages", - "Pathfinder", - "Picas", - "Portrait", - "Position", - "Radial", - "Rectangle", - "Rensuuji", - "Rotation", - "Ruby", - "Ruby…", - "Satin", - "Saturation", - "Script", - "Scripts", - "Section", - "Serif", - "Shatai", - "Signature", - "Simple", - "Structure", - "Style", - "Styles", - "Tate-Chu-Yoko", - "Triangle", - "Version", - "Vertical", - "Violet", - "Warichu", - "Web", - "a, b, c, d...", - "document", - "i, ii, iii, iv...", - "pages", - "ΔLOC", - "⇧", - "−Bin", - "−LOC", - "⌃", - "⌘", - "⌥", - ]; - - #[test] - fn french_translates_every_interface_string() { - let keys = interface_strings(); - // A new English string needs a French entry (or a place on `SAME_IN_FRENCH`) before it ships. - let mut untranslated: Vec<_> = keys.iter().filter(|key| tr("fr", key) == **key && !SAME_IN_FRENCH.contains(key)).collect(); - untranslated.sort(); - assert!(untranslated.is_empty(), "French falls back to English for: {untranslated:?}"); - for key in SAME_IN_FRENCH { - assert!(keys.contains(key), "SAME_IN_FRENCH lists a string the interface no longer has: {key}"); - assert_eq!(tr("fr", key), *key, "SAME_IN_FRENCH lists a string French translates: {key}"); - } - for tool in designcraft_tools::TOOL_GROUPS.iter().flat_map(|group| group.iter()) { - assert!(tr("fr", tool.label).starts_with("Outil "), "{}", tool.id); - } - assert_eq!(tr("fr", "Selection Tool"), "Outil Sélection"); - assert_eq!(tr("fr", "Don't Save"), "Ne pas enregistrer"); } #[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::>(); - 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::>() - }; - 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}")); } @@ -4941,62 +4807,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::>(); - 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("")); @@ -5009,45 +4819,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::>(); - 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] { @@ -5058,14 +4829,10 @@ mod tests { assert_eq!(tr("pl", "{pages} Pages in {spreads} Spreads"), "Strony: {pages}, rozkładówki: {spreads}"); } - /// Every English string the interface passes to `tr`, as far as the catalogs, command and tool - /// tables and the list of UI-only labels know them. - fn interface_strings() -> std::collections::HashSet<&'static str> { - 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); @@ -5085,6 +4852,8 @@ mod tests { "(unchanged)", "[No Language]", "Cell Number:", + "Differs from this document", + "Digits:", "Change fill to:", "Chinese: Simplified", "Chinese: Traditional", @@ -5120,6 +4889,7 @@ mod tests { "Found", "Grab bag", "Key Object", + "Load Styles", "layer", "layers", "Lines +/−", @@ -5135,6 +4905,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", @@ -5156,48 +4927,6 @@ mod tests { keys } - #[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 keys = interface_strings(); - // 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::>(); - 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"); - } - #[test] fn czech_counts_use_the_plural_form_for_the_number() { for (n, form) in [(0, "chyb"), (1, "chyba"), (2, "chyby"), (4, "chyby"), (5, "chyb"), (11, "chyb"), (22, "chyb")] { @@ -5222,51 +4951,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}"); - } - } - } } diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index d47657b09..4f4177cbd 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -2037,7 +2037,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"), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 18fa402a5..535c87629 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2460,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] diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 2a271324a..87537abd7 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -3367,16 +3367,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)>) { @@ -3793,8 +3789,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() { @@ -3804,153 +3800,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] @@ -4993,9 +4859,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()); @@ -5652,9 +5519,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), @@ -5662,7 +5526,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::>(); assert!(ids.iter().enumerate().all(|(i, id)| !ids[..i].contains(id)), "native ids are unique: {ids:?}"); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 1cbd623a6..fd0094f9b 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -3148,8 +3148,19 @@ mod tests { /// are translated, with a sample of each script's figures. #[test] fn digits_choices_use_the_reference_names() { - let shown: Vec = 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 = 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]