Browse / github.com/storytold/designcraft / patch
Table menu in InDesign's order, with Insert Table… (⌥⇧⌘T) and its dialog laid out as InDesign's
Table menu in InDesign's order, with Insert Table… (⌥⇧⌘T) and its dialog laid out as InDesign's Found while replaying Adobe's tutorial *How to make a table in InDesign* (Matthew Pizzi, Train Simple; adobe.com/learn/indesign/web/indesign-creating-a-table). In the lesson, a double-click in a text frame and Table › Insert Table open the dialog: 5 body rows, 3 columns, OK. DesignCraft's Table menu and dialog looked different from InDesign's: - **Menu wording.** The first item read *Create Table…*. With a text insertion point, InDesign's reads **Insert Table… ⌥⇧⌘T**. - **Menu order.** DesignCraft's was its own: - Insert, Delete and Select came before Cell Options; - Distribute Columns ca
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- moppeeer (imported from pull request #1676, open)
- Base
dcce40fb8731213c1f8e31cdd23444995afc62ce- Size
- +233 −41 in 6 files
- Review
- pending
- Shared
- 2026-10-28
- Id
306d752f31e81f4a76b10fbe
How it went
No other operator has reported using it yet.
Diff
raw · ROADMAP.md crates/engine/src/cmd/table.rs crates/engine/src/tests_table.rs crates/ui-egui/src/dialogs.rs crates/ui-egui/src/menus.rs docs/target-app-parity.md
@@ -405,6 +405,7 @@ Newest first. - **2026-10-22** · Autoflow (Shift-click with the loaded text cursor, autoflow on Place, `text.thread {autoflow}`) fills the pages that follow before it adds any, as InDesign does: one frame per column on each following page, whatever else the page holds, then new pages at the end of the document. It inserted pages right after the story's last frame, in front of the document's own pages. Frames it set past the end of the text go again; a page the document had stays, and a page autoflow added only for such a frame goes with it. Smart Text Reflow of the primary text frame is unchanged. No readiness percentage change. - **2026-10-16** · Edit › Keyboard Shortcuts imports a set saved by InDesign's Show Set (.txt, Windows or Mac key names, UTF-8 or UTF-16): matched commands take its Default shortcuts or lose theirs, other commands give up keys it takes, and a report lists what it changed and the items it could not match (`window.importShortcuts`, #1180). Tool keys stay fixed. No readiness percentage change. - **2026-10-23** · Translations: a missing string shows in English and fails no test; `cargo xtask i18n-report`, run by `cargo xtask ci`, prints the missing strings per language. Tests keep duplicate-key and placeholder checks and the language mechanics (lookup, context captions, plural captions, right-to-left). No readiness percentage change.+- **2026-10-23** · Tables: the Table menu follows InDesign's order (Cell Options before Insert, Delete and Select; Merge, Unmerge and Split before Convert Cell Type and Convert Rows; Distribute Rows Evenly before Distribute Columns Evenly), and its first item is Insert Table… with InDesign's Cmd+Alt+Shift+T, which opens the dialog (the shortcut ran `table.insert` with its defaults, behind the dialog). The Table panel stays in Window › Type & Tables (Shift+F9). The Insert Table dialog is laid out as InDesign's: Body Rows and Columns, then Header and Footer Rows, as numbers with spinners and right-aligned labels, and a Table Style applied to the new table. No readiness percentage change. - **2026-10-22** · Hyphenation Settings, as in InDesign: the Paragraph panel menu's Hyphenation… (`app.hyphenationDialog`) sets the selected paragraphs' Hyphenate, Words with at Least, After First, Before Last, Hyphen Limit, Hyphenation Zone, the Better Spacing ↔ Fewer Hyphens slider (InDesign's 0–10 steps), Hyphenate Capitalized Words, Last Word and Across Column, with Preview, as one undo step; Paragraph Style Options › Hyphenation has the same form. The panel menu lists its items in InDesign's order. Replicated from a hyphenation tutorial: the same text in Minion Pro breaks into the same lines as InDesign's with hyphenation on, off, and at Fewer Hyphens. No readiness percentage change. - **2026-10-22** · Hyphenate Across Column (Hyphenation Settings; IDML HyphenateAcrossColumns) is applied: with it off, a paragraph whose line ending in an automatic hyphen would end a column or frame is re-composed without that hyphenation point, so the word moves on whole, as in InDesign. Opened and imported files keep their own setting, so their layouts don't change. No readiness percentage change. - **2026-10-22** · The Paragraph panel is laid out as InDesign's: the nine alignment buttons in groups; indent, spacing and drop cap fields by their icons in two columns, with Space Between Paragraphs Using Same Style (Ignore or a space); Shading and Border with the Paragraph Borders and Shading button and colour; Hyphenate with Do Not Align / Align to Baseline Grid. Its panel menu follows InDesign's order: Paragraph and Single-line Composer, Only Align First Line to Grid, Balance Ragged Lines, Optical Margin Alignment, Justification…, Keep Options…, Span Columns…, Hyphenation…, Drop Caps and Nested Styles…, GREP Styles… (these three now open for the selected paragraphs), Paragraph Rules…, Paragraph Borders and Shading…, Bullets and Numbering…, Restart/Continue Numbering, Define Lists… and the paragraph direction. Convert Bullets and Numbering to Text is listed but not available yet. No readiness percentage change.@@ -88,8 +88,9 @@ fn table_specs() -> Vec<CommandSpec> { "table.insert", "Create Table", [],- Some("Cmd+Alt+Shift+T"),- "{rows?: body rows (4), cols? | columns? (4), headerRows? (0), footerRows? (0), width?} — at the text insertion point",+ // InDesign's Cmd+Alt+Shift+T opens the Insert Table dialog (`app.insertTableDialog`).+ None,+ "{rows?: body rows (4, at most 500), cols? | columns? (4, at most 200), headerRows? (0), footerRows? (0, each at most 100), tableStyle?: name, width?} — at the text insertion point, styled in the same step", has_text, insert ),@@ -563,15 +564,38 @@ fn para_direction(d: &Document, story: StoryId, offset: usize) -> designcraft_do .map_or(designcraft_doc::TextDirection::LeftToRight, |pf| d.styles.resolve_para(pf).0.direction) } +/// The most body rows, columns, and header or footer rows `table.insert` makes (the Insert Table+/// dialog's limits too).+pub const MAX_BODY_ROWS: u64 = 500;+pub const MAX_COLUMNS: u64 = 200;+pub const MAX_HEADER_FOOTER_ROWS: u64 = 100;++/// A command's `tableStyle`: none for an absent one or [Basic Table] (a new table's look), else the+/// resolved style and the cell styles it names. Anything but an existing style's name is an error.+fn table_style_param(s: &Session, p: &Value, cmd: &str) -> Result<Option<(designcraft_doc::TableStyle, Vec<designcraft_doc::CellStyle>)>> {+ let name = match p.get("tableStyle") {+ None | Some(Value::Null) => return Ok(None),+ Some(Value::String(n)) if n == designcraft_doc::BASIC_TABLE => return Ok(None),+ Some(Value::String(n)) => n,+ Some(_) => return Err(bad(cmd, "`tableStyle` must be a table style's name")),+ };+ let styles = &s.doc()?.doc.styles;+ if !styles.table.iter().any(|x| x.name == *name) {+ return Err(bad(cmd, format!("no table style `{name}`")));+ }+ Ok(Some((styles.resolve_table_style(name), styles.cell.clone())))+}+ fn insert(s: &mut Session, p: &Value) -> Result<Value> { let t = s.doc()?.selection.text.ok_or_else(|| bad("table.insert", "no insertion point"))?; if t.cell.is_some() { return Err(bad("table.insert", "tables can't be nested in cells")); }- let rows = p.get("rows").and_then(Value::as_u64).unwrap_or(4).clamp(1, 500) as usize;- let cols = p.get("cols").or_else(|| p.get("columns")).and_then(Value::as_u64).unwrap_or(4).clamp(1, 200) as usize;- let header = p.get("headerRows").and_then(Value::as_u64).unwrap_or(0).min(100) as usize;- let footer = p.get("footerRows").and_then(Value::as_u64).unwrap_or(0).min(100) as usize;+ let rows = p.get("rows").and_then(Value::as_u64).unwrap_or(4).clamp(1, MAX_BODY_ROWS) as usize;+ let cols = p.get("cols").or_else(|| p.get("columns")).and_then(Value::as_u64).unwrap_or(4).clamp(1, MAX_COLUMNS) as usize;+ let header = p.get("headerRows").and_then(Value::as_u64).unwrap_or(0).min(MAX_HEADER_FOOTER_ROWS) as usize;+ let footer = p.get("footerRows").and_then(Value::as_u64).unwrap_or(0).min(MAX_HEADER_FOOTER_ROWS) as usize;+ let style = table_style_param(s, p, "table.insert")?; let width = p.get("width").and_then(Value::as_f64).unwrap_or_else(|| column_width(s, &t)); s.edit(|d, sel| { let id = d.alloc();@@ -585,6 +609,9 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> { c.text.paras[0] = designcraft_doc::ParaFormat { table: None, ..pf.clone() }; c.text.chars[0].format = cf.clone(); }+ if let Some((ts, cells)) = &style {+ ts.apply_to(&mut table, cells);+ } let r = t.range(); st.delete(r.clone()); st.insert_table(r.start, table);@@ -426,6 +426,26 @@ fn rows_columns_merge_and_options() { assert_eq!(st.doc.story(sid).unwrap().text, "Intro"); } +/// `table.insert` styles the table in the same step; a style that isn't there, or isn't a name,+/// leaves the story alone. Its dimensions stop at the documented limits.+#[test]+fn insert_table_with_a_style_is_one_step() {+ let (mut s, sid, _) = session_with_frame();+ s.execute("style.table.create", &json!({"name": "Grid"})).unwrap();+ for bad in [json!("Missing"), json!(7)] {+ assert!(s.execute("table.insert", &json!({"tableStyle": bad})).is_err(), "{bad}");+ }+ assert!(s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().tables.is_empty());+ let r = s.execute("table.insert", &json!({"rows": 900, "cols": 300, "headerRows": 150, "tableStyle": "Grid"})).unwrap();+ let t = s.execute("table.get", &json!({"table": r["table"]})).unwrap();+ assert_eq!(t["style"], "Grid");+ let n = |k: &str| t[k].as_array().unwrap().len() as u64;+ use crate::cmd::table::{MAX_BODY_ROWS, MAX_COLUMNS, MAX_HEADER_FOOTER_ROWS};+ assert_eq!((n("rows"), n("columns")), (MAX_BODY_ROWS + MAX_HEADER_FOOTER_ROWS, MAX_COLUMNS));+ s.execute("edit.undo", &json!({})).unwrap();+ assert!(s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().tables.is_empty(), "one undo");+}+ #[test] fn convert_text_to_table_and_back() { let (mut s, sid, _) = session_with_frame();@@ -138,6 +138,65 @@ fn text_field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, w: f32) { } } +/// The dialog's chosen table style: none when unset, null or empty (a new table's [Basic Table]).+fn table_style_field(d: &Dialog) -> Option<String> {+ d.fields.get("tableStyle").and_then(Value::as_str).filter(|s| !s.is_empty()).map(str::to_string)+}++/// The dialog's `tableStyle` as the table commands take it: null for none, the style's name, or+/// a value that is neither (set through `ui.dialog.set`) as it is, for the command to refuse.+fn table_style_value(d: &Dialog) -> Value {+ match d.fields.get("tableStyle") {+ None | Some(Value::String(_)) => table_style_field(d).map_or(Value::Null, Value::String),+ Some(v) => v.clone(),+ }+}++/// Table › Insert Table, laid out as InDesign's: Table Dimensions (body rows and columns, then+/// header and footer rows, each a number with a spinner, labels on the right edge of their+/// column) and the Table Style to apply.+fn insert_table_dialog(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {+ let lang = &app.ui.language;+ crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(lang, "Table Dimensions")).font(semibold(12.0)));+ use designcraft_engine::cmd::table::{MAX_BODY_ROWS, MAX_COLUMNS, MAX_HEADER_FOOTER_ROWS};+ // `table.insert`'s limits, so the table made is the one the fields show.+ let rows = [+ ("bodyRows", crate::i18n::tr(lang, "Body Rows"), 1.0, MAX_BODY_ROWS),+ ("columns", crate::i18n::tr_context(lang, "Columns", "table"), 1.0, MAX_COLUMNS),+ ("headerRows", crate::i18n::tr(lang, "Header Rows"), 0.0, MAX_HEADER_FOOTER_ROWS),+ ("footerRows", crate::i18n::tr(lang, "Footer Rows"), 0.0, MAX_HEADER_FOOTER_ROWS),+ ];+ // Body rows and columns, then (after a gap) header and footer rows; one label width for both.+ for (g, pair) in rows.chunks(2).enumerate() {+ if g > 0 {+ ui.add_space(8.0);+ }+ egui::Grid::new(("ins_table", g)).num_columns(2).min_col_width(100.0).spacing([8.0, 6.0]).show(ui, |ui| {+ for (key, label, min, max) in pair {+ ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| crate::rtl::label(ui, format!("{label}:")));+ let field = crate::widgets::NumField::number(key, d.n(key), "", 0).width(70.0).range(*min, *max as f64).spinner();+ if let Some(v) = field.show(ui) {+ d.fields.insert((*key).into(), json!(v.round()));+ }+ ui.end_row();+ }+ });+ }+ ui.add_space(8.0);+ let names: Vec<String> = app.session.active().map(|st| st.doc.styles.table.iter().map(|s| s.name.clone()).collect()).unwrap_or_default();+ let cur = table_style_field(d).unwrap_or_else(|| designcraft_doc::BASIC_TABLE.to_string());+ ui.horizontal(|ui| {+ crate::rtl::label(ui, format!("{}:", crate::i18n::tr(lang, "Table Style")));+ egui::ComboBox::from_id_salt("ins_table_style").selected_text(&cur).width(160.0).show_ui(ui, |ui| {+ for n in &names {+ if ui.selectable_label(*n == cur, n).clicked() {+ d.fields.insert("tableStyle".into(), json!(n));+ }+ }+ });+ });+}+ /// Extract once when opened. Cancel is read-only; confirmation adds exactly these colours. pub(crate) fn open_color_theme(app: &mut DesignApp, params: Value) { match app.run("color.extractTheme", params) {@@ -2163,7 +2222,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "newDocument" => crate::i18n::tr(&app.ui.language, "New Document"), "frameSize" => crate::i18n::tr(&app.ui.language, "Rectangle"), "goToPage" => crate::i18n::tr(&app.ui.language, "Go to Page"),- "insertTable" => crate::i18n::tr(&app.ui.language, "Create Table"),+ "insertTable" => crate::i18n::tr(&app.ui.language, "Insert Table…").trim_end_matches('…').trim_end(), "cellOptions" => crate::i18n::tr(&app.ui.language, "Cell Options"), "createGuides" => crate::i18n::tr(&app.ui.language, "Create Guides"), "marginsAndColumns" => crate::i18n::tr(&app.ui.language, "Margins and Columns"),@@ -2231,7 +2290,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { let available = (ctx.content_rect().size() - frame_margin - egui::vec2(16.0, 16.0)).max(egui::Vec2::splat(1.0)); let preferred_width: f32 = match d.id.as_str() { "alert" => 440.0,- "closeDocument" | "updateLink" | "deletePages" | "newWorkspace" | "pagesPanelOptions" => 380.0,+ "closeDocument" | "updateLink" | "deletePages" | "newWorkspace" | "pagesPanelOptions" | "insertTable" => 380.0, "newDocument" => 960.0, "hyphenation" => 560.0, "numberingSection" => 560.0,@@ -2923,21 +2982,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { }); } "ruby" => style_cjk::ruby_dialog(app, ui, &mut d),- "insertTable" => {- crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Table Dimensions")).font(semibold(12.0)));- egui::Grid::new("ins_table").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| {- crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Body Rows"));- text_field(ui, &mut d, "bodyRows", 60.0);- crate::rtl::label(ui, crate::i18n::tr_context(&app.ui.language, "Columns", "table"));- text_field(ui, &mut d, "columns", 60.0);- ui.end_row();- crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Header Rows"));- text_field(ui, &mut d, "headerRows", 60.0);- crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Footer Rows"));- text_field(ui, &mut d, "footerRows", 60.0);- ui.end_row();- });- }+ "insertTable" => insert_table_dialog(app, ui, &mut d), "cellOptions" => cell_options(app, ui, &mut d), "createGuides" => create_guides_dialog(app, ui, &mut d), "marginsAndColumns" => margins_and_columns_dialog(app, ui, &mut d),@@ -3251,11 +3296,22 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> { None => Err(format!("no page \"{typed}\"")), } }- "insertTable" => app.run(- "table.insert",- json!({"rows": d.n("bodyRows").unwrap_or(4.0).max(1.0) as u64, "cols": d.n("columns").unwrap_or(4.0).max(1.0) as u64,- "headerRows": d.n("headerRows").unwrap_or(0.0).max(0.0) as u64, "footerRows": d.n("footerRows").unwrap_or(0.0).max(0.0) as u64}),- ),+ "insertTable" => {+ // The table and its style in one step; a style that is gone leaves the document alone,+ // and the dialog stays open to be corrected.+ let style = table_style_value(&d);+ let r = app.run(+ "table.insert",+ json!({"rows": d.n("bodyRows").unwrap_or(4.0).max(1.0) as u64, "cols": d.n("columns").unwrap_or(4.0).max(1.0) as u64,+ "headerRows": d.n("headerRows").unwrap_or(0.0).max(0.0) as u64, "footerRows": d.n("footerRows").unwrap_or(0.0).max(0.0) as u64,+ "tableStyle": style}),+ );+ if let Err(e) = &r {+ app.status(e.clone());+ app.ui.dialog = Some(d);+ }+ r+ } "numberingSection" => { // On an error the dialog stays open to be corrected. let r = crate::section_options::confirm(app, &d);@@ -10370,6 +10426,52 @@ mod tests { app.session.active().and_then(|st| st.doc.styles.char_style(name).cloned()) } + /// Insert Table: the dimensions as typed, and the Table Style chosen is applied to the new+ /// table; [Basic Table] (the default) leaves it unstyled.+ #[test]+ fn insert_table_applies_its_dimensions_and_table_style() {+ let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());+ app.run("file.new", json!({})).unwrap();+ app.run("style.table.create", json!({"name": "Grid"})).unwrap();+ for (style, expected) in [("Grid", "Grid"), (designcraft_doc::BASIC_TABLE, "")] {+ app.run("frame.create", json!({"rect": [72, 72, 372, 372], "content": "text"})).unwrap();+ assert_eq!(app.run("app.insertTableDialog", json!({})).unwrap(), Value::Null);+ for (field, value) in [("bodyRows", json!(5)), ("columns", json!(3)), ("headerRows", json!(1)), ("tableStyle", json!(style))] {+ ctl(&mut app, "ui.dialog.set", json!({"field": field, "value": value}));+ }+ assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true);+ let t = app.run("table.get", json!({})).unwrap();+ let len = |k: &str| t[k].as_array().map_or(0, Vec::len);+ assert_eq!((len("rows"), len("columns"), t["headerRows"].as_u64()), (6, 3, Some(1)), "{t}");+ assert_eq!(t["style"].as_str().unwrap_or(""), expected, "{t}");+ // One Insert Table, one undo.+ app.run("edit.undo", json!({})).unwrap();+ assert!(app.run("table.get", json!({})).is_err(), "the table is gone");+ }+ // A null style is no style: the table is made, unstyled.+ app.run("frame.create", json!({"rect": [72, 72, 372, 372], "content": "text"})).unwrap();+ app.run("app.insertTableDialog", json!({})).unwrap();+ ctl(&mut app, "ui.dialog.set", json!({"field": "tableStyle", "value": null}));+ assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true);+ assert_eq!(app.run("table.get", json!({})).unwrap()["style"].as_str().unwrap_or(""), "");+ app.run("edit.undo", json!({})).unwrap();+ // A style that no longer exists changes nothing.+ let tables = |app: &DesignApp| app.session.active().unwrap().doc.stories.values().map(|s| s.tables.len()).sum::<usize>();+ app.run("frame.create", json!({"rect": [72, 72, 372, 372], "content": "text"})).unwrap();+ app.run("app.insertTableDialog", json!({})).unwrap();+ ctl(&mut app, "ui.dialog.set", json!({"field": "tableStyle", "value": "Gone"}));+ assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], false);+ assert_eq!(tables(&app), 0);+ let d = app.ui.dialog.as_ref().expect("the dialog stays open to be corrected");+ assert_eq!((d.id.as_str(), d.s("tableStyle")), ("insertTable", "Gone".to_string()));+ // So does a style that isn't a name: the command refuses it, rather than making the table+ // unstyled.+ ctl(&mut app, "ui.dialog.set", json!({"field": "tableStyle", "value": 42}));+ assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], false);+ assert_eq!(tables(&app), 0);+ assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("insertTable"), "the dialog stays open");+ }+ #[test] fn character_style_options_set_exactly_the_edited_attributes() { let mut app = styled_app();@@ -10544,7 +10646,7 @@ mod tests { assert!((centred.center() - egui::pos2(600.0, 400.0)).length() < 1.0, "{centred:?}"); assert!(centred.width() < 1000.0 && centred.height() < 700.0, "the area is the dialog, not the backdrop: {centred:?}"); - let title = h.get_by_label("Create Table").rect().center();+ let title = h.get_by_label("Insert Table").rect().center(); drag(&mut h, title, title + vec2(100.0, 60.0)); let moved = rect(&h); assert!((moved.min - centred.min - vec2(100.0, 60.0)).length() < 2.0, "{centred:?} -> {moved:?}");@@ -10554,7 +10656,7 @@ mod tests { assert_eq!(rect(&h), moved, "a drag in the body leaves the dialog in place"); // The title row stays inside the top edge.- let title = h.get_by_label("Create Table").rect().center();+ let title = h.get_by_label("Insert Table").rect().center(); drag(&mut h, title, title - vec2(0.0, 1000.0)); assert!(rect(&h).top().abs() < 1.0, "{:?}", rect(&h)); @@ -10566,7 +10668,7 @@ mod tests { assert!((rect(&h).center() - centred.center()).length() < 1.0, "{:?}", rect(&h)); // Past the left edge, about 100 px of the title row stays inside.- let title = h.get_by_label("Create Table").rect().center();+ let title = h.get_by_label("Insert Table").rect().center(); drag(&mut h, title, title - vec2(900.0, 0.0)); assert!((rect(&h).right() - 100.0).abs() < 2.0, "{:?}", rect(&h)); }@@ -97,7 +97,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ "{path | text} — a set saved by InDesign's Show Set (.txt): matched commands take its Default shortcuts (or none), other commands lose keys it takes → {set, imported, changed, conflictsCleared, skipped (an earlier item whose keys a later one took), unmatched, unmatchedWithoutShortcut, unreadable}", ), ("app.findChange", "Find/Change…", Some("Cmd+F"), "{}"),- ("app.insertTableDialog", "Create Table…", None, "{} — Insert Table dialog (body/header/footer rows, columns)"),+ ("app.insertTableDialog", "Insert Table…", Some("Cmd+Alt+Shift+T"), "{} — Insert Table dialog (body/header/footer rows, columns, table style)"), ( "app.cellOptionsDialog", "Strokes and Fills…",@@ -744,12 +744,17 @@ pub const MENUS: &[(&str, &[&str])] = &[ ], ), (+ // InDesign's order. The Table panel is in Window › Type & Tables (Shift+F9). "Table", &[ "ui:app.insertTableDialog", "cmd:table.convertFromText", "cmd:table.convertToText", "-",+ ">Cell Options",+ "ui:app.cellOptionsDialog",+ "<",+ "-", ">Insert", "cmd:table.insertRowAbove", "cmd:table.insertRowBelow",@@ -767,11 +772,10 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:table.selectTable", "<", "-",- ">Cell Options",- "ui:app.cellOptionsDialog",- "<", "cmd:table.merge", "cmd:table.unmerge",+ "cmd:table.splitHorizontally",+ "cmd:table.splitVertically", ">Convert Cell Type", "ui:app.graphicCell|Convert Cell to Graphic Cell…", "cmd:table.textCell",@@ -781,12 +785,9 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:table.convertRowsToBody", "cmd:table.convertRowsToFooter", "<",- "cmd:table.splitHorizontally",- "cmd:table.splitVertically",- "cmd:table.distributeColumns",- "cmd:table.distributeRows", "-",- "ui:app.tablePanel",+ "cmd:table.distributeRows",+ "cmd:table.distributeColumns", ], ), (@@ -5446,6 +5447,47 @@ mod tests { } } + #[test]+ fn the_table_menu_follows_indesigns_order() {+ // InDesign's Table menu with a text insertion point (Adobe's "How to make a table").+ let table = menu_tree().into_iter().find(|(m, _)| *m == "Table").unwrap().1;+ let labels: Vec<String> = table+ .iter()+ .map(|i| match i {+ Item::Sep => "-".to_string(),+ Item::Sub(n, _) => format!("{n} >"),+ Item::Cmd { label, .. } => label.clone(),+ })+ .collect();+ let expected = [+ "Insert Table…",+ "Convert Text to Table",+ "Convert Table to Text",+ "-",+ "Cell Options >",+ "-",+ "Insert >",+ "Delete >",+ "Select >",+ "-",+ "Merge Cells",+ "Unmerge Cells",+ "Split Cell Horizontally",+ "Split Cell Vertically",+ "Convert Cell Type >",+ "Convert Rows >",+ "-",+ "Distribute Rows Evenly",+ "Distribute Columns Evenly",+ ];+ assert_eq!(labels, expected);+ // Cmd+Alt+Shift+T opens the dialog, as InDesign's; the engine command has no shortcut of+ // its own. The Table panel is in Window › Type & Tables.+ assert_eq!(ui_label("app.insertTableDialog").and_then(|c| c.1), Some("Cmd+Alt+Shift+T"));+ assert_eq!(designcraft_engine::find_command("table.insert").unwrap().shortcut, None);+ assert_eq!(ui_label("app.tablePanel").and_then(|c| c.1), Some("Shift+F9"));+ }+ #[test] fn the_type_menu_follows_indesigns_order() { // InDesign's Type menu, as Chris Nielsen opens it in "Numbering Page Sections" (2:09).@@ -280,7 +280,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Tools: Selection (click, marquee, move, Alt-duplicate, resize handles), Direct Selection, Type (draw frame, click caret, select, type; Option/Alt+Left/Right kerns at the caret or tracks a selection by the Kerning/Tracking increment, ×5 with Cmd/Ctrl; words move with Cmd+Left/Right on macOS and Ctrl+Left/Right elsewhere), Rectangle/Ellipse/Polygon (+ frame variants), Line, Hand, Zoom. - PDF export (krilla): real selectable text with embedded font subsets, DeviceCMYK/RGB + spot Separations, bleed boxes, crop/bleed marks + page info, pages or spreads, PDF/A-2b (PDF/X-4 output intent pending) — `file.exportPdf`, File › Export PDF…, `designcraft-cli run --export out.pdf`. File › Export PDF… now opens an "Export PDF" options dialog (General / Compression / Marks and Bleeds / Advanced) and remembers the last-used settings across exports and sessions. - IDML interchange (`designcraft-idml`): export and import of swatches, styles, fonts, preferences, parent spreads, spreads/pages, frames, groups, images (embedded or linked), formatted threaded stories; attributes a file leaves unset take InDesign's defaults (Keep Lines Together at start/end of paragraph, 12 pt corners, paragraph rules in Text Color, auto-sizing from the centre, numbered lists restarting in each story, alternating table fills Black 20 % / None; a tint of −1 is the colour's own; frames take the fill, stroke, corners and Text Frame Options they don't write from their object style chain, then [None]) — opens in InDesign 2026 and round-trips InDesign-exported files (`file.exportIdml`, `file.openIdml`, File → Export IDML…, CLI `--in x.idml` / `--export x.idml`).-- 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; Type tool drags of row and column borders (Shift keeps the table size, or scales every row/column on the outer edge; `table.resizeBorder`); `table.*` commands.+- 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 (in InDesign's order), Table panel, Insert Table dialog (with Table Style), caret/typing/Tab navigation and cell selection in cells; Type tool drags of row and column borders (Shift keeps the table size, or scales every row/column on the outer edge; `table.resizeBorder`); `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, with a previewing dialog), snippets, object styles, Numbering & Section Options.