A: per-page sizes — Page tool and layout.pageSize

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 16:54:24 -04:00
parent ea20dc196c
commit e79ec65323
6 changed files with 134 additions and 3 deletions

View File

@ -30,6 +30,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool).
- Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`.
- Place text files: Word (.docx: styles by name with their attributes, bold/italic/underline/size/font, footnotes, tables, tabs, breaks), RTF and plain text, into the insertion point, the selected frame or a new frame; autoflow adds pages and threaded frames until the text fits; Remove Styles option (`designcraft-textimport`).
- Page tool (⇧P) and `layout.pageSize`: pages of their own size (or a preset); the spine stays put and objects keep their place on their page.
- File › Print (⌘P): printer, copies, page range, spreads, marks, bleed; printed as PDF through the system spooler (`file.print`, `file.printers`, `dryRun` for agents). Shortcuts now act like choosing the menu item (dialogs open), and UI commands win shortcut ties (⌘N opens New Document).
- Nested styles (through/up to N sentences, words, characters, letters, digits, tabs, breaks, spaces or given characters) and GREP styles (regex → character style), edited in Paragraph Style Options and composed under local formatting; `ui.dialog.open` for agents. Nested line styles and their IDML mapping are still to come.
- Table and cell styles: cell styles (fill, insets, vertical justification, strokes, paragraph style) and table styles (region cell styles, border, alternating rows, spacing); apply from the Table panel, edits re-apply to every user; `style.cell.*`, `style.table.*`. IDML mapping is still to come.

View File

@ -219,6 +219,15 @@ pub fn specs() -> Vec<CommandSpec> {
})
}
),
cmd!(
"layout.pageSize",
"Page Size",
[],
None,
"{pages: [1-based page numbers], width?, height?, preset?: \"A4\"|\"Letter\"|…} — pages of their own size (Page tool); objects on the pages keep their place on them",
has_doc,
page_size
),
cmd!(
"layout.section",
"Numbering & Section Options…",
@ -461,6 +470,63 @@ fn parent_ref(d: &designcraft_doc::Document, v: Option<&Value>) -> Option<Option
#[allow(dead_code)]
fn _r(_: Result<()>) {}
/// Layout › Page Size (the Page tool): resize pages one by one. Left pages grow to the left
/// (the spine stays put); objects move with their page.
fn page_size(s: &mut crate::Session, p: &Value) -> Result<Value> {
let pages: Vec<usize> =
p.get("pages").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|n| n as usize).collect()).unwrap_or_default();
let n = s.doc()?.doc.page_count();
if pages.is_empty() || pages.iter().any(|x| *x == 0 || *x > n) {
return Err(bad("layout.pageSize", "give `pages` (1-based)"));
}
let (mut w, mut h) = (p.get("width").and_then(Value::as_f64), p.get("height").and_then(Value::as_f64));
if let Some(name) = str_param(p, "preset") {
let pr = designcraft_doc::build::PRESETS
.iter()
.find(|x| x.name.eq_ignore_ascii_case(name))
.ok_or_else(|| bad("layout.pageSize", format!("no preset `{name}`")))?;
w = Some(pr.width);
h = Some(pr.height);
}
s.edit(|d, _| {
for page in &pages {
let Some((si, pi)) = d.page_loc(page - 1) else { continue };
let sp = std::sync::Arc::make_mut(&mut d.spreads[si]);
let old: Vec<f64> = sp.pages.iter().map(|x| x.x).collect();
let pg = &mut sp.pages[pi];
let dw = w.map_or(0.0, |w| w.max(1.0) - pg.width);
if let Some(w) = w {
pg.width = w.max(1.0);
}
if let Some(h) = h {
pg.height = h.max(1.0);
}
let left = pg.side == designcraft_doc::PageSide::Left;
sp.relayout();
// Keep the spine: shift the whole spread back when a left page grows.
if left && dw != 0.0 {
for q in &mut sp.pages {
q.x -= dw;
}
}
let new: Vec<f64> = sp.pages.iter().map(|x| x.x).collect();
// Objects follow their page.
let shifts: Vec<(f64, f64, f64)> =
sp.pages.iter().enumerate().map(|(k, q)| (old[k], old[k] + q.width - if k == pi { dw } else { 0.0 }, new[k] - old[k])).collect();
for it in &mut sp.items {
let cx = it.bounds().center().x;
if let Some((_, _, dx)) = shifts.iter().find(|(a, b, _)| cx >= *a && cx < *b)
&& *dx != 0.0
{
let it = std::sync::Arc::make_mut(it);
it.xf = designcraft_geom::Affine::translate((*dx, 0.0)) * it.xf;
}
}
}
Ok(json!({"pages": pages.len()}))
})
}
/// File › Document Setup values.
fn document_setup(d: &designcraft_doc::Document) -> Value {
let st = &d.settings;
@ -524,3 +590,31 @@ mod layer_tests {
assert_eq!(d.item(designcraft_doc::ItemId(f)).unwrap().layer.0, l4);
}
}
#[cfg(test)]
mod page_size_tests {
use serde_json::json;
#[test]
fn page_size_per_page_keeps_objects_on_their_page() {
let mut s = crate::Session::new();
s.execute("file.new", &json!({"pages": 3})).unwrap();
// Page 3 is the right page of spread 2 (pages 2–3); an object on it.
let d = s.doc().unwrap().doc.clone();
let (si, pi) = d.page_loc(2).unwrap();
let x0 = d.spreads[si].pages[pi].x;
let id = s.execute("frame.create", &json!({"spread": si, "rect": [x0 + 100.0, 100, x0 + 200.0, 200]})).unwrap()["id"].as_u64().unwrap();
s.execute("layout.pageSize", &json!({"pages": [2], "width": 300, "height": 500})).unwrap();
let d = s.doc().unwrap().doc.clone();
let left = &d.spreads[si].pages[0];
assert_eq!((left.width, left.height), (300.0, 500.0));
assert_eq!(d.spreads[si].pages[1].x, x0, "the spine stays");
let it = d.item(designcraft_doc::ItemId(id)).unwrap();
assert_eq!(it.bounds().x0, x0 + 100.0);
assert_eq!(d.page_of_item(it.id), Some(2));
s.execute("layout.pageSize", &json!({"pages": [3], "preset": "A4"})).unwrap();
let d = s.doc().unwrap().doc.clone();
assert!((d.spreads[si].pages[1].width - 595.276).abs() < 0.01);
assert!(s.execute("layout.pageSize", &json!({"pages": [9], "width": 100})).is_err());
}
}

View File

@ -273,6 +273,7 @@ pub fn create(id: &str) -> Box<dyn Tool> {
"hand" => Box::new(nav::HandTool::default()),
"placeGun" => Box::new(nav::PlaceGun::default()),
"zoom" => Box::new(nav::ZoomTool::default()),
"page" => Box::new(nav::PageTool),
other => Box::new(NoopTool(tool_info(other).map(|t| t.id).unwrap_or("selection"))),
}
}

View File

@ -148,3 +148,27 @@ impl Tool for PlaceGun {
Cursor::LoadedGraphic
}
}
/// Page tool (Shift+P): click a page to set its own size.
#[derive(Default)]
pub struct PageTool;
impl Tool for PageTool {
fn id(&self) -> &'static str {
"page"
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
if ev.kind != PointerKind::Up {
return vec![];
}
let Some((designcraft_doc::SpreadRef::Doc(si), sp)) = cx.layout.spread_at(ev.pos) else { return vec![] };
let Some(spread) = cx.doc.spreads.get(si) else { return vec![] };
let Some(pi) = spread.pages.iter().position(|p| p.bounds().contains(sp)) else { return vec![] };
let pg = &spread.pages[pi];
let n = cx.doc.first_page_of_spread(si) + pi + 1;
vec![Action::Dialog("cmd:layout.pageSize".into(), json!({"pages": [n], "width": pg.width, "height": pg.height}))]
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
Cursor::Arrow
}
}

View File

@ -203,3 +203,14 @@ fn zoom_tool_click_and_scrub() {
}
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 100.0)).is_empty());
}
#[test]
fn page_tool_opens_page_size() {
let d = Document::new(&NewDocument::default());
let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false));
let cx = ctx(&d, &s, &c, &l);
let off = l.offset(SpreadRef::Doc(0));
let mut t = create("page");
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 200.0 + off.x + d.spreads[0].pages[0].x, 200.0 + off.y));
assert_eq!(a, vec![Action::Dialog("cmd:layout.pageSize".into(), serde_json::json!({"pages": [1], "width": 612.0, "height": 792.0}))]);
}

View File

@ -10,13 +10,13 @@ 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 | 58 | 25 | 14 | 73% |
| P1 | 97 | 59 | 25 | 13 | 74% |
| P2 | 76 | 8 | 7 | 61 | 15% |
| Area | Parity |
|---|---|
| Application shell & workspace | 80% |
| Documents, pages, spreads | 77% |
| Documents, pages, spreads | 81% |
| Layers | 95% |
| Frames, shapes & paths | 87% |
| Transform | 93% |
@ -64,7 +64,7 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t
| Documents, pages, spreads | Facing pages / spreads | P0 | D |
| Documents, pages, spreads | Pages per spread up to 10 | P1 | M |
| Documents, pages, spreads | Insert/move/duplicate/delete pages | P0 | D |
| Documents, pages, spreads | Per-page size (Page tool) | P1 | M |
| Documents, pages, spreads | Per-page size (Page tool) | P1 | D |
| Documents, pages, spreads | Margins and Columns per page | P0 | D |
| Documents, pages, spreads | Spread rotation (view) | P2 | M |
| Documents, pages, spreads | Pasteboard | P0 | D |