diff --git a/ROADMAP.md b/ROADMAP.md index 202e808..eb5a678 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -146,6 +146,7 @@ Newest first. - **2026-10-11** · The View menu follows InDesign's: proofing first, then zoom, Rotate Spread, Screen Mode, Display Performance, rulers, Extras, Grids & Guides and Structure; the view items that sat at the end of the Type menu moved there. No readiness percentage change. - **2026-10-12** · Underlines and strikethroughs are drawn as one bar per stretch of text on screen, as in PDF export: a bar per letter left faint seams between the letters. No readiness percentage change. - **2026-10-12** · Properties › Transform shows rotation, shear and X/Y scale, and Rotate 90° and Flip Horizontal/Vertical buttons, as InDesign's does. No readiness percentage change. +- **2026-10-12** · Edit › Step and Repeat has its InDesign dialog: Repeat (Count, or Rows and Columns with Create as a grid), Offset (Vertical, Horizontal, in ruler units) and Preview, which shows the copies while the dialog is open; Cancel takes them away and OK makes them as one undo step. No readiness percentage change. - **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change. - **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change. - **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change. diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 68e9210..5a4de00 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -302,8 +302,7 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> } "ui.dialog.confirm" => wrap(crate::dialogs::confirm(app)), "ui.dialog.cancel" => { - app.cancel_pdf_import(); - app.ui.dialog = None; + crate::dialogs::cancel(app); ok(Value::Null) } "ui.resize" => { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 14d2552..695a66a 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1656,6 +1656,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { ui.end_row(); }); } + STEP_AND_REPEAT => step_and_repeat_dialog(app, ui, &mut d), id if id.starts_with("cmd:") => command_form(app, ui, &mut d), "goToPage" => { ui.horizontal(|ui| { @@ -1733,14 +1734,20 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { Some(true) => { let _ = confirm(app); } - Some(false) => { - app.ui.dialog = None; - app.cancel_pdf_import(); - } + Some(false) => cancel(app), None => {} } } +/// Close the open dialog without applying it (Cancel, Esc, the control channel): a previewing +/// dialog puts the document back as it was. +pub fn cancel(app: &mut DesignApp) { + if app.ui.dialog.take().is_some_and(|d| d.fields.contains_key(PREVIEWED)) { + app.session.cancel_interaction(); + } + app.cancel_pdf_import(); +} + /// Apply the open dialog. pub fn confirm(app: &mut DesignApp) -> Result { let Some(d) = app.ui.dialog.take() else { return Err("no dialog open".into()) }; @@ -2167,6 +2174,20 @@ pub fn confirm(app: &mut DesignApp) -> Result { "alert" => Ok(Value::Null), "findChange" if d.b("objectMode") => Ok(Value::Null), "findChange" => app.run("find.change", json!({"find": d.s("find"), "change": d.s("change"), "grep": d.b("grep"), "caseSensitive": d.b("caseSensitive"), "wholeWord": d.b("wholeWord"), "scope": d.s("scope")})), + STEP_AND_REPEAT => { + // The preview goes; the copies are made once, as one undo step. + if d.fields.contains_key(PREVIEWED) { + app.session.cancel_interaction(); + } + let r = step_and_repeat_params(app, &d).and_then(|p| app.run("edit.stepAndRepeat", p)); + if let Err(e) = &r { + let mut d = d.clone(); + d.fields.remove(PREVIEWED); + d.fields.insert("status".into(), json!(e)); + app.ui.dialog = Some(d); + } + r + } id if id.starts_with("cmd:") => { let cid = &id[4..]; let doc = designcraft_engine::find_command(cid).map_or("", |c| c.params); @@ -3558,6 +3579,120 @@ fn command_form(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { } } +const STEP_AND_REPEAT: &str = "cmd:edit.stepAndRepeat"; +/// The params a previewing dialog shows on the page (its interaction is open while it is set). +const PREVIEWED: &str = "_previewed"; + +/// Edit › Step and Repeat, as in InDesign: Repeat (Count, or Rows and Columns with Create as a +/// grid), Offset (Vertical, Horizontal, in the document's ruler units) and Preview, which shows +/// the copies on the page while the dialog is open. +fn step_and_repeat_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { + let (hu, vu) = app.session.active().map_or((Unit::Picas, Unit::Picas), |s| (s.doc.settings.horizontal_units, s.doc.settings.vertical_units)); + for (k, v) in [ + ("count", json!("1")), + ("rows", json!("1")), + ("columns", json!("1")), + ("vertical", json!(format_measure(12.0, vu))), + ("horizontal", json!(format_measure(12.0, hu))), + ] { + d.fields.entry(k).or_insert(v); + } + let lang = app.ui.language.clone(); + let tr = |s: &str| crate::i18n::tr(&lang, s).to_string(); + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + crate::rtl::label(ui, egui::RichText::new(tr("Repeat")).font(semibold(12.0))); + egui::Grid::new("sr_repeat").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + let grid = d.b("grid"); + let fields: &[(&str, &str)] = if grid { &[("rows", "Rows:"), ("columns", "Columns:")] } else { &[("count", "Count:")] }; + for (k, l) in fields { + crate::rtl::label(ui, tr(l)); + text_field(ui, d, k, 70.0); + ui.end_row(); + } + crate::rtl::label(ui, ""); + check(ui, d, "grid", &tr("Create as a grid")); + ui.end_row(); + }); + ui.add_space(10.0); + crate::rtl::label(ui, egui::RichText::new(tr("Offset")).font(semibold(12.0))); + ui.horizontal(|ui| { + for (k, l) in [("vertical", "Vertical:"), ("horizontal", "Horizontal:")] { + crate::rtl::label(ui, tr(l)); + text_field(ui, d, k, 80.0); + ui.add_space(12.0); + } + }); + }); + ui.add_space(32.0); + check(ui, d, "preview", &tr("Preview")); + }); + step_and_repeat_preview(app, d); + if let Some(st) = d.fields.get("status").and_then(Value::as_str) { + ui.add_space(6.0); + crate::rtl::label(ui, egui::RichText::new(st).color(crate::theme::Tokens::get(ui.ctx()).text_dim)); + } +} + +/// `edit.stepAndRepeat`'s params from the dialog: whole counts, offsets in the ruler units +/// (or any unit typed: "-0.75 in", "3p"). Empty fields are 1 copy and 1p0. +fn step_and_repeat_params(app: &DesignApp, d: &Dialog) -> Result { + let (hu, vu) = app.session.active().map_or((Unit::Picas, Unit::Picas), |s| (s.doc.settings.horizontal_units, s.doc.settings.vertical_units)); + let invalid = |s: &str| format!("{}: {}", crate::i18n::tr(&app.ui.language, "Invalid value"), s.trim()); + // Fields not shown yet (a dialog confirmed over the control channel) take the defaults. + let whole = |k: &str| { + let s = d.s(k); + if s.trim().is_empty() { Ok(1) } else { s.trim().parse::().map_err(|_| invalid(&s)) } + }; + let measure = |k: &str, u: Unit| { + let s = d.s(k); + if s.trim().is_empty() { + return Ok(12.0); + } + parse_measure(&s, u).ok().filter(|v| v.is_finite()).ok_or_else(|| invalid(&s)) + }; + let (dx, dy) = (measure("horizontal", hu)?, measure("vertical", vu)?); + Ok(if d.b("grid") { + json!({"rows": whole("rows")?, "columns": whole("columns")?, "dx": dx, "dy": dy}) + } else { + json!({"count": whole("count")?, "dx": dx, "dy": dy}) + }) +} + +/// Preview on: the copies show on the page (redone when a field changes); off: they go. +fn step_and_repeat_preview(app: &mut DesignApp, d: &mut Dialog) { + let shown = d.fields.get(PREVIEWED).cloned(); + if !d.b("preview") { + if shown.is_some() { + app.session.cancel_interaction(); + d.fields.remove(PREVIEWED); + d.fields.remove("status"); + } + return; + } + let params = match step_and_repeat_params(app, d) { + Ok(p) => p, + Err(e) => { + d.fields.insert("status".into(), json!(e)); + return; + } + }; + if shown.as_ref() == Some(¶ms) { + return; + } + if shown.is_none() + && let Err(e) = app.session.begin_interaction("Step and Repeat") + { + d.fields.insert("status".into(), json!(e.to_string())); + return; + } + match app.session.preview("edit.stepAndRepeat", params.clone()) { + Ok(()) => d.fields.remove("status"), + Err(e) => d.fields.insert("status".into(), json!(e.to_string())), + }; + d.fields.insert(PREVIEWED.into(), params); +} + /// New Cross-Reference: link to a paragraph (paragraph styles on the left, their paragraphs on /// the right) or a text anchor, with a cross-reference format. fn insert_xref(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { @@ -3744,6 +3879,81 @@ fn find_font(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { mod tests { use super::*; + #[test] + fn step_and_repeat_dialog_previews_and_makes_the_copies_as_one_undo_step() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [72, 600, 540, 640]})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + let open = |app: &mut DesignApp| { + crate::menus::activate(app, "edit.stepAndRepeat", &Value::Null); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some(STEP_AND_REPEAT)); + }; + let set = |app: &mut DesignApp, k: &str, v: Value| { + app.ui.dialog.as_mut().unwrap().fields.insert(k.into(), v); + }; + let items = |app: &DesignApp| app.session.doc().unwrap().doc.spreads[0].items.len(); + let undo = |app: &DesignApp| app.session.doc().unwrap().history.undo.len(); + let (n0, u0) = (items(&app), undo(&app)); + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0)); + open(&mut app); + dialog_frame(&mut app, &ctx, screen, vec![]); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + for expected in ["Step and Repeat", "Repeat", "Count:", "Create as a grid", "Offset", "Vertical:", "Horizontal:", "Preview", "OK", "Cancel"] { + visible_label(&labels, screen, expected); + } + // Preview: the copies show, and change with the fields, without an undo step. + set(&mut app, "count", json!("5")); + set(&mut app, "vertical", json!("-0.75 in")); + set(&mut app, "horizontal", json!("0")); + set(&mut app, "preview", json!(true)); + dialog_frame(&mut app, &ctx, screen, vec![]); + assert_eq!((items(&app), undo(&app)), (n0 + 5, u0)); + set(&mut app, "count", json!("3")); + dialog_frame(&mut app, &ctx, screen, vec![]); + assert_eq!(items(&app), n0 + 3); + set(&mut app, "preview", json!(false)); + dialog_frame(&mut app, &ctx, screen, vec![]); + assert_eq!(items(&app), n0, "Preview off takes them away"); + set(&mut app, "preview", json!(true)); + dialog_frame(&mut app, &ctx, screen, vec![]); + assert_eq!(items(&app), n0 + 3); + // Cancel puts the document back. + cancel(&mut app); + assert!(app.ui.dialog.is_none()); + assert_eq!((items(&app), undo(&app)), (n0, u0)); + // OK after a preview makes the copies once, as one undo step, 0.75 in apart. + app.run("selection.set", json!({"ids": [id]})).unwrap(); + open(&mut app); + set(&mut app, "count", json!("2")); + set(&mut app, "vertical", json!("-0.75 in")); + set(&mut app, "horizontal", json!("0")); + set(&mut app, "preview", json!(true)); + dialog_frame(&mut app, &ctx, screen, vec![]); + let r = confirm(&mut app).unwrap(); + assert_eq!((items(&app), undo(&app)), (n0 + 2, u0 + 1)); + let last = r["ids"][1].as_u64().unwrap(); + let top = app.session.doc().unwrap().doc.item(designcraft_doc::ItemId(last)).unwrap().bounds().min_y(); + assert!((top - (600.0 - 108.0)).abs() < 1e-6, "{top}"); + // Create as a grid: Rows × Columns cells, the original being one. + app.run("selection.set", json!({"ids": [id]})).unwrap(); + open(&mut app); + set(&mut app, "grid", json!(true)); + set(&mut app, "rows", json!("2")); + set(&mut app, "columns", json!("3")); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + visible_label(&labels, screen, "Rows:"); + visible_label(&labels, screen, "Columns:"); + assert_eq!(confirm(&mut app).unwrap()["created"], 5); + // A value that isn't a measure keeps the dialog open and says so. + open(&mut app); + set(&mut app, "vertical", json!("up")); + assert!(confirm(&mut app).is_err()); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!(d.s("status"), "Invalid value: up"); + } + fn dialog_context(language: &str) -> egui::Context { let ctx = egui::Context::default(); crate::theme::install_fonts(&ctx, language); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index b06f532..6893eb5 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -750,6 +750,14 @@ const TABLE: &[(&str, [&str; 5])] = &[ ), ("Apply Cell Style", ["Apply Cell Style", "Apply Cell Style", "Apply Cell Style", "Apply Cell Style", "应用单元格样式"]), ("Apply Color", ["Apply Color", "Apply Color", "Apply Color", "Apply Color", "应用颜色"]), + ("Repeat", ["Wiederholen", "Répéter", "Repetir", "繰り返し", "重复"]), + ("Count:", ["Anzahl:", "Nombre :", "Recuento:", "繰り返し数:", "计数:"]), + ("Create as a grid", ["Als Raster erstellen", "Créer en tant que grille", "Crear como cuadrícula", "グリッドとして作成", "创建为网格"]), + ("Rows:", ["Zeilen:", "Rangées :", "Filas:", "行:", "行:"]), + ("Columns:", ["Spalten:", "Colonnes :", "Columnas:", "列:", "列:"]), + ("Vertical:", ["Vertikal:", "Vertical :", "Vertical:", "垂直方向:", "垂直:"]), + ("Horizontal:", ["Horizontal:", "Horizontal :", "Horizontal:", "水平方向:", "水平:"]), + ("Invalid value", ["Ungültiger Wert", "Valeur non valide", "Valor no válido", "無効な値", "无效值"]), ("Apply Condition", ["Apply Condition", "Apply Condition", "Apply Condition", "Apply Condition", "应用条件"]), ( "Apply Parent to Pages…", diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 75f01bf..3cdc44a 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -1046,6 +1046,14 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Formatting affects container (J)", "تطبيق التنسيق على الحاوية (J)"), ("Formatting affects text (J)", "تطبيق التنسيق على النص (J)"), ("Apply Color", "تطبيق اللون"), + ("Repeat", "تكرار"), + ("Count:", "العدد:"), + ("Create as a grid", "إنشاء كشبكة"), + ("Rows:", "الصفوف:"), + ("Columns:", "الأعمدة:"), + ("Vertical:", "رأسي:"), + ("Horizontal:", "أفقي:"), + ("Invalid value", "قيمة غير صالحة"), ("Apply None", "إزالة اللون"), ("Height Only", "الارتفاع فقط"), ("Width Only", "العرض فقط"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 73e3616..9ffc64c 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -417,6 +417,14 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Apply Attributes (Eyedropper)", "Applica attributi (Contagocce)"), ("Apply Cell Style", "Applica stile di cella"), ("Apply Color", "Applica colore"), + ("Repeat", "Ripeti"), + ("Count:", "Conteggio:"), + ("Create as a grid", "Crea come griglia"), + ("Rows:", "Righe:"), + ("Columns:", "Colonne:"), + ("Vertical:", "Verticale:"), + ("Horizontal:", "Orizzontale:"), + ("Invalid value", "Valore non valido"), ("Apply Condition", "Applica condizione"), ("Apply Parent to Pages…", "Applica pagina mastro alle pagine…"), ("ArtCraft App Page…", "Pagina dell'app ArtCraft…"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 47b1cfb..01c72ce 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -419,6 +419,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Apply Attributes (Eyedropper)", "属性を適用 (スポイト)"), ("Apply Cell Style", "セルスタイルを適用"), ("Apply Color", "カラーを適用"), + ("Repeat", "繰り返し"), + ("Count:", "繰り返し数:"), + ("Create as a grid", "グリッドとして作成"), + ("Rows:", "行:"), + ("Invalid value", "無効な値"), ("Apply Condition", "条件を適用"), ("Apply Parent to Pages…", "ページに親ページを適用…"), ("ArtCraft App Page…", "ArtCraft アプリページ…"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index c4f141d..25b7269 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -1050,6 +1050,14 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Formatting affects container (J)", "Formatação afeta o contêiner (J)"), ("Formatting affects text (J)", "Formatação afeta o texto (J)"), ("Apply Color", "Aplicar cor"), + ("Repeat", "Repetir"), + ("Count:", "Contagem:"), + ("Create as a grid", "Criar como grade"), + ("Rows:", "Linhas:"), + ("Columns:", "Colunas:"), + ("Vertical:", "Vertical:"), + ("Horizontal:", "Horizontal:"), + ("Invalid value", "Valor inválido"), ("Apply None", "Aplicar nenhum"), ("Height Only", "Somente altura"), ("Width Only", "Somente largura"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 10396ce..4158c2f 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -420,6 +420,14 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Apply Attributes (Eyedropper)", "Застосувати атрибути (піпетка)"), ("Apply Cell Style", "Застосувати стиль клітинки"), ("Apply Color", "Застосувати колір"), + ("Repeat", "Повторення"), + ("Count:", "Кількість:"), + ("Create as a grid", "Створити як сітку"), + ("Rows:", "Рядки:"), + ("Columns:", "Стовпці:"), + ("Vertical:", "Вертикально:"), + ("Horizontal:", "Горизонтально:"), + ("Invalid value", "Неприпустиме значення"), ("Apply Condition", "Застосувати умову"), ("Apply Parent to Pages…", "Застосувати шаблон до сторінок…"), ("ArtCraft App Page…", "Сторінка програми ArtCraft…"), diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b38fab8..eb7d7e5 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -276,7 +276,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Tables (M8): tables anchored in stories (header/footer/body rows, merged cells, per-edge strokes, fills + alternating fills, insets, vertical justification, at-least/exact row heights); composed into the text column (columns scale to fit, rows break across columns/frames with repeating headers/footers, overset); rendered, exported to PDF (real text) and IDML (export + import); Table menu, Table panel, Create Table dialog, caret/typing/Tab navigation and cell selection in cells; `table.*` commands. - Hyperlinks (text or frames → URL / e-mail / page) and bookmarks, exported as PDF link annotations and outline. - EPUB 3 (reflowable) export: stories in reading order, CSS from paragraph/character styles, images, navigation (`file.exportEpub`, CLI `--export x.epub`). -- Data Merge (one linked CSV, TSV, text, or Excel file; text, image, QR, and hyperlink placeholders; preview; a new document with one record per page or records tiled on a single page), spell checking (public-domain Moby list + document dictionary, suggestions), Step and Repeat (count or grid), snippets, object styles, Numbering & Section Options. +- Data Merge (one linked CSV, TSV, text, or Excel file; text, image, QR, and hyperlink placeholders; preview; a new document with one record per page or records tiled on a single page), spell checking (public-domain Moby list + document dictionary, suggestions), Step and Repeat (count or grid, with a previewing dialog), snippets, object styles, Numbering & Section Options. - Footnotes: Type › Insert Footnote (caret moves into the note), Document Footnote Options (numbering style incl. symbols, start/restart per page/spread/section, prefix/suffix, reference position/character style, paragraph style, separator, spacing, first baseline, rule above); notes composed at the bottom of the referencing column with the body text making room; edited in place on the canvas; rendered, exported to PDF (real text) and IDML (InDesign's structure, verified opening in InDesign 2026) and imported from IDML; `footnote.*` commands. - Paragraph Rules: Rule Above / Rule Below (on, weight, colour, tint, column or text width, offset, left/right indent) set in Type › Paragraph Rules…, the Paragraph panel menu and Paragraph Style Options › Paragraph Rules; inherited style values shown, and `type.para` / `style.paragraph.edit` change only the rule fields named. - Cross-references and text anchors: Insert Cross-Reference (paragraph by style, or text anchor), 11 formats with InDesign's building blocks (full/partial paragraph, paragraph text/number, page number, anchor name, chapter, file name) plus user formats; resolved live at composition — never out of date, no Update step; unresolved destinations show `??`; PDF link annotations to the destination page; IDML export/import as InDesign cross-reference sources, hyperlinks, text destinations and formats (verified in InDesign 2026); `xref.*` / `anchor.*` commands.