Merge PR #4155: Tables: Convert to Text asks for the separator and converts only the selected rows

This commit is contained in:
aiongg
2026-10-25 02:21:10 +00:00
6 changed files with 473 additions and 16 deletions
+1
View File
@@ -312,6 +312,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
| 2026-10-16 | Create Source: the automatic Tag is three letters of the first author and the last two digits of the year (`Smi20`), with 1, 2… added when taken, as Word for Mac 16.113.4 does (user-checked). Editing Author or Year keeps the Tag, as in Word, and the citations in the text show the new author and year (#2094; the reporter expected the Tag to follow). |
| 2026-10-18 | Shape fills keep and draw gradients and transparency (#1500): Word 365 cover pages show their theme gradient panel and translucent wave; DrawingML and VML shapes, text boxes, group members (`a:grpFill`) and canvases are read, drawn (screen, PNG, PDF) and saved with their gradient stops, angles, focus and transparency; theme fill styles (`a:fillRef`) resolve to the fill they name. `.json` documents keep opaque fills as `[r,g,b]`. Edited as in Word: Shape Fill › Gradient (Light and Dark Variations) and the Format Shape pane (solid colour and transparency; gradient presets, type, direction, angle and stops) |
| 2026-10-18 | Split Cells and Convert Text to Table dialogs (#2592), matched to Word: Split Cells starts from the selection (twice its columns, its rows; Merge cells before split offered for several cells or one cell merged across columns); `table.split` gains `rows` and `mergeFirst`, a merged cell splits into 1 to its height rows (its height cuts it into rows, 1 × 1 changes nothing, a number that doesn't divide the height or more than it is refused with Word's messages) and column splits make equal parts without moving other rows' grid lines. Convert Text to Table guesses columns live, shows the row count, and offers Initial column width (Auto or up to 22 in, kept as each column's preferred width), AutoFit to contents/window and Paragraphs/Commas/Tabs/Other separators; `table.fromText` gains `paragraph`/`other` + `char`, `columns`, `autofit` and `width`. |
| 2026-10-24 | Table Layout › Convert to Text opens its dialog: Separate text with Paragraph marks, Tabs (the default), Commas or Other. With rows selected only those rows become text, and the rows above and below stay tables; the caret alone converts the whole table. The text keeps its formatting and objects, and a table nested in a cell stays a table; `table.toText` gains `other` + `char`. |
| 2026-10-16 | Font list entries of fonts with a very tall ascent (Amiri, Amiri Quran) sit in their row like the others instead of low or cut in half (#2570) |
| 2026-10-16 | Read Mode draws documents cleanly (#1772): the arrows, Page Up/Down and Escape work as soon as it opens (the canvas kept a stable widget id, so it no longer lost the keyboard), only whole columns show, centred, at any column width, text size or window size, and the columns end above the page-turn bar instead of under it. |
| 2026-10-16 | Comment and revision balloons are readable in the dark theme (#2045): they were drawn on a fixed white card under light text; the card, markup area and author colours are now theme tokens with a contrast test. |
+189 -16
View File
@@ -3,8 +3,8 @@
use super::table_formula::parse_number;
use serde_json::{Value, json};
use wordcraft_doc::collate::{SortKey, sort_key};
use wordcraft_doc::props::{Align, Border, BorderStyle, Borders, HeightRule, Rgb, TextDirection, VAlign};
use wordcraft_doc::{Block, Document, Paragraph, Path, Pos, RevisionKind, StoryRef, Table, para_block};
use wordcraft_doc::props::{Align, Border, BorderStyle, Borders, HeightRule, Rgb, TextDirection, VAlign, VMerge};
use wordcraft_doc::{Block, Document, InlineObject, Paragraph, Path, Pos, RevisionKind, StoryRef, Table, para_block};
use super::sel_result;
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
@@ -171,7 +171,8 @@ pub fn specs() -> Vec<CommandSpec> {
.params(r#"{"text"?: string, "title"?: string} (empty clears)"#)),
t(CommandSpec::new("table.sort", "Sort", "Table Layout › Data", sort_table)
.params(r#"{"column"?: n, "descending"?: bool, "header"?: bool}"#)),
t(CommandSpec::new("table.toText", "Convert to Text", "Table Layout › Data", to_text).params(r#"{"separator"?: "tab|comma|paragraph"}"#)),
t(CommandSpec::new("table.toText", "Convert to Text", "Table Layout › Data", to_text)
.params(r#"{"separator"?: "tab|comma|paragraph|other", "char"?: string (with other)} (the selected rows, or the whole table)"#)),
t(CommandSpec::new("table.formula", "Formula", "Table Layout › Data", formula).params(r#"{"formula"?: "=SUM(ABOVE)"}"#)),
t(CommandSpec::new("table.selectTable", "Select Table", "Table Layout › Table › Select", |s, _| {
let (tp, _, _) = cell(s)?;
@@ -944,26 +945,198 @@ fn sort_table(s: &mut Session, v: &Value) -> CmdResult {
})
}
/// Table Layout › Data › Convert to Text: the selected rows, or the whole table with the caret
/// in one cell, become paragraphs. Each row is one paragraph with its cells set apart by the
/// separator (a tab, a comma or another character); with `paragraph` each cell paragraph
/// stays a separate paragraph. The text keeps its formatting and objects, a table nested in a cell stays
/// a table, and rows above and below the selected ones stay a table each.
fn to_text(s: &mut Session, v: &Value) -> CmdResult {
let (tp, _, _) = cell(s)?;
let sep = match p::str(v, "separator").unwrap_or("tab") {
"comma" => ",",
"paragraph" => "\n",
_ => "\t",
let sep = match p::str(v, "separator") {
None | Some("tab") => Some('\t'),
Some("comma") => Some(','),
Some("paragraph") => None,
Some("other") => match p::str(v, "char").and_then(|c| c.chars().next()) {
// U+FFFC marks an object in the text, so it cannot set the cells apart.
Some(c) if c != '\n' && c != '\r' && c != wordcraft_doc::para::OBJ => Some(c),
_ => return Err(CmdError::Params("`char` (the separating character) is required with `other`".into())),
},
Some(x) => return Err(CmdError::Params(format!("unknown separator `{x}`"))),
};
let story = s.sel.focus.story;
let t = s.doc.table(story, &tp).cloned().ok_or_else(|| CmdError::Failed("no table".into()))?;
let mut t = s.doc.table(story, &tp).cloned().ok_or_else(|| CmdError::Failed("no table".into()))?;
// Several cells selected: their rows. A caret (or a selection inside one cell): every row.
let cells = selected_cells(s);
let (r0, r1) = match (cells.iter().map(|x| x.0).min(), cells.iter().map(|x| x.0).max()) {
(Some(a), Some(b)) if cells.len() > 1 || s.cell_range().is_some() => (a, b.min(t.rows.len().saturating_sub(1))),
_ => (0, t.rows.len().saturating_sub(1)),
};
// Content controls around several rows that the cut crosses: the rows above close them, the
// rows below open them again.
let open_above = row_controls_open_after(t.rows.get(..r0).unwrap_or_default());
let open_below = row_controls_open_after(t.rows.get(..=r1).unwrap_or_default());
let mut tail_rows = if r1 < t.rows.len() { t.rows.split_off(r1 + 1) } else { Vec::new() };
let rows = t.rows.split_off(r0.min(t.rows.len()));
if let Some(last) = t.rows.last_mut() {
last.controls.close = last.controls.close.saturating_add(u32::try_from(open_above.len()).unwrap_or(u32::MAX));
}
if let Some(first) = tail_rows.first_mut() {
let own = std::mem::replace(&mut first.controls.open, open_below);
first.controls.open.extend(own);
}
let mut blocks = Vec::new();
if !t.rows.is_empty() {
let mut head = Table { props: t.props.clone(), grid: t.grid.clone(), rows: std::mem::take(&mut t.rows), controls: Default::default() };
head.controls.open = std::mem::take(&mut t.controls.open);
end_vmerge_above(&mut head);
blocks.push(Block::Table(head));
}
let text_from = blocks.len();
for row in rows {
row_to_text(row, sep, &mut blocks);
}
// The caret goes to the first converted paragraph (into a nested table when there is none).
let caret = match blocks.iter().enumerate().skip(text_from).find(|(_, b)| matches!(b, Block::Para(_))) {
Some((k, _)) => tp.with_last(tp.last() + k),
None => Path(tp.with_last(tp.last() + text_from).0.into_iter().chain([0, 0, 0]).collect()),
};
if !tail_rows.is_empty() {
let mut tail = Table { props: t.props.clone(), grid: t.grid.clone(), rows: tail_rows, controls: Default::default() };
tail.controls.close = std::mem::take(&mut t.controls.close);
// A floating table keeps its position in the part above; the part below goes in the text
// flow.
if text_from > 0 {
tail.props.float = None;
}
start_vmerge_below(&mut tail);
blocks.push(Block::Table(tail));
}
// A content control around the table keeps both ends: on the text where no table part is
// left to carry them.
let (open, close) = (std::mem::take(&mut t.controls.open), std::mem::take(&mut t.controls.close));
match blocks.get_mut(text_from) {
Some(Block::Para(p)) => {
for (k, mut control) in open.into_iter().enumerate() {
control.block = true;
let start = InlineObject::ControlStart { control: Box::new(control) };
p.insert_object(k * wordcraft_doc::para::OBJ.len_utf8(), start, &Default::default())?;
}
}
Some(Block::Table(x)) => {
let inner = std::mem::replace(&mut x.controls.open, open);
x.controls.open.extend(inner);
}
None => {}
}
match blocks.last_mut() {
Some(Block::Para(p)) => {
// The count is read from the file; at most 1,024 ends go onto the text.
for _ in 0..close.min(1024) {
let end = p.len();
p.insert_object(end, InlineObject::ControlEnd, &Default::default())?;
}
}
Some(Block::Table(x)) => x.controls.close = x.controls.close.saturating_add(close),
None => {}
}
s.doc.remove_block(story, &tp)?;
let mut i = tp.last();
for row in &t.rows {
let text: Vec<String> = row.cells.iter().map(cell_text).collect();
for line in text.join(sep).split('\n') {
s.doc.insert_block(story, &tp.with_last(i), Block::Para(Paragraph::with_text(line, Default::default())))?;
i += 1;
for (k, b) in blocks.into_iter().enumerate() {
s.doc.insert_block(story, &tp.with_last(tp.last() + k), b)?;
}
s.sel = Selection::caret(s.doc.clamp(&Pos { story, path: caret, off: 0 }));
sel_result(s)
}
/// One row of Convert to Text as blocks: a paragraph per row (and per further paragraph in a
/// cell) with the cells set apart by `sep`, or each cell's blocks as they are without one.
fn row_to_text(row: wordcraft_doc::Row, sep: Option<char>, out: &mut Vec<Block>) {
let Some(sep) = sep else {
for b in row.cells.into_iter().flat_map(|c| c.blocks) {
out.push(match std::sync::Arc::unwrap_or_clone(b) {
Block::Para(mut para) => {
para.touch();
Block::Para(para)
}
other => other,
});
}
return;
};
let mut line: Option<Paragraph> = None;
for (ci, cell) in row.cells.into_iter().enumerate() {
if ci > 0 {
// The separator takes no formatting from the text before it.
line.get_or_insert_with(Paragraph::new).append(Paragraph::with_text(sep.encode_utf8(&mut [0; 4]), Default::default()));
}
let mut first = true;
for b in cell.blocks {
match std::sync::Arc::unwrap_or_clone(b) {
Block::Para(mut para) => {
para.section = None;
match line.as_mut() {
Some(p) if first => p.append(para),
_ => {
if let Some(done) = line.take() {
out.push(Block::Para(done));
}
para.touch();
line = Some(para);
}
}
}
other => {
// A nested table stays a table between the row's paragraphs.
if let Some(done) = line.take() {
out.push(Block::Para(done));
}
out.push(other);
}
}
first = false;
}
}
if let Some(done) = line {
out.push(Block::Para(done));
}
}
/// The row content controls still open after `rows`, outermost first.
fn row_controls_open_after(rows: &[wordcraft_doc::Row]) -> Vec<wordcraft_doc::ContentControl> {
let mut open = Vec::new();
for row in rows {
open.extend(row.controls.open.iter().cloned());
let close = usize::try_from(row.controls.close).unwrap_or(usize::MAX).min(open.len());
open.truncate(open.len() - close);
}
open
}
/// Ends each vertical merge that starts in the last row, for a table whose rows below were
/// taken out.
fn end_vmerge_above(t: &mut Table) {
if let Some(row) = t.rows.last_mut() {
for c in &mut row.cells {
if c.props.vmerge == VMerge::Restart {
c.props.vmerge = VMerge::None;
}
}
}
}
/// Starts each vertical merge that the first row continues in that row, or ends it there when
/// the row below doesn't continue it, for a table whose rows above were taken out.
fn start_vmerge_below(t: &mut Table) {
let Some(first) = t.rows.first() else { return };
let cols: Vec<(usize, usize)> = (0..first.cells.len())
.filter(|&c| first.cells.get(c).is_some_and(|x| x.props.vmerge == VMerge::Continue))
.map(|c| (c, t.grid_col(0, c)))
.collect();
for (c, g) in cols {
let more = t.cell_at_grid(1, g).and_then(|i| t.rows.get(1)?.cells.get(i)).is_some_and(|x| x.props.vmerge == VMerge::Continue);
if let Some(x) = t.rows.first_mut().and_then(|r| r.cells.get_mut(c)) {
x.props.vmerge = if more { VMerge::Restart } else { VMerge::None };
}
}
s.sel = Selection::caret(Pos { story, path: tp, off: 0 });
sel_result(s)
}
/// Table Layout › Data › Formula: inserts a formula field at the caret, evaluated now and again
+203
View File
@@ -7030,6 +7030,209 @@ fn convert_text_to_table_keeps_inline_content() {
assert_eq!(cell(3).props_of_char(0).del, Some(1), "cell 4 lost its tracked deletion");
}
/// A table `cols` wide holding `cells` row by row, typed in with Tab; the caret ends in the
/// last cell.
fn typed_table(cols: usize, cells: &[&str]) -> Session {
let mut s = s();
run(&mut s, "insert.table", json!({"rows": cells.len() / cols, "cols": cols}));
for (k, c) in cells.iter().enumerate() {
if k > 0 {
run(&mut s, "text.tab", json!({}));
}
if !c.is_empty() {
run(&mut s, "text.insert", json!({"text": c}));
}
}
s
}
/// The body as `T` for a table and the text of each paragraph.
fn body_outline(s: &Session) -> Vec<String> {
s.doc.body.iter().map(|b| b.as_para().map_or_else(|| "T".to_string(), |p| p.text.clone())).collect()
}
fn cell_at(table: u32, r: u32, c: u32) -> serde_json::Value {
json!({"story": "body", "path": [table, r, c, 0], "off": 0})
}
/// Convert to Text sets a row's cells apart with a tab, a comma, another character or
/// paragraph marks; rows are paragraphs.
#[test]
fn convert_to_text_takes_each_separator() {
let cases = [
(json!({}), vec!["a\tb", "c\td"]),
(json!({"separator": "tab"}), vec!["a\tb", "c\td"]),
(json!({"separator": "comma"}), vec!["a,b", "c,d"]),
(json!({"separator": "other", "char": ";"}), vec!["a;b", "c;d"]),
(json!({"separator": "paragraph"}), vec!["a", "b", "c", "d"]),
];
for (params, want) in cases {
let mut s = typed_table(2, &["a", "b", "c", "d"]);
run(&mut s, "table.toText", params.clone());
let got = body_outline(&s);
assert_eq!(got.get(..want.len()), Some(&want.iter().map(|x| x.to_string()).collect::<Vec<_>>()[..]), "{params}: {got:?}");
assert!(s.doc.body.iter().all(|b| b.as_table().is_none()), "{params}");
}
// U+FFFC marks an object in a paragraph's text, so it cannot be the separator.
let obj = wordcraft_doc::para::OBJ.to_string();
for bad in [
json!({"separator": "other"}),
json!({"separator": "other", "char": "\n"}),
json!({"separator": "other", "char": obj}),
json!({"separator": "pipe"}),
] {
let mut s = typed_table(2, &["a", "b", "c", "d"]);
let depth = s.undo_depth();
assert!(s.run("table.toText", &bad).is_err(), "{bad}");
assert!(s.doc.body.iter().any(|b| b.as_table().is_some()), "{bad}: the table stays");
assert_eq!(s.undo_depth(), depth);
}
}
/// With rows selected, only those become text; the rows above and below stay tables, and a
/// vertical merge cut by the selection starts again below it.
#[test]
fn convert_to_text_converts_the_selected_rows() {
let mut s = typed_table(2, &["a", "b", "c", "d", "e", "f", "g", "h"]);
let t = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32;
// Column 1 merged down rows 1 to 3.
run(&mut s, "select.range", json!({"anchor": cell_at(t, 1, 1), "focus": cell_at(t, 3, 1)}));
run(&mut s, "table.merge", json!({}));
run(&mut s, "select.range", json!({"anchor": cell_at(t, 1, 0), "focus": cell_at(t, 1, 1)}));
run(&mut s, "table.toText", json!({}));
let at = t as usize;
// The merged cell holds d, f and h, one paragraph each.
assert_eq!(body_outline(&s).get(at..at + 5), Some(&["T", "c\td", "f", "h", "T"].map(String::from)[..]), "{:?}", body_outline(&s));
let head = s.doc.body[at].as_table().unwrap();
let tail = s.doc.body[at + 4].as_table().unwrap();
assert_eq!((head.rows.len(), tail.rows.len()), (1, 2));
use wordcraft_doc::props::VMerge;
let merges: Vec<VMerge> = tail.rows.iter().map(|r| r.cells.last().unwrap().props.vmerge).collect();
assert_eq!(merges, [VMerge::Restart, VMerge::Continue], "the merge starts again in the first row left");
assert_eq!(s.sel.focus.path.0, [t + 1], "the caret goes to the converted text");
// A merge that went on into the converted rows ends in the last row above them.
let mut s = typed_table(2, &["a", "b", "c", "d", "e", "f"]);
let t = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32;
run(&mut s, "select.range", json!({"anchor": cell_at(t, 0, 0), "focus": cell_at(t, 1, 0)}));
run(&mut s, "table.merge", json!({}));
run(&mut s, "select.range", json!({"anchor": cell_at(t, 1, 1), "focus": cell_at(t, 2, 1)}));
run(&mut s, "table.toText", json!({}));
let head = s.doc.body[t as usize].as_table().unwrap();
assert_eq!(head.rows.len(), 1);
assert_eq!(head.rows[0].cells[0].props.vmerge, VMerge::None, "no merge goes on below the table");
// A floating table keeps its place in the part above; the part below goes in the text flow.
let mut s = typed_table(2, &["a", "b", "c", "d", "e", "f"]);
let t = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32;
let float = wordcraft_doc::props::TableFloat { x: 100.0, y: 200.0, overlap: true, ..Default::default() };
s.doc.table_mut(StoryRef::Body, &wordcraft_doc::Path(vec![t])).unwrap().props.float = Some(float);
run(&mut s, "select.range", json!({"anchor": cell_at(t, 1, 0), "focus": cell_at(t, 1, 1)}));
run(&mut s, "table.toText", json!({}));
let at = t as usize;
assert_eq!(s.doc.body[at].as_table().unwrap().props.float, Some(float));
assert_eq!(s.doc.body[at + 2].as_table().unwrap().props.float, None, "the part below doesn't sit on the part above");
// The caret alone in a cell converts every row, as before.
let mut s = typed_table(2, &["a", "b", "c", "d", "e", "f"]);
run(&mut s, "table.toText", json!({}));
assert!(s.doc.body.iter().all(|b| b.as_table().is_none()));
}
/// A content control around the table keeps both its ends when the first rows, the last rows
/// or every row become text.
#[test]
fn convert_to_text_keeps_a_content_control_around_the_table() {
use wordcraft_doc::{ContentControl, ControlWrap, InlineObject};
let has = |p: &wordcraft_doc::Paragraph, start: bool| {
p.objects.iter().any(|o| if start { matches!(o, InlineObject::ControlStart { .. }) } else { matches!(o, InlineObject::ControlEnd) })
};
for rows in [Some((0, 0)), Some((2, 2)), None] {
let mut s = typed_table(2, &["a", "b", "c", "d", "e", "f"]);
let t = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32;
s.doc.table_mut(StoryRef::Body, &wordcraft_doc::Path(vec![t])).unwrap().controls =
ControlWrap { open: vec![ContentControl { tag: "wrap".into(), ..Default::default() }], close: 1 };
assert!(!s.doc.has_unbalanced_controls());
match rows {
Some((r0, r1)) => run(&mut s, "select.range", json!({"anchor": cell_at(t, r0, 0), "focus": cell_at(t, r1, 1)})),
None => run(&mut s, "select.range", json!({"anchor": cell_at(t, 1, 0), "focus": cell_at(t, 1, 0)})),
};
run(&mut s, "table.toText", json!({}));
assert!(!s.doc.has_unbalanced_controls(), "{rows:?}: {:?}", body_outline(&s));
let at = t as usize;
if rows != Some((2, 2)) {
assert!(s.doc.body[at].as_para().is_some_and(|p| has(p, true)), "{rows:?}: the control starts on the text");
}
if rows != Some((0, 0)) {
let last = s.doc.body.iter().skip(at).rfind(|b| b.as_para().is_some_and(|p| !p.text.is_empty()) || b.as_table().is_some()).unwrap();
assert!(last.as_para().is_some_and(|p| has(p, false)), "{rows:?}: the control ends on the text");
}
}
// A control around rows 0 to 3, row 1 converted: the table above closes it, the table below
// opens it again.
let mut s = typed_table(2, &["a", "b", "c", "d", "e", "f", "g", "h"]);
let t = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32;
{
let table = s.doc.table_mut(StoryRef::Body, &wordcraft_doc::Path(vec![t])).unwrap();
table.rows[0].controls.open = vec![ContentControl { tag: "rows".into(), ..Default::default() }];
table.rows[3].controls.close = 1;
}
run(&mut s, "select.range", json!({"anchor": cell_at(t, 1, 0), "focus": cell_at(t, 1, 1)}));
run(&mut s, "table.toText", json!({}));
let at = t as usize;
let head = s.doc.body[at].as_table().unwrap();
let tail = s.doc.body[at + 2].as_table().unwrap();
assert_eq!((head.rows.len(), head.rows[0].controls.close), (1, 1), "the table above closes the control");
assert_eq!(tail.rows[0].controls.open.iter().map(|c| c.tag.as_str()).collect::<Vec<_>>(), ["rows"], "the table below opens it");
assert_eq!(tail.rows[1].controls.close, 1);
assert!(!s.doc.has_unbalanced_controls());
}
/// The text keeps its formatting, and a table nested in a cell stays a table.
#[test]
fn convert_to_text_keeps_formatting_and_nested_tables() {
let mut s = typed_table(2, &["Name", "X"]);
let t = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32;
let first = json!({"story": "body", "path": [t, 0, 0, 0], "off": 0});
let end = json!({"story": "body", "path": [t, 0, 0, 0], "off": 4});
run(&mut s, "select.range", json!({"anchor": first, "focus": end}));
run(&mut s, "format.bold", json!({}));
// A table nested in the second cell, after its text.
s.sel = crate::Selection::caret(wordcraft_doc::Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![t, 0, 1, 0]), off: 1 });
run(&mut s, "text.newParagraph", json!({}));
run(&mut s, "insert.table", json!({"rows": 1, "cols": 1}));
run(&mut s, "text.insert", json!({"text": "Inner"}));
s.sel = crate::Selection::caret(wordcraft_doc::Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![t, 0, 0, 0]), off: 0 });
run(&mut s, "table.toText", json!({}));
let at = t as usize;
let line = s.doc.body[at].as_para().unwrap();
assert_eq!(line.text, "Name\tX");
assert_eq!(line.props_of_char(0).bold, Some(true), "the text lost its bold");
assert_ne!(line.props_of_char(4).bold, Some(true), "the tab takes no formatting");
let inner = s.doc.body.iter().skip(at + 1).find_map(|b| b.as_table()).expect("the nested table stays a table");
assert_eq!(table_cell_text(inner), "Inner");
// A selected row whose first cell starts with a nested table: the caret goes to the row's
// text, not back into the table above.
let mut s = typed_table(2, &["A", "B", "", "D"]);
let t = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32;
s.sel = crate::Selection::caret(wordcraft_doc::Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![t, 1, 0, 0]), off: 0 });
run(&mut s, "insert.table", json!({"rows": 1, "cols": 1}));
let row = &s.doc.body[t as usize].as_table().unwrap().rows[1];
assert!(row.cells[0].blocks.first().is_some_and(|b| b.as_table().is_some()), "the cell starts with the nested table");
run(&mut s, "select.range", json!({"anchor": cell_at(t, 1, 1), "focus": cell_at(t, 1, 1)}));
s.sel.anchor = wordcraft_doc::Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![t, 1, 0, 1]), off: 0 };
run(&mut s, "table.toText", json!({}));
let at = t as usize;
assert_eq!(body_outline(&s).get(at..at + 3), Some(&["T", "T", "\tD"].map(String::from)[..]), "{:?}", body_outline(&s));
assert_eq!(s.sel.focus.path.0, [t + 2], "the caret is on the row's text");
}
fn table_cell_text(t: &wordcraft_doc::Table) -> String {
t.rows.iter().flat_map(|r| &r.cells).flat_map(|c| &c.blocks).filter_map(|b| b.as_para()).map(|p| p.text.clone()).collect()
}
#[test]
fn delete_columns_deletes_every_selected_column() {
// Issue #431: only the selection's focus column went.
+77
View File
@@ -35,6 +35,7 @@ pub enum LayoutDialog {
Sort(SortForm),
SplitCells(SplitCellsForm),
TextToTable(TextToTableForm),
TableToText(TableToTextForm),
Size(SizeForm),
PageNumbers(PageNumbersForm),
}
@@ -52,6 +53,7 @@ impl LayoutDialog {
LayoutDialog::Sort(_) => "tableSort",
LayoutDialog::SplitCells(_) => "splitCells",
LayoutDialog::TextToTable(_) => "textToTable",
LayoutDialog::TableToText(_) => "tableToText",
LayoutDialog::Size(_) => "pictureSize",
LayoutDialog::PageNumbers(_) => "pageNumbers",
}
@@ -70,6 +72,7 @@ impl LayoutDialog {
LayoutDialog::Sort(_) => "Sort",
LayoutDialog::SplitCells(_) => "Split Cells",
LayoutDialog::TextToTable(_) => "Convert Text to Table",
LayoutDialog::TableToText(_) => "Convert to Text",
LayoutDialog::Size(_) => "Size",
LayoutDialog::PageNumbers(_) => "Page Number Format",
}
@@ -89,6 +92,7 @@ pub fn open(name: &str, app: &mut WordApp) -> Option<LayoutDialog> {
"tableSort" => LayoutDialog::Sort(SortForm::read(app)?),
"splitCells" => LayoutDialog::SplitCells(SplitCellsForm::read(app)?),
"textToTable" => LayoutDialog::TextToTable(TextToTableForm::read(app)?),
"tableToText" => LayoutDialog::TableToText(TableToTextForm::read(app)?),
"pictureSize" => LayoutDialog::Size(SizeForm::read(app)?),
"pageNumbers" => LayoutDialog::PageNumbers(PageNumbersForm::read(app)),
_ => return None,
@@ -108,6 +112,7 @@ pub fn body(app: &mut WordApp, ui: &mut Ui, d: &mut LayoutDialog) -> bool {
LayoutDialog::Sort(f) => sort_ui(app, ui, f),
LayoutDialog::SplitCells(f) => split_cells_ui(app, ui, f),
LayoutDialog::TextToTable(f) => text_to_table_ui(app, ui, f),
LayoutDialog::TableToText(f) => table_to_text_ui(app, ui, f),
LayoutDialog::Size(f) => size_ui(app, ui, f),
LayoutDialog::PageNumbers(f) => page_numbers_ui(app, ui, f),
}
@@ -1120,6 +1125,55 @@ fn text_to_table_ui(app: &mut WordApp, ui: &mut Ui, f: &mut TextToTableForm) ->
cancel
}
// ---------------------------------------------------------------------------------------------
// Convert to Text
/// The Convert to Text dialog (Table Layout › Data): what sets the cells of a row apart in the
/// text (`paragraph`, `tab`, `comma` or `other` with `other_char`). It opens on Tabs.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TableToTextForm {
pub separator: String,
pub other_char: String,
}
impl TableToTextForm {
/// None when the caret isn't in a table.
pub fn read(app: &WordApp) -> Option<TableToTextForm> {
app.session.sel.focus.path.cell()?;
Some(TableToTextForm { separator: "tab".into(), other_char: "-".into() })
}
/// `table.toText` parameters for OK.
pub fn params(&self) -> Value {
let mut v = json!({"separator": self.separator});
if self.separator == "other" {
v["char"] = json!(self.other_char);
}
v
}
}
fn table_to_text_ui(app: &mut WordApp, ui: &mut Ui, f: &mut TableToTextForm) -> bool {
heading(ui, "Separate text with");
ui.radio_value(&mut f.separator, "paragraph".to_string(), tl!("Paragraph marks"));
ui.radio_value(&mut f.separator, "tab".to_string(), tl!("Tabs"));
ui.radio_value(&mut f.separator, "comma".to_string(), tl!("Commas"));
ui.horizontal(|ui| {
ui.radio_value(&mut f.separator, "other".to_string(), tl!("Other:"));
let r = ui.add(egui::TextEdit::singleline(&mut f.other_char).char_limit(1).desired_width(24.0));
if r.changed() {
f.separator = "other".into();
}
});
let ready = f.separator != "other" || !f.other_char.is_empty();
let (ok, cancel) = buttons(ui, "OK", ready);
if ok {
return run(app, "table.toText", f.params());
}
cancel
}
// ---------------------------------------------------------------------------------------------
// Size
@@ -1907,6 +1961,29 @@ mod tests {
assert_eq!(t.rows.iter().map(|r| r.cells.len()).collect::<Vec<_>>(), [3, 3, 3, 3]);
}
/// Convert to Text opens its dialog on Tabs; OK converts with the separator chosen.
#[test]
fn table_to_text_opens_its_dialog() {
let mut a = table_app(2, &["a", "b", "c", "d"]);
assert_eq!(a.run("table.toText", json!({})).unwrap(), json!({"pending": "tableToText"}));
assert!(a.session.doc.body.iter().any(|b| b.as_table().is_some()), "nothing converted yet");
let LayoutDialog::TableToText(mut f) = form(&mut a) else { panic!("table to text") };
assert_eq!((f.separator.as_str(), f.other_char.as_str()), ("tab", "-"));
assert_eq!(f.params(), json!({"separator": "tab"}));
f.separator = "other".into();
f.other_char = "/".into();
assert_eq!(f.params(), json!({"separator": "other", "char": "/"}));
a.run("table.toText", f.params()).unwrap();
assert!(a.dialog.is_none());
let texts: Vec<&str> = a.session.doc.body.iter().filter_map(|b| b.as_para()).map(|p| p.text.as_str()).collect();
assert!(texts.contains(&"a/b") && texts.contains(&"c/d"), "{texts:?}");
assert!(a.session.doc.body.iter().all(|b| b.as_table().is_none()));
// Outside a table there is nothing to convert.
let mut a = app();
assert_eq!(a.run("ui.dialog", json!({"name": "tableToText"})).unwrap(), json!({"dialog": null}));
}
/// The envelope sizes go through the interface language's catalog and use the region's
/// decimal separator (#1128; the untranslated-text audit found `#10 4.13 × 9.5 in` drawn in
/// English).
+2
View File
@@ -2822,6 +2822,8 @@ fn input_dialog(id: &str, params: &Value) -> Option<&'static str> {
// Split Cells and Convert Text to Table without settings ask for them (#2592).
"table.split" if params.as_object().is_none_or(|m| m.is_empty()) => Some("splitCells"),
"table.fromText" if params.as_object().is_none_or(|m| m.is_empty()) => Some("textToTable"),
// Convert to Text without a separator asks for one.
"table.toText" if params.as_object().is_none_or(|m| m.is_empty()) => Some("tableToText"),
// Tabs, Borders and Shading, and Page Borders without settings show their dialogs (#320).
"para.tabs" if params.as_object().is_none_or(|m| m.is_empty()) => Some("tabs"),
"para.borders" if params.as_object().is_none_or(|m| m.is_empty()) => Some("borders"),
+1
View File
@@ -123,6 +123,7 @@ Footnote and endnote editing keeps body and note text at normal opacity, on ligh
| 2026-10-18 | trivial | Split Cells on a merged cell: non-divisor row counts refused with a divisor message, 1 × 1 on a merged cell does nothing (#2592 follow-up) |
| 2026-10-18 | trivial | Split Cells merged-cell limits and equal parts, Convert Text to Table Initial column width, both from the Word check (#2592) |
| 2026-10-16 | trivial | Split Cells and Convert Text to Table dialogs (#2592): 58 modal dialogs |
| 2026-10-24 | trivial | Convert to Text dialog (Paragraph marks, Tabs, Commas, Other); only the selected rows become text |
| 2026-10-16 | trivial | Font list: each entry's capitals are centred in its row, so a font with a very tall ascent (Amiri, Amiri Quran) no longer sits low or is cut in half, and the list reads as one even list (#2570) |
| 2026-10-16 | trivial | Read Mode keeps the keyboard on entering, shows only whole columns, centred, and ends them above the page-turn bar (#1772) |
| 2026-10-16 | trivial | Comment and revision balloons are readable in the dark theme: the card, the markup area, the author stripes and the leader lines come from theme tokens, and the text reaches 4.5:1 (#2045) |