Merge branch 'pr/573' into batch1

This commit is contained in:
Brandon Thomas 2026-10-13 02:23:12 -04:00
commit ea8dc959d1
10 changed files with 262 additions and 7 deletions

View File

@ -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.

View File

@ -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" => {

View File

@ -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<Value, String> {
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<Value, String> {
"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<Value, String> {
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::<u64>().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(&params) {
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);

View File

@ -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…",

View File

@ -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", "العرض فقط"),

View File

@ -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…"),

View File

@ -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 アプリページ…"),

View File

@ -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"),

View File

@ -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…"),

View File

@ -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.