diff --git a/ROADMAP.md b/ROADMAP.md
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -366,6 +366,8 @@ 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: Convert Text to Table… and Convert Table to Text… open InDesign's dialogs (they converted at once, with tabs and paragraphs only). Column and Row Separator take Tab, Comma, Paragraph or any characters typed; Number of Columns, available when both separators are the same, fills rows of that many cells (a list of paragraphs into two columns); Convert Text to Table also applies a Table Style. `table.convertFromText` takes `rowSeparator`, `columns` and `tableStyle`, and `table.convertToText` its separators. Convert Text to Table is available only with text selected outside a table. 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.
diff --git a/crates/doc/src/table.rs b/crates/doc/src/table.rs
--- a/crates/doc/src/table.rs
+++ b/crates/doc/src/table.rs
@@ -417,12 +417,15 @@ impl Table {
     pub fn from_strings(id: u64, data: &[Vec<String>], width: f64, para: &ParaFormat, fmt: &CharFormat) -> Table {
         let cols = data.iter().map(Vec::len).max().unwrap_or(1).max(1);
         let mut t = Table::new(id, data.len().max(1), cols, 0, 0, width);
-        for (r, row) in data.iter().enumerate() {
-            for (c, s) in row.iter().enumerate() {
+        // Every cell takes the text's format, the padding of a short row too.
+        for r in 0..t.nrows() {
+            for c in 0..t.ncols() {
                 let Some(cell) = t.cell_mut(r, c) else { continue };
                 cell.text = Story::with_text(StoryId(0), "", ParaFormat { table: None, ..para.clone() });
                 cell.text.chars[0].format = fmt.clone();
-                cell.text.insert(0, s);
+                if let Some(s) = data.get(r).and_then(|row| row.get(c)) {
+                    cell.text.insert(0, s);
+                }
             }
         }
         t
@@ -1011,14 +1014,19 @@ impl Table {
 
     /// Cell texts, rows separated by `\n`, cells by `\t` (Convert Table to Text, search, agents).
     pub fn plain_text(&self) -> String {
+        self.text_with("\t", "\n")
+    }
+
+    /// The cells' text, columns separated by `col` and rows by `row` (Convert Table to Text).
+    pub fn text_with(&self, col: &str, row: &str) -> String {
         let mut out = String::new();
         for r in 0..self.nrows() {
             if r > 0 {
-                out.push('\n');
+                out.push_str(row);
             }
             for c in 0..self.ncols() {
                 if c > 0 {
-                    out.push('\t');
+                    out.push_str(col);
                 }
                 if let Some(cell) = self.cell(r, c) {
                     out.push_str(&cell.text.text.replace('\n', " "));
@@ -1319,6 +1327,19 @@ mod tests {
         assert_eq!(right.selected_owners(CellRange::new(1, 0, 1, 1)), vec![(0, 1), (1, 0)], "owner order, not encounter order");
     }
 
+    /// A short last row's padding cells take the converted text's paragraph and character format,
+    /// like the cells with text.
+    #[test]
+    fn from_strings_formats_the_padding_cells_too() {
+        let data = vec![vec!["a".to_string(), "b".to_string()], vec!["c".to_string()]];
+        let para = ParaFormat { style: "Body".into(), ..Default::default() };
+        let fmt = CharFormat { style: "Strong".into(), ..Default::default() };
+        let t = Table::from_strings(1, &data, 200.0, &para, &fmt);
+        let pad = &t.cell(1, 1).unwrap().text;
+        assert_eq!((pad.paras[0].style.as_str(), pad.chars[0].format.style.as_str()), ("Body", "Strong"));
+        assert_eq!(pad.text, "");
+    }
+
     #[test]
     fn insert_table_gets_own_paragraph() {
         let mut s = story("before after");
diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs
--- a/crates/engine/src/cmd/table.rs
+++ b/crates/engine/src/cmd/table.rs
@@ -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
         ),
@@ -98,7 +99,7 @@ fn table_specs() -> Vec<CommandSpec> {
             "Convert Text to Table",
             ["Table"],
             None,
-            "{columnSeparator?: tab|comma, width?: table width (default: the column)} — selected paragraphs become rows",
+            "{columnSeparator?: tab|comma|paragraph|text (tab), rowSeparator?: paragraph|tab|comma|text (paragraph), columns?: number of columns when both separators are the same (1, at most 200), tableStyle?: name, width?: table width (default: the column)} — the selected paragraphs become a table, as InDesign's Convert Text to Table; the words tab, comma and paragraph name those separators, any other text is the separator itself",
             has_text,
             convert_from_text
         ),
@@ -107,7 +108,7 @@ fn table_specs() -> Vec<CommandSpec> {
             "Convert Table to Text",
             ["Table"],
             None,
-            "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — cells separated by tabs, rows by paragraphs",
+            "{columnSeparator?: tab|comma|paragraph|text (tab), rowSeparator?: paragraph|tab|comma|text (paragraph), story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — the table becomes its cells' text, as InDesign's Convert Table to Text",
             in_table,
             convert_to_text
         ),
@@ -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);
@@ -593,12 +620,41 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
     })
 }
 
+/// A Convert Text to Table / Convert Table to Text separator: `tab`, `comma`, `paragraph` or the
+/// separator's own text (InDesign's fields take any characters).
+fn separator(p: &Value, key: &str, default: &str, cmd: &str) -> Result<String> {
+    let sep = match p.get(key) {
+        None | Some(Value::Null) => return Ok(default.to_string()),
+        Some(Value::String(s)) => match s.as_str() {
+            "tab" => "\t",
+            "comma" => ",",
+            "paragraph" => "\n",
+            other => other,
+        },
+        Some(_) => "",
+    };
+    if sep.is_empty() {
+        return Err(bad(cmd, format!("`{key}` must be tab, comma, paragraph or the separator's characters")));
+    }
+    Ok(sep.to_string())
+}
+
 fn convert_from_text(s: &mut Session, p: &Value) -> Result<Value> {
-    let t = s.doc()?.selection.text.ok_or_else(|| bad("table.convertFromText", "no text selected"))?;
+    const ID: &str = "table.convertFromText";
+    let t = s.doc()?.selection.text.ok_or_else(|| bad(ID, "no text selected"))?;
     if t.cell.is_some() {
-        return Err(bad("table.convertFromText", "select text outside tables"));
-    }
-    let sep = if str_param(p, "columnSeparator") == Some("comma") { ',' } else { '\t' };
+        return Err(bad(ID, "select text outside tables"));
+    }
+    let (col_sep, row_sep) = (separator(p, "columnSeparator", "\t", ID)?, separator(p, "rowSeparator", "\n", ID)?);
+    // With one separator for both, InDesign fills rows of Number of Columns cells.
+    let per_row = match p.get("columns") {
+        None | Some(Value::Null) => 1,
+        Some(v) => v
+            .as_u64()
+            .filter(|n| (1..=MAX_COLUMNS).contains(n))
+            .ok_or_else(|| bad(ID, format!("`columns` must be a whole number from 1 to {MAX_COLUMNS}")))? as usize,
+    };
+    let style = table_style_param(s, p, ID)?;
     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();
@@ -610,11 +666,20 @@ fn convert_from_text(s: &mut Session, p: &Value) -> Result<Value> {
             return Err(bad("table.convertFromText", "the selection contains a table"));
         }
         let (a, b) = (ranges[pa].start, ranges[pb].end);
-        let data: Vec<Vec<String>> = st.text[a..b].split('\n').map(|line| line.split(sep).map(|c| c.trim().to_string()).collect()).collect();
+        let text = &st.text[a..b];
+        let data: Vec<Vec<String>> = if col_sep == row_sep {
+            let cells: Vec<String> = text.split(col_sep.as_str()).map(|c| c.trim().to_string()).collect();
+            cells.chunks(per_row).map(<[String]>::to_vec).collect()
+        } else {
+            text.split(row_sep.as_str()).map(|row| row.split(col_sep.as_str()).map(|c| c.trim().to_string()).collect()).collect()
+        };
         let pf = st.paras[pa].clone();
         let cf = st.format_after(a).clone();
         let mut table = Table::from_strings(id, &data, width, &pf, &cf);
         table.options.direction = direction;
+        if let Some((ts, cells)) = &style {
+            ts.apply_to(&mut table, cells);
+        }
         st.delete(a..b);
         let anchor = st.insert_table(a, table);
         *sel = Selection::text(TextSel::caret(t.story, anchor));
@@ -624,9 +689,11 @@ fn convert_from_text(s: &mut Session, p: &Value) -> Result<Value> {
 
 fn convert_to_text(s: &mut Session, p: &Value) -> Result<Value> {
     let g = target(s, p, "table.convertToText")?;
+    let col_sep = separator(p, "columnSeparator", "\t", "table.convertToText")?;
+    let row_sep = separator(p, "rowSeparator", "\n", "table.convertToText")?;
     s.edit(|d, sel| {
         let st = d.story_mut(g.story).ok_or_else(|| bad("table.convertToText", "no story"))?;
-        let text = st.tables.get(&g.table).map(|t| t.plain_text()).unwrap_or_default();
+        let text = st.tables.get(&g.table).map(|t| t.text_with(&col_sep, &row_sep)).unwrap_or_default();
         let a = st.table_anchor(g.table).ok_or_else(|| bad("table.convertToText", "no anchor"))?;
         st.replace(a..a + designcraft_doc::TABLE_ANCHOR.len_utf8(), &text);
         *sel = Selection::text(TextSel::caret(g.story, a + text.len()));
diff --git a/crates/engine/src/tests_table.rs b/crates/engine/src/tests_table.rs
--- a/crates/engine/src/tests_table.rs
+++ b/crates/engine/src/tests_table.rs
@@ -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();
@@ -444,6 +464,62 @@ fn convert_text_to_table_and_back() {
     assert_eq!(s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().text, "Ink\tCoverage\nPlum\t80%\nSunset\t45%\nAfter");
 }
 
+/// InDesign's Convert Text to Table and Convert Table to Text separators: tab, comma, paragraph or
+/// any characters; with one separator for both, rows of Number of Columns cells; a Table Style.
+#[test]
+fn convert_text_to_table_with_indesigns_separators() {
+    let (mut s, sid, _) = session_with_frame();
+    let convert = |s: &mut Session, text: &str, p: Value| -> Result<Value> {
+        s.execute("story.setText", &json!({"story": sid, "text": format!("{text}\nAfter")})).unwrap();
+        s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": text.len()})).unwrap();
+        s.execute("table.convertFromText", &p)
+    };
+    let cells = |s: &mut Session, r: &Value| -> (usize, Vec<String>) {
+        let t = s.execute("table.get", &json!({"table": r["table"]})).unwrap();
+        let texts = t["cells"].as_array().unwrap().iter().map(|c| c["text"].as_str().unwrap().to_string()).collect();
+        (t["columns"].as_array().unwrap().len(), texts)
+    };
+    // Comma-separated columns, paragraphs for rows.
+    let r = convert(&mut s, "Ink, Coverage\nPlum, 80%", json!({"columnSeparator": "comma"})).unwrap();
+    assert_eq!(cells(&mut s, &r), (2, vec!["Ink".into(), "Coverage".into(), "Plum".into(), "80%".into()]));
+    check(&s);
+    // The lesson's list: one paragraph per cell; with two columns, two cells a row.
+    let list = "Name:\nAddress:\nCity:\nState:";
+    let r = convert(&mut s, list, json!({"columnSeparator": "paragraph", "rowSeparator": "paragraph", "columns": 2})).unwrap();
+    assert_eq!(r["rows"], 2);
+    assert_eq!(cells(&mut s, &r).0, 2);
+    let r = convert(&mut s, list, json!({"columnSeparator": "tab"})).unwrap();
+    assert_eq!((r["rows"].as_u64(), cells(&mut s, &r).0), (Some(4), 1), "a column of paragraphs");
+    // Any characters separate: here " | " between columns and ";" between rows.
+    let r = convert(&mut s, "a | b;c | d", json!({"columnSeparator": " | ", "rowSeparator": ";"})).unwrap();
+    assert_eq!(cells(&mut s, &r), (2, vec!["a".into(), "b".into(), "c".into(), "d".into()]));
+    // A table style is applied to the new table.
+    s.execute("style.table.create", &json!({"name": "Grid"})).unwrap();
+    let r = convert(&mut s, "x\ty", json!({"tableStyle": "Grid"})).unwrap();
+    assert_eq!(s.execute("table.get", &json!({"table": r["table"]})).unwrap()["style"], "Grid");
+    // A short last row: its padding cell takes the text's format too.
+    let r = convert(&mut s, "a\nb\nc", json!({"columnSeparator": "paragraph", "rowSeparator": "paragraph", "columns": 2})).unwrap();
+    let t = s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().tables[&r["table"].as_u64().unwrap()].clone();
+    let fmt = |c: usize| t.cell(1, c).unwrap().text.chars[0].format.clone();
+    assert_eq!(fmt(1), fmt(0), "the padding cell's format is the text's");
+    for bad in [
+        json!({"columnSeparator": ""}),
+        json!({"rowSeparator": 3}),
+        json!({"columns": 0}),
+        json!({"columns": crate::cmd::table::MAX_COLUMNS + 1}),
+        json!({"tableStyle": "Missing"}),
+        json!({"tableStyle": 123}),
+    ] {
+        assert!(convert(&mut s, "p\tq", bad.clone()).is_err(), "{bad}");
+    }
+    check(&s);
+    // Back to text with a comma between columns and a tab between rows.
+    let r = convert(&mut s, "Plum\t80%\nSunset\t45%", json!({})).unwrap();
+    s.execute("table.convertToText", &json!({"table": r["table"], "columnSeparator": "comma", "rowSeparator": "tab"})).unwrap();
+    check(&s);
+    assert_eq!(s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().text, "Plum,80%\tSunset,45%\nAfter");
+}
+
 #[test]
 fn caret_placement_in_cells_and_cell_formatting() {
     let (mut s, sid, fid) = session_with_frame();
diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs
--- a/crates/ui-egui/src/dialogs.rs
+++ b/crates/ui-egui/src/dialogs.rs
@@ -34,6 +34,8 @@ impl Dialog {
             "goToPage" => json!({"page": "1"}),
             "qrCode" => json!({"type": "url", "content": "https://", "color": "[Black]"}),
             "insertTable" => json!({"bodyRows": 4, "columns": 4, "headerRows": 0, "footerRows": 0}),
+            "convertTextToTable" => json!({"columnSeparator": "tab", "rowSeparator": "paragraph", "columns": 1}),
+            "convertTableToText" => json!({"columnSeparator": "tab", "rowSeparator": "paragraph"}),
             "insertXref" => json!({"linkTo": "paragraph", "style": "", "target": "", "format": ""}),
             "findChange" => json!({"find": "", "change": "", "grep": false, "caseSensitive": false, "wholeWord": false, "scope": "document"}),
             "documentSetup" => json!({}),
@@ -137,6 +139,142 @@ fn text_field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, w: f32) {
     }
 }
 
+/// Separators the Convert dialogs offer by name; anything else typed in the field is the
+/// separator's own characters.
+const SEPARATORS: [(&str, &str); 3] = [("tab", "Tab"), ("comma", "Comma"), ("paragraph", "Paragraph")];
+
+/// The characters a separator field stands for (`table.convertFromText` resolves it the same
+/// way), so "comma" and a typed "," are the same separator.
+fn resolved_separator(v: &str) -> &str {
+    match v {
+        "tab" => "\t",
+        "comma" => ",",
+        "paragraph" => "\n",
+        other => other,
+    }
+}
+
+/// Whether the Convert dialog's two separators are the same (Number of Columns then applies).
+fn same_separators(d: &Dialog) -> bool {
+    resolved_separator(&d.s("columnSeparator")) == resolved_separator(&d.s("rowSeparator"))
+}
+
+/// A separator field, as InDesign's: type any characters, or pick Tab, Comma or Paragraph from
+/// its menu. Stored as `tab`/`comma`/`paragraph` or the typed text.
+fn separator_field(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, key: &str) {
+    let cur = d.s(key);
+    let name = |v: &str| SEPARATORS.iter().find(|s| s.0 == v).map(|s| crate::i18n::tr(lang, s.1).to_string());
+    let mut shown = name(&cur).unwrap_or_else(|| cur.clone());
+    ui.horizontal(|ui| {
+        ui.spacing_mut().item_spacing.x = 2.0;
+        if ui.add(egui::TextEdit::singleline(&mut shown).id_salt(("sep", key)).desired_width(150.0)).changed() {
+            let v = SEPARATORS.iter().find(|s| crate::i18n::tr(lang, s.1) == shown).map_or(shown.clone(), |s| s.0.to_string());
+            d.fields.insert(key.into(), json!(v));
+        }
+        ui.menu_button("▾", |ui| {
+            for (v, label) in SEPARATORS {
+                if ui.selectable_label(cur == v, crate::i18n::tr(lang, label)).clicked() {
+                    d.fields.insert(key.into(), json!(v));
+                    ui.close();
+                }
+            }
+        });
+    });
+}
+
+/// Table › Convert Text to Table and Convert Table to Text, laid out as InDesign's: Column and
+/// Row Separator; converting text to a table also has Number of Columns (only when both
+/// separators are the same: the cells then fill rows of that many) and Table Style.
+fn convert_table_dialog(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
+    let lang = &app.ui.language;
+    let to_table = d.id == "convertTextToTable";
+    let label = |ui: &mut egui::Ui, text: &str, on: bool| {
+        ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
+            ui.add_enabled(on, egui::Label::new(crate::rtl::widget(ui, format!("{}:", crate::i18n::tr(lang, text)))));
+        });
+    };
+    egui::Grid::new(("convert_table", to_table)).num_columns(2).min_col_width(120.0).spacing([8.0, 6.0]).show(ui, |ui| {
+        label(ui, "Column Separator", true);
+        separator_field(lang, ui, d, "columnSeparator");
+        ui.end_row();
+        label(ui, "Row Separator", true);
+        separator_field(lang, ui, d, "rowSeparator");
+        ui.end_row();
+        if !to_table {
+            return;
+        }
+        let same = same_separators(d);
+        label(ui, "Number of Columns", same);
+        let max = designcraft_engine::cmd::table::MAX_COLUMNS as f64;
+        let field = crate::widgets::NumField::number("columns", d.n("columns"), "", 0).width(70.0).range(1.0, max).spinner().enabled(same);
+        if let Some(v) = field.show(ui) {
+            d.fields.insert("columns".into(), json!(v.round()));
+        }
+        ui.end_row();
+        label(ui, "Table Style", true);
+        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());
+        egui::ComboBox::from_id_salt("convert_table_style").selected_text(&cur).width(170.0).show_ui(ui, |ui| {
+            for n in &names {
+                if ui.selectable_label(*n == cur, n).clicked() {
+                    d.fields.insert("tableStyle".into(), json!(n));
+                }
+            }
+        });
+        ui.end_row();
+    });
+}
+
+/// 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)
+}
+
+/// 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) {
@@ -2044,7 +2182,10 @@ 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(),
+        // The menu items' translations (every language has them), without the ellipsis.
+        "convertTextToTable" => crate::i18n::tr(&app.ui.language, "Convert Text to Table…").trim_end_matches('…').trim_end(),
+        "convertTableToText" => crate::i18n::tr(&app.ui.language, "Convert Table to Text…").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"),
@@ -2111,7 +2252,8 @@ 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" | "convertTableToText" => 380.0,
+            "convertTextToTable" => 420.0,
             "newDocument" => 960.0,
             "hyphenation" => 560.0,
             "numberingSection" => 560.0,
@@ -2796,21 +2938,8 @@ 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),
+                    "convertTextToTable" | "convertTableToText" => convert_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),
@@ -3123,11 +3252,41 @@ 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}),
-        ),
+        "convertTextToTable" | "convertTableToText" => {
+            let r = if d.id == "convertTextToTable" {
+                let same = same_separators(&d);
+                let style = table_style_field(&d).filter(|s| s != designcraft_doc::BASIC_TABLE);
+                app.run(
+                    "table.convertFromText",
+                    json!({"columnSeparator": d.s("columnSeparator"), "rowSeparator": d.s("rowSeparator"),
+                        "columns": if same { d.fields.get("columns").cloned().unwrap_or(json!(1)) } else { Value::Null }, "tableStyle": style}),
+                )
+            } else {
+                app.run("table.convertToText", json!({"columnSeparator": d.s("columnSeparator"), "rowSeparator": d.s("rowSeparator")}))
+            };
+            // On an error (an emptied separator field) the dialog stays open to be corrected.
+            if let Err(e) = &r {
+                app.status(e.clone());
+                app.ui.dialog = Some(d);
+            }
+            r
+        }
+        "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_field(&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);
@@ -9987,6 +10146,90 @@ 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()));
+    }
+
+    /// Convert Text to Table: available with text selected (not with a caret alone); its
+    /// separators and Number of Columns shape the table. Convert Table to Text takes it back with
+    /// the separators chosen there.
+    #[test]
+    fn convert_dialogs_use_their_separators() {
+        let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
+        app.run("file.new", json!({})).unwrap();
+        let text = "Name:\nAddress:\nCity:\nState:";
+        let sid = app.run("frame.create", json!({"rect": [72, 72, 372, 372], "content": "text", "text": text})).unwrap()["story"].clone();
+        app.run("text.select", json!({"story": sid, "anchor": 0, "focus": 0})).unwrap();
+        assert!(!crate::menus::menu_enabled(&app, "app.convertTextToTableDialog"), "a caret alone");
+        assert!(!crate::menus::menu_enabled(&app, "app.convertTableToTextDialog"), "no table");
+        app.run("text.select", json!({"story": sid, "anchor": 0, "focus": text.len()})).unwrap();
+        assert!(crate::menus::menu_enabled(&app, "app.convertTextToTableDialog"));
+        app.run("app.convertTextToTableDialog", json!({})).unwrap();
+        assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("convertTextToTable"));
+        // "comma" and a typed "," are the same separator, so Number of Columns applies; a count of
+        // 0 is refused, not taken as 1, and the dialog stays open.
+        ctl(&mut app, "ui.dialog.set", json!({"field": "columnSeparator", "value": "comma"}));
+        ctl(&mut app, "ui.dialog.set", json!({"field": "rowSeparator", "value": ","}));
+        assert!(same_separators(app.ui.dialog.as_ref().unwrap()));
+        ctl(&mut app, "ui.dialog.set", json!({"field": "columns", "value": 0}));
+        assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], false);
+        assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("convertTextToTable"));
+        // An emptied separator: the command refuses it and the dialog stays open, as typed.
+        ctl(&mut app, "ui.dialog.set", json!({"field": "rowSeparator", "value": ""}));
+        assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], false);
+        assert_eq!(app.ui.dialog.as_ref().map(|d| (d.id.as_str(), d.s("rowSeparator"))), Some(("convertTextToTable", String::new())));
+        for (field, value) in [("columnSeparator", json!("paragraph")), ("rowSeparator", json!("paragraph")), ("columns", json!(2))] {
+            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")), (2, 2), "{t}");
+        assert!(crate::menus::menu_enabled(&app, "app.convertTableToTextDialog"));
+        app.run("app.convertTableToTextDialog", json!({})).unwrap();
+        ctl(&mut app, "ui.dialog.set", json!({"field": "columnSeparator", "value": ", "}));
+        assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true);
+        let story = app.session.active().unwrap().doc.story(designcraft_doc::StoryId(sid.as_u64().unwrap())).unwrap().text.clone();
+        // (The empty paragraph after a table that ends its story stays after the text.)
+        assert_eq!(story.trim_end(), "Name:, Address:\nCity:, State:");
+    }
+
     #[test]
     fn character_style_options_set_exactly_the_edited_attributes() {
         let mut app = styled_app();
@@ -10161,7 +10404,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:?}");
@@ -10171,7 +10414,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));
 
@@ -10183,7 +10426,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));
     }
diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs
--- a/crates/ui-egui/src/i18n.rs
+++ b/crates/ui-egui/src/i18n.rs
@@ -1075,6 +1075,19 @@ const TABLE: &[(&str, [&str; 5])] = &[
         ],
     ),
     ("Load Styles", ["Formate laden", "Charger les styles", "Cargar estilos", "スタイルを読み込み", "加载样式"]),
+    ("Tab", ["Tabulator", "Tabulation", "Tabulación", "タブ", "制表符"]),
+    (
+        "Convert Text to Table…",
+        ["Text in Tabelle umwandeln …", "Convertir le texte en tableau…", "Convertir texto en tabla…", "テキストを表に変換…", "将文本转换为表格…"],
+    ),
+    (
+        "Convert Table to Text…",
+        ["Tabelle in Text umwandeln …", "Convertir le tableau en texte…", "Convertir tabla en texto…", "表をテキストに変換…", "将表格转换为文本…"],
+    ),
+    ("Column Separator", ["Spaltentrennzeichen", "Séparateur de colonnes", "Separador de columnas", "列分解文字", "列分隔符"]),
+    ("Row Separator", ["Zeilentrennzeichen", "Séparateur de rangs", "Separador de filas", "行分解文字", "行分隔符"]),
+    ("Number of Columns", ["Spaltenanzahl", "Nombre de colonnes", "Número de columnas", "列数", "列数"]),
+    ("Comma", ["Komma", "Virgule", "Coma", "カンマ", "逗号"]),
     ("Restart Numbering", ["Nummerierung neu beginnen", "Recommencer la numérotation", "Reiniciar numeración", "番号の振り直し", "重新开始编号"]),
     ("Continue Numbering", ["Nummerierung fortführen", "Poursuivre la numérotation", "Continuar numeración", "番号の継続", "继续编号"]),
     ("Keep Options…", ["Umbruchoptionen …", "Options d'enchaînement…", "Opciones de separación…", "段落分離禁止オプション…", "保持选项…"]),
diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs
--- a/crates/ui-egui/src/i18n/ar.rs
+++ b/crates/ui-egui/src/i18n/ar.rs
@@ -30,6 +30,12 @@ pub(super) const TABLE: &[(&str, &str)] = &[
     ("Cut", "قص"),
     ("Copy", "نسخ"),
     ("Paste", "لصق"),
+    ("Convert Text to Table…", "تحويل النص إلى جدول…"),
+    ("Convert Table to Text…", "تحويل الجدول إلى نص…"),
+    ("Column Separator", "فاصل الأعمدة"),
+    ("Row Separator", "فاصل الصفوف"),
+    ("Number of Columns", "عدد الأعمدة"),
+    ("Comma", "فاصلة"),
     ("Restart Numbering", "إعادة الترقيم"),
     ("Continue Numbering", "متابعة الترقيم"),
     ("Keep Options…", "خيارات الإبقاء…"),
diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs
--- a/crates/ui-egui/src/i18n/cs.rs
+++ b/crates/ui-egui/src/i18n/cs.rs
@@ -31,6 +31,12 @@ pub(super) const TABLE: &[(&str, &str)] = &[
     ("Cut", "Vyjmout"),
     ("Copy", "Kopírovat"),
     ("Paste", "Vložit"),
+    ("Convert Text to Table…", "Převést text na tabulku…"),
+    ("Convert Table to Text…", "Převést tabulku na text…"),
+    ("Column Separator", "Oddělovač sloupců"),
+    ("Row Separator", "Oddělovač řádků"),
+    ("Number of Columns", "Počet sloupců"),
+    ("Comma", "Čárka"),
     ("Restart Numbering", "Začít číslování znovu"),
     ("Continue Numbering", "Pokračovat v číslování"),
     ("Keep Options…", "Volby zachování…"),
diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs
--- a/crates/ui-egui/src/i18n/de.rs
+++ b/crates/ui-egui/src/i18n/de.rs
@@ -31,6 +31,12 @@ pub(super) const TABLE: &[(&str, &str)] = &[
     ("Cut", "Ausschneiden"),
     ("Copy", "Kopieren"),
     ("Paste", "Einfügen"),
+    ("Convert Text to Table…", "Text in Tabelle umwandeln …"),
+    ("Convert Table to Text…", "Tabelle in Text umwandeln …"),
+    ("Column Separator", "Spaltentrennzeichen"),
+    ("Row Separator", "Zeilentrennzeichen"),
+    ("Number of Columns", "Spaltenanzahl"),
+    ("Comma", "Komma"),
     ("Restart Numbering", "Nummerierung neu beginnen"),
     ("Continue Numbering", "Nummerierung fortführen"),
     ("Keep Options…", "Umbruchoptionen …"),
diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs
--- a/crates/ui-egui/src/i18n/fr.rs
+++ b/crates/ui-egui/src/i18n/fr.rs
@@ -349,7 +349,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[
     ("Subscript:", "Indice :"),
     ("Superscript:", "Exposant :"),
     ("Swap Text Fill and Stroke", "Permuter le fond et le contour du texte"),
-    ("Tab", "Tabulation"),
     ("Table of Contents", "Table des matières"),
     ("Table/Convert Cell Type", "Tableau/Convertir le type de cellule"),
     ("Table/Delete", "Tableau/Supprimer"),
diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs
--- a/crates/ui-egui/src/i18n/it.rs
+++ b/crates/ui-egui/src/i18n/it.rs
@@ -28,6 +28,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[
     ("Cut", "Taglia"),
     ("Copy", "Copia"),
     ("Paste", "Incolla"),
+    ("Tab", "Tabulazione"),
+    ("Convert Text to Table…", "Converti testo in tabella…"),
+    ("Convert Table to Text…", "Converti tabella in testo…"),
+    ("Column Separator", "Separatore colonna"),
+    ("Row Separator", "Separatore riga"),
+    ("Number of Columns", "Numero di colonne"),
+    ("Comma", "Virgola"),
     ("Restart Numbering", "Riavvia numerazione"),
     ("Continue Numbering", "Continua numerazione"),
     ("Keep Options…", "Opzioni di separazione…"),
diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs
--- a/crates/ui-egui/src/i18n/ja.rs
+++ b/crates/ui-egui/src/i18n/ja.rs
@@ -30,6 +30,12 @@ pub(super) const TABLE: &[(&str, &str)] = &[
     ("Cut", "カット"),
     ("Copy", "コピー"),
     ("Paste", "ペースト"),
+    ("Convert Text to Table…", "テキストを表に変換…"),
+    ("Convert Table to Text…", "表をテキストに変換…"),
+    ("Column Separator", "列分解文字"),
+    ("Row Separator", "行分解文字"),
+    ("Number of Columns", "列数"),
+    ("Comma", "カンマ"),
     ("Restart Numbering", "番号の振り直し"),
     ("Continue Numbering", "番号の継続"),
     ("Keep Options…", "段落分離禁止オプション…"),
diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs
--- a/crates/ui-egui/src/i18n/ka.rs
+++ b/crates/ui-egui/src/i18n/ka.rs
@@ -31,6 +31,12 @@ pub(super) const TABLE: &[(&str, &str)] = &[
     ("Cut", "ამოჭრა"),
     ("Copy", "კოპირება"),
     ("Paste", "ჩასმა"),
+    ("Convert Text to Table…", "ტექსტის ცხრილად გარდაქმნა…"),
+    ("Convert Table to Text…", "ცხრილის ტექსტად გარდაქმნა…"),
+    ("Column Separator", "სვეტების გამყოფი"),
+    ("Row Separator", "სტრიქონების გამყოფი"),
+    ("Number of Columns", "სვეტების რაოდენობა"),
+    ("Comma", "მძიმე"),
     ("Restart Numbering", "ნუმერაციის თავიდან დაწყება"),
     ("Continue Numbering", "ნუმერაციის გაგრძელება"),
     ("Keep Options…", "შენარჩუნების პარამეტრები…"),
diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs
--- a/crates/ui-egui/src/i18n/pl.rs
+++ b/crates/ui-egui/src/i18n/pl.rs
@@ -29,6 +29,12 @@ pub(super) const TABLE: &[(&str, &str)] = &[
     ("Cut", "Wytnij"),
     ("Copy", "Kopiuj"),
     ("Paste", "Wklej"),
+    ("Convert Text to Table…", "Zamień tekst na tabelę…"),
+    ("Convert Table to Text…", "Zamień tabelę na tekst…"),
+    ("Column Separator", "Separator kolumn"),
+    ("Row Separator", "Separator wierszy"),
+    ("Number of Columns", "Liczba kolumn"),
+    ("Comma", "Przecinek"),
     ("Restart Numbering", "Zacznij numerowanie od nowa"),
     ("Continue Numbering", "Kontynuuj numerowanie"),
     ("Keep Options…", "Opcje utrzymywania…"),
diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs
--- a/crates/ui-egui/src/i18n/pt_br.rs
+++ b/crates/ui-egui/src/i18n/pt_br.rs
@@ -31,6 +31,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[
     ("Cut", "Recortar"),
     ("Copy", "Copiar"),
     ("Paste", "Colar"),
+    ("Tab", "Tabulação"),
+    ("Convert Text to Table…", "Converter texto em tabela…"),
+    ("Convert Table to Text…", "Converter tabela em texto…"),
+    ("Column Separator", "Separador de coluna"),
+    ("Row Separator", "Separador de linha"),
+    ("Number of Columns", "Número de colunas"),
+    ("Comma", "Vírgula"),
     ("Restart Numbering", "Reiniciar numeração"),
     ("Continue Numbering", "Continuar numeração"),
     ("Keep Options…", "Opções de manutenção…"),
diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs
--- a/crates/ui-egui/src/i18n/ru.rs
+++ b/crates/ui-egui/src/i18n/ru.rs
@@ -33,6 +33,12 @@ pub(super) const TABLE: &[(&str, &str)] = &[
     ("Cut", "Вырезать"),
     ("Copy", "Копировать"),
     ("Paste", "Вставить"),
+    ("Convert Text to Table…", "Преобразовать текст в таблицу…"),
+    ("Convert Table to Text…", "Преобразовать таблицу в текст…"),
+    ("Column Separator", "Разделитель столбцов"),
+    ("Row Separator", "Разделитель строк"),
+    ("Number of Columns", "Число столбцов"),
+    ("Comma", "Запятая"),
     ("Restart Numbering", "Начать нумерацию заново"),
     ("Continue Numbering", "Продолжить нумерацию"),
     ("Keep Options…", "Параметры удержания…"),
diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs
--- a/crates/ui-egui/src/i18n/uk.rs
+++ b/crates/ui-egui/src/i18n/uk.rs
@@ -29,6 +29,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[
     ("Cut", "Вирізати"),
     ("Copy", "Копіювати"),
     ("Paste", "Вставити"),
+    ("Tab", "Табуляція"),
+    ("Convert Text to Table…", "Перетворити текст на таблицю…"),
+    ("Convert Table to Text…", "Перетворити таблицю на текст…"),
+    ("Column Separator", "Роздільник стовпців"),
+    ("Row Separator", "Роздільник рядків"),
+    ("Number of Columns", "Кількість стовпців"),
+    ("Comma", "Кома"),
     ("Restart Numbering", "Почати нумерацію знову"),
     ("Continue Numbering", "Продовжити нумерацію"),
     ("Keep Options…", "Параметри утримання…"),
diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs
--- a/crates/ui-egui/src/menus.rs
+++ b/crates/ui-egui/src/menus.rs
@@ -92,7 +92,14 @@ 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.convertTextToTableDialog",
+        "Convert Text to Table…",
+        None,
+        "{} — Convert Text to Table dialog (column and row separators, number of columns, table style)",
+    ),
+    ("app.convertTableToTextDialog", "Convert Table to Text…", None, "{} — Convert Table to Text dialog (column and row separators)"),
     (
         "app.cellOptionsDialog",
         "Strokes and Fills…",
@@ -730,11 +737,16 @@ 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",
+            "ui:app.convertTextToTableDialog",
+            "ui:app.convertTableToTextDialog",
+            "-",
+            ">Cell Options",
+            "ui:app.cellOptionsDialog",
+            "<",
             "-",
             ">Insert",
             "cmd:table.insertRowAbove",
@@ -753,11 +765,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",
@@ -767,12 +778,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",
         ],
     ),
     (
@@ -1270,6 +1278,20 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value
             app.ui.dialog = Some(crate::dialogs::Dialog::new("findChange", json!({})));
             Ok(Value::Null)
         }
+        "app.convertTextToTableDialog" => {
+            if !menu_enabled(app, id) {
+                return Some(Err("select the text to convert to a table".into()));
+            }
+            app.ui.dialog = Some(crate::dialogs::Dialog::new("convertTextToTable", p.clone()));
+            Ok(Value::Null)
+        }
+        "app.convertTableToTextDialog" => {
+            if !menu_enabled(app, id) {
+                return Some(Err("place the insertion point in a table to convert it to text".into()));
+            }
+            app.ui.dialog = Some(crate::dialogs::Dialog::new("convertTableToText", p.clone()));
+            Ok(Value::Null)
+        }
         "app.insertTableDialog" => {
             if app.session.active().is_none_or(|d| d.selection.text.is_none()) {
                 return Some(Err("place the insertion point in a text frame to create a table".into()));
@@ -2506,6 +2528,15 @@ pub fn menu_enabled(app: &DesignApp, id: &str) -> bool {
     if id == "guide.selectAll" && (!app.ui.guides || app.ui.guides_locked) {
         return false;
     }
+    // As InDesign's: Convert Text to Table wants selected text outside a table, Convert Table to
+    // Text a table.
+    if id == "app.convertTextToTableDialog" {
+        let selected = app.session.active().and_then(|d| d.selection.text).is_some_and(|t| t.cell.is_none() && !t.is_caret());
+        return selected && enabled(app, "table.convertFromText");
+    }
+    if id == "app.convertTableToTextDialog" {
+        return enabled(app, "table.convertToText");
+    }
     if ui_label(id).is_some() {
         return app.session.active().is_some() || id.starts_with("app.") || id.starts_with("window.") || id.starts_with("help.");
     }
@@ -4986,6 +5017,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).
diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md
--- a/docs/target-app-parity.md
+++ b/docs/target-app-parity.md
@@ -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), Convert Text to Table and Convert Table to Text dialogs (any separators, Number of Columns, 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.
