mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-28 20:37:10 -06:00
Table sizing: Height/Width boxes, AutoFit to contents and a Table Properties dialog (#287)
* Table sizing: Height/Width boxes, real AutoFit Contents, Table Properties dialog (#44) - Table Layout > Cell Size gets Height and Width boxes (inches) for the caret cell's row and column; each edit runs table.rowHeight / table.columnWidth, a drag is one Undo. - AutoFit Contents now measures every column's narrowest (longest word) and widest (unwrapped text) content with the layout's text shaping, shares the text width the way Word does, and writes the widths into the table grid and cell widths. - Table Properties opens a dialog (table width, alignment, indent; row height and rule, break across pages, header row; column width; cell width and vertical alignment). OK applies only what changed as one table.properties step; with settings the command still works headlessly, and without them it reports the current values. - table.columnWidth clears the table's preferred width so the column keeps the new size; table.rowHeight takes an optional exact rule. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Progress docs: Table Properties dialog and AutoFit Contents landed (#44) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Table sizing: saturating column-span sum in sync_cell_widths Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Merge fix: one region_text (main's) after #287 met the landed table-style code Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
Brandon Thomas
parent
c0bbcbac24
commit
08e8d4ade0
+172
-43
@@ -1,7 +1,7 @@
|
||||
//! Table Design and Table Layout tabs.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_doc::props::{Align, Border, BorderStyle, Borders, Rgb, TextDirection, VAlign};
|
||||
use wordcraft_doc::props::{Align, Border, BorderStyle, Borders, HeightRule, Rgb, TextDirection, VAlign};
|
||||
use wordcraft_doc::{Block, Paragraph, Path, Pos, StoryRef, Table, para_block};
|
||||
|
||||
use super::sel_result;
|
||||
@@ -96,30 +96,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.params(r#"{"value": "topLeft|topCenter|…|bottomRight"}"#)),
|
||||
t(CommandSpec::new("table.textDirection", "Text Direction", "Table Layout › Alignment", text_direction)
|
||||
.params(r#"{"value"?: "horizontal|down|up"} (default: the next direction after the caret cell's, like Word's button)"#)),
|
||||
t(CommandSpec::new("table.autofit", "AutoFit", "Table Layout › Cell Size", |s, v| {
|
||||
let mode = p::str(v, "mode").unwrap_or("window");
|
||||
let tw = super::page::sect(s).text_width();
|
||||
with_table(s, |t| match mode {
|
||||
"fixed" => t.props.fixed = true,
|
||||
"contents" => {
|
||||
t.props.fixed = false;
|
||||
t.props.width = None;
|
||||
t.props.width_pct = None;
|
||||
}
|
||||
_ => {
|
||||
t.props.fixed = false;
|
||||
t.props.width_pct = Some(100.0);
|
||||
let n = t.grid.len().max(1) as f32;
|
||||
t.grid.iter_mut().for_each(|g| *g = tw / n);
|
||||
}
|
||||
})
|
||||
})
|
||||
.params(r#"{"mode": "contents|window|fixed"}"#)),
|
||||
t(CommandSpec::new("table.autofit", "AutoFit", "Table Layout › Cell Size", autofit).params(r#"{"mode": "contents|window|fixed"}"#)),
|
||||
t(CommandSpec::new("table.distributeColumns", "Distribute Columns", "Table Layout › Cell Size", |s, _| {
|
||||
with_table(s, |t| {
|
||||
let total: f32 = t.grid.iter().sum();
|
||||
let n = t.grid.len().max(1) as f32;
|
||||
t.grid.iter_mut().for_each(|g| *g = total / n);
|
||||
sync_cell_widths(t);
|
||||
})
|
||||
})),
|
||||
t(CommandSpec::new("table.distributeRows", "Distribute Rows", "Table Layout › Cell Size", |s, _| {
|
||||
@@ -133,25 +116,25 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
t(CommandSpec::new("table.columnWidth", "Table Column Width", "Table Layout › Cell Size", |s, v| {
|
||||
let w = p::req_f32(v, "width")?.clamp(6.0, 1584.0);
|
||||
let (_, r, c) = cell(s)?;
|
||||
with_table(s, |t| {
|
||||
let g = t.grid_col(r, c);
|
||||
if let Some(x) = t.grid.get_mut(g) {
|
||||
*x = w;
|
||||
}
|
||||
})
|
||||
with_table(s, |t| set_column_width(t, r, c, w))
|
||||
})
|
||||
.params(r#"{"width": pt}"#)),
|
||||
.params(r#"{"width": pt} (the caret cell's column; the table's preferred width is cleared so the column gets exactly this)"#)),
|
||||
t(CommandSpec::new("table.rowHeight", "Table Row Height", "Table Layout › Cell Size", |s, v| {
|
||||
let h = p::req_f32(v, "height")?.clamp(1.0, 1584.0);
|
||||
let rule = match p::str(v, "rule") {
|
||||
None | Some("atLeast") => HeightRule::AtLeast,
|
||||
Some("exact") => HeightRule::Exact,
|
||||
Some(x) => return Err(CmdError::Params(format!("unknown height rule `{x}`"))),
|
||||
};
|
||||
let (_, r, _) = cell(s)?;
|
||||
with_table(s, |t| {
|
||||
if let Some(row) = t.rows.get_mut(r) {
|
||||
row.props.height = Some(h);
|
||||
row.props.height_rule = wordcraft_doc::props::HeightRule::AtLeast;
|
||||
row.props.height_rule = rule;
|
||||
}
|
||||
})
|
||||
})
|
||||
.params(r#"{"height": pt}"#)),
|
||||
.params(r#"{"height": pt, "rule"?: "atLeast|exact"}"#)),
|
||||
t(CommandSpec::new("table.repeatHeader", "Repeat Header Rows", "Table Layout › Data", |s, v| {
|
||||
let (_, r, _) = cell(s)?;
|
||||
let on = p::bool(v, "value");
|
||||
@@ -180,25 +163,171 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
select_cells(s, &tp, Some((r, Some(c))))
|
||||
})
|
||||
.pure()),
|
||||
t(CommandSpec::new("table.properties", "Properties", "Table Layout › Table", |s, v| {
|
||||
if let Some(a) = p::str(v, "align") {
|
||||
let al = match a {
|
||||
"center" => Align::Center,
|
||||
"right" => Align::Right,
|
||||
_ => Align::Left,
|
||||
};
|
||||
return with_table(s, |t| t.props.align = Some(al));
|
||||
}
|
||||
let (tp, _, _) = cell(s)?;
|
||||
let t = s.doc.table(s.sel.focus.story, &tp).cloned().unwrap_or_default();
|
||||
Ok(serde_json::to_value(&t.props).unwrap_or(Value::Null))
|
||||
})
|
||||
.params(r#"{"align"?: "left|center|right"}"#)),
|
||||
t(CommandSpec::new("table.properties", "Properties", "Table Layout › Table", properties).params(
|
||||
r#"{"align"?: "left|center|right", "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.quick", "Quick Tables", "Insert › Tables", quick_table).params(r#"{"kind"?: "calendar|tabular|matrix"}"#),
|
||||
]
|
||||
}
|
||||
|
||||
/// Table Layout › AutoFit: Contents sizes the columns to their text, Window spreads them across
|
||||
/// the text column, Fixed keeps the widths as they are.
|
||||
fn autofit(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let mode = p::str(v, "mode").unwrap_or("window");
|
||||
let (tp, _, _) = cell(s)?;
|
||||
let story = s.sel.focus.story;
|
||||
let tw = super::page::sect(s).text_width();
|
||||
match mode {
|
||||
"fixed" => with_table(s, |t| t.props.fixed = true),
|
||||
"contents" => {
|
||||
let t = s.doc.table(story, &tp).ok_or_else(|| CmdError::Disabled("the cursor isn't in a table".into()))?;
|
||||
let avail = (tw - t.props.indent.filter(|i| i.is_finite()).unwrap_or(0.0).max(0.0)).max(72.0);
|
||||
let widths = wordcraft_layout::autofit_widths(&wordcraft_layout::measure_table_columns(&s.doc, t), avail);
|
||||
with_table(s, |t| {
|
||||
t.props.fixed = false;
|
||||
t.props.width = None;
|
||||
t.props.width_pct = None;
|
||||
t.grid = widths.iter().map(|w| w.max(6.0)).collect();
|
||||
sync_cell_widths(t);
|
||||
})
|
||||
}
|
||||
_ => with_table(s, |t| {
|
||||
t.props.fixed = false;
|
||||
t.props.width_pct = Some(100.0);
|
||||
let n = t.grid.len().max(1) as f32;
|
||||
t.grid.iter_mut().for_each(|g| *g = tw / n);
|
||||
sync_cell_widths(t);
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Give every cell the width of the grid columns it spans, so the cells' preferred widths
|
||||
/// (saved to .docx) agree with the grid.
|
||||
fn sync_cell_widths(t: &mut Table) {
|
||||
let grid = t.grid.clone();
|
||||
for row in &mut t.rows {
|
||||
let mut g = 0usize;
|
||||
for c in &mut row.cells {
|
||||
let span = c.span();
|
||||
let w: f32 = grid.get(g..g.saturating_add(span).min(grid.len())).unwrap_or(&[]).iter().sum();
|
||||
if w > 0.0 {
|
||||
c.props.width = Some(w);
|
||||
}
|
||||
g += span;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the width of the grid column cell `c` of row `r` starts in. The table's preferred width
|
||||
/// goes, so the layout doesn't scale the column back.
|
||||
fn set_column_width(t: &mut Table, r: usize, c: usize, w: f32) {
|
||||
let g = t.grid_col(r, c);
|
||||
if let Some(x) = t.grid.get_mut(g) {
|
||||
*x = w;
|
||||
t.props.width = None;
|
||||
t.props.width_pct = None;
|
||||
sync_cell_widths(t);
|
||||
}
|
||||
}
|
||||
|
||||
/// Table Properties: table, row, column and cell settings in one undoable step. Row, column and
|
||||
/// cell settings apply to the caret's. Without any setting it returns the current values.
|
||||
fn properties(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let (tp, r, c) = cell(s)?;
|
||||
let keys =
|
||||
["align", "width", "widthPct", "indent", "rowHeight", "rowHeightRule", "allowBreak", "headerRow", "columnWidth", "cellWidth", "valign"];
|
||||
if !keys.iter().any(|k| v.get(*k).is_some()) {
|
||||
let t = s.doc.table(s.sel.focus.story, &tp).cloned().unwrap_or_default();
|
||||
let mut out = serde_json::to_value(&t.props).unwrap_or(Value::Null);
|
||||
let row = t.rows.get(r);
|
||||
let cl = row.and_then(|x| x.cells.get(c));
|
||||
if let Some(o) = out.as_object_mut() {
|
||||
o.insert("row".into(), row.map(|x| serde_json::to_value(&x.props).unwrap_or(Value::Null)).unwrap_or(Value::Null));
|
||||
o.insert("columnWidth".into(), t.grid.get(t.grid_col(r, c)).map_or(Value::Null, |w| json!(w)));
|
||||
o.insert("cell".into(), cl.map(|x| serde_json::to_value(&x.props).unwrap_or(Value::Null)).unwrap_or(Value::Null));
|
||||
}
|
||||
return Ok(out);
|
||||
}
|
||||
let align = match p::str(v, "align") {
|
||||
None => None,
|
||||
Some("left") => Some(Align::Left),
|
||||
Some("center") => Some(Align::Center),
|
||||
Some("right") => Some(Align::Right),
|
||||
Some(x) => return Err(CmdError::Params(format!("unknown table alignment `{x}`"))),
|
||||
};
|
||||
let rule = match p::str(v, "rowHeightRule") {
|
||||
None => None,
|
||||
Some("atLeast") => Some(HeightRule::AtLeast),
|
||||
Some("exact") => Some(HeightRule::Exact),
|
||||
Some(x) => return Err(CmdError::Params(format!("unknown height rule `{x}`"))),
|
||||
};
|
||||
let valign = match p::str(v, "valign") {
|
||||
None => None,
|
||||
Some("top") => Some(VAlign::Top),
|
||||
Some("center") => Some(VAlign::Center),
|
||||
Some("bottom") => Some(VAlign::Bottom),
|
||||
Some(x) => return Err(CmdError::Params(format!("unknown vertical alignment `{x}`"))),
|
||||
};
|
||||
// `null` clears a preferred size (automatic); a number sets it.
|
||||
let size = |k: &str, lo: f32, hi: f32| -> Option<Option<f32>> {
|
||||
match v.get(k)? {
|
||||
Value::Null => Some(None),
|
||||
x => x.as_f64().map(|f| f as f32).filter(|f| f.is_finite()).map(|f| Some(f.clamp(lo, hi))),
|
||||
}
|
||||
};
|
||||
let width = size("width", 6.0, 1584.0);
|
||||
let width_pct = p::f32(v, "widthPct").map(|x| x.clamp(1.0, 100.0));
|
||||
let indent = p::f32(v, "indent").map(|x| x.clamp(-1584.0, 1584.0));
|
||||
let row_height = size("rowHeight", 1.0, 1584.0);
|
||||
let allow_break = p::bool(v, "allowBreak");
|
||||
let header = p::bool(v, "headerRow");
|
||||
let column_width = p::f32(v, "columnWidth").map(|x| x.clamp(6.0, 1584.0));
|
||||
let cell_width = size("cellWidth", 6.0, 1584.0);
|
||||
with_table(s, |t| {
|
||||
if let Some(a) = align {
|
||||
t.props.align = Some(a);
|
||||
}
|
||||
if let Some(i) = indent {
|
||||
t.props.indent = Some(i);
|
||||
}
|
||||
if let Some(cw) = column_width {
|
||||
set_column_width(t, r, c, cw);
|
||||
}
|
||||
if let Some(w) = width {
|
||||
t.props.width = w;
|
||||
t.props.width_pct = None;
|
||||
}
|
||||
if let Some(pct) = width_pct {
|
||||
t.props.width_pct = Some(pct);
|
||||
}
|
||||
if let Some(row) = t.rows.get_mut(r) {
|
||||
if let Some(h) = row_height {
|
||||
row.props.height = h;
|
||||
row.props.height_rule = if h.is_some() { rule.unwrap_or(HeightRule::AtLeast) } else { HeightRule::Auto };
|
||||
} else if let Some(rule) = rule
|
||||
&& row.props.height.is_some()
|
||||
{
|
||||
row.props.height_rule = rule;
|
||||
}
|
||||
if let Some(b) = allow_break {
|
||||
row.props.cant_split = !b;
|
||||
}
|
||||
if let Some(h) = header {
|
||||
row.props.header = h;
|
||||
}
|
||||
if let Some(cl) = row.cells.get_mut(c) {
|
||||
if let Some(w) = cell_width {
|
||||
cl.props.width = w;
|
||||
}
|
||||
if let Some(va) = valign {
|
||||
cl.props.valign = va;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn cell(s: &Session) -> Result<(Path, usize, usize), CmdError> {
|
||||
s.sel.focus.path.cell().ok_or_else(|| CmdError::Disabled("the cursor isn't in a table".into()))
|
||||
}
|
||||
|
||||
@@ -619,6 +619,115 @@ fn tables_commands() {
|
||||
assert!(s.run("table.merge", &json!({})).is_err());
|
||||
}
|
||||
|
||||
/// AutoFit Contents (#44) measures the text: a column of short words narrows, a column with a
|
||||
/// long sentence widens (wrapping within the page), and the widths are written to the grid.
|
||||
#[test]
|
||||
fn autofit_contents_sizes_columns_to_their_text() {
|
||||
let mut s = s();
|
||||
run(&mut s, "insert.table", json!({"rows": 2, "cols": 3}));
|
||||
let grid = |s: &Session| s.doc.body.iter().find_map(|b| b.as_table()).unwrap().grid.clone();
|
||||
let before = grid(&s);
|
||||
run(&mut s, "text.insert", json!({"text": "A"}));
|
||||
run(&mut s, "text.tab", json!({}));
|
||||
run(
|
||||
&mut s,
|
||||
"text.insert",
|
||||
json!({"text": "This cell holds a long sentence that would wrap many times in a third of the page width, so it should get most of the room."}),
|
||||
);
|
||||
run(&mut s, "text.tab", json!({}));
|
||||
run(&mut s, "text.insert", json!({"text": "B"}));
|
||||
run(&mut s, "table.autofit", json!({"mode": "contents"}));
|
||||
let after = grid(&s);
|
||||
let tw = cmd::page::sect(&s).text_width();
|
||||
assert!(after[0] < before[0] / 2.0, "short column narrows: {before:?} → {after:?}");
|
||||
assert!(after[2] < before[2] / 2.0, "short column narrows: {before:?} → {after:?}");
|
||||
assert!(after[1] > before[1] * 1.5, "long column widens: {before:?} → {after:?}");
|
||||
let total: f32 = after.iter().sum();
|
||||
assert!(total <= tw + 0.5 && total > tw - 1.0, "the long text fills the page width: {total} vs {tw}");
|
||||
let t = s.doc.body.iter().find_map(|b| b.as_table()).unwrap();
|
||||
assert!(t.props.width_pct.is_none() && !t.props.fixed);
|
||||
assert_eq!(t.rows[0].cells[1].props.width, Some(after[1]), "cell widths follow the grid");
|
||||
// Short text only: every column gets just what its text needs.
|
||||
s.doc = wordcraft_doc::Document::new();
|
||||
s.sel = crate::Selection::caret(Pos::body(0, 0));
|
||||
run(&mut s, "insert.table", json!({"rows": 1, "cols": 2}));
|
||||
run(&mut s, "text.insert", json!({"text": "Name"}));
|
||||
run(&mut s, "text.tab", json!({}));
|
||||
run(&mut s, "text.insert", json!({"text": "Quantity"}));
|
||||
run(&mut s, "table.autofit", json!({"mode": "contents"}));
|
||||
let g = grid(&s);
|
||||
assert!(g[0] < g[1] && g[1] < 100.0, "{g:?}");
|
||||
// The layout uses the new widths.
|
||||
let w = s.layout().pages[0].items.iter().find_map(|it| match it {
|
||||
wordcraft_layout::Placed::Cell { rect, row: 0, cell: 0, .. } => Some(rect.w),
|
||||
_ => None,
|
||||
});
|
||||
assert!(w.is_some_and(|w| (w - g[0]).abs() < 0.5), "{w:?} vs {g:?}");
|
||||
}
|
||||
|
||||
/// The Height and Width boxes (Table Layout › Cell Size) run `table.rowHeight` and
|
||||
/// `table.columnWidth`; each edit is one Undo.
|
||||
#[test]
|
||||
fn cell_size_boxes_set_row_height_and_column_width() {
|
||||
use wordcraft_doc::props::HeightRule;
|
||||
let mut s = s();
|
||||
run(&mut s, "insert.table", json!({"rows": 2, "cols": 2}));
|
||||
run(&mut s, "table.autofit", json!({"mode": "window"}));
|
||||
let t0 = s.doc.body.iter().find_map(|b| b.as_table()).unwrap().clone();
|
||||
run(&mut s, "table.columnWidth", json!({"width": 100.0}));
|
||||
run(&mut s, "table.rowHeight", json!({"height": 30.0, "rule": "exact"}));
|
||||
let t = s.doc.body.iter().find_map(|b| b.as_table()).unwrap();
|
||||
assert_eq!(t.grid[0], 100.0);
|
||||
assert_eq!(t.rows[0].cells[0].props.width, Some(100.0));
|
||||
assert!(t.props.width_pct.is_none(), "the column isn't scaled back to the window");
|
||||
assert_eq!((t.rows[0].props.height, t.rows[0].props.height_rule), (Some(30.0), HeightRule::Exact));
|
||||
let rect = s.layout().pages[0].items.iter().find_map(|it| match it {
|
||||
wordcraft_layout::Placed::Cell { rect, row: 0, cell: 0, .. } => Some(*rect),
|
||||
_ => None,
|
||||
});
|
||||
assert!(rect.is_some_and(|r| (r.w - 100.0).abs() < 0.5 && (r.h - 30.0).abs() < 0.5), "{rect:?}");
|
||||
assert!(s.run("table.rowHeight", &json!({"height": 30.0, "rule": "sideways"})).is_err());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.body.iter().find_map(|b| b.as_table()).unwrap(), &t0);
|
||||
}
|
||||
|
||||
/// Table Properties (#44): table, row, column and cell settings land as one undo step, and
|
||||
/// without settings the command reports the current ones (the dialog's source).
|
||||
#[test]
|
||||
fn table_properties_apply_in_one_step() {
|
||||
use wordcraft_doc::props::{Align, HeightRule, VAlign};
|
||||
let mut s = s();
|
||||
run(&mut s, "insert.table", json!({"rows": 2, "cols": 2}));
|
||||
let t0 = s.doc.body.iter().find_map(|b| b.as_table()).unwrap().clone();
|
||||
run(
|
||||
&mut s,
|
||||
"table.properties",
|
||||
json!({"align": "center", "indent": 18.0, "width": 300.0, "rowHeight": 40.0, "rowHeightRule": "exact", "allowBreak": false,
|
||||
"headerRow": true, "columnWidth": 120.0, "cellWidth": 110.0, "valign": "bottom"}),
|
||||
);
|
||||
let t = s.doc.body.iter().find_map(|b| b.as_table()).unwrap();
|
||||
assert_eq!((t.props.align, t.props.indent, t.props.width), (Some(Align::Center), Some(18.0), Some(300.0)));
|
||||
let row = &t.rows[0];
|
||||
assert_eq!((row.props.height, row.props.height_rule, row.props.cant_split, row.props.header), (Some(40.0), HeightRule::Exact, true, true));
|
||||
assert_eq!(t.grid[0], 120.0);
|
||||
assert_eq!((row.cells[0].props.width, row.cells[0].props.valign), (Some(110.0), VAlign::Bottom));
|
||||
let applied = t.clone();
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.body.iter().find_map(|b| b.as_table()).unwrap(), &t0, "one undo step per apply");
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(s.doc.body.iter().find_map(|b| b.as_table()).unwrap(), &applied);
|
||||
let state = run(&mut s, "table.properties", json!({}));
|
||||
assert_eq!(state["align"], "center");
|
||||
assert_eq!(state["row"]["heightRule"], "exact");
|
||||
assert_eq!(state["columnWidth"], 120.0);
|
||||
assert_eq!(state["cell"]["valign"], "bottom");
|
||||
// Clearing the row height makes it automatic again; bad values are refused.
|
||||
run(&mut s, "table.properties", json!({"rowHeight": null}));
|
||||
assert_eq!(s.doc.body.iter().find_map(|b| b.as_table()).unwrap().rows[0].props.height_rule, HeightRule::Auto);
|
||||
assert!(s.run("table.properties", &json!({"valign": "middle"})).is_err());
|
||||
}
|
||||
|
||||
/// Table Layout › Text Direction (#226) cycles the selected cells' text through horizontal,
|
||||
/// top-to-bottom and bottom-to-top, and the layout turns the text.
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user