mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-24 10:16:59 -06:00
Table › Convert Rows › To Header, To Body and To Footer, as in InDesign
This commit is contained in:
@@ -347,6 +347,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
|
||||
|
||||
Newest first.
|
||||
|
||||
- **2026-10-15** · Table ▸ Convert Rows ▸ To Header / To Body / To Footer, as in InDesign: the selected rows become header rows when they touch the top of the table or its header rows, footer rows when they touch the bottom or its footer rows, and header or footer rows next to the body go back to body rows. The items are greyed out when the rows can't change (a gap, the last body row, a merged cell across the new boundary); one undo step. No readiness percentage change.
|
||||
- **2026-10-14** · The Type tool draws a new text frame over a locked empty frame, as InDesign does, instead of turning the locked frame into a text frame. No readiness percentage change.
|
||||
- **2026-10-14** · 100% black shows as pure black on screen by default, as InDesign's Appearance of Black does out of the box (Display All Blacks as Rich Black); Preferences › Appearance of Black › Display All Blacks Accurately still shows it as the dark grey it prints, and a saved choice is kept. No readiness percentage change.
|
||||
- **2026-10-14** · Pathfinder keeps the frontmost object, as InDesign does: a photo placed over shapes and intersected with them shows through the shapes (it was dropped, and the shapes kept their black fill); Add, Exclude Overlap and Minus Back take the front object's fill too, Subtract still keeps the back one. No readiness percentage change.
|
||||
|
||||
@@ -931,6 +931,47 @@ impl Table {
|
||||
}
|
||||
}
|
||||
|
||||
/// Header and footer row counts after Table ▸ Convert Rows turns rows `r0..=r1` into `kind`
|
||||
/// rows. Header rows grow from the top and footer rows from the bottom, so the rows must
|
||||
/// touch the table's edge or the rows already converted; back to body, they must touch the
|
||||
/// body. One body row stays, and no merged cell may straddle a moved boundary.
|
||||
pub fn convert_rows_counts(&self, r0: usize, r1: usize, kind: RowKind) -> Result<(usize, usize), String> {
|
||||
let (r0, r1) = (r0.min(r1), r0.max(r1));
|
||||
let n = self.nrows();
|
||||
if r1 >= n {
|
||||
return Err("row out of range".into());
|
||||
}
|
||||
let (h, f) = (self.header_rows(), self.footer_rows());
|
||||
let body_end = n - f;
|
||||
let (nh, nf) = match kind {
|
||||
RowKind::Header if r0 > h => return Err("select rows at the top of the table or right after its header rows".into()),
|
||||
RowKind::Header if r1 >= body_end => return Err("footer rows can't become header rows".into()),
|
||||
RowKind::Header => (h.max(r1 + 1), f),
|
||||
RowKind::Footer if r1 + 1 < body_end => return Err("select rows at the bottom of the table or right before its footer rows".into()),
|
||||
RowKind::Footer if r0 < h => return Err("header rows can't become footer rows".into()),
|
||||
RowKind::Footer => (h, f.max(n - r0)),
|
||||
RowKind::Body => {
|
||||
if (r0 < h && r1 + 1 < h) || (r1 >= body_end && r0 > body_end) {
|
||||
return Err("select the header or footer rows next to the body".into());
|
||||
}
|
||||
(if r0 < h { r0 } else { h }, if r1 >= body_end { n - 1 - r1 } else { f })
|
||||
}
|
||||
};
|
||||
if (nh, nf) == (h, f) {
|
||||
return Err("the rows already are of that kind".into());
|
||||
}
|
||||
if nh + nf >= n {
|
||||
return Err("a table keeps at least one body row".into());
|
||||
}
|
||||
let owners = self.owners();
|
||||
let nc = self.ncols();
|
||||
let straddled = |b: usize| (0..nc).any(|c| owners.get(b * nc + c).is_some_and(|o| o.0 < b));
|
||||
if (nh != h && straddled(nh)) || (nf != f && straddled(n - nf)) {
|
||||
return Err("unmerge the cells that span header, body and footer rows first".into());
|
||||
}
|
||||
Ok((nh, nf))
|
||||
}
|
||||
|
||||
/// Cell texts, rows separated by `\n`, cells by `\t` (Convert Table to Text, search, agents).
|
||||
pub fn plain_text(&self) -> String {
|
||||
let mut out = String::new();
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
use designcraft_compose as compose;
|
||||
use designcraft_doc::story::floor_char_boundary;
|
||||
use designcraft_doc::{
|
||||
AltFills, CellAddr, CellRange, CellStroke, Document, RowHeightMode, Selection, StoryId, StrokeType, Table, TableSel, TextSel,
|
||||
AltFills, CellAddr, CellRange, CellStroke, Document, RowHeightMode, RowKind, Selection, StoryId, StrokeType, Table, TableSel, TextSel,
|
||||
VerticalJustification,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
@@ -220,6 +220,33 @@ fn table_specs() -> Vec<CommandSpec> {
|
||||
in_table,
|
||||
options
|
||||
),
|
||||
cmd!(
|
||||
"table.convertRowsToHeader",
|
||||
"To Header",
|
||||
["Table", "Convert Rows"],
|
||||
None,
|
||||
"{rows?: [a,b]} — the target rows, at the top of the table or right after its header rows",
|
||||
|s| can_convert_rows(s, RowKind::Header),
|
||||
|s, p| convert_rows(s, p, RowKind::Header)
|
||||
),
|
||||
cmd!(
|
||||
"table.convertRowsToBody",
|
||||
"To Body",
|
||||
["Table", "Convert Rows"],
|
||||
None,
|
||||
"{rows?: [a,b]} — the target header rows next to the body, footer rows next to it, or both",
|
||||
|s| can_convert_rows(s, RowKind::Body),
|
||||
|s, p| convert_rows(s, p, RowKind::Body)
|
||||
),
|
||||
cmd!(
|
||||
"table.convertRowsToFooter",
|
||||
"To Footer",
|
||||
["Table", "Convert Rows"],
|
||||
None,
|
||||
"{rows?: [a,b]} — the target rows, at the bottom of the table or right before its footer rows",
|
||||
|s| can_convert_rows(s, RowKind::Footer),
|
||||
|s, p| convert_rows(s, p, RowKind::Footer)
|
||||
),
|
||||
cmd!(noundo "table.select", "Select Cells", [], None, "{story?, table?, rows?: [a,b], cols?: [a,b], what?: cell|row|column|table}", in_table_or_ids, select),
|
||||
cmd!(noundo "table.selectTable", "Select Table", ["Table", "Select"], None, "{}", in_table, |s, p| {
|
||||
select(s, &super::with_param(p, "what", json!("table")))
|
||||
@@ -946,6 +973,27 @@ fn options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Convert Rows is greyed out unless the selected cells (or the caret's cell) can change kind.
|
||||
fn can_convert_rows(s: &Session, kind: RowKind) -> std::result::Result<(), String> {
|
||||
let st = s.active().ok_or("no document open")?;
|
||||
if st.selection.cells.is_none() {
|
||||
in_cell(s)?;
|
||||
}
|
||||
let g = target(s, &Value::Null, "table.convertRows").map_err(|e| e.to_string())?;
|
||||
let t = st.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).ok_or("no table")?;
|
||||
t.convert_rows_counts(g.range.r0, g.range.r1, kind).map(|_| ())
|
||||
}
|
||||
|
||||
fn convert_rows(s: &mut Session, p: &Value, kind: RowKind) -> Result<Value> {
|
||||
const ID: &str = "table.convertRows";
|
||||
let g = target(s, p, ID)?;
|
||||
edit_table(s, &g, ID, |t| {
|
||||
let (h, f) = t.convert_rows_counts(g.range.r0, g.range.r1, kind).map_err(|e| bad(ID, e))?;
|
||||
t.set_header_footer(h, f);
|
||||
Ok(json!({"headerRows": h, "footerRows": f}))
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- selection ----------
|
||||
|
||||
fn select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -873,3 +873,92 @@ fn split_selected_block_and_merged_cells_preserves_geometry_and_history() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn header_footer(s: &mut Session) -> (u64, u64) {
|
||||
let t = s.execute("table.get", &json!({})).unwrap();
|
||||
(t["headerRows"].as_u64().unwrap(), t["footerRows"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn convert_enabled(s: &Session, to: &str) -> bool {
|
||||
crate::cmd::find_command(&format!("table.convertRowsTo{to}")).unwrap().info(s).enabled
|
||||
}
|
||||
|
||||
fn select_rows(s: &mut Session, a: usize, b: usize) {
|
||||
s.execute("table.select", &json!({"what": "row", "rows": [a, b]})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_rows_follows_the_table_edges() {
|
||||
let (mut s, _, _) = session_with_frame();
|
||||
s.execute("table.insert", &json!({"rows": 6, "cols": 2})).unwrap();
|
||||
// Rows at the top become header rows, in one undo step.
|
||||
select_rows(&mut s, 0, 1);
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
s.execute("table.convertRowsToHeader", &json!({})).unwrap();
|
||||
assert_eq!(header_footer(&mut s), (2, 0));
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(header_footer(&mut s), (0, 0));
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(header_footer(&mut s), (2, 0));
|
||||
// A row with a body row between it and the header rows can't become a header row.
|
||||
select_rows(&mut s, 3, 3);
|
||||
assert!(!convert_enabled(&s, "Header"));
|
||||
assert!(s.execute("table.convertRowsToHeader", &json!({})).is_err());
|
||||
assert_eq!(header_footer(&mut s), (2, 0));
|
||||
// The row right after the header rows can.
|
||||
select_rows(&mut s, 2, 2);
|
||||
s.execute("table.convertRowsToHeader", &json!({})).unwrap();
|
||||
assert_eq!(header_footer(&mut s), (3, 0));
|
||||
// Footer rows come from the bottom.
|
||||
select_rows(&mut s, 3, 3);
|
||||
assert!(!convert_enabled(&s, "Footer"));
|
||||
select_rows(&mut s, 5, 5);
|
||||
s.execute("table.convertRowsToFooter", &json!({})).unwrap();
|
||||
assert_eq!(header_footer(&mut s), (3, 1));
|
||||
select_rows(&mut s, 4, 4);
|
||||
s.execute("table.convertRowsToFooter", &json!({})).unwrap();
|
||||
assert_eq!(header_footer(&mut s), (3, 2));
|
||||
// The last body row stays a body row.
|
||||
select_rows(&mut s, 3, 3);
|
||||
assert!(!convert_enabled(&s, "Header") && !convert_enabled(&s, "Footer") && !convert_enabled(&s, "Body"));
|
||||
// Back to body: only header rows next to the body, and footer rows next to it.
|
||||
select_rows(&mut s, 0, 0);
|
||||
assert!(!convert_enabled(&s, "Body"));
|
||||
select_rows(&mut s, 1, 2);
|
||||
s.execute("table.convertRowsToBody", &json!({})).unwrap();
|
||||
assert_eq!(header_footer(&mut s), (1, 2));
|
||||
select_rows(&mut s, 5, 5);
|
||||
assert!(!convert_enabled(&s, "Body"));
|
||||
select_rows(&mut s, 4, 4);
|
||||
s.execute("table.convertRowsToBody", &json!({})).unwrap();
|
||||
assert_eq!(header_footer(&mut s), (1, 1));
|
||||
// Header rows can't jump to the footer, nor footer rows to the header.
|
||||
select_rows(&mut s, 0, 0);
|
||||
assert!(!convert_enabled(&s, "Footer"));
|
||||
select_rows(&mut s, 5, 5);
|
||||
assert!(!convert_enabled(&s, "Header"));
|
||||
// The whole table back to body rows.
|
||||
s.execute("table.select", &json!({"what": "table"})).unwrap();
|
||||
s.execute("table.convertRowsToBody", &json!({})).unwrap();
|
||||
assert_eq!(header_footer(&mut s), (0, 0));
|
||||
check(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_rows_keeps_merged_cells_on_one_side() {
|
||||
let (mut s, _, _) = session_with_frame();
|
||||
s.execute("table.insert", &json!({"rows": 4, "cols": 2})).unwrap();
|
||||
s.execute("table.select", &json!({"rows": [0, 1], "cols": [1, 1]})).unwrap();
|
||||
s.execute("table.merge", &json!({})).unwrap();
|
||||
// Row 0 alone would split the merged cell between header and body.
|
||||
select_rows(&mut s, 0, 0);
|
||||
assert!(!convert_enabled(&s, "Header"));
|
||||
select_rows(&mut s, 0, 1);
|
||||
s.execute("table.convertRowsToHeader", &json!({})).unwrap();
|
||||
assert_eq!(header_footer(&mut s), (2, 0));
|
||||
// Likewise back: row 1 alone would leave half of it in the header.
|
||||
select_rows(&mut s, 1, 1);
|
||||
assert!(!convert_enabled(&s, "Body"));
|
||||
check(&s);
|
||||
}
|
||||
|
||||
@@ -1762,6 +1762,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Yellow", "ყვითელი"),
|
||||
("Close Panel", "პანელის დახურვა"),
|
||||
("Distribute Rows Evenly", "სტრიქონების თანაბრად განაწილება"),
|
||||
("Convert Rows", "სტრიქონების გარდაქმნა"),
|
||||
("To Header", "სათაურად"),
|
||||
("To Body", "ძირითად სტრიქონებად"),
|
||||
("To Footer", "ქვედა კოლონტიტულად"),
|
||||
("Don't Save", "არ შეინახო"),
|
||||
("Group with", "ერთად დაჯგუფება"),
|
||||
("Pin Bar Position", "ზოლის პოზიციის ჩამაგრება"),
|
||||
|
||||
@@ -650,6 +650,11 @@ pub const MENUS: &[(&str, &[&str])] = &[
|
||||
"ui:app.graphicCell|Convert Cell to Graphic Cell…",
|
||||
"cmd:table.textCell",
|
||||
"<",
|
||||
">Convert Rows",
|
||||
"cmd:table.convertRowsToHeader",
|
||||
"cmd:table.convertRowsToBody",
|
||||
"cmd:table.convertRowsToFooter",
|
||||
"<",
|
||||
"cmd:table.splitHorizontally",
|
||||
"cmd:table.splitVertically",
|
||||
"cmd:table.distributeColumns",
|
||||
|
||||
+3
-3
@@ -126,8 +126,8 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format ·
|
||||
|
||||
### 12. Tables in depth (F)
|
||||
- **Missing:** anchored objects in cells not drawn (#163); Table ▸ Table Options and Cell Options
|
||||
submenus as InDesign lays them out, Diagonal Lines, Convert Rows to Header/Body/Footer, Edit
|
||||
Header/Footer, Go to Row; general reliability (#20).
|
||||
submenus as InDesign lays them out, Diagonal Lines, Edit Header/Footer,
|
||||
Go to Row; general reliability (#20).
|
||||
- **Estimate:** 25–40 h.
|
||||
|
||||
### 13. Hyphenation and spelling languages (T, L)
|
||||
@@ -160,7 +160,7 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format ·
|
||||
Window ▸ Arrange (2-up … 6-up), Trap Presets, Overlays, Edit ▸ Assign Profiles / Convert to
|
||||
Profile / Edit Original, File ▸ File Info / Open PDF / Document and Print Presets / User
|
||||
Settings, Type ▸ Size presets, Table ▸ Table Options / Cell Options submenus, Paste Before/After,
|
||||
Convert Rows/Columns, Object ▸ Interactive ▸ Convert to (form fields),
|
||||
Convert Columns, Object ▸ Interactive ▸ Convert to (form fields),
|
||||
View ▸ Extras badges and Story Editor options.
|
||||
- **Estimate:** 40–70 h (most are thin wrappers over existing engine features).
|
||||
- **Doc:** [ui-parity.md](ui-parity.md)
|
||||
|
||||
@@ -214,7 +214,7 @@ table is the one summed.
|
||||
| Type & text frames | 99% | 45% | 40–60 | Primary Text Frame pages users can't delete (alpha blocker, #165); autoflow doesn't fill existing pages; no threading into grouped frames with the loaded cursor; caret formatting discarded (#289); canvas IME (#322); Notes/Track Changes menus; frame grids |
|
||||
| Typography | 100% | 45% | (typography dimension) | see [typography-parity.md](typography-parity.md) |
|
||||
| Styles | 100% | 60% | 15–25 | Paragraph Styles edit/delete reported failing (#169); style options depth |
|
||||
| Tables | 100% | 55% | 25–40 | anchored objects in cells not drawn (#163); Table ▸ Table Options submenus, Diagonal Lines, Convert Rows, Edit Header/Footer |
|
||||
| Tables | 100% | 55% | 25–40 | anchored objects in cells not drawn (#163); Table ▸ Table Options submenus, Diagonal Lines, Edit Header/Footer |
|
||||
| Long documents | 100% | 45% | 30–50 | Books are shallow (sync, book pagination, book PDF/TOC/index); Table of Contents Styles |
|
||||
| Interactivity & digital | 100% | 40% | 30–50 | HTML export basic (#314); fixed-layout EPUB is page images; no animation, timing, media playback |
|
||||
| Output & production | 92% | 55% | 40–70 | PDF/X-4 only (no X-1a/X-3), no PDF/print presets or `.joboptions`, print via craft-print (CUPS/Windows), no trapping; Output Preview panel |
|
||||
|
||||
Reference in New Issue
Block a user