O: Select submenu, Convert Shape, split table cells

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 22:11:07 -04:00
parent bddaaac887
commit 2cd4102469
8 changed files with 445 additions and 10 deletions

View File

@ -37,6 +37,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- Global Light (`object.globalLight`, `globalLight` on drop/inner shadows; Use Global Light in the Effects panel) and Generate Static Caption (`object.caption`: template over name, path, alt text, label, effective ppi, dimensions, format; any side, offset, style).
- Control panel scale X/Y, rotation and shear fields (`transform.info`, absolute `transform.set {scaleX, scaleY, rotation, shear}` about the reference point) and Preferences › Transformations are Totals for nested objects and placed graphics.
- Variable fonts: each named instance is a style (Font menu, shaping with feature variations, metrics, outlines) and embeds at its axis settings in PDF; free axis sliders still to come.
- Object ▸ Select (first/next above, next/last below, container, content, previous/next in group), full Object ▸ Convert Shape (`object.convertShape`), Table ▸ Split Cell Horizontally/Vertically.
- Drag and drop text editing: drag selected text to move it (Alt copies) with its formatting (`text.release`).
- Image Import Options: place any page of a multi-page PDF (`file.place {pdfPage}`; the app asks which page), shown on screen and embedded as that page in PDF export.
- Define Lists: named numbered lists that continue across stories in page order (`list.define`, paragraph `listName` / `startAt`), round-tripped through IDML; `null` in `type.para` / `type.char` now removes an override.

View File

@ -602,6 +602,95 @@ impl Table {
self.set_regions(&regions);
}
/// Split the cell covering (r, c) in two: `horizontal` stacks the halves (Split Cell
/// Horizontally), otherwise side by side. A merged cell splits its span; a single cell gets a
/// new row (column) that the cells beside it span, the row height (column width) shared.
pub fn split_cell(&mut self, r: usize, c: usize, horizontal: bool) {
if r >= self.nrows() || c >= self.ncols() {
return;
}
let (or, oc) = self.owner(r, c);
let (rs, cs) = self.cell(or, oc).map_or((1, 1), |x| (x.row_span as usize, x.col_span as usize));
let mut regions: Vec<_> = self.regions().into_iter().filter(|&(rr, cc, _, _)| (rr, cc) != (or, oc)).collect();
let span = if horizontal { rs } else { cs };
if span > 1 {
let a = span.div_ceil(2);
if horizontal {
regions.push((or, oc, a, cs));
regions.push((or + a, oc, rs - a, cs));
} else {
regions.push((or, oc, rs, a));
regions.push((or, oc + a, rs, cs - a));
}
self.set_regions(&regions);
return;
}
if horizontal {
let h = self.rows[or].height;
self.insert_rows(or + 1, 1);
self.rows[or].height = h / 2.0;
self.rows[or + 1].height = h / 2.0;
// insert_rows doesn't grow regions that end at the split row; everything else on it
// spans the new row now.
let mut regions: Vec<_> = self.regions();
let (nr, nc) = (self.nrows(), self.ncols());
let mut covered = vec![false; nr * nc];
for &(rr, cc, rrs, ccs) in &regions {
for y in rr..rr + rrs {
for x in cc..cc + ccs {
covered[y * nc + x] = true;
}
}
}
for reg in regions.iter_mut() {
let (rr, cc, rrs, ccs) = *reg;
if rr + rrs == or + 1 && !(cc <= oc && oc < cc + ccs) {
*reg = (rr, cc, rrs + 1, ccs);
}
}
for x in 0..nc {
if (oc..oc + cs).contains(&x) || covered[or * nc + x] {
continue;
}
regions.push((or, x, 2, 1));
}
if cs > 1 {
regions.push((or + 1, oc, 1, cs));
}
self.set_regions(&regions);
} else {
let w = self.columns[oc].width;
self.insert_cols(oc + 1, 1, w / 2.0);
self.columns[oc].width = w / 2.0;
let mut regions: Vec<_> = self.regions();
let (nr, nc) = (self.nrows(), self.ncols());
let mut covered = vec![false; nr * nc];
for &(rr, cc, rrs, ccs) in &regions {
for y in rr..rr + rrs {
for x in cc..cc + ccs {
covered[y * nc + x] = true;
}
}
}
for reg in regions.iter_mut() {
let (rr, cc, rrs, ccs) = *reg;
if cc + ccs == oc + 1 && !(rr <= or && or < rr + rrs) {
*reg = (rr, cc, rrs, ccs + 1);
}
}
for y in 0..nr {
if (or..or + rs).contains(&y) || covered[y * nc + oc] {
continue;
}
regions.push((y, oc, 1, 2));
}
if rs > 1 {
regions.push((or, oc + 1, rs, 1));
}
self.set_regions(&regions);
}
}
/// Mark the first `header` rows as header rows and the last `footer` rows as footer rows.
pub fn set_header_footer(&mut self, header: usize, footer: usize) {
let n = self.nrows();
@ -971,3 +1060,30 @@ mod tests {
assert!(st.tables.is_empty());
}
}
#[cfg(test)]
mod split_tests {
use super::*;
#[test]
fn split_single_and_merged_cells() {
let mut t = Table::new(1, 2, 2, 0, 0, 100.0);
t.split_cell(0, 0, true);
assert_eq!((t.nrows(), t.ncols()), (3, 2));
// The cell beside the split one spans both new rows.
assert_eq!(t.cell(0, 1).unwrap().row_span, 2);
assert_eq!(t.owner(1, 1), (0, 1));
assert_eq!(t.owner(1, 0), (1, 0), "the new half is its own cell");
t.split_cell(2, 1, false);
assert_eq!(t.ncols(), 3);
assert_eq!(t.cell(0, 0).unwrap().col_span, 1);
assert_eq!(t.owner(2, 2), (2, 2));
assert_eq!(t.owner(0, 2), (0, 1), "the merged cell above widens");
// A merged cell splits its own span instead of adding rows.
let mut m = Table::new(1, 4, 1, 0, 0, 100.0);
m.merge(CellRange { r0: 0, c0: 0, r1: 3, c1: 0 }).unwrap();
m.split_cell(0, 0, true);
assert_eq!(m.nrows(), 4);
assert_eq!((m.cell(0, 0).unwrap().row_span, m.cell(2, 0).unwrap().row_span), (2, 2));
}
}

View File

@ -27,6 +27,7 @@ mod place_text;
pub mod preflight;
mod prefs;
mod print;
mod select;
pub mod spelling;
mod style;
pub mod table;
@ -136,6 +137,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(captions::specs());
v.extend(lists::specs());
v.extend(path_type::specs());
v.extend(select::specs());
v.extend(package::specs());
v.extend(print::specs());
v.extend(text::specs());

View File

@ -0,0 +1,249 @@
//! Object › Select (stacking order, container and content, objects in a group) and Object ›
//! Convert Shape.
use designcraft_doc::{Content, Document, Item, ItemId, ItemLoc, Selection, Shape, SpreadRef};
use designcraft_geom::corners::{CornerOptions, CornerShape};
use designcraft_geom::{Point, Rect, shapes};
use serde_json::{Value, json};
use super::{CommandSpec, bad, cmd, has_doc, has_selection, ok, str_param, targets};
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(noundo "select.firstAbove", "First Object Above", ["Object", "Select"], Some("Cmd+Alt+Shift+]"), "{} — the frontmost object on the spread (layers included)", has_doc, |s, _| select_stacked(s, Step::Top)),
cmd!(noundo "select.nextAbove", "Next Object Above", ["Object", "Select"], Some("Cmd+Alt+]"), "{}", has_doc, |s, _| select_stacked(s, Step::Up)),
cmd!(noundo "select.nextBelow", "Next Object Below", ["Object", "Select"], Some("Cmd+Alt+["), "{}", has_doc, |s, _| select_stacked(s, Step::Down)),
cmd!(noundo "select.lastBelow", "Last Object Below", ["Object", "Select"], Some("Cmd+Alt+Shift+["), "{} — the backmost object on the spread", has_doc, |s, _| select_stacked(s, Step::Bottom)),
cmd!(noundo "select.container", "Container", ["Object", "Select"], None, "{} — the group or frame holding the selection", has_selection, |s, _| select_container(s)),
cmd!(noundo "select.content", "Content", ["Object", "Select"], None, "{} — the graphic in a frame, or the first object in a group", has_selection, |s, _| select_content(s)),
cmd!(noundo "select.nextInGroup", "Next Object in Group", ["Object", "Select"], None, "{}", has_selection, |s, _| select_sibling(s, 1)),
cmd!(noundo "select.previousInGroup", "Previous Object in Group", ["Object", "Select"], None, "{}", has_selection, |s, _| select_sibling(s, -1)),
cmd!(
"object.convertShape",
"Convert Shape",
["Object", "Convert Shape"],
None,
"{to: rectangle|roundedRectangle|beveledRectangle|inverseRoundedRectangle|ellipse|triangle|polygon|line|orthogonalLine|openPath|closedPath, ids?}",
has_selection,
convert_shape
),
]
}
#[derive(Clone, Copy)]
enum Step {
Top,
Up,
Down,
Bottom,
}
/// Selectable top-level items of spread `r`, back to front.
fn stacking(d: &Document, r: SpreadRef) -> Vec<ItemId> {
let Some(sp) = d.spread(r) else { return vec![] };
let mut v = Vec::new();
for layer in d.layers.iter().rev() {
if !layer.visible || layer.locked {
continue;
}
v.extend(sp.items.iter().filter(|it| it.layer == layer.id && !it.hidden && !it.locked).map(|it| it.id));
}
v
}
fn select_stacked(s: &mut Session, how: Step) -> Result<Value> {
let st = s.doc_mut()?;
let cur = st.selection.items.first().and_then(|id| st.doc.find(*id).map(|l| (*id, l)));
let r = cur.as_ref().map(|(_, l)| l.spread).or_else(|| st.doc.spread_refs().next()).ok_or_else(|| bad("select", "no spread"))?;
let order = stacking(&st.doc, r);
if order.is_empty() {
return ok();
}
// Nested selections step from their top-level object.
let at = cur
.and_then(|(_, l)| st.doc.spread(l.spread).and_then(|sp| sp.items.get(l.top())).map(|it| it.id))
.and_then(|id| order.iter().position(|x| *x == id));
let n = order.len();
let i = match (how, at) {
(Step::Top, _) | (Step::Down, None) => n - 1,
(Step::Bottom, _) | (Step::Up, None) => 0,
(Step::Up, Some(i)) => (i + 1).min(n - 1),
(Step::Down, Some(i)) => i.saturating_sub(1),
};
st.selection = Selection::items(vec![order[i]]);
st.revision += 1;
Ok(json!({"id": order[i].0}))
}
fn parent_loc(l: &ItemLoc) -> Option<ItemLoc> {
(l.path.len() > 1).then(|| ItemLoc { spread: l.spread, path: l.path[..l.path.len() - 1].to_vec() })
}
fn select_container(s: &mut Session) -> Result<Value> {
let st = s.doc_mut()?;
let Some(id) = st.selection.items.first().copied() else { return ok() };
if st.selection.content {
st.selection = Selection::items(vec![id]);
} else if let Some(p) = st.doc.find(id).and_then(|l| parent_loc(&l)).and_then(|l| st.doc.item_at(&l).map(|it| it.id)) {
st.selection = Selection::items(vec![p]);
} else {
return ok();
}
st.revision += 1;
Ok(json!({"id": st.selection.items[0].0}))
}
fn select_content(s: &mut Session) -> Result<Value> {
let st = s.doc_mut()?;
let Some(id) = st.selection.items.first().copied() else { return ok() };
let Some(it) = st.doc.item(id) else { return ok() };
if let Some(c) = it.children().first() {
st.selection = Selection::items(vec![c.id]);
} else if matches!(it.content, Content::Graphic(_)) {
st.selection = Selection { content: true, ..Selection::items(vec![id]) };
} else {
return ok();
}
st.revision += 1;
Ok(json!({"id": st.selection.items[0].0}))
}
fn select_sibling(s: &mut Session, dir: isize) -> Result<Value> {
let st = s.doc_mut()?;
let Some(id) = st.selection.items.first().copied() else { return ok() };
let Some(l) = st.doc.find(id) else { return ok() };
let Some(pl) = parent_loc(&l) else { return ok() };
let Some(parent) = st.doc.item_at(&pl) else { return ok() };
let kids = parent.children();
let n = kids.len() as isize;
let i = *l.path.last().expect("nested") as isize;
// InDesign's "next" goes down the stack (towards the back), wrapping.
let j = (i - dir).rem_euclid(n) as usize;
let next = kids[j].id;
st.selection = Selection::items(vec![next]);
st.revision += 1;
Ok(json!({"id": next.0}))
}
fn convert_shape(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "object.convertShape";
let to = str_param(p, "to").ok_or_else(|| bad(ID, "`to` required"))?.to_string();
let ids = targets(s, p)?;
let sides = s.prefs.polygon_sides;
let inset = s.prefs.star_inset;
s.edit(|d, _| {
let mut changed = 0;
for id in &ids {
let Some(it) = d.item_mut(*id) else { continue };
if it.shape == Shape::Group {
continue;
}
convert(it, &to, sides, inset).map_err(|e| bad(ID, e))?;
changed += 1;
}
Ok(json!({"changed": changed}))
})
}
fn convert(it: &mut Item, to: &str, sides: u32, inset: f64) -> std::result::Result<(), String> {
let r: Rect = it.path.bounds().unwrap_or(Rect::ZERO);
let text = matches!(it.content, Content::Text(_));
let corners = |shape: CornerShape| CornerOptions::uniform(shape, (r.width().min(r.height()) / 6.0).clamp(1.0, 12.0));
let (path, shape, c) = match to {
"rectangle" => (shapes::rectangle(r), Shape::Rectangle, CornerOptions::default()),
"roundedRectangle" => (shapes::rectangle(r), Shape::Rectangle, corners(CornerShape::Rounded)),
"beveledRectangle" => (shapes::rectangle(r), Shape::Rectangle, corners(CornerShape::Bevel)),
"inverseRoundedRectangle" => (shapes::rectangle(r), Shape::Rectangle, corners(CornerShape::InverseRounded)),
"ellipse" => (shapes::ellipse(r), Shape::Oval, CornerOptions::default()),
"triangle" => (super::object::polygon_in(r, 3, 0.0), Shape::Polygon, CornerOptions::default()),
"polygon" => (super::object::polygon_in(r, sides, inset), Shape::Polygon, CornerOptions::default()),
"line" | "orthogonalLine" if text => return Err("a text frame can't become a line".into()),
"line" => (shapes::line(Point::new(r.x0, r.y0), Point::new(r.x1, r.y1)), Shape::GraphicLine, CornerOptions::default()),
"orthogonalLine" => {
let c = r.center();
let (a, b) =
if r.width() >= r.height() { (Point::new(r.x0, c.y), Point::new(r.x1, c.y)) } else { (Point::new(c.x, r.y0), Point::new(c.x, r.y1)) };
(shapes::line(a, b), Shape::GraphicLine, CornerOptions::default())
}
"openPath" | "closedPath" => {
let closed = to == "closedPath";
if !closed && text {
return Err("a text frame needs a closed path".into());
}
let mut path = it.path.clone();
for sp in &mut path.subpaths {
sp.closed = closed;
}
(path, Shape::Path, it.corners)
}
o => return Err(format!("unknown shape `{o}`")),
};
it.path = path;
it.shape = shape;
it.corners = c;
Ok(())
}
#[cfg(test)]
mod tests {
use serde_json::json;
use crate::Session;
fn ids(s: &Session) -> Vec<u64> {
s.doc().unwrap().selection.items.iter().map(|i| i.0).collect()
}
#[test]
fn select_by_stacking_order_and_groups() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let mk = |s: &mut Session, x: f64| s.execute("frame.create", &json!({"rect": [x, 100.0, x + 50.0, 150.0]})).unwrap()["id"].as_u64().unwrap();
let (a, b, c) = (mk(&mut s, 0.0), mk(&mut s, 60.0), mk(&mut s, 120.0));
s.execute("select.lastBelow", &json!({})).unwrap();
assert_eq!(ids(&s), [a]);
s.execute("select.nextAbove", &json!({})).unwrap();
assert_eq!(ids(&s), [b]);
s.execute("select.firstAbove", &json!({})).unwrap();
assert_eq!(ids(&s), [c]);
s.execute("select.nextAbove", &json!({})).unwrap();
assert_eq!(ids(&s), [c], "already on top");
s.execute("select.nextBelow", &json!({})).unwrap();
assert_eq!(ids(&s), [b]);
let g = s.execute("object.group", &json!({"ids": [a, b]})).unwrap()["id"].as_u64().unwrap();
s.execute("edit.deselectAll", &json!({})).unwrap();
s.doc_mut().unwrap().selection = designcraft_doc::Selection::items(vec![designcraft_doc::ItemId(g)]);
s.execute("select.content", &json!({})).unwrap();
let first = ids(&s)[0];
assert!(first == a || first == b);
s.execute("select.nextInGroup", &json!({})).unwrap();
assert_ne!(ids(&s)[0], first);
s.execute("select.nextInGroup", &json!({})).unwrap();
assert_eq!(ids(&s)[0], first, "wraps");
s.execute("select.container", &json!({})).unwrap();
assert_eq!(ids(&s), [g]);
}
#[test]
fn convert_shape_keeps_bounds() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let a = s.execute("frame.create", &json!({"rect": [100, 100, 200, 160]})).unwrap()["id"].as_u64().unwrap();
let item = |s: &Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(a)).unwrap().clone();
for to in ["ellipse", "triangle", "polygon", "roundedRectangle", "rectangle"] {
s.execute("object.convertShape", &json!({"to": to})).unwrap();
let b = item(&s).bounds();
assert!((b.width() - 100.0).abs() < 1e-6 && (b.height() - 60.0).abs() < 1e-6, "{to}: {b:?}");
}
s.execute("object.convertShape", &json!({"to": "orthogonalLine"})).unwrap();
let it = item(&s);
assert_eq!(it.shape, designcraft_doc::Shape::GraphicLine);
assert_eq!(it.bounds().height(), 0.0);
s.execute("object.convertShape", &json!({"to": "closedPath"})).unwrap();
assert!(item(&s).path.subpaths[0].closed);
let t = s.execute("frame.create", &json!({"rect": [0, 0, 50, 50], "content": "text"})).unwrap()["id"].as_u64().unwrap();
assert!(s.execute("object.convertShape", &json!({"to": "line", "ids": [t]})).is_err());
assert!(s.execute("object.convertShape", &json!({"to": "blob", "ids": [a]})).is_err());
}
}

View File

@ -122,6 +122,24 @@ fn table_specs() -> Vec<CommandSpec> {
sort_rows
),
cmd!("table.unmerge", "Unmerge Cells", ["Table"], None, "{}", in_table, unmerge),
cmd!(
"table.splitHorizontally",
"Split Cell Horizontally",
["Table"],
None,
"{} — the target cell becomes two, one above the other",
in_table,
|s, p| split(s, p, true)
),
cmd!(
"table.splitVertically",
"Split Cell Vertically",
["Table"],
None,
"{} — the target cell becomes two, side by side",
in_table,
|s, p| split(s, p, false)
),
cmd!(
"table.setCell",
"Cell Options",
@ -497,6 +515,14 @@ fn merge(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
fn split(s: &mut Session, p: &Value, horizontal: bool) -> Result<Value> {
let g = target(s, p, "table.split")?;
edit_table(s, &g, "table.split", |t| {
t.split_cell(g.range.r0, g.range.c0, horizontal);
Ok(json!({"rows": t.nrows(), "cols": t.ncols()}))
})
}
fn unmerge(s: &mut Session, p: &Value) -> Result<Value> {
let g = target(s, p, "table.unmerge")?;
edit_table(s, &g, "table.unmerge", |t| {
@ -1128,5 +1154,8 @@ mod sort_tests {
assert_eq!(col(&s, 0), ["Name", "Apple", "fig", "pear"], "header stays; case-insensitive");
s.execute("table.sortRows", &json!({"column": 1, "descending": true})).unwrap();
assert_eq!(col(&s, 1), ["Qty", "100", "10", "9"], "numbers by value");
s.execute("table.splitVertically", &json!({"rows": [1, 1], "cols": [0, 0]})).unwrap();
let r = s.execute("table.splitHorizontally", &json!({"rows": [0, 0], "cols": [1, 1]})).unwrap();
assert_eq!((r["rows"].as_u64(), r["cols"].as_u64()), (Some(5), Some(3)));
}
}

View File

@ -338,6 +338,32 @@ pub const MENUS: &[(&str, &[&str])] = &[
"cmd:object.outerGlow",
"cmd:object.feather",
"<",
">Select",
"cmd:select.firstAbove",
"cmd:select.nextAbove",
"cmd:select.nextBelow",
"cmd:select.lastBelow",
"-",
"cmd:select.container",
"cmd:select.content",
"-",
"cmd:select.previousInGroup",
"cmd:select.nextInGroup",
"<",
">Convert Shape",
"cmd:object.convertShape|Rectangle|{\"to\": \"rectangle\"}",
"cmd:object.convertShape|Rounded Rectangle|{\"to\": \"roundedRectangle\"}",
"cmd:object.convertShape|Beveled Rectangle|{\"to\": \"beveledRectangle\"}",
"cmd:object.convertShape|Inverse Rounded Rectangle|{\"to\": \"inverseRoundedRectangle\"}",
"cmd:object.convertShape|Ellipse|{\"to\": \"ellipse\"}",
"cmd:object.convertShape|Triangle|{\"to\": \"triangle\"}",
"cmd:object.convertShape|Polygon|{\"to\": \"polygon\"}",
"cmd:object.convertShape|Line|{\"to\": \"line\"}",
"cmd:object.convertShape|Orthogonal Line|{\"to\": \"orthogonalLine\"}",
"-",
"cmd:object.convertShape|Open Path|{\"to\": \"openPath\"}",
"cmd:object.convertShape|Closed Path|{\"to\": \"closedPath\"}",
"<",
">Captions",
"cmd:object.caption",
"<",
@ -385,6 +411,8 @@ pub const MENUS: &[(&str, &[&str])] = &[
"-",
"cmd:table.merge",
"cmd:table.unmerge",
"cmd:table.splitHorizontally",
"cmd:table.splitVertically",
"cmd:table.distributeColumns",
"-",
"ui:app.tablePanel",

View File

@ -754,11 +754,21 @@ fn arrange_menu(app: &mut DesignApp, resp: &egui::Response) {
fn convert_shape_menu(app: &mut DesignApp, resp: &egui::Response) {
egui::Popup::menu(resp).show(|ui| {
for (label, shape) in
[("Rectangle", "none"), ("Rounded Rectangle", "rounded"), ("Beveled Rectangle", "bevel"), ("Inverse Rounded Rectangle", "inverseRounded")]
{
for (label, to) in [
("Rectangle", "rectangle"),
("Rounded Rectangle", "roundedRectangle"),
("Beveled Rectangle", "beveledRectangle"),
("Inverse Rounded Rectangle", "inverseRoundedRectangle"),
("Ellipse", "ellipse"),
("Triangle", "triangle"),
("Polygon", "polygon"),
("Line", "line"),
("Orthogonal Line", "orthogonalLine"),
] {
if ui.button(label).clicked() {
let _ = app.run("object.cornerOptions", json!({"shape": shape, "size": 12.0}));
if let Err(e) = app.run("object.convertShape", json!({"to": to})) {
app.status(format!("Convert Shape: {e}"));
}
ui.close();
}
}

View File

@ -10,7 +10,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia
| Priority | Features | Done | Partial | Missing | Parity |
|---|---|---|---|---|---|
| P0 | 102 | 99 | 3 | 0 | 99% |
| P1 | 97 | 68 | 27 | 2 | 84% |
| P1 | 97 | 71 | 24 | 2 | 86% |
| P2 | 76 | 12 | 13 | 51 | 24% |
| Area | Parity |
@ -18,7 +18,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia
| Application shell & workspace | 80% |
| Documents, pages, spreads | 84% |
| Layers | 95% |
| Frames, shapes & paths | 88% |
| Frames, shapes & paths | 90% |
| Transform | 100% |
| Fill, stroke, colour | 86% |
| Effects & transparency | 62% |
@ -26,7 +26,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia
| Type & text frames | 88% |
| Typography | 87% |
| Styles | 92% |
| Tables | 85% |
| Tables | 88% |
| Long documents | 79% |
| Interactivity & digital | 35% |
| Output & production | 70% |
@ -102,7 +102,7 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t
| Frames, shapes & paths | Pencil, Smooth, Erase | P1 | D |
| Frames, shapes & paths | Scissors | P1 | D |
| Frames, shapes & paths | Content type | P0 | D |
| Frames, shapes & paths | Convert Shape | P1 | P |
| Frames, shapes & paths | Convert Shape | P1 | D |
| Frames, shapes & paths | Corner Options | P0 | D |
| Frames, shapes & paths | Pathfinder | P1 | D |
| Frames, shapes & paths | Compound paths | P1 | D |
@ -122,7 +122,7 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t
| Frames, shapes & paths | Group/Ungroup | P0 | D |
| Frames, shapes & paths | Lock / Unlock All on Spread | P0 | D |
| Frames, shapes & paths | Hide / Show All on Spread | P1 | D |
| Frames, shapes & paths | Select submenu | P1 | P |
| Frames, shapes & paths | Select submenu | P1 | D |
| Frames, shapes & paths | Select All / Deselect All | P0 | D |
| Frames, shapes & paths | Duplicate | P0 | D |
| Frames, shapes & paths | Paste Into / Paste in Place | P0 | D |
@ -248,7 +248,7 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t
| Tables | Create / Insert Table | P1 | D |
| Tables | Convert Text to Table / Table to Text | P1 | D |
| Tables | Rows/columns insert/delete/resize/distribute | P1 | D |
| Tables | Merge/Unmerge/Split cells | P1 | P |
| Tables | Merge/Unmerge/Split cells | P1 | D |
| Tables | Header/footer rows | P1 | D |
| Tables | Table options | P1 | D |
| Tables | Cell options | P1 | D |