diff --git a/ROADMAP.md b/ROADMAP.md index dd34f3b2c..94c4b5f5b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -434,6 +434,7 @@ Newest first. - **2026-10-30** · Parent pages: a running-header variable shows its name in angle brackets on the parent itself (it was blank), as InDesign shows it; on the document pages, and in text with no page, it shows its text as before. No readiness percentage change. - **2026-10-30** · Without facing pages, a page of a multi-page spread shows the parent page in its place, as in InDesign: the second page of a two-page spread shows the parent's second page. Every single-sided page showed the parent's first page, so an InDesign book set up without facing pages but laid out in two-page spreads repeated the left parent page's items (a black heading bar) on its right pages and lost the right parent page's. No readiness percentage change. - **2026-10-30** · Dropping images from the file manager works as in InDesign: images dropped together load the place cursor in the order they came, and one image dropped off a frame that takes it loads the place cursor instead of being placed at once (on a frame it still fills it). Documents and text files dropped keep opening and flowing as before. Ported from #1307 and #1666; see [multi-image placement](docs/multi-image-placement.md). No readiness percentage change. +- **2026-10-30** · File › Place Contact Sheet… puts every image loaded in the place cursor on new pages, in a grid of equal frames (`place.contactSheet`), as one undo step. Ported from #1392; see [multi-image placement](docs/multi-image-placement.md). No readiness percentage change. - **2026-10-30** · Placing several images works as in InDesign. File › Place takes several files, and they load the place cursor in the order chosen: it shows the first one's thumbnail with how many are loaded ("(6)"), each click places the next, arrow keys while dragging lay out a grid (`place.grid`), and Escape unloads the rest (`place.cancel`). Switching, opening or closing a document unloads it too, back to the Selection tool. A drag with the cursor draws the frame at the graphic's proportions, with its scale. Ported from #1307, #1392 and #1432 (multi-image placement, cursor thumbnail and count); see [multi-image placement](docs/multi-image-placement.md). No readiness percentage change. - **2026-10-30** · macOS: a file dragged in from the Finder fills the frame it is dropped on. winit sends no pointer events during such a drag, so the drop asks the system where the pointer is (`Services::pointer`, from `NSEvent.mouseLocation`), at any UI scale. No readiness percentage change. - **2026-10-30** · File > Package opens a dialog, as the reference app's: a Summary of fonts, links and images and inks, pages for each, a Printing Instructions step, and a PDF made with the options of a preset (#1514, #1515). diff --git a/crates/engine/src/cmd/file/place.rs b/crates/engine/src/cmd/file/place.rs index c804aaf31..dd0d50608 100644 --- a/crates/engine/src/cmd/file/place.rs +++ b/crates/engine/src/cmd/file/place.rs @@ -528,6 +528,44 @@ pub(super) fn place_grid(s: &mut Session, p: &Value) -> Result { Ok(json!({"ids": ids.iter().map(|id| id.0).collect::>(), "remaining": loaded_count(s)})) } +/// Place all queued images on new pages as one undoable edit. +pub(super) fn place_contact_sheet(s: &mut Session, p: &Value) -> Result { + const ID: &str = "place.contactSheet"; + validate_place_cursor(s, ID)?; + let (columns, rows, gutter) = grid_dimensions(p, ID, 3, 4)?; + let margin = crate::cmd::f64_or(p, "margin", 36.0); + if !margin.is_finite() || margin < 0.0 { + return Err(bad(ID, "invalid margin")); + } + let images: Vec<_> = s.loaded.iter().chain(s.loaded_queue.iter()).copied().collect(); + if images.is_empty() { + return Err(bad(ID, "load images first using File > Place")); + } + let layer = s.doc()?.active_layer; + let per_page = columns.saturating_mul(rows).max(1); + let count = images.len().div_ceil(per_page); + let ids = s.edit(|d, sel| { + let after = d.page_count().saturating_sub(1); + d.insert_pages(Some(after), count, None)?; + let mut ids = Vec::with_capacity(images.len()); + for (page_offset, chunk) in images.chunks(per_page).enumerate() { + let index = after.saturating_add(1).saturating_add(page_offset); + let (si, pi) = d.page_loc(index).ok_or_else(|| bad(ID, "missing page"))?; + let b = d.spreads.get(si).and_then(|sp| sp.pages.get(pi)).ok_or_else(|| bad(ID, "missing page"))?.bounds(); + let cells = grid_cells(Rect::new(b.x0 + margin, b.y0 + margin, b.x1 - margin, b.y1 - margin), columns, rows, gutter, ID)?; + for (image, rect) in chunk.iter().zip(cells) { + ids.push(insert_grid_image(d, designcraft_doc::SpreadRef::Doc(si), layer, *image, rect, ID)?); + } + } + *sel = Selection::items(ids.clone()); + Ok(ids) + })?; + for _ in &ids { + advance_place_cursor(s); + } + Ok(json!({"pages": count, "images": ids.len()})) +} + /// File › Place of an IDML or DesignCraft document: the objects of one page (`layoutPage`, /// 1-based) grouped, top-left at x/y (default: the source position on the first page). fn place_layout_page(s: &mut Session, p: &Value, bytes: &[u8], format: Option) -> Result { @@ -604,7 +642,9 @@ pub(super) mod image_place_tests { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); s.set_tool("rectangleFrame"); - for (cmd, p) in [("place.drop", json!({"x": 10, "y": 10})), ("place.grid", json!({"rect": [0, 0, 100, 100]}))] { + for (cmd, p) in + [("place.drop", json!({"x": 10, "y": 10})), ("place.grid", json!({"rect": [0, 0, 100, 100]})), ("place.contactSheet", json!({}))] + { let e = s.execute(cmd, &p).unwrap_err().to_string(); assert!(e.contains("nothing loaded"), "{cmd}: {e}"); assert_eq!(s.tool_id(), "rectangleFrame", "{cmd}"); diff --git a/crates/engine/src/cmd/file/registry.rs b/crates/engine/src/cmd/file/registry.rs index bfeed2198..4092bbff2 100644 --- a/crates/engine/src/cmd/file/registry.rs +++ b/crates/engine/src/cmd/file/registry.rs @@ -3,7 +3,7 @@ use super::EngineError; use super::bytes::{base64_encode, to_bytes, to_bytes_with_preview, with_document_extension}; use super::new::{file_new, file_sample, save_preset}; use super::open::{file_open, file_open_bytes, file_open_recent, has_recent, recent_entry}; -use super::place::{file_place, place_drop, place_grid, place_load, read_source}; +use super::place::{file_place, place_contact_sheet, place_drop, place_grid, place_load, read_source}; use super::save::{FolderAccess, can_revert, file_revert, file_save, recovery_dir}; use super::snippets::{snippet_export, snippet_place}; use super::{CommandSpec, PRESETS, Value, always, bad, cmd, has_doc, json, ok, str_param}; @@ -151,6 +151,15 @@ pub fn specs() -> Vec { has_doc, place_grid ), + cmd!( + "place.contactSheet", + "Place Contact Sheet", + ["File"], + None, + "{columns?: 3, rows?: 4, gutter?: 6, margin?: 36} — place all loaded images on new pages", + has_doc, + place_contact_sheet + ), cmd!(noundo "place.cancel", "Cancel Image Placement", [], None, "{}", has_doc, |s, _| { s.loaded = None; s.loaded_queue.clear(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f09aae7f2..f3b375403 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2441,6 +2441,32 @@ fn image_grid_and_load_fail_atomically() { assert!(Arc::ptr_eq(&before, &s.doc().unwrap().doc)); assert_eq!(s.loaded_queue.len(), 2); } + assert!(s.execute("place.contactSheet", &json!({"margin": 10000})).is_err()); + assert!(Arc::ptr_eq(&before, &s.doc().unwrap().doc)); +} + +#[test] +fn contact_sheet_adds_pages_and_undoes_as_one_edit() { + let mut s = session(); + load_test_images(&mut s, 25); + let pages = s.doc().unwrap().doc.page_count(); + let r = s.execute("place.contactSheet", &json!({"columns": 3, "rows": 4})).unwrap(); + assert_eq!(r["pages"], 3); + assert_eq!(r["images"], 25); + let doc = &s.doc().unwrap().doc; + assert_eq!(doc.page_count(), pages + 3); + for page_index in pages..pages + 3 { + let (si, pi) = doc.page_loc(page_index).unwrap(); + let bounds = doc.spreads[si].pages[pi].bounds(); + let items: Vec<_> = doc.spreads[si].items.iter().filter(|i| bounds.contains(i.bounds().center())).collect(); + assert_eq!(items.len(), if page_index == pages + 2 { 1 } else { 12 }); + for item in items { + assert!(bounds.contains(item.bounds().origin())); + } + } + assert!(s.loaded.is_none() && s.loaded_queue.is_empty()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.page_count(), pages); } #[test] @@ -2489,12 +2515,12 @@ fn a_place_click_with_a_queue_places_the_next_image_without_a_grid() { #[test] fn undoing_loaded_assets_cancels_every_placement_cursor() { - for command in ["place.drop", "place.grid"] { + for command in ["place.drop", "place.grid", "place.contactSheet"] { let mut s = session(); load_test_images(&mut s, 3); s.execute("edit.undo", &json!({})).unwrap(); let before = s.doc().unwrap().doc.clone(); - let params = json!({"rect": [0, 0, 200, 200]}); + let params = if command == "place.contactSheet" { json!({}) } else { json!({"rect": [0, 0, 200, 200]}) }; assert!(s.execute(command, ¶ms).is_err(), "{command}"); assert!(s.loaded.is_none(), "{command}"); assert!(s.loaded_queue.is_empty(), "{command}"); diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index d8aa4bbc3..e265dfc48 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -982,6 +982,10 @@ pub(crate) mod browser_import_tests { assert_eq!(app.session.tool_id(), "placeGun"); assert_eq!(app.session.loaded_queue.len(), 2); assert!(app.session.documents()[1].doc.assets.is_empty()); + app.run("app.contactSheetDialog", json!({})).unwrap(); + let result = dialogs::confirm(&mut app).unwrap(); + assert_eq!(result["images"], 3); + assert_eq!(result["pages"], 1); } /// Files dropped together are placed in the order they came: images around a PDF go into the @@ -1308,6 +1312,74 @@ pub(crate) mod browser_import_tests { assert_eq!(loaded_names(&scripted), ["b.svg", "a.svg", "c.svg"]); } + #[test] + fn contact_sheet_measures_use_the_document_unit() { + let mut app = app(); + app.run("document.preferences", json!({"horizontalUnits": "millimeters"})).unwrap(); + app.run("app.contactSheetDialog", json!({})).unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!(d.unit, designcraft_geom::Unit::Millimeters); + assert_eq!(d.s("gutter"), "6pt"); + } + + /// A bare number in a field means the document's ruler unit, whether it was typed or came as + /// a number over `ui.dialog.open` or `ui.dialog.set`. + #[test] + fn contact_sheet_numbers_are_in_the_document_unit_however_they_came() { + let frames = |margin: Value, gutter: Value, set: bool| { + let mut app = app(); + app.run("document.preferences", json!({"horizontalUnits": "inches"})).unwrap(); + let request = app.import_request("place"); + for name in ["one.png", "two.png"] { + app.services.inbox.as_ref().unwrap().lock().unwrap().push(ImportedFile { + request: request.clone(), + name: name.into(), + bytes: png(200), + }); + } + app.drain_inbox(); + let fields = json!({"columns": 2, "rows": 1, "gutter": gutter, "margin": margin}); + app.ui.dialog = Some(dialogs::contact_sheet_dialog(&app, if set { json!({"columns": 2, "rows": 1}) } else { fields.clone() })); + if set { + let d = app.ui.dialog.as_mut().unwrap(); + d.set_field("gutter", fields["gutter"].clone()); + d.set_field("margin", fields["margin"].clone()); + } + dialogs::confirm(&mut app).unwrap(); + let doc = &app.session.doc().unwrap().doc; + doc.spreads.last().unwrap().items.iter().map(|item| item.bounds()).collect::>() + }; + let typed = frames(json!("1"), json!("0.5"), false); + assert_eq!(typed.len(), 2); + assert_eq!(frames(json!(1), json!(0.5), false), typed, "numbers over ui.dialog.open"); + assert_eq!(frames(json!(1), json!(0.5), true), typed, "numbers over ui.dialog.set"); + assert_eq!(frames(json!("72pt"), json!("36pt"), false), typed, "an explicit unit still says what it means"); + } + + #[test] + fn contact_sheet_validation_preserves_fields_and_pending_images() { + let mut app = app(); + let request = app.import_request("place"); + deliver(&mut app, request, "image.png", png(200)); + app.run("app.contactSheetDialog", json!({})).unwrap(); + let before = app.session.doc().unwrap().doc.clone(); + for (field, value) in [("rows", "bad"), ("rows", "0"), ("rows", "4"), ("margin", "999999pt")] { + app.ui.dialog.as_mut().unwrap().fields.insert(field.into(), json!(value)); + if field == "rows" && value == "4" { + continue; + } + assert!(dialogs::confirm(&mut app).is_err()); + let dialog = app.ui.dialog.as_ref().unwrap(); + assert_eq!(dialog.s(field), value); + assert!(!dialog.s("status").is_empty()); + assert!(std::sync::Arc::ptr_eq(&before, &app.session.doc().unwrap().doc)); + assert!(app.session.loaded.is_some()); + } + app.ui.dialog.as_mut().unwrap().fields.insert("margin".into(), json!("36pt")); + assert!(dialogs::confirm(&mut app).is_ok()); + assert!(app.ui.dialog.is_none()); + } + #[test] fn multi_image_inbox_cancels_when_original_document_closed() { let mut app = app(); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 0b8fbb6d5..bf4be1519 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -406,7 +406,11 @@ fn handle_request(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest app.session.reset_find_scope(); } let fields = p.get("fields").cloned().unwrap_or(Value::Null); - let d = if id == "frameSize" { crate::dialogs::frame_size_dialog(app, fields) } else { crate::dialogs::Dialog::new(id, fields) }; + let d = match id { + "frameSize" => crate::dialogs::frame_size_dialog(app, fields), + "contactSheet" => crate::dialogs::contact_sheet_dialog(app, fields), + _ => crate::dialogs::Dialog::new(id, fields), + }; let v = serde_json::to_value(&d).unwrap_or_default(); app.ui.dialog = Some(d); ok(v) @@ -533,6 +537,18 @@ mod automation_contract_tests { app.run("style.paragraph.edit", json!({"name":designcraft_doc::BASIC_PARAGRAPH,"chars":{"size":18}})).unwrap(); assert_eq!(app.session.doc().unwrap().history.undo.len(), history + 1); } + /// `ui.dialog.open` opens Place Contact Sheet as the menu does: bare numbers are in the + /// document's ruler unit, so `1` on an inch document is an inch, not a pica. + #[test] + fn a_contact_sheet_opened_over_the_control_channel_uses_the_document_unit() { + let mut app = app(); + let ctx = egui::Context::default(); + app.run("document.preferences", json!({"horizontalUnits": "inches"})).unwrap(); + let response = call(&mut app, &ctx, "ui.dialog.open", json!({"id": "contactSheet", "fields": {"margin": "1"}})); + assert_eq!(response["ok"], true, "{response}"); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!(d.unit, designcraft_geom::Unit::Inches); + } fn app() -> DesignApp { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); app.session.execute("file.new", &json!({"pages":2})).unwrap(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 776de89cf..c50f00e5c 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -36,6 +36,7 @@ impl Dialog { }; let defaults = match id { "newDocument" => crate::new_document::defaults(), + "contactSheet" => json!({"columns": 3, "rows": 4, "gutter": "6pt", "margin": "36pt"}), "goToPage" => json!({"page": "1"}), "epubExport" => json!({"split": false, "mode": "style", "style": designcraft_doc::BASIC_PARAGRAPH, "coverImage": ""}), "qrCode" => json!({"type": "url", "content": "https://", "color": "[Black]"}), @@ -1882,6 +1883,20 @@ fn go_to_row_dialog(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { } } +/// File › Place Contact Sheet…: bare numbers typed in the dialog are in the document's ruler unit +/// (the defaults keep their explicit `pt`). +pub fn open_contact_sheet(app: &mut DesignApp) { + app.ui.dialog = Some(contact_sheet_dialog(app, json!({}))); +} + +/// The Place Contact Sheet dialog with `fields`, its bare numbers in the document's ruler unit, +/// for the menu and for `ui.dialog.open` alike. +pub fn contact_sheet_dialog(app: &DesignApp, fields: Value) -> Dialog { + let mut d = Dialog::new("contactSheet", fields); + d.unit = doc_unit(app); + d +} + /// The document's vertical ruler unit: what a bare number means in a height field. fn doc_vertical_unit(app: &DesignApp) -> Unit { app.session.active().map(|s| s.doc.settings.vertical_units).unwrap_or(Unit::Picas) @@ -3306,6 +3321,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "qrCode" => crate::i18n::tr(&app.ui.language, "Generate QR Code"), "preferences" => crate::i18n::tr(&app.ui.language, "Preferences"), "print" => crate::i18n::tr(&app.ui.language, "Print"), + "contactSheet" => crate::i18n::tr(&app.ui.language, "Place Contact Sheet"), "pdfImport" => crate::i18n::tr(&app.ui.language, "Place PDF"), "pdfExport" => crate::i18n::tr(&app.ui.language, "Export PDF"), "epubExport" => crate::i18n::tr(&app.ui.language, "EPUB Export Options"), @@ -3695,6 +3711,19 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { crate::object_style_dialog::ID => crate::object_style_dialog::show(app, ui, &mut d), "preferences" => preferences(app, ui, &mut d, body_height), "print" => print_dialog(app, ui, &mut d), + "contactSheet" => { + if !d.s("status").is_empty() { + ui.label(d.s("status")); + } + ui.label(crate::i18n::tr(&app.ui.language, "Place loaded images on new pages, in file order.")); + for (label, key) in [("Columns", "columns"), ("Rows", "rows"), ("Gutter", "gutter"), ("Page margin", "margin")] { + let label = crate::i18n::tr(&app.ui.language, label); + ui.horizontal(|ui| { + ui.add_sized([95.0, 20.0], egui::Label::new(label)); + text_field_named(ui, &mut d, key, 80.0, label); + }); + } + } "pdfImport" => { ui.label(egui::RichText::new(if d.b("async") { d.s("name") } else { d.s("path") }).size(11.0)); ui.horizontal(|ui| { @@ -5043,6 +5072,27 @@ fn confirm_part3(app: &mut DesignApp, d: Dialog) -> Result { app.canvas.shown = None; Ok(r) } + "contactSheet" => { + let result = (|| { + // A bare number means the ruler unit, as typed or as a JSON number (`Dialog::m` + // takes the latter as points). + let measure = |k: &str| parse_measure(&d.s(k), d.unit).ok(); + let params = json!({ + "columns": d.s("columns").trim().parse::().map_err(|_| "Columns must be a whole number")?, + "rows": d.s("rows").trim().parse::().map_err(|_| "Rows must be a whole number")?, + "gutter": measure("gutter").ok_or("Enter a valid gutter")?, + "margin": measure("margin").ok_or("Enter a valid margin")?, + }); + app.run("place.contactSheet", params) + })(); + // A mistake keeps the dialog, with what was typed and why it can't be placed. + if let Err(error) = &result { + let mut d = d; + d.fields.insert("status".into(), json!(error)); + app.ui.dialog = Some(d); + } + result + } "pdfImport" => { let crop = d.s("crop"); // One page number, 1 or more; a blank field is page 1. A range or list ("4-5", "2,5"), a diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index d735edf96..b9b10c93c 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -22,6 +22,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Save a Copy…", "حفظ نسخة…"), ("Revert", "استعادة"), ("Place…", "وضع…"), + ("Place Contact Sheet…", "وضع ورقة مصغّرات…"), + ("Place Contact Sheet", "وضع ورقة مصغّرات"), + ("Place loaded images on new pages, in file order.", "وضع الصور المحمّلة على صفحات جديدة، بترتيب الملفات."), + ("Page margin", "هامش الصفحة"), ("Export PDF…", "تصدير PDF…"), ("Print…", "طباعة…"), ("Print", "طباعة"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 8528add3d..adf27567b 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -25,6 +25,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Save a Copy…", "Uložit kopii…"), ("Revert", "Vrátit k uložené verzi"), ("Place…", "Umístit…"), + ("Place Contact Sheet…", "Umístit kontaktní arch…"), + ("Place Contact Sheet", "Umístit kontaktní arch"), + ("Place loaded images on new pages, in file order.", "Umístit načtené obrázky na nové stránky v pořadí souborů."), + ("Page margin", "Okraj stránky"), ("Export PDF…", "Exportovat PDF…"), ("Print…", "Tisk…"), ("Document Setup…", "Nastavení dokumentu…"), diff --git a/crates/ui-egui/src/i18n/hu.rs b/crates/ui-egui/src/i18n/hu.rs index b70f7f649..686b6ef0d 100644 --- a/crates/ui-egui/src/i18n/hu.rs +++ b/crates/ui-egui/src/i18n/hu.rs @@ -1247,6 +1247,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Place with Import Options", "Elhelyezés importálási beállításokkal"), ("Place with Import Options…", "Elhelyezés importálási beállításokkal…"), ("Place…", "Elhelyezés…"), + ("Place Contact Sheet…", "Kontaktlap elhelyezése…"), + ("Place Contact Sheet", "Kontaktlap elhelyezése"), + ("Place loaded images on new pages, in file order.", "A betöltött képek elhelyezése új oldalakon, fájlsorrendben."), + ("Page margin", "Oldalmargó"), ("Plain", "Egyszerű"), ("Plain Text", "Egyszerű szöveg"), ("Play on Page Load", "Lejátszás az oldal betöltésekor"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 2a668ee3c..ae9aac01a 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -25,6 +25,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Save a Copy…", "Salva una copia…"), ("Revert", "Ripristina"), ("Place…", "Inserisci…"), + ("Place Contact Sheet…", "Inserisci provino…"), + ("Place Contact Sheet", "Inserisci provino"), + ("Place loaded images on new pages, in file order.", "Inserisci le immagini caricate in nuove pagine, nell'ordine dei file."), + ("Page margin", "Margine pagina"), ("Export PDF…", "Esporta PDF…"), ("Print…", "Stampa…"), ("Document Setup…", "Impostazione documento…"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 78c8a3cce..f3006244c 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -25,6 +25,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Save a Copy…", "ასლის შენახვა…"), ("Revert", "წინა ვერსიაზე დაბრუნება"), ("Place…", "განთავსება…"), + ("Place Contact Sheet…", "საკონტაქტო ფურცლის განთავსება…"), + ("Place Contact Sheet", "საკონტაქტო ფურცლის განთავსება"), + ("Place loaded images on new pages, in file order.", "ჩატვირთული გამოსახულებების ახალ გვერდებზე განთავსება, ფაილების თანმიმდევრობით."), + ("Page margin", "გვერდის ველი"), ("Image import cancelled while a dialog is open", "გამოსახულებების იმპორტი გაუქმდა, რადგან დიალოგი გახსნილია"), ("Place cancelled while a dialog is open", "განთავსება გაუქმდა, რადგან დიალოგი ღიაა"), ("Export PDF…", "PDF ექსპორტი…"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 321f5e9f9..449278ad5 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -23,6 +23,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Save a Copy…", "Zapisz kopię…"), ("Revert", "Przywróć"), ("Place…", "Umieść…"), + ("Place Contact Sheet…", "Umieść stykówkę…"), + ("Place Contact Sheet", "Umieść stykówkę"), + ("Place loaded images on new pages, in file order.", "Umieść wczytane obrazy na nowych stronach, w kolejności plików."), + ("Page margin", "Margines strony"), ("Export PDF…", "Eksportuj PDF…"), ("Print…", "Drukuj…"), ("Document Setup…", "Ustawienia dokumentu…"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index fbda873b1..6af0c888b 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -26,6 +26,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Save a Copy…", "Salvar uma cópia…"), ("Revert", "Reverter"), ("Place…", "Inserir…"), + ("Place Contact Sheet…", "Inserir folha de contato…"), + ("Place Contact Sheet", "Inserir folha de contato"), + ("Place loaded images on new pages, in file order.", "Inserir as imagens carregadas em novas páginas, na ordem dos arquivos."), + ("Page margin", "Margem da página"), ("Export PDF…", "Exportar PDF…"), ("Print…", "Imprimir…"), ("Print", "Imprimir"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index e2e44ab93..0f86b5f77 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -27,6 +27,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Save a Copy…", "Сохранить копию…"), ("Revert", "Восстановить сохраненную версию"), ("Place…", "Поместить…"), + ("Place Contact Sheet…", "Поместить контактный лист…"), + ("Place Contact Sheet", "Поместить контактный лист"), + ("Place loaded images on new pages, in file order.", "Поместить загруженные изображения на новые страницы в порядке файлов."), + ("Page margin", "Поле страницы"), ("Export PDF…", "Экспорт в PDF…"), ("Print…", "Печать…"), ("Document Setup…", "Настройка документа…"), diff --git a/crates/ui-egui/src/i18n/sv.rs b/crates/ui-egui/src/i18n/sv.rs index 0c071296b..a59a99b62 100644 --- a/crates/ui-egui/src/i18n/sv.rs +++ b/crates/ui-egui/src/i18n/sv.rs @@ -21,6 +21,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Save a Copy…", "Spara en kopia…"), ("Revert", "Återgå"), ("Place…", "Placera…"), + ("Place Contact Sheet…", "Placera kontaktark…"), + ("Place Contact Sheet", "Placera kontaktark"), + ("Place loaded images on new pages, in file order.", "Placera inlästa bilder på nya sidor, i filordning."), + ("Page margin", "Sidmarginal"), ("Export PDF…", "Exportera PDF…"), ("Print…", "Skriv ut…"), ("Print", "Skriv ut"), diff --git a/crates/ui-egui/src/i18n/table.rs b/crates/ui-egui/src/i18n/table.rs index 44c9b68a9..1671f3fdf 100644 --- a/crates/ui-egui/src/i18n/table.rs +++ b/crates/ui-egui/src/i18n/table.rs @@ -175,6 +175,25 @@ pub(super) const TABLE: &[(&str, [&str; 5])] = &[ ("Save a Copy…", ["Kopie speichern …", "Enregistrer une copie…", "Guardar una copia…", "コピーを保存…", "保存副本…"]), ("Revert", ["Zurück zur letzten Version", "Version précédente", "Volver a la versión guardada", "復帰", "恢复到上次保存"]), ("Place…", ["Platzieren …", "Importer…", "Colocar…", "配置…", "置入…"]), + ( + "Place Contact Sheet…", + ["Kontaktbogen platzieren…", "Importer une planche-contact…", "Colocar hoja de contactos…", "コンタクトシートを配置…", "置入联系表…"], + ), + ( + "Place Contact Sheet", + ["Kontaktbogen platzieren", "Importer une planche-contact", "Colocar hoja de contactos", "コンタクトシートを配置", "置入联系表"], + ), + ( + "Place loaded images on new pages, in file order.", + [ + "Geladene Bilder in Dateireihenfolge auf neuen Seiten platzieren.", + "Importer les images chargées sur de nouvelles pages, dans l’ordre des fichiers.", + "Colocar las imágenes cargadas en páginas nuevas, en el orden de los archivos.", + "読み込んだ画像をファイル順に新しいページに配置します。", + "按文件顺序将载入的图像置入新页面。", + ], + ), + ("Page margin", ["Seitenrand", "Marge de page", "Margen de página", "ページマージン", "页边距"]), ( "Image import cancelled while a dialog is open", [ @@ -1206,7 +1225,7 @@ pub(super) const TABLE: &[(&str, [&str; 5])] = &[ ), ("Color Settings", ["Color Settings", "Réglages des couleurs", "Color Settings", "Color Settings", "颜色设置"]), ("Color:", ["Color:", "Couleur :", "Color:", "Color:", "颜色:"]), - ("Columns", ["Columns", "Colonnes", "Columns", "Columns", "栏"]), + ("Columns", ["Columns", "Colonnes", "Columnas", "Columns", "栏"]), ("Composer:", ["Composer:", "Compositeur :", "Composer:", "Composer:", "排版器:"]), ("Copies:", ["Copies:", "Copies :", "Copies:", "Copies:", "份数:"]), ("Count", ["Count", "Compter", "Count", "Count", "数量"]), @@ -1309,7 +1328,7 @@ pub(super) const TABLE: &[(&str, [&str; 5])] = &[ ("Go To", ["Go To", "Atteindre", "Go To", "Go To", "转到"]), ("Go to Page", ["Go to Page", "Atteindre la page", "Go to Page", "Go to Page", "转到页面"]), ("Go to Row", ["Go to Row", "Atteindre la ligne", "Go to Row", "Go to Row", "转到行"]), - ("Gutter", ["Gutter", "Gouttière", "Gutter", "Gutter", "栏间距"]), + ("Gutter", ["Gutter", "Gouttière", "Medianil", "Gutter", "栏间距"]), ("Header Rows", ["Header Rows", "Lignes d'en-tête", "Header Rows", "Header Rows", "表头行"]), ("Height", ["Height", "Hauteur", "Height", "Height", "高度"]), ("Height:", ["Height:", "Hauteur :", "Height:", "Height:", "高度:"]), @@ -3760,7 +3779,7 @@ pub(super) const TABLE: &[(&str, [&str; 5])] = &[ ), ("Rotate 180°", ["180° drehen", "Rotation à 180°", "Girar 180°", "180°回転", "旋转 180°"]), ("Rotation Angle", ["Rotation Angle", "Angle de rotation", "Rotation Angle", "Rotation Angle", "旋转角度"]), - ("Rows", ["Rows", "Lignes", "Rows", "Rows", "行数"]), + ("Rows", ["Rows", "Lignes", "Filas", "Rows", "行数"]), ("Run", ["Run", "Exécuter", "Run", "Run", "运行"]), ("Scale X Percentage", ["Scale X Percentage", "Échelle X en pourcentage", "Scale X Percentage", "Scale X Percentage", "水平缩放百分比"]), ("Scale Y Percentage", ["Scale Y Percentage", "Échelle Y en pourcentage", "Scale Y Percentage", "Scale Y Percentage", "垂直缩放百分比"]), diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 741c75763..8f4bdb2a5 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -249,6 +249,28 @@ fn arrangement_accessible_name_is_localized_in_every_interface_language() { } } +/// File › Place Contact Sheet… and its dialog read in every interface language, including those +/// whose own catalog is all `tr` looks in. +#[test] +fn contact_sheet_strings_are_translated_in_every_interface_language() { + let strings = [ + "Place Contact Sheet…", + "Place Contact Sheet", + "Place loaded images on new pages, in file order.", + "Columns", + "Rows", + "Gutter", + "Page margin", + ]; + for (code, name) in LANGUAGES.iter().filter(|(code, _)| !code.is_empty()) { + for s in strings { + let text = tr(code, s); + assert!(text != s && !text.trim().is_empty(), "{name}: {s:?} is still English"); + } + } + assert_eq!(["Columns", "Rows", "Gutter"].map(|s| tr("es", s)), ["Columnas", "Filas", "Medianil"]); +} + /// The Links panel's captions for a status its background check hasn't found yet, and for when /// the check can't run. #[test] diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 2451780bd..f738549b6 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -26,6 +26,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Save a Copy…", "Зберегти копію…"), ("Revert", "Повернути збережену версію"), ("Place…", "Розмістити…"), + ("Place Contact Sheet…", "Розмістити аркуш мініатюр…"), + ("Place Contact Sheet", "Розмістити аркуш мініатюр"), + ("Place loaded images on new pages, in file order.", "Розмістити завантажені зображення на нових сторінках у порядку файлів."), + ("Page margin", "Поле сторінки"), ("Export PDF…", "Експортувати PDF…"), ("Print…", "Друк…"), ("Document Setup…", "Параметри документа…"), diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 2ca1f4735..455fbe9d9 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -154,6 +154,10 @@ fn run_ui_part1(app: &mut DesignApp, id: &str, p: &Value) -> Option app.pick_and_open("place"), }, + "app.contactSheetDialog" => { + crate::dialogs::open_contact_sheet(app); + Ok(Value::Null) + } "app.placeWithOptions" => { // Word/RTF Import Options: style mapping and conflicts before the text lands. let Some(path) = p.get("path").and_then(Value::as_str) else { return Some(rerun_with_path(app, "place", id, p, "path")) }; diff --git a/crates/ui-egui/src/menus/tables.rs b/crates/ui-egui/src/menus/tables.rs index d476375f1..bebc31fb1 100644 --- a/crates/ui-egui/src/menus/tables.rs +++ b/crates/ui-egui/src/menus/tables.rs @@ -16,6 +16,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:file.revert", "-", "ui:app.placeDialog", + "ui:app.contactSheetDialog", "ui:app.placeWithOptions", "-", "ui:app.exportPdf", diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index d3b474ecb..f4da6e11b 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -21,6 +21,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ Some("Cmd+D"), "{path?, paths?: [path] (several, loaded into the place cursor in order)} — without either, choose the files in a dialog", ), + ("app.contactSheetDialog", "Place Contact Sheet…", None, "{} — place the images loaded in the place cursor on new pages"), ("app.saveDialog", "Save As…", Some("Cmd+Shift+S"), "{path?} — without a path, choose where in a dialog"), ("app.save", "Save", Some("Cmd+S"), "{}"), ("app.quit", "Quit DesignCraft", Some("Cmd+Q"), "{} — close the window; each document with unsaved changes asks first"), diff --git a/docs/multi-image-placement.md b/docs/multi-image-placement.md index d43cea942..ecca474aa 100644 --- a/docs/multi-image-placement.md +++ b/docs/multi-image-placement.md @@ -1,4 +1,4 @@ -# Multiple images +# Multiple images and contact sheets Choose **File > Place** (Cmd+D on macOS) and select several images, or drop a selection of images from your file manager onto the document. Multiple images load into a placement cursor in the order supplied by the file picker (or the drop). The cursor shows the next image's thumbnail and, beside it, how many are loaded ("(6)"). @@ -9,7 +9,9 @@ Choose **File > Place** (Cmd+D on macOS) and select several images, or drop a se - Press Escape or select another tool to cancel the remaining placement. - Switching to, opening or closing a document also cancels it, and goes back to the Selection tool. -A single selected file retains the normal Place behavior, including filling a selected frame and PDF import options. Several files go to the place cursor whatever is selected, even with a PDF first. A multi-page PDF in a batch pauses the batch at its import options; once confirmed, it queues after the images loaded before it and the rest of the batch follows. Cancelling them ends the batch, and the images already loaded stay. The place cursor holds up to 1000 images, counting those already in it when a batch adds to them. Rows and columns each range from 1 to 100. A batch containing an unreadable image fails without replacing the existing queue or changing the document. An invalid grid area also leaves the document and queue unchanged. +For larger selections, choose **File > Place Contact Sheet…** after loading the images. Set columns, rows, gutter and page margin. All remaining images are fitted proportionally and centered in equal frames across as many new pages as needed. The default is 3 columns by 4 rows. New pages use the last document page's size and have no parent-page artwork. The entire contact sheet is one undo step. + +A single selected file retains the normal Place behavior, including filling a selected frame and PDF import options. Several files go to the place cursor whatever is selected, even with a PDF first. A multi-page PDF in a batch pauses the batch at its import options; once confirmed, it queues after the images loaded before it and the rest of the batch follows. Cancelling them ends the batch, and the images already loaded stay. The place cursor holds up to 1000 images, counting those already in it when a batch adds to them. Rows and columns each range from 1 to 100. A batch containing an unreadable image fails without replacing the existing queue or changing the document. An invalid grid or contact-sheet area also leaves the document and queue unchanged. ## Dropping files @@ -29,7 +31,8 @@ All placement behavior is available through the engine, CLI, control channel and {"command":"place.load","params":{"files":[{"path":"/images/one.png"},{"path":"/images/two.jpg"}]}} {"command":"place.load","params":{"files":[{"path":"/images/three.png"}],"append":true}} {"command":"place.grid","params":{"rect":[36,36,576,756],"columns":3,"rows":4,"gutter":6}} +{"command":"place.contactSheet","params":{"columns":3,"rows":4,"gutter":6,"margin":36}} {"command":"place.cancel","params":{}} ``` -Each source can instead supply `base64` and `name`, including on the web. `append` queues the images after those already loaded instead of replacing them. `place.grid` consumes only the images that fit. Undo removes placed frames together, while the cursor remains at its current position in the queue. The UI command `app.placeDialog` takes `paths` for a File > Place of several files without the dialog. +Each source can instead supply `base64` and `name`, including on the web. `append` queues the images after those already loaded instead of replacing them. `place.grid` consumes only the images that fit; `place.contactSheet` consumes the entire queue. Undo removes placed frames/pages together, while the cursor remains at its current position in the queue. The UI command `app.placeDialog` takes `paths` for a File > Place of several files without the dialog.