mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-19 02:10:15 -05:00
Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
30 files changed
+1302
-46
No files matched your search
+4
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** trivial (a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial (table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial (table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: print-area strings translated in every catalog; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
|
||||
|
||||
@@ -137,10 +137,11 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| Date | What landed |
|
||||
| 2026-10-16 | Web Layout text fills the window width and re-wraps once a resize settles, or at once on a zoom, instead of a fixed 800 pt column; Draft and Outline keep their 800 pt column (#1876). |
|
||||
| 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 | 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 or 1 × 1 unmerges it, more is refused with Word's message) 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-14 | DOCX row table border exceptions render and survive saving. Explicit cell borders win; a row exception does not change other rows. |
|
||||
| 2026-10-14 | Tab stops edited on the ruler: the corner selector picks Left/Center/Right/Decimal/Bar, a click sets a stop (Alt skips the snap), dragging moves it with a guide line and position tooltip, dragging off the ruler clears it; markers draw per kind and mirror for RTL; one `para.tabs` call and one undo step per gesture |
|
||||
| 2026-10-12 | Fields keep `w:dirty` through DOCX saves (#877): a begin marker flagged by a generator that couldn't compute a result (pandoc-style TOC placeholders) keeps the flag on open/save — as a buffered field or a range field — so Word still refreshes it on open; computing a result (Update Field, Update Table) clears it, like Word's F9 |
|
||||
|---|---|
|
||||
| 2026-10-17 | Language dialog keeps inherited proofing language and no-proof choices (#2717). Opening and accepting unchanged no longer replaces style-derived values with English US and proofing enabled. |
|
||||
| 2026-10-14 | Consistent preferred cell widths override a stale table grid when both totals agree, for fixed and AutoFit tables. Conflicting widths, spans and percentages remain unchanged. |
|
||||
| 2026-10-14 | Legacy VML freeforms (`v:shape` with its own `path`), such as the rule lines in many older headers, are read as freeform shapes: they show on screen and in PDFs and are saved back as DrawingML custom geometry instead of vanishing |
|
||||
@@ -408,6 +409,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
|---|---|---|
|
||||
| 2026-10-16 | trivial | Progress log: Web Layout fills the window width (#1876) |
|
||||
| 2026-10-17 | trivial | Language dialog keeps inherited proofing language and no-proof choices (#2717); readiness estimates unchanged |
|
||||
| 2026-10-18 | trivial | Progress log: Split Cells and Convert Text to Table dialogs, matched to the Word check (#2592) |
|
||||
| 2026-10-14 | trivial | Progress log: row table border exceptions |
|
||||
| 2026-10-12 | minor | Czech proofing review: short-word splits rank after deletion, NOSUGGEST excludes suggestions while accepting typed forms, affix comments apply only to headers, ratios keep spaces and repeated-word messages use Czech quotes; synthetic engine end-to-end and latency checks |
|
||||
| 2026-10-12 | minor | Czech spelling from the installed Hunspell dictionary, runtime-table suggestions and generic checks; Czech interface writes cs-CZ documents; no Czech hyphenation patterns bundled; estimates unchanged |
|
||||
|
||||
+124
-21
@@ -256,28 +256,15 @@ impl Table {
|
||||
}
|
||||
}
|
||||
|
||||
/// Split cell `c` of row `r` into `n` cells horizontally. When the cell spans fewer grid
|
||||
/// columns than that, the grid is refined inside the cell only: the new borders divide it
|
||||
/// Split cell `c` of row `r` into `n` cells horizontally. Where the cell's grid lines don't
|
||||
/// fall on the equal parts, the grid is refined inside the cell only: the new borders divide it
|
||||
/// into equal parts, and the cells of other rows widen over the new grid columns, so their
|
||||
/// extent and the table's width stay as they were.
|
||||
pub fn split_cell(&mut self, r: usize, c: usize, n: usize) {
|
||||
/// extent and the table's width stay as they were. Returns the parts' spans, left to right.
|
||||
pub fn split_cell(&mut self, r: usize, c: usize, n: usize) -> Vec<usize> {
|
||||
let n = n.clamp(1, MAX_COLS);
|
||||
let Some(span) = self.rows.get(r).and_then(|row| row.cells.get(c)).map(Cell::span) else { return };
|
||||
let Some(span) = self.rows.get(r).and_then(|row| row.cells.get(c)).map(Cell::span) else { return Vec::new() };
|
||||
if n <= 1 {
|
||||
return;
|
||||
}
|
||||
if span >= n {
|
||||
let Some(row) = self.rows.get_mut(r) else { return };
|
||||
let Some(cell) = row.cells.get_mut(c) else { return };
|
||||
let w = cell.props.width.map(|w| w / n as f32);
|
||||
cell.props.width = w;
|
||||
cell.props.span = (span - (n - 1)) as u32;
|
||||
for k in 1..n {
|
||||
let mut nc = Cell::empty();
|
||||
nc.props.width = w;
|
||||
row.cells.insert(c + k, nc);
|
||||
}
|
||||
return;
|
||||
return vec![span];
|
||||
}
|
||||
let g = self.grid_col(r, c);
|
||||
let mut end = g.saturating_add(span);
|
||||
@@ -286,7 +273,7 @@ impl Table {
|
||||
self.grid.push(72.0);
|
||||
}
|
||||
if self.grid.len() < end {
|
||||
return;
|
||||
return vec![span];
|
||||
}
|
||||
for w in self.grid.get_mut(g..end).into_iter().flatten() {
|
||||
if !(w.is_finite() && *w > 0.0) {
|
||||
@@ -341,7 +328,7 @@ impl Table {
|
||||
// The cell now spans `g..end`: cut it at the new lines.
|
||||
let grid = self.grid.clone();
|
||||
let width = |a: usize, b: usize| grid.get(a..b).map(|x| x.iter().sum::<f32>());
|
||||
let Some(row) = self.rows.get_mut(r) else { return };
|
||||
let Some(row) = self.rows.get_mut(r) else { return Vec::new() };
|
||||
let mut a = g;
|
||||
let mut parts = Vec::with_capacity(lines.len() + 1);
|
||||
for &b in lines.iter().chain(std::iter::once(&end)) {
|
||||
@@ -363,6 +350,122 @@ impl Table {
|
||||
row.cells.insert((c + k).min(row.cells.len()), nc);
|
||||
}
|
||||
}
|
||||
parts.iter().map(|&(a, b)| b - a).collect()
|
||||
}
|
||||
|
||||
/// The rows cell `c` of row `r` covers: (first row, count). A vertically merged cell runs from
|
||||
/// its `Restart` cell down through the `Continue` cells that start at the same grid column.
|
||||
pub fn vertical_extent(&self, r: usize, c: usize) -> Option<(usize, usize)> {
|
||||
let g = self.grid_col(r, c);
|
||||
let at = |rr: usize| -> Option<VMerge> {
|
||||
let ci = self.cell_at_grid(rr, g)?;
|
||||
if self.grid_col(rr, ci) != g {
|
||||
return None;
|
||||
}
|
||||
Some(self.rows.get(rr)?.cells.get(ci)?.props.vmerge)
|
||||
};
|
||||
let mut top = r;
|
||||
if self.rows.get(r)?.cells.get(c)?.props.vmerge == VMerge::Continue {
|
||||
while let Some(up) = top.checked_sub(1).and_then(at) {
|
||||
if up == VMerge::None {
|
||||
break;
|
||||
}
|
||||
top -= 1;
|
||||
if up == VMerge::Restart {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut k = 1;
|
||||
if at(top) != Some(VMerge::None) {
|
||||
while at(top + k) == Some(VMerge::Continue) {
|
||||
k += 1;
|
||||
}
|
||||
}
|
||||
Some((top, k))
|
||||
}
|
||||
|
||||
/// Split Cells: cut cell `c` of row `r` into `cols` × `rows` cells. A cell one row high gets
|
||||
/// `rows - 1` new rows under it, over which the row's other cells merge down; a vertically
|
||||
/// merged cell is regrouped instead, so `rows` must divide its height. The content stays in
|
||||
/// the first part. Returns false (with the table unchanged) when the split can't be made.
|
||||
pub fn split_cell_grid(&mut self, r: usize, c: usize, cols: usize, rows: usize) -> bool {
|
||||
let (cols, rows) = (cols.clamp(1, MAX_COLS), rows.clamp(1, MAX_ROWS));
|
||||
let Some((top, k)) = self.vertical_extent(r, c) else { return false };
|
||||
let g = self.grid_col(r, c);
|
||||
let Some(ci) = self.cell_at_grid(top, g) else { return false };
|
||||
if (k > 1 && k % rows != 0) || (k == 1 && self.rows.len().saturating_add(rows - 1) > MAX_ROWS) {
|
||||
return false;
|
||||
}
|
||||
let height = if k > 1 { k } else { rows };
|
||||
if k > 1 && rows > 1 {
|
||||
let q = k / rows;
|
||||
for i in 0..k {
|
||||
if let Some(cj) = self.cell_at_grid(top + i, g)
|
||||
&& let Some(cell) = self.rows.get_mut(top + i).and_then(|row| row.cells.get_mut(cj))
|
||||
{
|
||||
cell.props.vmerge = if q == 1 {
|
||||
VMerge::None
|
||||
} else if i % q == 0 {
|
||||
VMerge::Restart
|
||||
} else {
|
||||
VMerge::Continue
|
||||
};
|
||||
}
|
||||
}
|
||||
} else if k == 1 && rows > 1 {
|
||||
let Some(src) = self.rows.get_mut(top) else { return false };
|
||||
let mut like = Row { props: src.props.clone(), cells: Vec::with_capacity(src.cells.len()), controls: ControlWrap::default() };
|
||||
like.props.header = false;
|
||||
for (i, cell) in src.cells.iter_mut().enumerate() {
|
||||
let mut nc = Cell::empty();
|
||||
nc.props = cell.props.clone();
|
||||
if i == ci {
|
||||
cell.props.vmerge = VMerge::None;
|
||||
nc.props.vmerge = VMerge::None;
|
||||
} else {
|
||||
// The row's other cells merge down over the new rows.
|
||||
if cell.props.vmerge == VMerge::None {
|
||||
cell.props.vmerge = VMerge::Restart;
|
||||
}
|
||||
nc.props.vmerge = VMerge::Continue;
|
||||
}
|
||||
like.cells.push(nc);
|
||||
}
|
||||
for _ in 1..rows {
|
||||
self.rows.insert(top + 1, like.clone());
|
||||
}
|
||||
}
|
||||
if cols > 1 {
|
||||
let spans = self.split_cell(top, ci, cols);
|
||||
let total: usize = spans.iter().sum();
|
||||
let vmerge = self.rows.get(top).and_then(|row| row.cells.get(ci)).map(|cell| cell.props.vmerge).unwrap_or_default();
|
||||
if let Some(row) = self.rows.get_mut(top) {
|
||||
for cell in row.cells.iter_mut().skip(ci + 1).take(spans.len().saturating_sub(1)) {
|
||||
cell.props.vmerge = vmerge;
|
||||
}
|
||||
}
|
||||
// The cells under it, which the new grid lines widened, are cut the same way.
|
||||
let grid = self.grid.clone();
|
||||
let width = |a: usize, b: usize| grid.get(a..b).map(|x| x.iter().sum::<f32>());
|
||||
for rr in top + 1..top + height {
|
||||
let Some(cj) = self.cell_at_grid(rr, g).filter(|&cj| self.grid_col(rr, cj) == g) else { continue };
|
||||
let Some(row) = self.rows.get_mut(rr) else { continue };
|
||||
let Some(cell) = row.cells.get_mut(cj).filter(|cell| cell.span() == total) else { continue };
|
||||
let mut a = g;
|
||||
let mut parts = Vec::with_capacity(spans.len());
|
||||
for (n, &span) in spans.iter().enumerate() {
|
||||
let mut part = if n == 0 { cell.clone() } else { Cell::empty() };
|
||||
part.props.vmerge = cell.props.vmerge;
|
||||
part.props.span = span as u32;
|
||||
part.props.width = width(a, a + span);
|
||||
a += span;
|
||||
parts.push(part);
|
||||
}
|
||||
row.cells.splice(cj..=cj, parts);
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub fn total_width(&self) -> f32 {
|
||||
|
||||
+151
-17
@@ -42,13 +42,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
fix_caret(s, &tp, r, 0)
|
||||
})),
|
||||
t(CommandSpec::new("table.merge", "Merge Cells", "Table Layout › Merge", merge)),
|
||||
t(CommandSpec::new("table.split", "Split Cells", "Table Layout › Merge", |s, v| {
|
||||
let n = p::u64(v, "columns").unwrap_or(2).clamp(1, 63) as usize;
|
||||
let (tp, r, c) = cell(s)?;
|
||||
s.doc.table_mut(s.sel.focus.story, &tp)?.split_cell(r, c, n);
|
||||
fix_caret(s, &tp, r, c)
|
||||
})
|
||||
.params(r#"{"columns"?: n}"#)),
|
||||
t(CommandSpec::new("table.split", "Split Cells", "Table Layout › Merge", split)
|
||||
.params(r#"{"columns"?: n (2), "rows"?: n (1), "mergeFirst"?: bool (true: several selected cells are merged first)}"#)),
|
||||
t(CommandSpec::new("table.splitTable", "Split Table", "Table Layout › Merge", split_table)),
|
||||
t(CommandSpec::new("table.style", "Table Styles", "Table Design › Table Styles", |s, v| {
|
||||
let st = p::req_str(v, "style")?;
|
||||
@@ -206,7 +201,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
t(CommandSpec::new("table.properties", "Properties", "Table Layout › Table", properties).params(
|
||||
r#"{"align"?: "left|center|right", "rtl"?: bool, "width"?: pt|null, "widthPct"?: percent, "indent"?: pt, "rowHeight"?: pt|null, "rowHeightRule"?: "atLeast|exact", "allowBreak"?: bool, "headerRow"?: bool, "columnWidth"?: pt, "cellWidth"?: pt|null, "valign"?: "top|center|bottom"} (row, column and cell settings apply to the caret's; without settings it returns the current ones)"#,
|
||||
)),
|
||||
CommandSpec::new("table.fromText", "Convert Text to Table", "Insert › Tables", from_text).params(r#"{"separator"?: "tab|comma"}"#),
|
||||
CommandSpec::new("table.fromText", "Convert Text to Table", "Insert › Tables", from_text)
|
||||
.params(r#"{"separator"?: "tab|comma|paragraph|other", "char"?: string (with other), "columns"?: n, "autofit"?: "initial|fixed|contents|window", "width"?: pt (initial column width, 0 = Auto, at most 1584)}"#),
|
||||
CommandSpec::new("table.quick", "Quick Tables", "Insert › Tables", quick_table).params(r#"{"kind"?: "calendar|tabular|matrix"}"#),
|
||||
]
|
||||
}
|
||||
@@ -416,7 +412,7 @@ fn with_table(s: &mut Session, f: impl Fn(&mut Table)) -> CmdResult {
|
||||
}
|
||||
|
||||
/// Selected cells: (row, cell) pairs covered by the selection (at least the caret's cell).
|
||||
fn selected_cells(s: &Session) -> Vec<(usize, usize)> {
|
||||
pub fn selected_cells(s: &Session) -> Vec<(usize, usize)> {
|
||||
// Whole cells selected (a column, say): just those, by grid column, so merged cells count.
|
||||
if let Some(range) = s.cell_range()
|
||||
&& s.sel.focus.path.cell().is_some_and(|(tp, _, _)| tp == range.table)
|
||||
@@ -682,6 +678,78 @@ fn merge(s: &mut Session, _: &Value) -> CmdResult {
|
||||
fix_caret(s, &tp, r0, c0)
|
||||
}
|
||||
|
||||
/// The selected cells as Split Cells sees them: one entry per cell, a vertically merged cell
|
||||
/// once (its top row), each as (top row, grid column).
|
||||
pub fn split_targets(s: &Session) -> Vec<(usize, usize)> {
|
||||
let Ok((tp, _, _)) = cell(s) else { return Vec::new() };
|
||||
let Some(t) = s.doc.table(s.sel.focus.story, &tp) else { return Vec::new() };
|
||||
let mut v: Vec<(usize, usize)> = selected_cells(s)
|
||||
.into_iter()
|
||||
.filter(|&(r, c)| t.rows.get(r).is_some_and(|row| c < row.cells.len()))
|
||||
.map(|(r, c)| (t.vertical_extent(r, c).map_or(r, |(top, _)| top), t.grid_col(r, c)))
|
||||
.collect();
|
||||
v.sort_unstable();
|
||||
v.dedup();
|
||||
v
|
||||
}
|
||||
|
||||
/// Split Cells: the caret's cell, or the selected cells (merged first unless `mergeFirst` is
|
||||
/// false), into `columns` × `rows` cells.
|
||||
fn split(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let cols = p::u64(v, "columns").unwrap_or(2).clamp(1, 63) as usize;
|
||||
let rows = p::u64(v, "rows").unwrap_or(1).clamp(1, 63) as usize;
|
||||
let mut cells = split_targets(s);
|
||||
let merge_first = cells.len() > 1 && p::bool(v, "mergeFirst").unwrap_or(true);
|
||||
if merge_first {
|
||||
// As in Word: the merged cell splits into at most the rows it covered.
|
||||
let mut sel_rows: Vec<usize> = selected_cells(s).iter().map(|&(r, _)| r).collect();
|
||||
sel_rows.sort_unstable();
|
||||
sel_rows.dedup();
|
||||
if rows > sel_rows.len() {
|
||||
return Err(CmdError::Params(format!("The number must be between 1 and {}.", sel_rows.len())));
|
||||
}
|
||||
merge(s, &json!({}))?;
|
||||
cells.clear();
|
||||
} else if cells.len() > 1 && rows > 1 {
|
||||
// Word greys out the row count when the cells split one by one.
|
||||
return Err(CmdError::Params("several cells split one by one keep their rows: `rows` must be 1".into()));
|
||||
}
|
||||
let (tp, r, c) = cell(s)?;
|
||||
let t = s.doc.table_mut(s.sel.focus.story, &tp)?;
|
||||
if cells.is_empty() {
|
||||
cells.push((t.vertical_extent(r, c).map_or(r, |(top, _)| top), t.grid_col(r, c)));
|
||||
}
|
||||
// As in Word: a merged cell splits into 1 to its height rows; it never adds rows.
|
||||
if let [(top, g)] = cells[..]
|
||||
&& let Some((_, k)) = t.cell_at_grid(top, g).and_then(|ci| t.vertical_extent(top, ci))
|
||||
&& k > 1
|
||||
&& rows > k
|
||||
{
|
||||
return Err(CmdError::Params(format!("The number must be between 1 and {k}.")));
|
||||
}
|
||||
// Rightmost grid column first, bottom up: a split only adds grid columns inside the cell it
|
||||
// splits, so the cells still to split keep their grid places. Each is found again by its grid
|
||||
// column when its turn comes.
|
||||
let first = cells.first().copied();
|
||||
cells.sort_unstable_by_key(|&(top, g)| std::cmp::Reverse((g, top)));
|
||||
for &(top, g) in &cells {
|
||||
let Some(ci) = t.cell_at_grid(top, g) else { continue };
|
||||
// 1 × 1 on a cell that was already merged down cuts it back into one cell per row, as
|
||||
// Word does; a cell just merged by Merge cells before split stays one cell.
|
||||
let height = t.vertical_extent(top, ci).map_or(1, |(_, k)| k);
|
||||
let rows = if !merge_first && height > 1 && rows == 1 && cols == 1 { height } else { rows };
|
||||
if !t.split_cell_grid(top, ci, cols, rows) {
|
||||
return Err(CmdError::Failed(format!(
|
||||
"can't split into {rows} rows: a merged cell splits into a number of rows that divides its height"
|
||||
)));
|
||||
}
|
||||
}
|
||||
// The caret goes to the first part of the first cell (the top of a merged cell).
|
||||
let (top, g) = first.unwrap_or((r, 0));
|
||||
let c = t.cell_at_grid(top, g).unwrap_or(c);
|
||||
fix_caret(s, &tp, top, c)
|
||||
}
|
||||
|
||||
fn split_table(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let (tp, r, _) = cell(s)?;
|
||||
if r == 0 {
|
||||
@@ -1100,8 +1168,9 @@ pub(crate) fn row_ends(doc: &Document, story: StoryRef, tp: &Path, r: usize) ->
|
||||
Some((Pos { story, path: first, off: 0 }, Pos { story, path: last, off: len }))
|
||||
}
|
||||
|
||||
fn from_text(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let sep = if p::str(v, "separator") == Some("comma") { ',' } else { '\t' };
|
||||
/// The paragraphs Convert Text to Table would convert: the selected ones, as text. Refused
|
||||
/// rather than dropping content when a table lies between them.
|
||||
pub fn text_table_lines(s: &Session) -> Result<Vec<String>, CmdError> {
|
||||
let (a, b) = s.sel.ordered();
|
||||
if a.path.parent() != b.path.parent() || a.story != b.story {
|
||||
return Err(CmdError::Failed("select paragraphs to convert".into()));
|
||||
@@ -1112,15 +1181,77 @@ fn from_text(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if lines.len() != b.path.last() - a.path.last() + 1 {
|
||||
return Err(CmdError::Failed("select only paragraphs to convert".into()));
|
||||
}
|
||||
let cols = lines.iter().map(|l| l.split(sep).count()).max().unwrap_or(1);
|
||||
Ok(lines)
|
||||
}
|
||||
|
||||
/// The cells Convert Text to Table makes of `lines`, row by row, and the column count. `sep`
|
||||
/// `None` separates at paragraphs: each paragraph is a cell, `columns` (default 1) to a row.
|
||||
/// Otherwise each paragraph starts a row; `columns` defaults to the most fields in one, and a
|
||||
/// paragraph with more fields than that carries on over the next rows.
|
||||
pub fn text_table_cells(lines: &[String], sep: Option<char>, columns: Option<usize>) -> (Vec<Vec<&str>>, usize) {
|
||||
let Some(sep) = sep else {
|
||||
let cols = columns.unwrap_or(1).clamp(1, wordcraft_doc::table::MAX_COLS);
|
||||
return (lines.chunks(cols).map(|ch| ch.iter().map(String::as_str).collect()).collect(), cols);
|
||||
};
|
||||
let cols = columns.unwrap_or_else(|| lines.iter().map(|l| l.split(sep).count()).max().unwrap_or(1)).max(1);
|
||||
let mut rows = Vec::new();
|
||||
for l in lines {
|
||||
let fields: Vec<&str> = l.split(sep).collect();
|
||||
rows.extend(fields.chunks(cols.min(wordcraft_doc::table::MAX_COLS)).map(<[&str]>::to_vec));
|
||||
}
|
||||
(rows, cols)
|
||||
}
|
||||
|
||||
/// Word's largest Initial column width, 22 in, in points.
|
||||
const MAX_INITIAL_WIDTH: f64 = 22.0 * 72.0;
|
||||
|
||||
fn from_text(s: &mut Session, v: &Value) -> CmdResult {
|
||||
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()) {
|
||||
Some(c) if c != '\n' && c != '\r' => 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 fit = match p::str(v, "autofit") {
|
||||
None | Some("initial") => None,
|
||||
Some(m @ ("fixed" | "contents" | "window")) => Some(m),
|
||||
Some(x) => return Err(CmdError::Params(format!("unknown AutoFit behavior `{x}`"))),
|
||||
};
|
||||
// Initial column width: 0 (or none) is Auto, the text width shared out; Word takes 0 to 22 in.
|
||||
let col_width = match v.get("width") {
|
||||
// Only the Initial column width uses it; AutoFit sizes the columns itself.
|
||||
_ if matches!(fit, Some("contents" | "window")) => 0.0,
|
||||
None | Some(Value::Null) => 0.0,
|
||||
Some(w) => match w.as_f64() {
|
||||
Some(w) if (0.0..=MAX_INITIAL_WIDTH).contains(&w) => w as f32,
|
||||
_ => return Err(CmdError::Params("`width` must be between 0 and 1584 pt (22 in)".into())),
|
||||
},
|
||||
};
|
||||
let columns = p::u64(v, "columns").map(|n| n.clamp(1, wordcraft_doc::table::MAX_COLS as u64) as usize);
|
||||
let (a, b) = s.sel.ordered();
|
||||
let lines = text_table_lines(s)?;
|
||||
let (cells, cols) = text_table_cells(&lines, sep, columns);
|
||||
// A table has at most 63 columns; a longer row would lose its last fields.
|
||||
if cols > 63 {
|
||||
if cols > wordcraft_doc::table::MAX_COLS {
|
||||
return Err(CmdError::Failed("a table can have at most 63 columns".into()));
|
||||
}
|
||||
if cells.len() > wordcraft_doc::table::MAX_ROWS {
|
||||
return Err(CmdError::Failed(format!("a table can have at most {} rows", wordcraft_doc::table::MAX_ROWS)));
|
||||
}
|
||||
let width = super::page::sect(s).text_width();
|
||||
let mut t = Table::new(lines.len().max(1), cols, width);
|
||||
for (r, l) in lines.iter().enumerate() {
|
||||
for (c, txt) in l.split(sep).enumerate() {
|
||||
let mut t = Table::new(cells.len().max(1), cols, width);
|
||||
if col_width > 0.0 {
|
||||
// Every column gets that preferred width, even past the margins (layout fits the table
|
||||
// to the text width, as Word draws it); the table itself has no preferred width.
|
||||
t.grid.iter_mut().for_each(|w| *w = col_width);
|
||||
t.rows.iter_mut().flat_map(|row| row.cells.iter_mut()).for_each(|c| c.props.width = Some(col_width));
|
||||
}
|
||||
for (r, l) in cells.iter().enumerate() {
|
||||
for (c, txt) in l.iter().enumerate() {
|
||||
if let Some(cl) = t.rows.get_mut(r).and_then(|row| row.cells.get_mut(c)) {
|
||||
cl.blocks = vec![para_block(Paragraph::with_text(txt.trim(), Default::default()))];
|
||||
}
|
||||
@@ -1133,7 +1264,10 @@ fn from_text(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let mut first = a.path.0.clone();
|
||||
first.extend([0, 0, 0]);
|
||||
s.sel = Selection::caret(Pos { story: a.story, path: Path(first), off: 0 });
|
||||
sel_result(s)
|
||||
match fit {
|
||||
Some(mode) => autofit(s, &json!({ "mode": mode })),
|
||||
None => sel_result(s),
|
||||
}
|
||||
}
|
||||
|
||||
fn quick_table(s: &mut Session, v: &Value) -> CmdResult {
|
||||
|
||||
@@ -5381,6 +5381,314 @@ fn split_cells_leaves_the_other_rows_alone() {
|
||||
assert_eq!(spans(&s), [vec![1, 1, 1, 1], vec![2, 1, 1]]);
|
||||
}
|
||||
|
||||
fn table_spans(s: &Session) -> Vec<Vec<usize>> {
|
||||
let t = s.doc.body.iter().find_map(|b| b.as_table()).expect("a table");
|
||||
t.rows.iter().map(|r| r.cells.iter().map(wordcraft_doc::Cell::span).collect()).collect()
|
||||
}
|
||||
|
||||
fn table_vmerge(s: &Session) -> Vec<Vec<wordcraft_doc::props::VMerge>> {
|
||||
let t = s.doc.body.iter().find_map(|b| b.as_table()).expect("a table");
|
||||
t.rows.iter().map(|r| r.cells.iter().map(|c| c.props.vmerge).collect()).collect()
|
||||
}
|
||||
|
||||
/// #2592: Split Cells into columns and rows. A one-row cell gets new rows under it, the row's
|
||||
/// other cells merging down over them; one undo puts the table back.
|
||||
#[test]
|
||||
fn split_cells_into_columns_and_rows() {
|
||||
use wordcraft_doc::props::VMerge::{Continue, None as No, Restart};
|
||||
let mut s = table_from("A\tB\nC\tD");
|
||||
let depth = s.undo_depth();
|
||||
run(&mut s, "table.split", json!({"columns": 2, "rows": 2}));
|
||||
assert_eq!(s.undo_depth(), depth + 1);
|
||||
assert_eq!(table_texts(&s), [vec!["A", "", "B"], vec!["", "", ""], vec!["C", "D"]]);
|
||||
assert_eq!(table_spans(&s), [vec![1, 1, 1], vec![1, 1, 1], vec![2, 1]]);
|
||||
assert_eq!(table_vmerge(&s), [vec![No, No, Restart], vec![No, No, Continue], vec![No, No]]);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(table_texts(&s), [["A", "B"], ["C", "D"]]);
|
||||
assert_eq!(table_spans(&s), [[1, 1], [1, 1]]);
|
||||
// Rows only.
|
||||
let mut s = table_from("A\tB");
|
||||
run(&mut s, "table.split", json!({"columns": 1, "rows": 3}));
|
||||
assert_eq!(table_texts(&s), [vec!["A", "B"], vec!["", ""], vec!["", ""]]);
|
||||
assert_eq!(table_vmerge(&s), [[No, Restart], [No, Continue], [No, Continue]]);
|
||||
}
|
||||
|
||||
/// #2592: Merge cells before split merges the selection, then splits the merged cell; without
|
||||
/// it every selected cell is split on its own.
|
||||
#[test]
|
||||
fn split_cells_merge_before_split() {
|
||||
use wordcraft_doc::props::VMerge::None as No;
|
||||
let cell = |r: u32, c: u32| json!({"story": "body", "path": [0, r, c, 0], "off": 0});
|
||||
let mut s = table_from("A\tB\nC\tD");
|
||||
run(&mut s, "select.range", json!({"anchor": cell(0, 0), "focus": cell(1, 1)}));
|
||||
let depth = s.undo_depth();
|
||||
run(&mut s, "table.split", json!({"columns": 2, "rows": 2}));
|
||||
assert_eq!(s.undo_depth(), depth + 1, "merge and split are one step");
|
||||
assert_eq!(table_spans(&s), [[1, 1], [1, 1]]);
|
||||
assert_eq!(table_vmerge(&s), [[No, No], [No, No]]);
|
||||
assert_eq!(table_texts(&s), [["ABCD", ""], ["", ""]]);
|
||||
|
||||
let mut s = table_from("A\tB\nC\tD");
|
||||
run(&mut s, "select.range", json!({"anchor": cell(0, 0), "focus": cell(1, 1)}));
|
||||
run(&mut s, "table.split", json!({"columns": 2, "mergeFirst": false}));
|
||||
assert_eq!(table_texts(&s), [["A", "", "B", ""], ["C", "", "D", ""]]);
|
||||
assert_eq!(table_spans(&s), [[1, 1, 1, 1], [1, 1, 1, 1]]);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(table_texts(&s), [["A", "B"], ["C", "D"]]);
|
||||
|
||||
// As in Word: merged cells split into at most as many rows as were selected, and cells split
|
||||
// one by one keep their rows.
|
||||
let mut s = table_from("A\tB\nC\tD");
|
||||
run(&mut s, "select.range", json!({"anchor": cell(0, 0), "focus": cell(1, 0)}));
|
||||
let depth = s.undo_depth();
|
||||
assert!(s.run("table.split", &json!({"columns": 1, "rows": 3})).is_err(), "rows 1 to 2 only");
|
||||
assert!(s.run("table.split", &json!({"columns": 2, "rows": 2, "mergeFirst": false})).is_err());
|
||||
assert_eq!(s.undo_depth(), depth);
|
||||
assert_eq!(table_texts(&s), [["A", "B"], ["C", "D"]]);
|
||||
run(&mut s, "table.split", json!({"columns": 1, "rows": 2}));
|
||||
assert_eq!(table_texts(&s), [["AC", "B"], ["", "D"]]);
|
||||
}
|
||||
|
||||
/// #2592: a vertically merged cell splits into rows that divide its height; other counts are
|
||||
/// refused without changing anything.
|
||||
#[test]
|
||||
fn split_cells_of_a_vertically_merged_cell() {
|
||||
use wordcraft_doc::props::VMerge::{Continue, None as No, Restart};
|
||||
let cell = |r: u32, c: u32| json!({"story": "body", "path": [0, r, c, 0], "off": 0});
|
||||
let merged = || {
|
||||
let mut s = table_from("A\tB\nC\tD\nE\tF\nG\tH");
|
||||
run(&mut s, "select.range", json!({"anchor": cell(0, 0), "focus": cell(3, 0)}));
|
||||
run(&mut s, "table.merge", json!({}));
|
||||
s
|
||||
};
|
||||
let mut s = merged();
|
||||
assert_eq!(table_vmerge(&s), [[Restart, No], [Continue, No], [Continue, No], [Continue, No]]);
|
||||
run(&mut s, "table.split", json!({"columns": 2, "rows": 2}));
|
||||
assert_eq!(s.doc.body.iter().find_map(|b| b.as_table()).unwrap().rows.len(), 4, "no new rows");
|
||||
assert_eq!(table_vmerge(&s), [[Restart, Restart, No], [Continue, Continue, No], [Restart, Restart, No], [Continue, Continue, No]]);
|
||||
assert_eq!(table_spans(&s), [[1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1]]);
|
||||
// The caret in a continued cell splits the whole merged cell.
|
||||
let mut s = merged();
|
||||
run(&mut s, "caret.set", json!({"pos": cell(2, 0)}));
|
||||
run(&mut s, "table.split", json!({"columns": 1, "rows": 4}));
|
||||
assert_eq!(table_vmerge(&s), [[No, No], [No, No], [No, No], [No, No]]);
|
||||
let mut s = merged();
|
||||
let (depth, before) = (s.undo_depth(), table_vmerge(&s));
|
||||
assert!(s.run("table.split", &json!({"columns": 1, "rows": 3})).is_err(), "3 doesn't divide 4");
|
||||
assert_eq!((s.undo_depth(), table_vmerge(&s)), (depth, before));
|
||||
}
|
||||
|
||||
/// #2592 review: cells split one by one split a vertically merged cell once, not once per row it
|
||||
/// covers, and every other selected cell in two.
|
||||
#[test]
|
||||
fn split_cells_one_by_one_with_a_merged_cell() {
|
||||
use wordcraft_doc::props::VMerge::{Continue, None as No, Restart};
|
||||
let cell = |r: u32, c: u32| json!({"story": "body", "path": [0, r, c, 0], "off": 0});
|
||||
let merged = || {
|
||||
let mut s = table_from("A\tB\nC\tD");
|
||||
run(&mut s, "select.range", json!({"anchor": cell(0, 0), "focus": cell(1, 0)}));
|
||||
run(&mut s, "table.merge", json!({}));
|
||||
s
|
||||
};
|
||||
let mut s = merged();
|
||||
run(&mut s, "table.selectTable", json!({}));
|
||||
run(&mut s, "table.split", json!({"columns": 2, "mergeFirst": false}));
|
||||
assert_eq!(table_spans(&s), [[1, 1, 1, 1], [1, 1, 1, 1]]);
|
||||
assert_eq!(table_texts(&s), [["AC", "", "B", ""], ["", "", "D", ""]]);
|
||||
assert_eq!(table_vmerge(&s), [[Restart, Restart, No, No], [Continue, Continue, No, No]]);
|
||||
// The merged cell alone is one cell.
|
||||
let mut s = merged();
|
||||
run(&mut s, "select.range", json!({"anchor": cell(0, 0), "focus": cell(1, 0)}));
|
||||
run(&mut s, "table.split", json!({"columns": 2, "mergeFirst": false}));
|
||||
assert_eq!(table_spans(&s), [[1, 1, 1], [1, 1, 1]]);
|
||||
assert_eq!(table_vmerge(&s), [[Restart, Restart, No], [Continue, Continue, No]]);
|
||||
}
|
||||
|
||||
/// #2592: Convert Text to Table separates at paragraphs, commas, tabs or another character,
|
||||
/// takes a column count and an AutoFit behaviour, and undoes in one step.
|
||||
#[test]
|
||||
fn convert_text_to_table_dialog_options() {
|
||||
let convert = |text: &str, params: serde_json::Value| {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": text}));
|
||||
run(&mut s, "select.all", json!({}));
|
||||
let depth = s.undo_depth();
|
||||
run(&mut s, "table.fromText", params);
|
||||
assert_eq!(s.undo_depth(), depth + 1);
|
||||
s
|
||||
};
|
||||
let s1 = convert("a;b;c\nd;e", json!({"separator": "other", "char": ";"}));
|
||||
assert_eq!(table_texts(&s1), [["a", "b", "c"], ["d", "e", ""]]);
|
||||
let s1 = convert("a\nb\nc\nd", json!({"separator": "paragraph"}));
|
||||
assert_eq!(table_texts(&s1), [["a"], ["b"], ["c"], ["d"]]);
|
||||
let s1 = convert("a\nb\nc", json!({"separator": "paragraph", "columns": 2}));
|
||||
assert_eq!(table_texts(&s1), [["a", "b"], ["c", ""]]);
|
||||
// More columns than fields pads; fewer wraps a paragraph's fields onto more rows.
|
||||
let s1 = convert("a\tb\tc\nd", json!({"columns": 4}));
|
||||
assert_eq!(table_texts(&s1), [["a", "b", "c", ""], ["d", "", "", ""]]);
|
||||
let s1 = convert("a\tb\tc\nd", json!({"columns": 2}));
|
||||
assert_eq!(table_texts(&s1), [["a", "b"], ["c", ""], ["d", ""]]);
|
||||
let mut s1 = convert("a,b\nc,d", json!({"separator": "comma", "autofit": "fixed"}));
|
||||
let t = |s: &Session| s.doc.body.iter().find_map(|b| b.as_table()).unwrap().clone();
|
||||
assert!(t(&s1).props.fixed);
|
||||
run(&mut s1, "edit.undo", json!({}));
|
||||
assert_eq!(body_texts(&s1), ["a,b", "c,d"]);
|
||||
let s1 = convert("a,b\nc,d", json!({"separator": "comma", "autofit": "window"}));
|
||||
assert_eq!((t(&s1).props.fixed, t(&s1).props.width_pct), (false, Some(100.0)));
|
||||
let s1 = convert("a,b\nc,d", json!({"separator": "comma", "autofit": "contents"}));
|
||||
let width = cmd::page::sect(&s1).text_width();
|
||||
assert!(!t(&s1).props.fixed && t(&s1).total_width() < width / 2.0, "{:?}", t(&s1).grid);
|
||||
// Junk is refused or clamped, never a panic.
|
||||
let mut s1 = s();
|
||||
run(&mut s1, "document.setText", json!({"text": "a-b"}));
|
||||
run(&mut s1, "select.all", json!({}));
|
||||
assert!(s1.run("table.fromText", &json!({"separator": "other"})).is_err(), "no character");
|
||||
assert!(s1.run("table.fromText", &json!({"separator": "other", "char": ""})).is_err());
|
||||
assert!(s1.run("table.fromText", &json!({"separator": "nonsense"})).is_err());
|
||||
assert!(s1.run("table.fromText", &json!({"autofit": "nonsense"})).is_err());
|
||||
assert_eq!(body_texts(&s1), ["a-b"]);
|
||||
run(&mut s1, "table.fromText", json!({"separator": "other", "char": "-", "columns": 1_000_000_000_u64}));
|
||||
assert_eq!(table_texts(&s1).first().map(Vec::len), Some(63));
|
||||
}
|
||||
|
||||
/// #2592, as Word does: Initial column width Auto gives equal columns across the text width;
|
||||
/// a width gives every column that preferred width (kept even when the table is drawn narrower
|
||||
/// to fit the text width), with no table width; both leave the table resizing to its contents.
|
||||
#[test]
|
||||
fn convert_text_to_table_initial_column_width() {
|
||||
let convert = |params: serde_json::Value| {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "a\tb\tc\nd\te\tf"}));
|
||||
run(&mut s, "select.all", json!({}));
|
||||
let r = s.run("table.fromText", ¶ms);
|
||||
(s, r)
|
||||
};
|
||||
let t = |s: &Session| s.doc.body.iter().find_map(|b| b.as_table()).unwrap().clone();
|
||||
let (s1, _) = convert(json!({"autofit": "initial"}));
|
||||
let width = cmd::page::sect(&s1).text_width();
|
||||
let tb = t(&s1);
|
||||
assert!(!tb.props.fixed && tb.props.width.is_none());
|
||||
assert!(tb.grid.iter().all(|&w| (w - width / 3.0).abs() < 0.1), "{:?}", tb.grid);
|
||||
let (s0, _) = convert(json!({"autofit": "initial", "width": 0}));
|
||||
assert_eq!(t(&s0).grid, tb.grid, "0 is Auto");
|
||||
|
||||
let (s1, _) = convert(json!({"autofit": "initial", "width": 72}));
|
||||
let tb = t(&s1);
|
||||
assert_eq!(tb.grid, [72.0, 72.0, 72.0]);
|
||||
assert!(tb.rows.iter().flat_map(|r| &r.cells).all(|c| c.props.width == Some(72.0)));
|
||||
assert!(!tb.props.fixed && tb.props.width.is_none());
|
||||
|
||||
// 5 in columns are stored as such but drawn within the text width.
|
||||
let (mut s1, _) = convert(json!({"width": 360}));
|
||||
assert_eq!(t(&s1).grid, [360.0, 360.0, 360.0]);
|
||||
let drawn: f32 = s1.layout().pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|it| match it {
|
||||
wordcraft_layout::Placed::Cell { rect, row: 0, .. } => Some(rect.w),
|
||||
_ => None,
|
||||
})
|
||||
.sum();
|
||||
assert!(drawn > 0.0 && drawn <= width + 0.5, "{drawn} vs {width}");
|
||||
|
||||
// A width is the Initial column width only: AutoFit to contents or the window ignores it.
|
||||
for mode in ["contents", "window"] {
|
||||
let (with, _) = convert(json!({"autofit": mode, "width": 72}));
|
||||
let (without, _) = convert(json!({ "autofit": mode }));
|
||||
let cell_widths = |s: &Session| t(s).rows.iter().flat_map(|r| r.cells.iter().map(|c| c.props.width)).collect::<Vec<_>>();
|
||||
assert_eq!((t(&with).grid, cell_widths(&with)), (t(&without).grid, cell_widths(&without)), "{mode}");
|
||||
}
|
||||
|
||||
// More than 22 in, or less than nothing, is refused without converting.
|
||||
for bad in [json!(1585), json!(-1), json!("wide")] {
|
||||
let (s1, r) = convert(json!({ "width": bad }));
|
||||
assert!(r.is_err(), "{bad}");
|
||||
assert!(s1.doc.body.iter().all(|b| b.as_table().is_none()));
|
||||
}
|
||||
}
|
||||
|
||||
/// #2592, as Word does: a vertically merged cell splits into 1 to its height rows; its height
|
||||
/// (or 1 × 1) cuts it back into one cell per row, more is refused with Word's message and
|
||||
/// nothing changes, also when neighbours are merged with it first.
|
||||
#[test]
|
||||
fn split_cells_of_a_merged_cell_follow_word() {
|
||||
use wordcraft_doc::props::VMerge::None as No;
|
||||
let cell = |r: u32, c: u32| json!({"story": "body", "path": [0, r, c, 0], "off": 0});
|
||||
let merged = || {
|
||||
let mut s = table_from("A\tB\nC\tD\nE\tF\nG\tH");
|
||||
run(&mut s, "select.range", json!({"anchor": cell(0, 0), "focus": cell(1, 0)}));
|
||||
run(&mut s, "table.merge", json!({}));
|
||||
s
|
||||
};
|
||||
for params in [json!({"columns": 1, "rows": 3}), json!({"columns": 1, "rows": 4}), json!({"columns": 2, "rows": 3})] {
|
||||
let mut s = merged();
|
||||
let (depth, before) = (s.undo_depth(), table_vmerge(&s));
|
||||
let e = s.run("table.split", ¶ms).unwrap_err().to_string();
|
||||
assert!(e.contains("The number must be between 1 and 2"), "{e}");
|
||||
assert_eq!((s.undo_depth(), table_vmerge(&s)), (depth, before));
|
||||
}
|
||||
for params in [json!({"columns": 1, "rows": 2}), json!({"columns": 1, "rows": 1})] {
|
||||
let mut s = merged();
|
||||
run(&mut s, "table.split", params.clone());
|
||||
assert_eq!(table_vmerge(&s), [[No, No], [No, No], [No, No], [No, No]], "{params}");
|
||||
assert_eq!(table_texts(&s)[0][0], "AC");
|
||||
assert_eq!(table_texts(&s)[1][0], "");
|
||||
}
|
||||
// Merge cells before split, then 1 × 1: one cell, as before (merging through Split Cells).
|
||||
let mut s = table_from("A\tB\nC\tD");
|
||||
run(&mut s, "select.range", json!({"anchor": cell(0, 0), "focus": cell(1, 1)}));
|
||||
run(&mut s, "table.split", json!({"columns": 1, "rows": 1}));
|
||||
assert_eq!(table_spans(&s), [[2], [2]]);
|
||||
assert_eq!(table_vmerge(&s), [[wordcraft_doc::props::VMerge::Restart], [wordcraft_doc::props::VMerge::Continue]]);
|
||||
// The merged cell and its neighbours, merged first: the limit is the selection's height.
|
||||
let mut s = merged();
|
||||
run(&mut s, "select.range", json!({"anchor": cell(0, 0), "focus": cell(1, 1)}));
|
||||
let e = s.run("table.split", &json!({"columns": 1, "rows": 3})).unwrap_err().to_string();
|
||||
assert!(e.contains("The number must be between 1 and 2"), "{e}");
|
||||
}
|
||||
|
||||
/// #2592, as Word does: a cell merged across columns splits into equal parts inside its old
|
||||
/// width; the other rows' cells keep their widths.
|
||||
#[test]
|
||||
fn split_cells_across_columns_into_equal_parts() {
|
||||
let cell = |r: u32, c: u32| json!({"story": "body", "path": [0, r, c, 0], "off": 0});
|
||||
let widths = |s: &Session| -> Vec<Vec<f32>> {
|
||||
let t = s.doc.body.iter().find_map(|b| b.as_table()).unwrap();
|
||||
(0..t.rows.len())
|
||||
.map(|r| {
|
||||
let row = &t.rows[r];
|
||||
(0..row.cells.len()).map(|c| (0..row.cells[c].span()).map(|k| t.grid[t.grid_col(r, c) + k]).sum()).collect()
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
for (across, parts) in [(2, 3), (3, 2), (2, 2)] {
|
||||
let mut s = table_from("A\tB\tC\nD\tE\tF");
|
||||
// Unequal columns, as in a real document.
|
||||
s.doc.table_mut(StoryRef::Body, &wordcraft_doc::Path(vec![0])).unwrap().grid = vec![100.0, 50.0, 150.0];
|
||||
run(&mut s, "select.range", json!({"anchor": cell(1, 0), "focus": cell(1, across - 1)}));
|
||||
run(&mut s, "table.merge", json!({}));
|
||||
let (top, old) = (widths(&s)[0].clone(), widths(&s)[1][0]);
|
||||
run(&mut s, "table.split", json!({"columns": parts, "rows": 1}));
|
||||
let w = widths(&s);
|
||||
let new: Vec<f32> = w[1].iter().take(parts as usize).copied().collect();
|
||||
assert!(new.iter().all(|x| (x - old / parts as f32).abs() < 0.1), "{across}->{parts}: {w:?}");
|
||||
let mut top_lines: Vec<f32> = Vec::new();
|
||||
let mut x = 0.0;
|
||||
for v in &w[0] {
|
||||
x += v;
|
||||
top_lines.push(x);
|
||||
}
|
||||
let mut expect = Vec::new();
|
||||
let mut x = 0.0;
|
||||
for v in &top {
|
||||
x += v;
|
||||
expect.push(x);
|
||||
}
|
||||
assert_eq!(top_lines.len(), expect.len(), "{across}->{parts}: {w:?}");
|
||||
assert!(top_lines.iter().zip(&expect).all(|(a, b)| (a - b).abs() < 0.1), "{across}->{parts}: {w:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #385: note options for the whole document or one section, and converting notes, which undoes
|
||||
/// in one step.
|
||||
#[test]
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
//! Layout, Review and Mailings dialogs (#407): Line Numbers (Layout › Line Numbers › Line
|
||||
//! Numbering Options…), Hyphenation (Hyphenation Options… and Manual…), Language (Review ›
|
||||
//! Language), Envelopes and Labels (Mailings › Create), Formula (Table Layout › Data, #1201)
|
||||
//! and Sort (Table Layout › Data, #1200). Each ends by running a command (`layout.lineNumbers`,
|
||||
//! Sort (Table Layout › Data, #1200), Split Cells (Table Layout › Merge) and Convert Text to Table
|
||||
//! (Insert › Table, #2592). Each ends by running a command (`layout.lineNumbers`,
|
||||
//! `layout.hyphenation`, `layout.manualHyphenation` with `caret.set` and `text.optionalHyphen`,
|
||||
//! `review.language`, `mailings.envelopes`, `mailings.labels`, `table.formula`, `table.sort`), so
|
||||
//! `review.language`, `mailings.envelopes`, `mailings.labels`, `table.formula`, `table.sort`,
|
||||
//! `table.split`, `table.fromText`), so
|
||||
//! scripts and agents get the same result without the dialog. Shown through
|
||||
//! [`crate::dialogs::Dialog::Layout`].
|
||||
|
||||
@@ -29,6 +31,8 @@ pub enum LayoutDialog {
|
||||
Labels(LabelsForm),
|
||||
Formula(FormulaForm),
|
||||
Sort(SortForm),
|
||||
SplitCells(SplitCellsForm),
|
||||
TextToTable(TextToTableForm),
|
||||
}
|
||||
|
||||
impl LayoutDialog {
|
||||
@@ -42,6 +46,8 @@ impl LayoutDialog {
|
||||
LayoutDialog::Labels(_) => "labels",
|
||||
LayoutDialog::Formula(_) => "tableFormula",
|
||||
LayoutDialog::Sort(_) => "tableSort",
|
||||
LayoutDialog::SplitCells(_) => "splitCells",
|
||||
LayoutDialog::TextToTable(_) => "textToTable",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +62,8 @@ impl LayoutDialog {
|
||||
LayoutDialog::Labels(_) => "Labels",
|
||||
LayoutDialog::Formula(_) => "Formula",
|
||||
LayoutDialog::Sort(_) => "Sort",
|
||||
LayoutDialog::SplitCells(_) => "Split Cells",
|
||||
LayoutDialog::TextToTable(_) => "Convert Text to Table",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -71,6 +79,8 @@ pub fn open(name: &str, app: &mut WordApp) -> Option<LayoutDialog> {
|
||||
"labels" => LayoutDialog::Labels(LabelsForm::read(app)),
|
||||
"tableFormula" => LayoutDialog::Formula(FormulaForm::read(app)?),
|
||||
"tableSort" => LayoutDialog::Sort(SortForm::read(app)?),
|
||||
"splitCells" => LayoutDialog::SplitCells(SplitCellsForm::read(app)?),
|
||||
"textToTable" => LayoutDialog::TextToTable(TextToTableForm::read(app)?),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -86,6 +96,8 @@ pub fn body(app: &mut WordApp, ui: &mut Ui, d: &mut LayoutDialog) -> bool {
|
||||
LayoutDialog::Labels(f) => labels_ui(app, ui, f),
|
||||
LayoutDialog::Formula(f) => formula_ui(app, ui, f),
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -804,6 +816,283 @@ fn sort_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SortForm) -> bool {
|
||||
cancel
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Split Cells
|
||||
|
||||
/// The Split Cells dialog's fields. As in Word, the defaults follow the selection: twice its
|
||||
/// columns and its rows. Merge cells before split is offered, and on, when several cells (or one
|
||||
/// cell merged across columns) are selected (`several`). A merged selection splits into at most
|
||||
/// `max_rows` rows, a vertically merged cell alone into at most its `height`; more gets Word's
|
||||
/// message ([`SplitCellsForm::check`]). Without merging each cell splits into columns on its own
|
||||
/// and the row count stays 1.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SplitCellsForm {
|
||||
pub columns: u32,
|
||||
pub rows: u32,
|
||||
pub merge_first: bool,
|
||||
pub several: bool,
|
||||
/// The selected cells, a vertically merged cell counted once.
|
||||
pub cells: u32,
|
||||
/// The rows a single selected cell covers (more than 1 when it is merged down).
|
||||
pub height: u32,
|
||||
/// The selected rows: the default row count and its limit while merging.
|
||||
pub max_rows: u32,
|
||||
/// The default column count: twice the most cells selected in a row.
|
||||
pub default_columns: u32,
|
||||
/// Word's message for a row count out of range, shown until OK is pressed again.
|
||||
#[serde(skip)]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl SplitCellsForm {
|
||||
/// The selected cells; none outside a table.
|
||||
pub fn read(app: &WordApp) -> Option<SplitCellsForm> {
|
||||
let s = &app.session;
|
||||
let (tp, _, _) = s.sel.focus.path.cell()?;
|
||||
let mut cells = wordcraft_engine::cmd::table::selected_cells(s);
|
||||
cells.sort_unstable();
|
||||
cells.dedup();
|
||||
let mut rows: Vec<usize> = cells.iter().map(|&(r, _)| r).collect();
|
||||
rows.dedup();
|
||||
let per_row = rows.iter().map(|&r| cells.iter().filter(|c| c.0 == r).count()).max().unwrap_or(1);
|
||||
// A vertically merged cell is one cell, however many rows it covers.
|
||||
let targets = wordcraft_engine::cmd::table::split_targets(s);
|
||||
let t = s.doc.table(s.sel.focus.story, &tp);
|
||||
let (span, height) = match (targets.as_slice(), t) {
|
||||
([(top, g)], Some(t)) => t
|
||||
.cell_at_grid(*top, *g)
|
||||
.map(|ci| {
|
||||
(t.rows.get(*top).and_then(|row| row.cells.get(ci)).map_or(1, |c| c.span()), t.vertical_extent(*top, ci).map_or(1, |(_, k)| k))
|
||||
})
|
||||
.unwrap_or((1, 1)),
|
||||
_ => (1, 1),
|
||||
};
|
||||
// Word offers Merge for one cell merged across columns too.
|
||||
let several = targets.len() > 1 || span > 1;
|
||||
let max_rows = if targets.len() > 1 { rows.len().clamp(1, 63) as u32 } else { 1 };
|
||||
let default_columns = per_row.saturating_mul(2).clamp(2, 63) as u32;
|
||||
Some(SplitCellsForm {
|
||||
columns: default_columns,
|
||||
rows: max_rows,
|
||||
merge_first: several,
|
||||
several,
|
||||
cells: targets.len().clamp(1, 63 * 63) as u32,
|
||||
height: height.clamp(1, 32_767) as u32,
|
||||
max_rows,
|
||||
default_columns,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Ticks or unticks Merge cells before split; unticked, each cell splits into two columns
|
||||
/// in its own row, as Word resets the boxes.
|
||||
pub fn set_merge(&mut self, on: bool) {
|
||||
self.merge_first = on && self.several;
|
||||
(self.columns, self.rows) = if self.merge_first { (self.default_columns, self.max_rows) } else { (2, 1) };
|
||||
}
|
||||
|
||||
/// Word's message when the row count is out of range: a merged selection splits into 1 to
|
||||
/// its rows, a merged cell into 1 to its height.
|
||||
pub fn check(&self) -> Option<String> {
|
||||
let limit = if self.cells > 1 && self.merge_first {
|
||||
self.max_rows
|
||||
} else if self.cells <= 1 && self.height > 1 {
|
||||
self.height
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
(self.rows > limit).then(|| crate::i18n::fmt(tl!("The number must be between 1 and {max}."), &[("max", &limit.to_string())]))
|
||||
}
|
||||
|
||||
/// `table.split` parameters for OK.
|
||||
pub fn params(&self) -> Value {
|
||||
json!({"columns": self.columns, "rows": self.rows, "mergeFirst": self.several && self.merge_first})
|
||||
}
|
||||
}
|
||||
|
||||
/// An alert line under a dialog's fields, in the error colour.
|
||||
fn alert(ui: &mut Ui, text: &Option<String>) {
|
||||
if let Some(e) = text {
|
||||
ui.colored_label(ui.visuals().error_fg_color, e.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
fn split_cells_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SplitCellsForm) -> bool {
|
||||
// Several cells split one by one keep their rows.
|
||||
let rows_on = !(f.cells > 1 && !f.merge_first);
|
||||
egui::Grid::new("split_cells").num_columns(2).show(ui, |ui| {
|
||||
ui.label(tl!("Number of columns:"));
|
||||
ui.add(egui::DragValue::new(&mut f.columns).range(1..=63));
|
||||
ui.end_row();
|
||||
ui.label(tl!("Number of rows:"));
|
||||
ui.add_enabled(rows_on, egui::DragValue::new(&mut f.rows).range(1..=63));
|
||||
ui.end_row();
|
||||
});
|
||||
let mut merge = f.merge_first;
|
||||
if ui.add_enabled(f.several, egui::Checkbox::new(&mut merge, tl!("Merge cells before split"))).changed() {
|
||||
f.set_merge(merge);
|
||||
}
|
||||
alert(ui, &f.error);
|
||||
let (ok, cancel) = buttons(ui, "OK", true);
|
||||
if ok {
|
||||
// Out of range: Word's message, and the dialog stays open.
|
||||
f.error = f.check();
|
||||
return f.error.is_none() && run(app, "table.split", f.params());
|
||||
}
|
||||
cancel
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Convert Text to Table
|
||||
|
||||
/// The Convert Text to Table dialog's fields: the selected paragraphs (`lines`), where to
|
||||
/// separate them (`paragraph`, `comma`, `tab` or `other` with `other_char`), the column count
|
||||
/// (guessed from the text for the separator, as Word does) and the AutoFit behaviour: an initial
|
||||
/// column width (`width`, "Auto" or a measurement up to 22 in), or AutoFit to contents or to the
|
||||
/// window. `rows` is the resulting row count, shown read-only.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TextToTableForm {
|
||||
#[serde(skip)]
|
||||
pub lines: Vec<String>,
|
||||
pub columns: u32,
|
||||
pub rows: usize,
|
||||
pub autofit: String,
|
||||
/// Initial column width as typed: "Auto" (or 0) shares the text width out.
|
||||
pub width: String,
|
||||
pub separator: String,
|
||||
pub other_char: String,
|
||||
/// Word's message for a width out of range, shown until OK is pressed again.
|
||||
#[serde(skip)]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl TextToTableForm {
|
||||
/// The selected paragraphs; none when the selection can't be converted (a table inside it).
|
||||
pub fn read(app: &WordApp) -> Option<TextToTableForm> {
|
||||
let lines = wordcraft_engine::cmd::table::text_table_lines(&app.session).ok()?;
|
||||
// Tabs when the text has them, else paragraphs (Word doesn't guess commas).
|
||||
let separator = if lines.iter().any(|l| l.contains('\t')) { "tab" } else { "paragraph" };
|
||||
let mut f = TextToTableForm {
|
||||
lines,
|
||||
columns: 1,
|
||||
rows: 0,
|
||||
autofit: "initial".into(),
|
||||
width: tl!("Auto").into(),
|
||||
separator: separator.into(),
|
||||
other_char: "-".into(),
|
||||
error: None,
|
||||
};
|
||||
f.guess_columns();
|
||||
Some(f)
|
||||
}
|
||||
|
||||
fn sep(&self) -> Option<char> {
|
||||
match self.separator.as_str() {
|
||||
"comma" => Some(','),
|
||||
"tab" => Some('\t'),
|
||||
"other" => Some(self.other_char.chars().next().unwrap_or('-')),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The column count the text gives with the chosen separator.
|
||||
pub fn guess_columns(&mut self) {
|
||||
let cols = wordcraft_engine::cmd::table::text_table_cells(&self.lines, self.sep(), None).1;
|
||||
self.columns = cols.clamp(1, 63) as u32;
|
||||
self.count_rows();
|
||||
}
|
||||
|
||||
/// The row count for the chosen separator and columns.
|
||||
pub fn count_rows(&mut self) {
|
||||
self.rows = wordcraft_engine::cmd::table::text_table_cells(&self.lines, self.sep(), Some(self.columns as usize)).0.len();
|
||||
}
|
||||
|
||||
/// The initial column width in points: 0 for Auto, `None` when it isn't a measurement from
|
||||
/// 0 to 22 in.
|
||||
fn width_pt(&self) -> Option<f32> {
|
||||
let text = self.width.trim();
|
||||
if text.is_empty() || text.eq_ignore_ascii_case("auto") || text == tl!("Auto") {
|
||||
return Some(0.0);
|
||||
}
|
||||
wordcraft_geom::parse_measure(text, unit()).filter(|pt| (0.0..=22.0 * 72.0).contains(pt))
|
||||
}
|
||||
|
||||
/// Word's message when the initial column width is out of range (only with that option).
|
||||
pub fn check(&self) -> Option<String> {
|
||||
if self.autofit != "initial" || self.width_pt().is_some() {
|
||||
return None;
|
||||
}
|
||||
let sep = crate::options::decimal();
|
||||
let (min, max) = (unit().format_with(0.0, sep), unit().format_with(22.0 * 72.0, sep));
|
||||
Some(crate::i18n::fmt(tl!("The measurement must be between {min} and {max}."), &[("min", &min), ("max", &max)]))
|
||||
}
|
||||
|
||||
/// `table.fromText` parameters for OK.
|
||||
pub fn params(&self) -> Value {
|
||||
let mut v = json!({"separator": self.separator, "columns": self.columns, "autofit": self.autofit});
|
||||
if self.separator == "other" {
|
||||
v["char"] = json!(self.other_char);
|
||||
}
|
||||
if self.autofit == "initial"
|
||||
&& let Some(w) = self.width_pt().filter(|&w| w > 0.0)
|
||||
{
|
||||
v["width"] = json!(w);
|
||||
}
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
fn text_to_table_ui(app: &mut WordApp, ui: &mut Ui, f: &mut TextToTableForm) -> bool {
|
||||
let before = (f.separator.clone(), f.other_char.clone(), f.columns);
|
||||
heading(ui, "Table size");
|
||||
egui::Grid::new("text_to_table").num_columns(2).show(ui, |ui| {
|
||||
ui.label(tl!("Number of columns:"));
|
||||
ui.add(egui::DragValue::new(&mut f.columns).range(1..=63));
|
||||
ui.end_row();
|
||||
ui.label(tl!("Number of rows:"));
|
||||
ui.label(f.rows.to_string());
|
||||
ui.end_row();
|
||||
});
|
||||
heading(ui, "AutoFit behavior");
|
||||
ui.horizontal(|ui| {
|
||||
ui.radio_value(&mut f.autofit, "initial".to_string(), tl!("Initial column width:"));
|
||||
ui.add_enabled(f.autofit == "initial", egui::TextEdit::singleline(&mut f.width).desired_width(72.0));
|
||||
});
|
||||
ui.radio_value(&mut f.autofit, "contents".to_string(), tl!("AutoFit to contents"));
|
||||
ui.radio_value(&mut f.autofit, "window".to_string(), tl!("AutoFit to window"));
|
||||
heading(ui, "Separate text at");
|
||||
egui::Grid::new("text_to_table_sep").num_columns(2).show(ui, |ui| {
|
||||
ui.radio_value(&mut f.separator, "paragraph".to_string(), tl!("Paragraphs"));
|
||||
ui.radio_value(&mut f.separator, "comma".to_string(), tl!("Commas"));
|
||||
ui.end_row();
|
||||
ui.radio_value(&mut f.separator, "tab".to_string(), tl!("Tabs"));
|
||||
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();
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
});
|
||||
if (f.separator.as_str(), f.other_char.as_str()) != (before.0.as_str(), before.1.as_str()) {
|
||||
f.guess_columns();
|
||||
} else if f.columns != before.2 {
|
||||
f.count_rows();
|
||||
}
|
||||
alert(ui, &f.error);
|
||||
let ready = f.separator != "other" || !f.other_char.is_empty();
|
||||
let (ok, cancel) = buttons(ui, "OK", ready);
|
||||
if ok {
|
||||
// Out of range: Word's message, and the dialog stays open.
|
||||
f.error = f.check();
|
||||
return f.error.is_none() && run(app, "table.fromText", f.params());
|
||||
}
|
||||
cancel
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
@@ -1060,6 +1349,155 @@ mod tests {
|
||||
assert!(a.dialog.is_none());
|
||||
}
|
||||
|
||||
fn table_shape(a: &WordApp) -> Vec<usize> {
|
||||
a.session.doc.body.iter().find_map(|b| b.as_table()).unwrap().rows.iter().map(|r| r.cells.len()).collect()
|
||||
}
|
||||
|
||||
/// #2592: Split Cells opens its dialog from the ribbon's command (scripts with settings never
|
||||
/// see it); OK splits the cell into the entered columns and rows. Merge cells before split is
|
||||
/// offered, and on, when several cells are selected.
|
||||
#[test]
|
||||
fn split_cells_opens_its_dialog() {
|
||||
let mut a = table_app(2, &["A", "B", "C", "D"]);
|
||||
assert_eq!(a.run("table.split", json!({})).unwrap(), json!({"pending": "splitCells"}));
|
||||
assert_eq!(table_shape(&a), [2, 2], "nothing split yet");
|
||||
let LayoutDialog::SplitCells(mut f) = form(&mut a) else { panic!("split cells") };
|
||||
assert_eq!((f.columns, f.rows, f.several), (2, 1, false));
|
||||
assert_eq!(f.params()["mergeFirst"], false);
|
||||
(f.columns, f.rows) = (3, 2);
|
||||
a.run("table.split", f.params()).unwrap();
|
||||
assert_eq!(table_shape(&a), [2, 4, 4]);
|
||||
assert!(a.dialog.is_none());
|
||||
|
||||
let mut a = table_app(2, &["A", "B", "C", "D"]);
|
||||
let cell = |r: u32, c: u32| json!({"story": "body", "path": [0, r, c, 0], "off": 0});
|
||||
a.run("select.range", json!({"anchor": cell(0, 0), "focus": cell(1, 1)})).unwrap();
|
||||
a.run("ui.dialog", json!({"name": "splitCells"})).unwrap();
|
||||
let LayoutDialog::SplitCells(mut f) = form(&mut a) else { panic!("split cells") };
|
||||
assert!(f.several && f.merge_first);
|
||||
(f.columns, f.rows) = (2, 1);
|
||||
a.run("table.split", f.params()).unwrap();
|
||||
// The merged cell spans both rows; its two parts do too.
|
||||
assert_eq!(table_shape(&a), [2, 2], "merged into one cell, then split in two");
|
||||
let t = a.session.doc.body.iter().find_map(|b| b.as_table()).unwrap();
|
||||
assert!(t.rows[1].cells.iter().all(|c| c.props.vmerge == wordcraft_doc::props::VMerge::Continue));
|
||||
|
||||
let mut a = app();
|
||||
a.run("ui.dialog", json!({"name": "splitCells"})).unwrap();
|
||||
assert!(a.dialog.is_none(), "no dialog outside a table");
|
||||
}
|
||||
|
||||
/// #2592, as Word does: Split Cells starts with twice the selected columns and the selected
|
||||
/// rows; Merge is off and greyed for one cell. Unticking it splits each cell on its own: one
|
||||
/// row and two columns again.
|
||||
#[test]
|
||||
fn split_cells_defaults_follow_the_selection() {
|
||||
let cell = |r: u32, c: u32| json!({"story": "body", "path": [0, r, c, 0], "off": 0});
|
||||
let defaults = |anchor: Value, focus: Value| {
|
||||
let mut a = table_app(3, &["A", "B", "C", "D", "E", "F", "G", "H", "I"]);
|
||||
a.run("select.range", json!({"anchor": anchor, "focus": focus})).unwrap();
|
||||
a.run("ui.dialog", json!({"name": "splitCells"})).unwrap();
|
||||
let LayoutDialog::SplitCells(f) = form(&mut a) else { panic!("split cells") };
|
||||
f
|
||||
};
|
||||
let f = defaults(cell(1, 1), cell(1, 1));
|
||||
assert_eq!((f.columns, f.rows, f.several, f.merge_first), (2, 1, false, false), "one cell");
|
||||
let f = defaults(cell(0, 0), cell(1, 0));
|
||||
assert_eq!((f.columns, f.rows, f.merge_first), (2, 2, true), "two cells in a column");
|
||||
let f = defaults(cell(0, 0), cell(0, 2));
|
||||
assert_eq!((f.columns, f.rows, f.merge_first), (6, 1, true), "a row of three");
|
||||
let mut f = defaults(cell(0, 0), cell(1, 2));
|
||||
assert_eq!((f.columns, f.rows, f.merge_first, f.max_rows), (6, 2, true, 2), "two rows of three");
|
||||
f.set_merge(false);
|
||||
assert_eq!((f.columns, f.rows), (2, 1), "each cell on its own");
|
||||
assert_eq!(f.params()["mergeFirst"], false);
|
||||
|
||||
// A merged cell is one cell, wherever in it the selection is.
|
||||
let mut a = table_app(3, &["A", "B", "C", "D", "E", "F", "G", "H", "I"]);
|
||||
a.run("select.range", json!({"anchor": cell(0, 0), "focus": cell(1, 0)})).unwrap();
|
||||
a.run("table.merge", json!({})).unwrap();
|
||||
a.run("select.range", json!({"anchor": cell(0, 0), "focus": cell(1, 0)})).unwrap();
|
||||
a.run("ui.dialog", json!({"name": "splitCells"})).unwrap();
|
||||
let LayoutDialog::SplitCells(mut f) = form(&mut a) else { panic!("split cells") };
|
||||
assert_eq!((f.columns, f.rows, f.several, f.merge_first), (2, 1, false, false), "one merged cell");
|
||||
// Word allows 1 to its height rows, with its message, and keeps the dialog open.
|
||||
f.rows = 3;
|
||||
assert_eq!(f.check().as_deref(), Some("The number must be between 1 and 2."));
|
||||
f.rows = 2;
|
||||
assert_eq!(f.check(), None);
|
||||
|
||||
// The merged cell and the two cells to its right: Merge ticked, 4 × 2.
|
||||
let mut a = table_app(3, &["A", "B", "C", "D", "E", "F", "G", "H", "I"]);
|
||||
a.run("select.range", json!({"anchor": cell(0, 1), "focus": cell(1, 1)})).unwrap();
|
||||
a.run("table.merge", json!({})).unwrap();
|
||||
a.run("select.range", json!({"anchor": cell(0, 1), "focus": cell(1, 2)})).unwrap();
|
||||
a.run("ui.dialog", json!({"name": "splitCells"})).unwrap();
|
||||
let LayoutDialog::SplitCells(mut f) = form(&mut a) else { panic!("split cells") };
|
||||
assert_eq!((f.columns, f.rows, f.several, f.merge_first), (4, 2, true, true), "merged cell and neighbours");
|
||||
f.rows = 3;
|
||||
assert_eq!(f.check().as_deref(), Some("The number must be between 1 and 2."));
|
||||
|
||||
// One cell merged across two columns: Merge offered and ticked, 2 × 1.
|
||||
let mut a = table_app(3, &["A", "B", "C", "D", "E", "F", "G", "H", "I"]);
|
||||
a.run("select.range", json!({"anchor": cell(2, 0), "focus": cell(2, 1)})).unwrap();
|
||||
a.run("table.merge", json!({})).unwrap();
|
||||
a.run("ui.dialog", json!({"name": "splitCells"})).unwrap();
|
||||
let LayoutDialog::SplitCells(f) = form(&mut a) else { panic!("split cells") };
|
||||
assert_eq!((f.columns, f.rows, f.several, f.merge_first), (2, 1, true, true), "a wide cell");
|
||||
assert_eq!(f.check(), None);
|
||||
}
|
||||
|
||||
/// #2592: Convert Text to Table opens its dialog; the separator and column count are guessed
|
||||
/// from the text, the row count follows the columns, and OK converts with the settings.
|
||||
#[test]
|
||||
fn text_to_table_opens_its_dialog() {
|
||||
let mut a = WordApp::new(Session::new(wordcraft_doc::Document::from_text("a\tb\tc\nd")), Services::default());
|
||||
a.run("select.all", json!({})).unwrap();
|
||||
assert_eq!(a.run("table.fromText", json!({})).unwrap(), json!({"pending": "textToTable"}));
|
||||
assert!(a.session.doc.body.iter().all(|b| b.as_table().is_none()), "nothing converted yet");
|
||||
let LayoutDialog::TextToTable(mut f) = form(&mut a) else { panic!("text to table") };
|
||||
assert_eq!((f.separator.as_str(), f.columns, f.rows, f.autofit.as_str(), f.width.as_str()), ("tab", 3, 2, "initial", "Auto"));
|
||||
assert!(f.params().get("width").is_none(), "Auto");
|
||||
f.width = "1 in".into();
|
||||
assert_eq!((f.check(), f.params()["width"].as_f64()), (None, Some(72.0)));
|
||||
f.width = "50 in".into();
|
||||
assert!(f.check().is_some_and(|e| e.starts_with("The measurement must be between 0")), "{:?}", f.check());
|
||||
f.width = "0".into();
|
||||
assert_eq!((f.check(), f.params().get("width")), (None, None), "0 is Auto");
|
||||
f.width = "Auto".into();
|
||||
f.separator = "paragraph".into();
|
||||
f.guess_columns();
|
||||
assert_eq!((f.columns, f.rows), (1, 2));
|
||||
f.separator = "tab".into();
|
||||
f.columns = 2;
|
||||
f.count_rows();
|
||||
assert_eq!(f.rows, 3, "a b / c / d");
|
||||
a.run("table.fromText", f.params()).unwrap();
|
||||
assert!(a.dialog.is_none());
|
||||
let t = a.session.doc.body.iter().find_map(|b| b.as_table()).unwrap();
|
||||
assert_eq!(t.rows.len(), 3);
|
||||
assert!(!t.props.fixed, "the table still resizes to fit its contents");
|
||||
|
||||
let mut a = app();
|
||||
a.run("select.all", json!({})).unwrap();
|
||||
a.run("ui.dialog", json!({"name": "textToTable"})).unwrap();
|
||||
let LayoutDialog::TextToTable(f) = form(&mut a) else { panic!("text to table") };
|
||||
assert_eq!((f.separator.as_str(), f.columns, f.rows), ("paragraph", 1, 2), "a comma in one paragraph only");
|
||||
|
||||
// Word opens comma text on Paragraphs too; Commas counts the columns, short lines pad.
|
||||
let mut a = WordApp::new(Session::new(wordcraft_doc::Document::from_text("a,b,c\nd,e,f\ng,h\ni")), Services::default());
|
||||
a.run("select.all", json!({})).unwrap();
|
||||
a.run("table.fromText", json!({})).unwrap();
|
||||
let LayoutDialog::TextToTable(mut f) = form(&mut a) else { panic!("text to table") };
|
||||
assert_eq!((f.separator.as_str(), f.columns, f.rows, f.other_char.as_str()), ("paragraph", 1, 4, "-"));
|
||||
f.separator = "comma".into();
|
||||
f.guess_columns();
|
||||
assert_eq!((f.columns, f.rows), (3, 4));
|
||||
a.run("table.fromText", f.params()).unwrap();
|
||||
let t = a.session.doc.body.iter().find_map(|b| b.as_table()).unwrap();
|
||||
assert_eq!(t.rows.iter().map(|r| r.cells.len()).collect::<Vec<_>>(), [3, 3, 3, 3]);
|
||||
}
|
||||
|
||||
/// The envelope sizes are in the interface language with the region's decimal separator
|
||||
/// (#1128; the untranslated-text audit found `#10 4.13 × 9.5 in` drawn in English).
|
||||
#[test]
|
||||
|
||||
@@ -2621,3 +2621,15 @@
|
||||
# Reading Mode column widths (#1040)
|
||||
Narrow Тясна
|
||||
Medium Средна
|
||||
# Split Cells and Convert Text to Table (#2592)
|
||||
Merge cells before split Обединяване на клетките преди разделяне
|
||||
Table size Размер на таблицата
|
||||
AutoFit behavior Поведение при автопобиране
|
||||
Initial column width: Начална ширина на колоните:
|
||||
AutoFit to contents Автопобиране по съдържанието
|
||||
AutoFit to window Автопобиране по прозореца
|
||||
Separate text at Разделяне на текста при
|
||||
Commas Запетаи
|
||||
Other: Друго:
|
||||
The number must be between 1 and {max}. Числото трябва да е между 1 и {max}.
|
||||
The measurement must be between {min} and {max}. Размерът трябва да е между {min} и {max}.
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -2624,3 +2624,15 @@ preview Title Ukázka
|
||||
# Reading Mode column widths (#1040)
|
||||
Narrow Úzká
|
||||
Medium Střední
|
||||
# Split Cells and Convert Text to Table (#2592)
|
||||
Merge cells before split Před rozdělením buňky sloučit
|
||||
Table size Velikost tabulky
|
||||
AutoFit behavior Automatické přizpůsobení
|
||||
Initial column width: Počáteční šířka sloupce:
|
||||
AutoFit to contents Automaticky přizpůsobit obsahu
|
||||
AutoFit to window Automaticky přizpůsobit oknu
|
||||
Separate text at Oddělit text pomocí
|
||||
Commas Čárky
|
||||
Other: Jiné:
|
||||
The number must be between 1 and {max}. Číslo musí být mezi 1 a {max}.
|
||||
The measurement must be between {min} and {max}. Míra musí být mezi {min} a {max}.
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -2232,3 +2232,15 @@
|
||||
# Reading Mode column widths (#1040)
|
||||
Narrow Schmal
|
||||
Medium Mittel
|
||||
# Split Cells and Convert Text to Table (#2592)
|
||||
Merge cells before split Zellen vor dem Teilen verbinden
|
||||
Table size Tabellengröße
|
||||
AutoFit behavior Automatische Anpassung
|
||||
Initial column width: Anfangsspaltenbreite:
|
||||
AutoFit to contents An Inhalt anpassen
|
||||
AutoFit to window An Fenster anpassen
|
||||
Separate text at Text trennen bei
|
||||
Commas Kommas
|
||||
Other: Andere:
|
||||
The number must be between 1 and {max}. Die Zahl muss zwischen 1 und {max} liegen.
|
||||
The measurement must be between {min} and {max}. Das Maß muss zwischen {min} und {max} liegen.
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -2599,3 +2599,15 @@ preview Title Título
|
||||
# Reading Mode column widths (#1040)
|
||||
Narrow Estrecha
|
||||
Medium Media
|
||||
# Split Cells and Convert Text to Table (#2592)
|
||||
Merge cells before split Combinar celdas antes de dividir
|
||||
Table size Tamaño de la tabla
|
||||
AutoFit behavior Autoajuste
|
||||
Initial column width: Ancho de columna inicial:
|
||||
AutoFit to contents Ajustar al contenido
|
||||
AutoFit to window Ajustar a la ventana
|
||||
Separate text at Separar texto en
|
||||
Commas Comas
|
||||
Other: Otro:
|
||||
The number must be between 1 and {max}. El número debe estar entre 1 y {max}.
|
||||
The measurement must be between {min} and {max}. La medida debe estar entre {min} y {max}.
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -2267,3 +2267,15 @@
|
||||
# Reading Mode column widths (#1040)
|
||||
Narrow Kitsas
|
||||
Medium Keskmine
|
||||
# Split Cells and Convert Text to Table (#2592)
|
||||
Merge cells before split Ühenda lahtrid enne tükeldamist
|
||||
Table size Tabeli suurus
|
||||
AutoFit behavior Automaatne sobitamine
|
||||
Initial column width: Veeru algne laius:
|
||||
AutoFit to contents Sobita sisuga
|
||||
AutoFit to window Sobita aknaga
|
||||
Separate text at Eralda tekst kohas
|
||||
Commas Komad
|
||||
Other: Muu:
|
||||
The number must be between 1 and {max}. Arv peab olema vahemikus 1 kuni {max}.
|
||||
The measurement must be between {min} and {max}. Mõõt peab olema vahemikus {min} kuni {max}.
|
||||
|
Can't render this file because it contains an unexpected character in line 841 and column 23.
|
@@ -2240,3 +2240,15 @@
|
||||
# Reading Mode column widths (#1040)
|
||||
Narrow Étroite
|
||||
Medium Moyenne
|
||||
# Split Cells and Convert Text to Table (#2592)
|
||||
Merge cells before split Fusionner les cellules avant le fractionnement
|
||||
Table size Taille du tableau
|
||||
AutoFit behavior Comportement de l'ajustement automatique
|
||||
Initial column width: Largeur de colonne initiale :
|
||||
AutoFit to contents Ajustement automatique au contenu
|
||||
AutoFit to window Ajustement automatique à la fenêtre
|
||||
Separate text at Séparer le texte au niveau des
|
||||
Commas Virgules
|
||||
Other: Autre :
|
||||
The number must be between 1 and {max}. Le nombre doit être compris entre 1 et {max}.
|
||||
The measurement must be between {min} and {max}. La mesure doit être comprise entre {min} et {max}.
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -2632,3 +2632,15 @@ preview Title Naslov
|
||||
Bottom Right Cell Donja desna ćelija
|
||||
Inside Horizontal Border Unutarnji vodoravni obrub
|
||||
Inside Vertical Border Unutarnji okomiti obrub
|
||||
# Split Cells and Convert Text to Table (#2592)
|
||||
Merge cells before split Spoji ćelije prije razdvajanja
|
||||
Table size Veličina tablice
|
||||
AutoFit behavior Automatsko prilagođavanje
|
||||
Initial column width: Početna širina stupca:
|
||||
AutoFit to contents Prilagodi sadržaju
|
||||
AutoFit to window Prilagodi prozoru
|
||||
Separate text at Odvoji tekst na
|
||||
Commas Zarezi
|
||||
Other: Ostalo:
|
||||
The number must be between 1 and {max}. Broj mora biti između 1 i {max}.
|
||||
The measurement must be between {min} and {max}. Mjera mora biti između {min} i {max}.
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -2628,3 +2628,15 @@
|
||||
# Reading Mode column widths (#1040)
|
||||
Narrow Keskeny
|
||||
Medium Közepes
|
||||
# Split Cells and Convert Text to Table (#2592)
|
||||
Merge cells before split Cellák egyesítése felosztás előtt
|
||||
Table size Táblázat mérete
|
||||
AutoFit behavior Automatikus igazítás
|
||||
Initial column width: Kezdeti oszlopszélesség:
|
||||
AutoFit to contents Igazítás a tartalomhoz
|
||||
AutoFit to window Igazítás az ablakhoz
|
||||
Separate text at Szöveg elválasztása
|
||||
Commas Vesszők
|
||||
Other: Egyéb:
|
||||
The number must be between 1 and {max}. A számnak 1 és {max} között kell lennie.
|
||||
The measurement must be between {min} and {max}. A méretnek {min} és {max} között kell lennie.
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -2641,3 +2641,15 @@ preview Title Esempio
|
||||
# Reading Mode column widths (#1040)
|
||||
Narrow Stretta
|
||||
Medium Media
|
||||
# Split Cells and Convert Text to Table (#2592)
|
||||
Merge cells before split Unisci le celle prima di dividere
|
||||
Table size Dimensioni tabella
|
||||
AutoFit behavior Adattamento automatico
|
||||
Initial column width: Larghezza colonna iniziale:
|
||||
AutoFit to contents Adatta al contenuto
|
||||
AutoFit to window Adatta alla finestra
|
||||
Separate text at Separa il testo in corrispondenza di
|
||||
Commas Virgole
|
||||
Other: Altro:
|
||||
The number must be between 1 and {max}. Il numero deve essere compreso tra 1 e {max}.
|
||||
The measurement must be between {min} and {max}. La misura deve essere compresa tra {min} e {max}.
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -2233,3 +2233,15 @@
|
||||
# Reading Mode column widths (#1040)
|
||||
Narrow 狭い
|
||||
Medium 中
|
||||
# Split Cells and Convert Text to Table (#2592)
|
||||
Merge cells before split 分割する前にセルを結合する
|
||||
Table size 表のサイズ
|
||||
AutoFit behavior 自動調整
|
||||
Initial column width: 列の幅の初期値:
|
||||
AutoFit to contents 文字列の幅に合わせる
|
||||
AutoFit to window ウィンドウ幅に合わせる
|
||||
Separate text at 文字列の区切り
|
||||
Commas カンマ
|
||||
Other: その他:
|
||||
The number must be between 1 and {max}. 1 から {max} までの数値を指定してください。
|
||||
The measurement must be between {min} and {max}. {min} から {max} までの値を指定してください。
|
||||
|
Can't render this file because it contains an unexpected character in line 918 and column 23.
|
@@ -2488,3 +2488,15 @@ preview Title 제목
|
||||
Bottom Right Cell 오른쪽 아래 셀
|
||||
Inside Horizontal Border 안쪽 가로 테두리
|
||||
Inside Vertical Border 안쪽 세로 테두리
|
||||
# Split Cells and Convert Text to Table (#2592)
|
||||
Merge cells before split 나누기 전에 셀 병합
|
||||
Table size 표 크기
|
||||
AutoFit behavior 자동 맞춤
|
||||
Initial column width: 초기 열 너비:
|
||||
AutoFit to contents 내용에 맞게 자동 맞춤
|
||||
AutoFit to window 창에 맞게 자동 맞춤
|
||||
Separate text at 텍스트 구분 기호
|
||||
Commas 쉼표
|
||||
Other: 기타:
|
||||
The number must be between 1 and {max}. 숫자는 1에서 {max} 사이여야 합니다.
|
||||
The measurement must be between {min} and {max}. 측정값은 {min}에서 {max} 사이여야 합니다.
|
||||
|
Can't render this file because it contains an unexpected character in line 924 and column 23.
|
@@ -2622,3 +2622,15 @@ preview Title Tytuł
|
||||
# Reading Mode column widths (#1040)
|
||||
Narrow Wąska
|
||||
Medium Średnia
|
||||
# Split Cells and Convert Text to Table (#2592)
|
||||
Merge cells before split Scal komórki przed podziałem
|
||||
Table size Rozmiar tabeli
|
||||
AutoFit behavior Autodopasowanie
|
||||
Initial column width: Początkowa szerokość kolumn:
|
||||
AutoFit to contents Dopasuj do zawartości
|
||||
AutoFit to window Dopasuj do okna
|
||||
Separate text at Oddziel tekst przy
|
||||
Commas Przecinki
|
||||
Other: Inny:
|
||||
The number must be between 1 and {max}. Liczba musi być z zakresu od 1 do {max}.
|
||||
The measurement must be between {min} and {max}. Miara musi być z zakresu od {min} do {max}.
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -2245,3 +2245,15 @@
|
||||
# Reading Mode column widths (#1040)
|
||||
Narrow Estreita
|
||||
Medium Média
|
||||
# Split Cells and Convert Text to Table (#2592)
|
||||
Merge cells before split Mesclar células antes de dividir
|
||||
Table size Tamanho da tabela
|
||||
AutoFit behavior Comportamento de autoajuste
|
||||
Initial column width: Largura inicial da coluna:
|
||||
AutoFit to contents Autoajustar ao conteúdo
|
||||
AutoFit to window Autoajustar à janela
|
||||
Separate text at Separar texto em
|
||||
Commas Vírgulas
|
||||
Other: Outro:
|
||||
The number must be between 1 and {max}. O número deve estar entre 1 e {max}.
|
||||
The measurement must be between {min} and {max}. A medida deve estar entre {min} e {max}.
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -2630,3 +2630,15 @@ preview Title Пример
|
||||
# Reading Mode column widths (#1040)
|
||||
Narrow Узкая
|
||||
Medium Средняя
|
||||
# Split Cells and Convert Text to Table (#2592)
|
||||
Merge cells before split Объединить ячейки перед разбиением
|
||||
Table size Размер таблицы
|
||||
AutoFit behavior Автоподбор ширины столбцов
|
||||
Initial column width: Исходная ширина столбцов:
|
||||
AutoFit to contents Автоподбор по содержимому
|
||||
AutoFit to window Автоподбор по ширине окна
|
||||
Separate text at Разделитель
|
||||
Commas Запятые
|
||||
Other: Другой:
|
||||
The number must be between 1 and {max}. Число должно быть от 1 до {max}.
|
||||
The measurement must be between {min} and {max}. Размер должен быть от {min} до {max}.
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -2243,3 +2243,15 @@
|
||||
# Reading Mode column widths (#1040)
|
||||
Narrow Uska
|
||||
Medium Srednja
|
||||
# Split Cells and Convert Text to Table (#2592)
|
||||
Merge cells before split Spoji ćelije pre deljenja
|
||||
Table size Veličina tabele
|
||||
AutoFit behavior Automatsko uklapanje
|
||||
Initial column width: Početna širina kolone:
|
||||
AutoFit to contents Automatski uklopi prema sadržaju
|
||||
AutoFit to window Automatski uklopi prema prozoru
|
||||
Separate text at Razdvoji tekst na
|
||||
Commas Zarezi
|
||||
Other: Drugo:
|
||||
The number must be between 1 and {max}. Broj mora biti između 1 i {max}.
|
||||
The measurement must be between {min} and {max}. Mera mora biti između {min} i {max}.
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -2247,3 +2247,15 @@
|
||||
# Reading Mode column widths (#1040)
|
||||
Narrow Уска
|
||||
Medium Средња
|
||||
# Split Cells and Convert Text to Table (#2592)
|
||||
Merge cells before split Споји ћелије пре дељења
|
||||
Table size Величина табеле
|
||||
AutoFit behavior Аутоматско уклапање
|
||||
Initial column width: Почетна ширина колоне:
|
||||
AutoFit to contents Аутоматски уклопи према садржају
|
||||
AutoFit to window Аутоматски уклопи према прозору
|
||||
Separate text at Раздвоји текст на
|
||||
Commas Зарези
|
||||
Other: Друго:
|
||||
The number must be between 1 and {max}. Број мора бити између 1 и {max}.
|
||||
The measurement must be between {min} and {max}. Мера мора бити између {min} и {max}.
|
||||
|
Can't render this file because it contains an unexpected character in line 11 and column 25.
|
@@ -2647,3 +2647,15 @@ preview Title Titel
|
||||
Bottom Right Cell Nedre högra cellen
|
||||
Inside Horizontal Border Inre vågrät kantlinje
|
||||
Inside Vertical Border Inre lodrät kantlinje
|
||||
# Split Cells and Convert Text to Table (#2592)
|
||||
Merge cells before split Sammanfoga celler före delning
|
||||
Table size Tabellstorlek
|
||||
AutoFit behavior Autopassning
|
||||
Initial column width: Ursprunglig kolumnbredd:
|
||||
AutoFit to contents Autoanpassa efter innehåll
|
||||
AutoFit to window Autoanpassa efter fönster
|
||||
Separate text at Avgränsa text vid
|
||||
Commas Kommatecken
|
||||
Other: Annat:
|
||||
The number must be between 1 and {max}. Talet måste vara mellan 1 och {max}.
|
||||
The measurement must be between {min} and {max}. Måttet måste vara mellan {min} och {max}.
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -2629,3 +2629,15 @@ preview Title Örnek
|
||||
# Reading Mode column widths (#1040)
|
||||
Narrow Dar
|
||||
Medium Orta
|
||||
# Split Cells and Convert Text to Table (#2592)
|
||||
Merge cells before split Bölmeden önce hücreleri birleştir
|
||||
Table size Tablo boyutu
|
||||
AutoFit behavior Otomatik sığdırma
|
||||
Initial column width: Başlangıç sütun genişliği:
|
||||
AutoFit to contents İçeriğe sığdır
|
||||
AutoFit to window Pencereye sığdır
|
||||
Separate text at Metni ayırma yeri
|
||||
Commas Virgüller
|
||||
Other: Diğer:
|
||||
The number must be between 1 and {max}. Sayı 1 ile {max} arasında olmalıdır.
|
||||
The measurement must be between {min} and {max}. Ölçü {min} ile {max} arasında olmalıdır.
|
||||
|
Can't render this file because it contains an unexpected character in line 793 and column 23.
|
@@ -2245,3 +2245,15 @@
|
||||
# Reading Mode column widths (#1040)
|
||||
Narrow Вузька
|
||||
Medium Середня
|
||||
# Split Cells and Convert Text to Table (#2592)
|
||||
Merge cells before split Об'єднати клітинки перед розділенням
|
||||
Table size Розмір таблиці
|
||||
AutoFit behavior Добір розміру
|
||||
Initial column width: Початкова ширина стовпців:
|
||||
AutoFit to contents Добір розміру за вмістом
|
||||
AutoFit to window Добір розміру за шириною вікна
|
||||
Separate text at Розділювач тексту
|
||||
Commas Коми
|
||||
Other: Інше:
|
||||
The number must be between 1 and {max}. Число має бути від 1 до {max}.
|
||||
The measurement must be between {min} and {max}. Розмір має бути від {min} до {max}.
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 31.
|
@@ -2672,3 +2672,15 @@ template Letter Thư
|
||||
# Reading Mode column widths (#1040)
|
||||
Narrow Hẹp
|
||||
Medium Trung bình
|
||||
# Split Cells and Convert Text to Table (#2592)
|
||||
Merge cells before split Gộp ô trước khi tách
|
||||
Table size Kích thước bảng
|
||||
AutoFit behavior Tự khớp
|
||||
Initial column width: Độ rộng cột ban đầu:
|
||||
AutoFit to contents Tự khớp nội dung
|
||||
AutoFit to window Tự khớp cửa sổ
|
||||
Separate text at Tách văn bản tại
|
||||
Commas Dấu phẩy
|
||||
Other: Khác:
|
||||
The number must be between 1 and {max}. Số phải nằm trong khoảng từ 1 đến {max}.
|
||||
The measurement must be between {min} and {max}. Số đo phải nằm trong khoảng từ {min} đến {max}.
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -2245,3 +2245,15 @@
|
||||
# Reading Mode column widths (#1040)
|
||||
Narrow 窄
|
||||
Medium 中等
|
||||
# Split Cells and Convert Text to Table (#2592)
|
||||
Merge cells before split 拆分前合并单元格
|
||||
Table size 表格尺寸
|
||||
AutoFit behavior 自动调整操作
|
||||
Initial column width: 初始列宽:
|
||||
AutoFit to contents 根据内容调整表格
|
||||
AutoFit to window 根据窗口调整表格
|
||||
Separate text at 文字分隔位置
|
||||
Commas 逗号
|
||||
Other: 其他字符:
|
||||
The number must be between 1 and {max}. 数字必须介于 1 和 {max} 之间。
|
||||
The measurement must be between {min} and {max}. 度量值必须介于 {min} 和 {max} 之间。
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -2234,3 +2234,15 @@
|
||||
# Reading Mode column widths (#1040)
|
||||
Narrow 窄
|
||||
Medium 中等
|
||||
# Split Cells and Convert Text to Table (#2592)
|
||||
Merge cells before split 分割前合併儲存格
|
||||
Table size 表格大小
|
||||
AutoFit behavior 自動調整行為
|
||||
Initial column width: 初始欄寬:
|
||||
AutoFit to contents 依內容自動調整
|
||||
AutoFit to window 依視窗自動調整
|
||||
Separate text at 分隔文字於
|
||||
Commas 逗號
|
||||
Other: 其他:
|
||||
The number must be between 1 and {max}. 數字必須介於 1 和 {max} 之間。
|
||||
The measurement must be between {min} and {max}. 度量值必須介於 {min} 和 {max} 之間。
|
||||
|
Can't render this file because it contains an unexpected character in line 919 and column 23.
|
@@ -2312,6 +2312,9 @@ fn input_dialog(id: &str, params: &Value) -> Option<&'static str> {
|
||||
"table.formula" if !has("formula") => Some("tableFormula"),
|
||||
// Sort without settings asks for the column, order and header row (#1200).
|
||||
"table.sort" if params.as_object().is_none_or(|m| m.is_empty()) => Some("tableSort"),
|
||||
// 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"),
|
||||
// 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"),
|
||||
|
||||
+4
-2
@@ -1,6 +1,6 @@
|
||||
# Where WordCraft falls short of Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** trivial (Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial (VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial (VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
|
||||
|
||||
@@ -30,7 +30,7 @@ Document renaming: desktop renaming requires a filesystem that supports hard lin
|
||||
| 2 | **Remaining native ink forms are dropped on open and lost on save, silently**; OLE objects show only their picture | B | Drawing-level native ink, its optional picture fallback, and direct run-level content parts are preserved; ink nested in groups/canvases remains unsupported. Drawing canvases are read and written (#344); group shapes (`wpg`) read and written (#267). Charts and SmartArt are drawn (#292) and, with OLE objects, written back with all their parts on save (#319) | Documents with diagrams drawn in Word or pen notes lose content without warning | 4–8 to preserve remaining native ink forms; 45–70 to render and edit charts and SmartArt (#13) | [file-format-parity.md](file-format-parity.md) |
|
||||
| 5 | **Content controls** landed (#381): `w:sdt` kept, shown and edited (Developer tab, Design Mode, Properties). Move tracking landed (#400): `w:moveFrom`/`w:moveTo` with their named ranges round-trip, a tracked cut and paste of a paragraph or sentence records a move, either end accepts/rejects both, green double strike/underline markup and "Moved from/to" balloons; still missing: drag-and-drop moves (no text drag yet) and move options in Track Changes Options. Formatting revisions landed (#41): `w:rPrChange`/`w:pPrChange`/`w:tblPrChange`/`w:trPrChange`/`w:tcPrChange`/`w:sectPrChange`/`w:numberingChange` round-trip, are recorded while tracking, accepted/rejected and shown in balloons; still missing: tracked style-definition changes and table/section changes made in WordCraft. Opaque custom XML backing stores now survive saves; binding evaluation remains absent. Replace All now checks content-control locks before editing any match; a refused batch preserves the document and undo history. | B | List-label pitch corrected in synthetic metric controls; Issues #41, #400; source grep | Legal and editorial review workflows; templates with form controls | 5–8 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 6 | **Print dialog depth**: desktop printer choice, copies, collation, duplex and grayscale submit through craft-print; File › Print retains preview, page ranges and odd/even (#641); driver tray/options and pages per sheet remain; web prints through the browser (#209) | B | `ui.print` → `apps/wordcraft/src/print.rs`; issue #15 | Everyone who prints | 5–8 | [hardware-parity.md](hardware-parity.md) |
|
||||
| 7 | **Dialog depth**: 54 modal dialogs vs Word's ~100 | B | `Dialog` enum in `crates/ui-egui/src/dialogs.rs` | Power users reach for dialogs the ribbon alone doesn't cover | 11–19 | [ui-parity.md](ui-parity.md) |
|
||||
| 7 | **Dialog depth**: 58 modal dialogs vs Word's ~100 (Split Cells and Convert Text to Table match Word's observed limits and defaults) | B | `Dialog` enum in `crates/ui-egui/src/dialogs.rs` | Power users reach for dialogs the ribbon alone doesn't cover | 11–19 | [ui-parity.md](ui-parity.md) |
|
||||
| 9 | **Password-protected `.doc` files** (AES) can't be opened; password-protected `.docx` opens and saves (agile encryption, #55 closed by #281) | | `crates/docx/src/crypt.rs`; `crates/docbin/src/lib.rs` opens XOR/RC4/RC4 CryptoAPI `.doc` with a password (#1079, untested on Word-written files; AES `.doc` refused) | Corporate and legal users with old files | 3–5 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 8 | **Proofing covers English, Polish, Portuguese, Italian, Croatian, Turkish, Russian and Czech only**; no dictionaries, grammar or hyphenation for the other ~42 languages Word proofs (text in them is left unmarked) | | `crates/proof` `ProofLang::PROOFED`: English, Polish, Portuguese (pt-BR via the installed VERO dictionary, #797), Italian (it-IT via the installed Hunspell dictionary), Croatian (hr-HR, 2026-10-13), Turkish (tr_TR spelling, grammar, TDK hyphenation, 2026-10-13) and Russian (the installed `ru_RU` dictionary, 2026-10-12), a language per run; Bulgarian has hyphenation only (#826); open PRs add Danish (#723), Russian (#894) and Czech (#1048); Word ships 120 proofing tools; issues #25, #40, #100 open | Every writer outside English, Polish, Portuguese, Italian, Croatian, Turkish and Russian | 20–40 | [localization-parity.md](localization-parity.md) |
|
||||
| 10 | **Objects**: text in a freely rotated text box (free rotation and flips landed, #332; text direction, alignment and linked text boxes landed, #369; contour wrap and wrap side landed, #354), shape effects, WordArt 3-D effects and connector routing around shapes (WordArt effects and warps and glued connectors landed, #416), dragging members inside a Drawing Canvas (the canvas landed, #344) | | [parity-checklist.md](parity-checklist.md) (Shape Format 11/11, Layout 22/22); group and ungroup landed (#267) | Newsletters, flyers, reports with diagrams | 20–30 | [target-app-parity.md](target-app-parity.md) |
|
||||
@@ -90,6 +90,8 @@ The Language dialog reads effective character formatting, including document def
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-17 | trivial | Language dialog keeps inherited proofing language and no-proof choices (#2717); readiness estimates unchanged |
|
||||
| 2026-10-18 | trivial | #7: Split Cells and Convert Text to Table follow the Word check (#2592) |
|
||||
| 2026-10-16 | trivial | #7: Split Cells and Convert Text to Table dialogs (#2592), 58 modal dialogs (matches ui-parity.md) |
|
||||
| 2026-10-14 | trivial | Row table border exceptions fixed; other row table exceptions remain unsupported |
|
||||
| 2026-10-12 | minor | Czech proofing review: short-word splits rank after deletion, NOSUGGEST excludes suggestions while accepting typed forms, affix comments apply only to headers, ratios keep spaces and repeated-word messages use Czech quotes; synthetic engine end-to-end and latency checks |
|
||||
| 2026-10-12 | minor | Czech spelling from the installed Hunspell dictionary, runtime-table suggestions and generic checks; Czech interface writes cs-CZ documents; no Czech hyphenation patterns bundled; estimates unchanged |
|
||||
|
||||
+4
-2
@@ -1,6 +1,6 @@
|
||||
# UI parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** trivial (a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
How WordCraft looks and feels next to Word: ribbon, dialogs, panes, keyboard, mouse and touch.
|
||||
Typing behaviour has its own checklist ([`typing-parity.md`](typing-parity.md)). All assets are our
|
||||
@@ -26,7 +26,7 @@ Document title opens Rename Document; the account badge opens Options › Genera
|
||||
| Modal dialogs | **55** (`Dialog` enum: Font, Enclose Characters, Paragraph, Tabs, Borders and Shading, Find, Go To, Insert Table, Table Properties, Page Setup, Link, Bookmark, Word Count, Zoom, Watermark, New Style, Modify Style, Manage Styles, Table Style, Commands, Icons, Templates and Add-ins, Organizer, Compare and Combine, AutoCorrect Options, Paste Special, About, Save Changes, Password, Encrypt with Password, Define New Multilevel List, Track Changes Options; Columns, Symbol with Special Characters, Field (#321); Field code (#339); mail merge Recipients, Insert Merge Field, Find Recipient, Rules, Match Fields, Check for Errors; Caption with New Label and Numbering, Index, Mark Index Entry, Custom Table of Contents with Options and TOC styles, Source Manager, Create/Edit Source (#402); Cross-reference (#916); Line Numbers, Hyphenation with Manual Hyphenation, Language, Envelopes, Labels (#407); Page Setup has a Paper size list and custom width and height, opened from Layout › Size › More Paper Sizes… (#1433)) plus panes and menus for the rest | ~100 (Options panes…) | ~45% | 11–19 |
|
||||
| Keyboard shortcuts | **152** commands bound in `CommandSpec::key`, 172 key combinations (counted from the registry on Linux, 2026-10-13); styles, spacing, symbols, fields, views, mail merge (#330); field keys Alt+F9, Shift+F9, Ctrl+Shift+F9, Ctrl+F11, Ctrl+Shift+F11, Shift+F11 (#339), Next Field on Alt+F1 and Alt+Shift+F1 (Word's other keys for it) since F11 went to full screen (#980; GNOME and KDE may keep Alt+F1 for their own overview or application menu, and then Next Field has no key there; Previous Field keeps Shift+F11); Alt+Shift+arrows (move paragraphs, promote/demote), Shift+F5 Go Back and Alt+X (#343); Outline view: Alt+Shift+Plus/Minus expand/collapse, Alt+Shift+1–9 and Alt+Shift+A Show Level (#493); Alt+Q to "Tell me" command search (#819, not on macOS). Missing: en dash on the numeric keypad | ~250 default shortcuts | ~70% | 2–4 |
|
||||
| Keyboard shortcuts | **152** commands bound in `CommandSpec::key`, 172 key combinations (counted from the registry on Linux, 2026-10-13); styles, spacing, symbols, fields, views, mail merge (#330); field keys Alt+F9, Shift+F9, Ctrl+Shift+F9, Ctrl+F11, Ctrl+Shift+F11, Shift+F11 (#339), Next Field on Alt+F1 and Alt+Shift+F1 (Word's other keys for it) since F11 went to full screen (#980; GNOME and KDE may keep Alt+F1 for their own overview or application menu, and then Next Field has no key there; Previous Field keeps Shift+F11); Alt+Shift+arrows (move paragraphs, promote/demote), Shift+F5 Go Back and Alt+X (#343); Outline view: Alt+Shift+Plus/Minus expand/collapse, Alt+Shift+1–9 and Alt+Shift+A Show Level (#493); Alt+Q to "Tell me" command search (#819, not on macOS); on macOS, Word for Mac's own keys: ⌘⇧S Save As, ⌘⌥E Insert Endnote, ⌘⌥A New Comment, ⌃H Replace, ⌘⌥R collapse the ribbon. Missing: en dash on the numeric keypad Insert Hyperlink has Word's Link to: Existing File or Web Page / Place in This Document (top of the document, headings, bookmarks) (#1234). | ~250 default shortcuts | ~70% | 2–4 |
|
||||
| Modal dialogs | **56** (`Dialog` enum: Discard Recovery, Font, Enclose Characters, Paragraph, Tabs, Borders and Shading, Find, Go To, Insert Table, Table Properties, Page Setup, Link, Bookmark, Word Count, Zoom, Watermark, New Style, Modify Style, Manage Styles, Table Style, Commands, Icons, Templates and Add-ins, Organizer, Compare and Combine, AutoCorrect Options, Paste Special, About, Save Changes, Password, Encrypt with Password, Define New Multilevel List, Track Changes Options; Columns, Symbol with Special Characters, Field (#321); Field code (#339); mail merge Recipients, Insert Merge Field, Find Recipient, Rules, Match Fields, Check for Errors; Caption with New Label and Numbering, Index, Mark Index Entry, Custom Table of Contents with Options and TOC styles, Source Manager, Create/Edit Source (#402); Cross-reference (#916); Line Numbers, Hyphenation with Manual Hyphenation, Language, Envelopes, Labels (#407); Page Setup has a Paper size list and custom width and height, opened from Layout › Size › More Paper Sizes… (#1433)) plus panes and menus for the rest | ~100 (Options panes…) | ~45% | 11–19 |
|
||||
| Modal dialogs | **58** (`Dialog` enum: Discard Recovery, Font, Enclose Characters, Paragraph, Tabs, Borders and Shading, Find, Go To, Insert Table, Table Properties, Page Setup, Link, Bookmark, Word Count, Zoom, Watermark, New Style, Modify Style, Manage Styles, Table Style, Commands, Icons, Templates and Add-ins, Organizer, Compare and Combine, AutoCorrect Options, Paste Special, About, Save Changes, Password, Encrypt with Password, Define New Multilevel List, Track Changes Options; Columns, Symbol with Special Characters, Field (#321); Field code (#339); mail merge Recipients, Insert Merge Field, Find Recipient, Rules, Match Fields, Check for Errors; Caption with New Label and Numbering, Index, Mark Index Entry, Custom Table of Contents with Options and TOC styles, Source Manager, Create/Edit Source (#402); Cross-reference (#916); Line Numbers, Hyphenation with Manual Hyphenation, Language, Envelopes, Labels (#407); Page Setup has a Paper size list and custom width and height, opened from Layout › Size › More Paper Sizes… (#1433); Split Cells (columns, rows, merge before split; defaults follow the selection as in Word: twice its columns, its rows, Merge offered for several cells or one merged across columns; a merged cell splits into 1 to its height rows, its height or 1 × 1 cutting it back into rows, more refused with Word's message; parts split across columns are equal and other rows keep their grid lines) and Convert Text to Table (live column count, read-only rows; Initial column width Auto or 0 to 22 in, AutoFit to contents/window; Paragraphs/Commas/Tabs/Other "-" separators) (#2592)) plus panes and menus for the rest | ~100 (Options panes…) | ~45% | 11–19 |
|
||||
| Task panes (Navigation, Styles, Comments, Reviewing, Format Picture/Shape, Thesaurus, Accessibility, Clipboard, Selection) | Navigation (long headings truncate with an ellipsis instead of expanding the pane, #759), Styles, Comments, Thesaurus, Restrict Editing, Accessibility (grouped findings, jump-to, fixes) and Alt Text (#386), Read Aloud, Zotero; Style Inspector (#236); Clipboard pane (#264) open | all | 70% | 4–6 |
|
||||
| Context menus (right-click) | text, spelling suggestions, fields (Update Field, Edit Field…, Toggle Field Codes, #339; Update Field on a table of contents entry updates the table, #1197); right-click selects a picture, shape or text box and offers Wrap Text (styles, Move with Text / Fix Position on Page, #513); tables, the items following the selection as in Word (Insert, Delete › Cells, Columns, Rows or Table whatever is selected (#1198), Merge and Split Cells, Distribute, AutoFit, Text Direction, Insert Caption…, Table Properties…; no Border Styles yet) | rich per context | 65% | 2–4 |
|
||||
| Backstage (Home, New, Open, Info, Save As, Print, Share, Export, Options) | Home and New separate (#254), Info properties kept (#268), Options in Word's panes with working settings (#485), Print page with preview, page range and copies (#641); launched with nothing to open, the app starts on Home (the Start screen: Blank document, templates, Recent; no document is open until one is picked or opened, so Esc or Back shows an empty window with the ribbon greyed out but for Help, #1129), with File › Options › General › Start-up to turn it off (#766); Info › Inspect Document finds and removes hidden text by its effective formatting (direct, character or paragraph style) in every story: body, headers, footers, notes, comments and text boxes, and lists what it couldn't reach (#1344) | | 68% | 4–6 |
|
||||
@@ -65,6 +65,8 @@ The Language dialog reads effective character formatting, including document def
|
||||
| 2026-10-16 | trivial | Web Layout text fills the window width and re-wraps once a resize settles or at once on a zoom, instead of a fixed 800 pt column; Draft and Outline keep their 800 pt column (#1876) |
|
||||
| 2026-10-17 | trivial | Language dialog keeps inherited proofing language and no-proof choices (#2717); readiness estimates unchanged |
|
||||
| 2026-10-16 | trivial | Create Source: automatic Tag like Word's (`Smi20`, `Kim001`); Edit Source keeps the Tag and citations show the new Author and Year (#2094; Word for Mac 16.113.4, user-checked) |
|
||||
| 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-13 | trivial | New/Modify Table Style: No Border on a part that inherits its lines hides them; emptying the font box makes the part's font inherited again (#1288) |
|
||||
| 2026-10-14 | trivial | Tab stops edited on the ruler: corner kind selector, click to set, drag to move or off to clear, per-kind markers mirrored for RTL; each gesture one `para.tabs` call and one undo step; Rulers 85% → 90% |
|
||||
| 2026-10-12 | trivial | Tables on the page: one selected cell (Select Cell, or a click along its left edge) is a cell selection when its border is dragged: the border moves in its row only (#962) |
|
||||
|
||||
Reference in new issue
Block a user