From e2d85026362a22fc4c7ec97003f93b5c93c398f5 Mon Sep 17 00:00:00 2001 From: texjer Date: Tue, 3 Nov 2026 21:54:22 -0500 Subject: [PATCH 1/6] =?UTF-8?q?Place:=20File=20=E2=80=BA=20Place=20Contact?= =?UTF-8?q?=20Sheet=E2=80=A6=20puts=20the=20loaded=20images=20on=20new=20p?= =?UTF-8?q?ages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split out of #2073. Port of #1392's contact sheet. - `place.contactSheet` places every image in the place cursor on new pages after the last one, in a grid of equal frames (columns, rows, gutter, page margin), as one undo step; a bad grid or margin changes nothing. - The dialog reads bare numbers in the document's unit, from the menu and from `ui.dialog.open` alike; a mistake keeps it open with what was typed and why. Its fields have accessible names; strings in every language. --- ROADMAP.md | 1 + crates/engine/src/cmd/file/place.rs | 42 +++++++++++++++++++++- crates/engine/src/cmd/file/registry.rs | 11 +++++- crates/engine/src/tests.rs | 30 ++++++++++++++-- crates/ui-egui/src/app/files.rs | 38 ++++++++++++++++++++ crates/ui-egui/src/control.rs | 18 +++++++++- crates/ui-egui/src/dialogs.rs | 47 +++++++++++++++++++++++++ crates/ui-egui/src/i18n/ka.rs | 4 +++ crates/ui-egui/src/i18n/sv.rs | 4 +++ crates/ui-egui/src/i18n/table.rs | 19 ++++++++++ crates/ui-egui/src/menus/run.rs | 4 +++ crates/ui-egui/src/menus/tables.rs | 1 + crates/ui-egui/src/menus/ui_commands.rs | 1 + docs/multi-image-placement.md | 9 +++-- 14 files changed, 221 insertions(+), 8 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 57c143340..40cce6b26 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -405,6 +405,7 @@ Newest first. - **2026-10-30** · Import warnings about a story have a Show button: it closes the warnings (the status line keeps them) and selects the story's first frame, fitted in the window. `file.open` returns `warningItems`, the frame each IDML or InDesign warning is about (or null), matching the converter's INDD story numbers (`story 30892`, written to IDML as `u78ac`) and IDML `Self` names to the imported stories. No readiness percentage change. - **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** · 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`). 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** · 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). - **2026-10-30** · IDML import reads TOC separators and entry styles written with the `$ID/` prefix or as `Properties` children, and the TOC separator notation (`^t`, `^y`, `^m`, `^>`) is shared by the dialog and IDML, with a special-character menu on the separator field (#1249, #1255). diff --git a/crates/engine/src/cmd/file/place.rs b/crates/engine/src/cmd/file/place.rs index ba9e0827a..819f18ef1 100644 --- a/crates/engine/src/cmd/file/place.rs +++ b/crates/engine/src/cmd/file/place.rs @@ -497,6 +497,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, name: &str, bytes: &[u8]) -> Result { @@ -573,7 +611,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 57aa1a5a6..194cfe2ec 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}; @@ -137,6 +137,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 564e42605..b8228976b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2063,6 +2063,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] @@ -2111,12 +2137,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 3df85b504..65097d2ef 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -893,6 +893,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 @@ -983,6 +987,40 @@ 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"); + } + + #[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 d70a79695..efa2cbd8a 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -390,7 +390,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) @@ -517,6 +521,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 7662c0cb0..a0a00c15c 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]"}), @@ -1526,6 +1527,20 @@ fn doc_unit(app: &DesignApp) -> Unit { app.session.active().map(|s| s.doc.settings.horizontal_units).unwrap_or(Unit::Picas) } +/// 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) @@ -2948,6 +2963,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"), @@ -3330,6 +3346,19 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { crate::package_dialog::INSTRUCTIONS => crate::package_dialog::show_instructions(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| { @@ -4611,6 +4640,24 @@ fn confirm_part3(app: &mut DesignApp, d: Dialog) -> Result { app.canvas.shown = None; Ok(r) } + "contactSheet" => { + let result = (|| { + 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": d.m("gutter").ok_or("Enter a valid gutter")?, + "margin": d.m("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/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 87fae4392..0c2ce3dc9 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/sv.rs b/crates/ui-egui/src/i18n/sv.rs index 8d7c46e15..40b0b2488 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 7e8ccdddc..60cba865d 100644 --- a/crates/ui-egui/src/i18n/table.rs +++ b/crates/ui-egui/src/i18n/table.rs @@ -146,6 +146,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", [ diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index b49b8c7f4..5228d3c60 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -148,6 +148,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 e7907e7da..50904af4f 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 0fbdaca45..db6d9eb54 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 997414bcd..ea5ee549b 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. They load into a placement cursor in the order supplied by the file picker. The cursor shows the next image's thumbnail and, beside it, how many are loaded ("(6)"). @@ -8,7 +8,9 @@ Choose **File > Place** (Cmd+D on macOS) and select several images. They load in - While drawing, Right/Left add/remove columns and Up/Down add/remove rows. Release to place a grid in row order with 6-point gutters. Extra images remain in the cursor; unused grid cells create no empty frames. - Press Escape or select another tool to cancel the remaining placement. -A single selected file retains the normal Place behavior, including filling a selected frame and PDF import options. Image batches support up to 1000 images. 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. Image batches support up to 1000 images. 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. Images here are PNG, JPEG, GIF, WebP, BMP, TIFF, SVG, PSD and EPS files. @@ -20,7 +22,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. From a93885877568e05c40eb16d6e117b5c42ffa2de7 Mon Sep 17 00:00:00 2001 From: texjer Date: Wed, 4 Nov 2026 10:44:31 -0500 Subject: [PATCH 2/6] Place: contact sheet strings in every interface language; Spanish grid labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Arabic, Czech, Hungarian, Italian, Polish, Portuguese (Brazil), Russian and Ukrainian read only their own catalogs, so File › Place Contact Sheet… and its dialog showed in English there. They now have the four new strings. - Spanish Columns, Rows and Gutter were still English in the shared table: Columnas, Filas, Medianil, as the Spanish catalog already says for "Columns:", "Rows:" and "Gutter:". --- crates/ui-egui/src/i18n/ar.rs | 4 ++++ crates/ui-egui/src/i18n/cs.rs | 4 ++++ crates/ui-egui/src/i18n/hu.rs | 4 ++++ crates/ui-egui/src/i18n/it.rs | 4 ++++ crates/ui-egui/src/i18n/pl.rs | 4 ++++ crates/ui-egui/src/i18n/pt_br.rs | 4 ++++ crates/ui-egui/src/i18n/ru.rs | 4 ++++ crates/ui-egui/src/i18n/table.rs | 6 +++--- crates/ui-egui/src/i18n/tests.rs | 22 ++++++++++++++++++++++ crates/ui-egui/src/i18n/uk.rs | 4 ++++ 10 files changed, 57 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 651b748f9..a5b8c9ce5 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 b6459bc36..6b8caa4be 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 890959761..b3a32ca3e 100644 --- a/crates/ui-egui/src/i18n/hu.rs +++ b/crates/ui-egui/src/i18n/hu.rs @@ -1223,6 +1223,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 70d08085e..183891a57 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/pl.rs b/crates/ui-egui/src/i18n/pl.rs index fcdb3718e..155e5f3eb 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 0ef8fa755..f104fd8de 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 8b933fe60..22533a4ff 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/table.rs b/crates/ui-egui/src/i18n/table.rs index 60cba865d..e83665438 100644 --- a/crates/ui-egui/src/i18n/table.rs +++ b/crates/ui-egui/src/i18n/table.rs @@ -1190,7 +1190,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", "数量"]), @@ -1292,7 +1292,7 @@ pub(super) const TABLE: &[(&str, [&str; 5])] = &[ ("Generate QR Code", ["Generate QR Code", "Générer un code QR", "Generate QR Code", "Generate QR Code", "生成二维码"]), ("Go To", ["Go To", "Atteindre", "Go To", "Go To", "转到"]), ("Go to Page", ["Go to Page", "Atteindre la page", "Go to Page", "Go to Page", "转到页面"]), - ("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:", "高度:"]), @@ -3723,7 +3723,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 b676a0c5f..a10f61438 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"]); +} + /// Checks language-specific context precedence and fallback to the plain translation. #[test] fn context_captions_come_from_their_context_or_the_plain_translation() { diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 03918520f..f902cd236 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…", "Параметри документа…"), From 64cc0c072dc11f6b2776d9b34aaf3fdf3712f647 Mon Sep 17 00:00:00 2001 From: texjer Date: Thu, 5 Nov 2026 14:13:55 -0500 Subject: [PATCH 3/6] Place: a PDF later in a batch joins the place cursor, whatever is selected With a graphic frame selected, `file.place` chose that frame before it looked at the place cursor, so a PDF confirmed with `append` filled the frame and left the images around it queued. An appended file now skips the selected-frame fallback; an explicit `frame` still applies, and a single placed file still fills the selected frame. --- crates/engine/src/cmd/file/place.rs | 9 ++++++--- crates/ui-egui/src/app/files.rs | 21 +++++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/file/place.rs b/crates/engine/src/cmd/file/place.rs index f80d9d69f..6bf3d263d 100644 --- a/crates/engine/src/cmd/file/place.rs +++ b/crates/engine/src/cmd/file/place.rs @@ -100,8 +100,11 @@ pub(super) fn file_place(s: &mut Session, p: &Value) -> Result { (nw, nh) } }; + // A file joining the place cursor's queue goes after the ones already there, whatever is selected. + let load_cursor_param = p.get("loadCursor").and_then(Value::as_bool) == Some(true); + let append = p.get("append").and_then(Value::as_bool) == Some(true); let target_frame = crate::cmd::id_param(p, "frame").or_else(|| { - if ["x", "y", "spread", "width"].iter().any(|key| p.get(*key).is_some_and(|value| !value.is_null())) { + if (load_cursor_param && append) || ["x", "y", "spread", "width"].iter().any(|key| p.get(*key).is_some_and(|value| !value.is_null())) { return None; } let st = s.active()?; @@ -115,8 +118,8 @@ pub(super) fn file_place(s: &mut Session, p: &Value) -> Result { 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); - if target_frame.is_none() && px.is_none() && py.is_none() && p.get("loadCursor").and_then(Value::as_bool) == Some(true) { - return load_cursor(s, asset, (bw, bh), p.get("append").and_then(Value::as_bool) == Some(true)); + if target_frame.is_none() && px.is_none() && py.is_none() && load_cursor_param { + return load_cursor(s, asset, (bw, bh), append); } s.edit(|d, sel| { let fresh = AssetId(d.alloc()); diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index e0bc04e22..ee576ccec 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -1014,6 +1014,27 @@ pub(crate) mod browser_import_tests { assert_eq!(loaded_names(&app), ["a.svg", "one.pdf", "b.svg", "c.svg", "a.svg"]); } + /// A PDF later in a batch joins the place cursor with the images around it, even with a + /// graphic frame selected: it doesn't fill that frame and leave the others queued. + #[test] + fn a_pdf_in_a_batch_queues_rather_than_filling_the_selected_frame() { + let dir = tempfile::tempdir().unwrap(); + let path = |n: &str| dir.path().join(n).to_string_lossy().to_string(); + std::fs::write(path("a.svg"), image()).unwrap(); + std::fs::write(path("one.pdf"), pdf_pages(1)).unwrap(); + std::fs::write(path("b.svg"), image()).unwrap(); + let mut app = app(); + app.services.read = Some(Box::new(|p| std::fs::read(p).map_err(|e| e.to_string()))); + let f = designcraft_doc::ItemId( + app.run("frame.create", json!({"rect": [200, 200, 300, 300], "content": "graphic"})).unwrap()["id"].as_u64().unwrap(), + ); + assert!(app.session.active().unwrap().selection.items.contains(&f)); + app.place_paths(vec![path("a.svg"), path("one.pdf"), path("b.svg")]).unwrap(); + assert_eq!(loaded_names(&app), ["a.svg", "one.pdf", "b.svg"]); + let frame = app.session.active().unwrap().doc.item(f).unwrap(); + assert!(!matches!(frame.content, designcraft_doc::Content::Graphic(_)), "the selected frame stays empty"); + } + /// File › Place with several files chosen in the dialog loads them all into the place /// cursor, in the order chosen; with one, it is placed as before. #[test] From 5e1cb976f9fc3aeaded864cc5952851ac6e81306 Mon Sep 17 00:00:00 2001 From: texjer Date: Thu, 5 Nov 2026 14:54:26 -0500 Subject: [PATCH 4/6] Place: the 1000-image cap holds for the whole place cursor when appending Appending only checked each batch against the cap, and a single image or a PDF added with append wasn't checked at all, so repeated appends could grow the cursor without limit. Refused before decoding a batch or adding an asset. --- crates/engine/src/cmd/file/place.rs | 90 ++++++++++++++++++++++++++++- docs/multi-image-placement.md | 2 +- 2 files changed, 90 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/file/place.rs b/crates/engine/src/cmd/file/place.rs index 6bf3d263d..c462fe773 100644 --- a/crates/engine/src/cmd/file/place.rs +++ b/crates/engine/src/cmd/file/place.rs @@ -253,6 +253,7 @@ pub(super) fn place_load(s: &mut Session, p: &Value) -> Result { if sources.is_empty() || sources.len() > MAX_LOADED_IMAGES { return Err(bad("place.load", format!("select between 1 and {MAX_LOADED_IMAGES} images"))); } + check_cursor_room(s, sources.len(), append, "place.load")?; let mut images = Vec::with_capacity(sources.len()); for source in sources { let (bytes, name, link) = read_source(source)?; @@ -280,9 +281,18 @@ pub(super) fn place_load(s: &mut Session, p: &Value) -> Result { Ok(json!({"remaining": loaded_count(s), "loaded": true})) } -/// Most images one `place.load` takes. +/// Most images the place cursor holds, and so most one `place.load` takes. const MAX_LOADED_IMAGES: usize = 1000; +/// Whether `incoming` images fit in the place cursor: appended, they go after the ones it holds. +fn check_cursor_room(s: &Session, incoming: usize, append: bool, command: &str) -> Result<()> { + let held = if append { loaded_count(s) } else { 0 }; + if held.saturating_add(incoming) > MAX_LOADED_IMAGES { + return Err(bad(command, format!("the place cursor holds at most {MAX_LOADED_IMAGES} images, and it has {held}"))); + } + Ok(()) +} + /// How many images the place cursor holds: the current one and those queued after it. fn loaded_count(s: &Session) -> usize { s.loaded_queue.len().saturating_add(usize::from(s.loaded.is_some())) @@ -291,6 +301,7 @@ fn loaded_count(s: &Session) -> usize { /// Add a decoded asset with a fresh id, replacing the previous placement queue (or, with /// `append` and an image already loaded, queued after it). fn load_cursor(s: &mut Session, mut asset: Asset, size: (f64, f64), append: bool) -> Result { + check_cursor_room(s, 1, append, "place.load")?; let name = asset.name.clone(); let (pw, ph) = asset.pixels.unwrap_or_default(); let aid = s.edit(|d, _| { @@ -1107,3 +1118,80 @@ mod nested_frame_tests { assert_eq!(d.spreads[0].items.len(), 1, "no new object"); } } + +#[cfg(test)] +mod cursor_cap_tests { + use serde_json::{Value, json}; + + use super::MAX_LOADED_IMAGES; + use crate::Session; + + fn png() -> String { + let png = designcraft_render::Rendered { width: 4, height: 4, pixels: [200, 90, 30, 255].repeat(16) }.to_png(); + super::super::base64_encode(&png) + } + + /// A session whose place cursor holds `held` images, loaded without decoding each of them. + fn holding(held: usize) -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("place.load", &json!({"base64": png(), "name": "first.png"})).unwrap(); + let first = s.loaded.unwrap(); + s.loaded_queue = std::iter::repeat_n(first, held - 1).collect(); + s + } + + fn count(s: &Session) -> usize { + super::loaded_count(s) + } + + /// Every way of adding to the cursor stops at the cap, and a refused add changes nothing. + #[test] + fn appending_stops_at_the_cap_on_the_whole_cursor() { + let image = png(); + let single = json!({"base64": image, "name": "a.png", "append": true}); + let placed = json!({"base64": image, "name": "a.png", "append": true, "loadCursor": true}); + let two = json!({"files": [{"base64": image, "name": "a.png"}, {"base64": image, "name": "b.png"}], "append": true}); + for (command, params) in [("place.load", &single), ("file.place", &placed), ("place.load", &two)] { + let mut s = holding(MAX_LOADED_IMAGES); + let assets = s.doc().unwrap().doc.assets.len(); + assert!(s.execute(command, params).is_err(), "{command} {params}"); + assert_eq!(count(&s), MAX_LOADED_IMAGES, "{command} {params}"); + assert_eq!(s.doc().unwrap().doc.assets.len(), assets, "{command} {params}"); + } + // One over, as a batch. + let mut s = holding(MAX_LOADED_IMAGES - 1); + assert!(s.execute("place.load", &two).is_err()); + assert_eq!(count(&s), MAX_LOADED_IMAGES - 1); + } + + /// The last places up to the cap load, and an unreadable batch reports the cap before decoding. + #[test] + fn appending_up_to_the_cap_still_loads() { + let image = png(); + for params in + [json!({"base64": image, "name": "a.png", "append": true}), json!({"files": [{"base64": image, "name": "a.png"}], "append": true})] + { + let mut s = holding(MAX_LOADED_IMAGES - 1); + s.execute("place.load", ¶ms).unwrap(); + assert_eq!(count(&s), MAX_LOADED_IMAGES, "{params}"); + } + let mut s = holding(MAX_LOADED_IMAGES); + let error = + s.execute("place.load", &json!({"files": [{"base64": "not an image", "name": "a.png"}], "append": true})).unwrap_err().to_string(); + assert!(error.contains(&MAX_LOADED_IMAGES.to_string()), "{error}"); + } + + /// Without `append` the new images replace the cursor, so what it holds doesn't count. + #[test] + fn replacing_a_full_cursor_is_not_capped() { + let image = png(); + let mut s = holding(MAX_LOADED_IMAGES); + let result: Value = s.execute("place.load", &json!({"base64": image, "name": "a.png"})).unwrap(); + assert_eq!(result["loaded"], true); + assert_eq!(count(&s), 1); + let mut s = holding(MAX_LOADED_IMAGES); + s.execute("place.load", &json!({"files": [{"base64": image, "name": "a.png"}]})).unwrap(); + assert_eq!(count(&s), 1); + } +} diff --git a/docs/multi-image-placement.md b/docs/multi-image-placement.md index 99e8c809d..58cbdb60b 100644 --- a/docs/multi-image-placement.md +++ b/docs/multi-image-placement.md @@ -11,7 +11,7 @@ Choose **File > Place** (Cmd+D on macOS) and select several images. They load in 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. A multi-page PDF in a batch stops the batch at its import options; once confirmed, it queues after the images loaded before it. Image batches support up to 1000 images. 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. +A single selected file retains the normal Place behavior, including filling a selected frame and PDF import options. A multi-page PDF in a batch stops the batch at its import options; once confirmed, it queues after the images loaded before it. 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. Images here are PNG, JPEG, GIF, WebP, BMP, TIFF, SVG, PSD and EPS files. From dd311b88031dcae02e0e2223883517cc6a134eb0 Mon Sep 17 00:00:00 2001 From: texjer Date: Thu, 5 Nov 2026 15:00:33 -0500 Subject: [PATCH 5/6] Place: the files after a multi-page PDF in a batch wait for its options They were left behind in place_paths when the PDF's Import Options opened, so the PDF was placed alone and the rest of what was chosen never loaded. They now wait in the dialog and carry on when it is confirmed; Cancel ends the batch. --- crates/ui-egui/src/app/files.rs | 82 +++++++++++++++++++++++++++------ crates/ui-egui/src/dialogs.rs | 16 ++++++- docs/multi-image-placement.md | 2 +- 3 files changed, 84 insertions(+), 16 deletions(-) diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index ee576ccec..bf9ce5e7b 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -52,18 +52,21 @@ impl DesignApp { self.place_file(if append { json!({"path": path, "append": true}) } else { json!({"path": path}) }) } - /// Place an ordered batch of files, as File › Place with several chosen. The batch stops when - /// a file opens a dialog (a multi-page PDF's Import Options, an alert), as the inbox's does; - /// every file that loads the cursor adds to what the earlier ones loaded, in the order the - /// files were chosen (images in a row load together, before the file after them). A file that - /// can't be placed gets an alert and stops the batch; its error is returned, so automation - /// doesn't carry on as if the batch had loaded. + /// Place an ordered batch of files, as File › Place with several chosen. A multi-page PDF's + /// Import Options pause the batch: the files after it wait in the dialog and carry on when it + /// is confirmed, and Cancel ends the batch. Any other dialog (an alert, one already open) stops + /// it, as the inbox's does. Every file that loads the cursor adds to what the earlier ones + /// loaded, in the order the files were chosen (images in a row load together, before the file + /// after them). A file that can't be placed gets an alert and stops the batch; its error is + /// returned, so automation doesn't carry on as if the batch had loaded. pub(crate) fn place_paths(&mut self, paths: Vec) -> Result<(), String> { let mut images = Vec::new(); // Images already in the place cursor stay: the batch queues after them. let mut loaded = self.session.loaded.is_some(); - // `None` at the end loads the images left. - for path in paths.into_iter().map(Some).chain([None]) { + let mut paths = paths.into_iter(); + loop { + // `None` at the end loads the images left. + let path = paths.next(); if let Some(p) = &path && is_cursor_image(p) { @@ -71,7 +74,7 @@ impl DesignApp { continue; } if path.is_none() && images.is_empty() { - break; + return Ok(()); } if self.ui.dialog.is_some() { self.status(crate::i18n::tr(&self.ui.language, "Place cancelled while a dialog is open")); @@ -81,12 +84,14 @@ impl DesignApp { let r = self.load_place_cursor(std::mem::take(&mut images), loaded)?; loaded |= r.get("loaded").and_then(Value::as_bool) == Some(true); } - if let Some(p) = path { - let r = self.place_path(&p, loaded)?; - loaded |= r.get("loaded").and_then(Value::as_bool) == Some(true); + let Some(p) = path else { return Ok(()) }; + let r = self.place_path(&p, loaded)?; + loaded |= r.get("loaded").and_then(Value::as_bool) == Some(true); + if let Some(d) = self.ui.dialog.as_mut().filter(|d| d.id == "pdfImport") { + d.fields.insert(dialogs::PDF_IMPORT_REST.into(), json!(paths.collect::>())); + return Ok(()); } } - Ok(()) } /// `place.load` of `files` (`{path}` or `{name, base64}` each), queued after the images @@ -947,6 +952,57 @@ pub(crate) mod browser_import_tests { assert_eq!(loaded_names(&app), ["a.png", "b.png", "three.pdf"]); } + /// The files after a multi-page PDF in a native batch wait for its Import Options, through a + /// second such PDF too, and keep the order chosen. + #[test] + fn native_batch_continues_after_a_multi_page_pdf_is_confirmed() { + let dir = tempfile::tempdir().unwrap(); + let path = |n: &str| dir.path().join(n).to_string_lossy().to_string(); + for name in ["a.svg", "b.svg", "c.svg", "d.svg"] { + std::fs::write(path(name), image()).unwrap(); + } + std::fs::write(path("three.pdf"), pdf_pages(3)).unwrap(); + std::fs::write(path("two.pdf"), pdf_pages(2)).unwrap(); + let mut app = app(); + app.services.read = Some(Box::new(|p| std::fs::read(p).map_err(|e| e.to_string()))); + let batch = ["a.svg", "three.pdf", "b.svg", "c.svg", "two.pdf", "d.svg"].map(path).to_vec(); + app.place_paths(batch).unwrap(); + assert_eq!(loaded_names(&app), ["a.svg"], "waiting for the first PDF's options"); + dialogs::confirm(&mut app).unwrap(); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("pdfImport"), "the second PDF asks in its turn"); + assert_eq!(loaded_names(&app), ["a.svg", "three.pdf", "b.svg", "c.svg"]); + dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(loaded_names(&app), ["a.svg", "three.pdf", "b.svg", "c.svg", "two.pdf", "d.svg"]); + } + + /// A refused page number keeps the rest of the batch waiting; Cancel ends the batch, and what + /// loaded before the PDF stays. + #[test] + fn native_batch_waits_through_a_refused_page_and_ends_at_cancel() { + let dir = tempfile::tempdir().unwrap(); + let path = |n: &str| dir.path().join(n).to_string_lossy().to_string(); + std::fs::write(path("a.svg"), image()).unwrap(); + std::fs::write(path("b.svg"), image()).unwrap(); + std::fs::write(path("three.pdf"), pdf_pages(3)).unwrap(); + let mut app = app(); + app.services.read = Some(Box::new(|p| std::fs::read(p).map_err(|e| e.to_string()))); + let batch = ["a.svg", "three.pdf", "b.svg"].map(path).to_vec(); + app.place_paths(batch.clone()).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("page".into(), json!("4-5")); + assert!(dialogs::confirm(&mut app).is_err()); + app.ui.dialog.as_mut().unwrap().fields.insert("page".into(), json!("2")); + dialogs::confirm(&mut app).unwrap(); + assert_eq!(loaded_names(&app), ["a.svg", "three.pdf", "b.svg"]); + + let mut app = self::app(); + app.services.read = Some(Box::new(|p| std::fs::read(p).map_err(|e| e.to_string()))); + app.place_paths(batch).unwrap(); + dialogs::cancel(&mut app); + assert!(app.ui.dialog.is_none()); + assert_eq!(loaded_names(&app), ["a.svg"]); + } + /// A single multi-page PDF still replaces what the cursor holds, as any single Place does. #[test] fn a_single_multi_page_pdf_replaces_the_cursor_when_confirmed() { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index e6f6311b2..6730c3590 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1563,6 +1563,9 @@ fn text_frame_params(app: &DesignApp, d: &Dialog) -> Result, Ok(p) } +/// In a native batch's `pdfImport` dialog: the paths chosen after its PDF, placed once it is confirmed. +pub(crate) const PDF_IMPORT_REST: &str = "rest"; + /// File › Document Setup, filled from the document. /// The active document's ruler unit (picas without a document). fn doc_unit(app: &DesignApp) -> Unit { @@ -4758,9 +4761,18 @@ fn confirm_part3(app: &mut DesignApp, d: Dialog) -> Result { if let Some(target) = d.fields.get("target").and_then(Value::as_u64) { app.activate_import_target(&crate::ImportRequest { purpose: "place".into(), target: Some(target) })?; } - app.place_file( + let placed = app.place_file( json!({"path": d.s("path"), "pdfPage": page, "pdfCrop": if crop.is_empty() { "crop".to_string() } else { crop }, "append": d.b("append")}), - ) + )?; + // The files chosen after this PDF, which waited for its options. + let rest: Vec = d + .fields + .get(PDF_IMPORT_REST) + .and_then(Value::as_array) + .map(|paths| paths.iter().filter_map(Value::as_str).map(str::to_string).collect()) + .unwrap_or_default(); + app.place_paths(rest)?; + Ok(placed) } "print" => { let pages = if d.s("range") == "range" { json!(d.s("pages")) } else { Value::Null }; diff --git a/docs/multi-image-placement.md b/docs/multi-image-placement.md index 58cbdb60b..8086650f5 100644 --- a/docs/multi-image-placement.md +++ b/docs/multi-image-placement.md @@ -11,7 +11,7 @@ Choose **File > Place** (Cmd+D on macOS) and select several images. They load in 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. A multi-page PDF in a batch stops the batch at its import options; once confirmed, it queues after the images loaded before it. 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. +A single selected file retains the normal Place behavior, including filling a selected frame and PDF import options. 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. Images here are PNG, JPEG, GIF, WebP, BMP, TIFF, SVG, PSD and EPS files. From fea50adfafe5bb1290edde8a6e15c3cca86ad0cb Mon Sep 17 00:00:00 2001 From: texjer Date: Thu, 5 Nov 2026 15:58:57 -0500 Subject: [PATCH 6/6] Place: contact sheet numbers mean the document unit however they arrive A number sent over ui.dialog.open or ui.dialog.set showed as 2 in the field but was sent as 2 points, while a typed 2 meant the ruler unit. --- crates/ui-egui/src/app/files.rs | 34 +++++++++++++++++++++++++++++++++ crates/ui-egui/src/dialogs.rs | 7 +++++-- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index bf9ce5e7b..38d7f137a 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -1124,6 +1124,40 @@ pub(crate) mod browser_import_tests { 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(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 6730c3590..f6a15386b 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -4721,11 +4721,14 @@ fn confirm_part3(app: &mut DesignApp, d: Dialog) -> Result { } "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": d.m("gutter").ok_or("Enter a valid gutter")?, - "margin": d.m("margin").ok_or("Enter a valid margin")?, + "gutter": measure("gutter").ok_or("Enter a valid gutter")?, + "margin": measure("margin").ok_or("Enter a valid margin")?, }); app.run("place.contactSheet", params) })();