From e43a5c6e6325699ce0f78db0f92564540e2c9489 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 21 Oct 2026 01:34:45 +0200 Subject: [PATCH 01/16] Canvas: an empty graphic frame's X joins its corners A four-cornered empty graphic frame draws its X from corner to corner, so a frame slanted with Direct Selection keeps the X on its corners, as InDesign draws it. Other shapes still span their bounds. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/canvas.rs | 52 +++++++++++++++++++++++++++++++++--- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 0128924c0..63713bc60 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-21** · An empty graphic frame's X joins its corners, so a frame slanted with Direct Selection keeps its X corner to corner, as InDesign draws it; it used to stay across the old rectangle. Found in Zero2Graphic's *How to Page Layout design in Adobe InDesign 2022 CC*. No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. - **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8d94f099f..64e544d78 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1149,14 +1149,27 @@ fn draw_item_edges(painter: &egui::Painter, xf: &Xf, doc: &Document, it: &Item, } // Empty graphic frame: the X. if it.object_style == designcraft_doc::BASIC_GRAPHICS_FRAME && matches!(it.content, Content::Unassigned) { - let r = it.inner_bounds(); let m = a * it.xf; - let p = |x: f64, y: f64| xf.to_screen(m * Point::new(x, y)); - painter.line_segment([p(r.x0, r.y0), p(r.x1, r.y1)], Stroke::new(hair(painter), col.gamma_multiply(0.75))); - painter.line_segment([p(r.x1, r.y0), p(r.x0, r.y1)], Stroke::new(hair(painter), col.gamma_multiply(0.75))); + for (p0, p1) in frame_x(it) { + painter.line_segment([xf.to_screen(m * p0), xf.to_screen(m * p1)], Stroke::new(hair(painter), col.gamma_multiply(0.75))); + } } } +/// The X of an empty graphic frame, in its own space: a four-cornered frame joins its opposite +/// corners, so a frame slanted with Direct Selection keeps its X corner to corner, as InDesign +/// draws it; other shapes span their bounds. +fn frame_x(it: &Item) -> [(Point, Point); 2] { + if let [sp] = it.path.subpaths.as_slice() + && sp.closed + && let [c0, c1, c2, c3] = sp.anchors.as_slice() + { + return [(c0.p, c2.p), (c1.p, c3.p)]; + } + let r = it.inner_bounds(); + [(Point::new(r.x0, r.y0), Point::new(r.x1, r.y1)), (Point::new(r.x1, r.y0), Point::new(r.x0, r.y1))] +} + /// Layer colour of the first selected item: live-corner marks are outlined in it. fn selection_color(app: &DesignApp) -> Color32 { let fallback = Color32::from_rgb(79, 153, 255); @@ -3081,6 +3094,37 @@ mod tests { } } + /// An empty graphic frame's X joins its corners: a rectangle's, and a frame slanted by + /// moving its top points, as in the Zero2Graphic tutorial's row of parallelograms. + #[test] + fn an_empty_frames_x_follows_its_corners() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [100, 100, 200, 160]})).unwrap()["id"].as_u64().unwrap(); + let x = |app: &DesignApp| { + let it = app.session.active().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().clone(); + let m = it.xf; + frame_x(&it).map(|(a, b)| { + let (a, b) = (m * a, m * b); + [(a.x.round(), a.y.round()), (b.x.round(), b.y.round())] + }) + }; + let mut lines = x(&app).to_vec(); + lines.iter_mut().for_each(|l| l.sort_by(|a, b| a.partial_cmp(b).unwrap())); + lines.sort_by(|a, b| a.partial_cmp(b).unwrap()); + assert_eq!(lines, vec![[(100.0, 100.0), (200.0, 160.0)], [(100.0, 160.0), (200.0, 100.0)]], "corner to corner"); + // Slant it: the two top points move 30 pt right. + let it = app.session.active().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().clone(); + let top: Vec = + it.path.subpaths[0].anchors.iter().enumerate().filter(|(_, a)| (it.xf * a.p).y < 130.0).map(|(k, _)| json!([0, k])).collect(); + assert_eq!(top.len(), 2); + app.run("path.moveAnchors", json!({"id": id, "anchors": top, "dx": 30, "dy": 0})).unwrap(); + let mut lines = x(&app).to_vec(); + lines.iter_mut().for_each(|l| l.sort_by(|a, b| a.partial_cmp(b).unwrap())); + lines.sort_by(|a, b| a.partial_cmp(b).unwrap()); + assert_eq!(lines, vec![[(100.0, 160.0), (230.0, 100.0)], [(130.0, 100.0), (200.0, 160.0)]], "the slanted corners"); + } + /// Each preview mode's shown area: Preview stops at the trim, Bleed reaches the bleed box and /// Slug the slug box. A slug narrower than the bleed never pulls the edge back in. #[test] From 5d6f79d3256f308214ac49bcc9a2af398d741ecb Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:55:46 +0200 Subject: [PATCH 02/16] An ellipse or polygon frame keeps the X across its bounds The corner-to-corner X is for rectangles (slanted or not): four corner points without handles, on a Rectangle or Path item. An ellipse frame (four smooth anchors) and a four-sided polygon (a diamond) would otherwise get a plus through their centre; they keep the X across their bounds. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 64e544d78..c98bdba99 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1160,9 +1160,13 @@ fn draw_item_edges(painter: &egui::Painter, xf: &Xf, doc: &Document, it: &Item, /// corners, so a frame slanted with Direct Selection keeps its X corner to corner, as InDesign /// draws it; other shapes span their bounds. fn frame_x(it: &Item) -> [(Point, Point); 2] { - if let [sp] = it.path.subpaths.as_slice() + // A rectangle (slanted or not: four corner points, no handles). Ellipses, polygons (a + // four-sided one is a diamond) and curved paths keep the X across their bounds. + if matches!(it.shape, designcraft_doc::Shape::Rectangle | designcraft_doc::Shape::Path) + && let [sp] = it.path.subpaths.as_slice() && sp.closed && let [c0, c1, c2, c3] = sp.anchors.as_slice() + && sp.anchors.iter().all(|a| a.h_in == a.p && a.h_out == a.p) { return [(c0.p, c2.p), (c1.p, c3.p)]; } @@ -3123,6 +3127,26 @@ mod tests { lines.iter_mut().for_each(|l| l.sort_by(|a, b| a.partial_cmp(b).unwrap())); lines.sort_by(|a, b| a.partial_cmp(b).unwrap()); assert_eq!(lines, vec![[(100.0, 160.0), (230.0, 100.0)], [(130.0, 100.0), (200.0, 160.0)]], "the slanted corners"); + // An ellipse frame (four smooth anchors) keeps the X across its bounds, not a plus. + let e = app.run("frame.create", json!({"rect": [300, 100, 400, 160], "shape": "ellipse"})).unwrap()["id"].as_u64().unwrap(); + let it = app.session.active().unwrap().doc.item(designcraft_doc::ItemId(e)).unwrap().clone(); + let mut lines: Vec<[(f64, f64); 2]> = frame_x(&it) + .map(|(a, b)| { + let (a, b) = (it.xf * a, it.xf * b); + [(a.x.round(), a.y.round()), (b.x.round(), b.y.round())] + }) + .to_vec(); + lines.iter_mut().for_each(|l| l.sort_by(|a, b| a.partial_cmp(b).unwrap())); + lines.sort_by(|a, b| a.partial_cmp(b).unwrap()); + assert_eq!(lines, vec![[(300.0, 100.0), (400.0, 160.0)], [(300.0, 160.0), (400.0, 100.0)]], "the ellipse's bounds"); + // So does a four-sided polygon frame (a diamond): its corners would cross as a plus. + let mut diamond = it.clone(); + diamond.shape = designcraft_doc::Shape::Polygon; + for a in &mut diamond.path.subpaths[0].anchors { + (a.h_in, a.h_out) = (a.p, a.p); + } + let lines: Vec<_> = frame_x(&diamond).to_vec(); + assert!(lines.iter().all(|(a, b)| (a.x - b.x).abs() > 1e-6 && (a.y - b.y).abs() > 1e-6), "diagonals, not a plus: {lines:?}"); } /// Each preview mode's shown area: Preview stops at the trim, Bleed reaches the bleed box and From 57ec8cad22f82694ab8e65bdc234f169ec9dcca6 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Sun, 25 Oct 2026 17:19:30 +0100 Subject: [PATCH 03/16] Canvas: an edited diamond keeps its frame X across its bounds Moving a point makes a polygon a path (as the engine does for any point edit), and four handle-free corners took the rectangle's branch: a diamond's corners crossed as a plus. A path's corners are used only when both diagonals cross as an X; a test edits a four-sided polygon with path.moveAnchors. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 1a50aaa74..5636169ea 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1216,12 +1216,19 @@ fn draw_item_edges(painter: &egui::Painter, xf: &Xf, doc: &Document, it: &Item, /// draws it; other shapes span their bounds. fn frame_x(it: &Item) -> [(Point, Point); 2] { // A rectangle (slanted or not: four corner points, no handles). Ellipses, polygons (a - // four-sided one is a diamond) and curved paths keep the X across their bounds. + // four-sided one is a diamond) and curved paths keep the X across their bounds. Edited points + // make any of them a path: a four-cornered one whose corners would cross as a plus (a diamond, + // edited) keeps the bounds too. + let crosses = |a: Point, b: Point| { + let d = b - a; + d.x.abs() > 0.18 * d.hypot() && d.y.abs() > 0.18 * d.hypot() + }; if matches!(it.shape, designcraft_doc::Shape::Rectangle | designcraft_doc::Shape::Path) && let [sp] = it.path.subpaths.as_slice() && sp.closed && let [c0, c1, c2, c3] = sp.anchors.as_slice() && sp.anchors.iter().all(|a| a.h_in == a.p && a.h_out == a.p) + && (it.shape == designcraft_doc::Shape::Rectangle || (crosses(c0.p, c2.p) && crosses(c1.p, c3.p))) { return [(c0.p, c2.p), (c1.p, c3.p)]; } @@ -3745,6 +3752,13 @@ mod tests { } let lines: Vec<_> = frame_x(&diamond).to_vec(); assert!(lines.iter().all(|(a, b)| (a.x - b.x).abs() > 1e-6 && (a.y - b.y).abs() > 1e-6), "diagonals, not a plus: {lines:?}"); + // Still after one of its points moves (which makes it a path). + let p = app.run("frame.create", json!({"rect": [300, 200, 400, 260], "shape": "polygon", "sides": 4, "starInset": 0})).unwrap()["id"].as_u64().unwrap(); + app.run("path.moveAnchors", json!({"id": p, "anchors": [[0, 0]], "dx": 5, "dy": 0})).unwrap(); + let it = app.session.active().unwrap().doc.item(designcraft_doc::ItemId(p)).unwrap().clone(); + assert_eq!(it.shape, designcraft_doc::Shape::Path); + let lines: Vec<_> = frame_x(&it).to_vec(); + assert!(lines.iter().all(|(a, b)| (a.x - b.x).abs() > 1e-6 && (a.y - b.y).abs() > 1e-6), "an edited diamond: {lines:?}"); } /// Each preview mode's shown area: Preview stops at the trim, Bleed reaches the bleed box and From ec682809be487ffb59f6a7fa53fbcbb5a325304c Mon Sep 17 00:00:00 2001 From: moppeeer Date: Sun, 25 Oct 2026 17:47:19 +0100 Subject: [PATCH 04/16] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 4d277c9a9..ee8b933b2 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3837,7 +3837,9 @@ mod tests { let lines: Vec<_> = frame_x(&diamond).to_vec(); assert!(lines.iter().all(|(a, b)| (a.x - b.x).abs() > 1e-6 && (a.y - b.y).abs() > 1e-6), "diagonals, not a plus: {lines:?}"); // Still after one of its points moves (which makes it a path). - let p = app.run("frame.create", json!({"rect": [300, 200, 400, 260], "shape": "polygon", "sides": 4, "starInset": 0})).unwrap()["id"].as_u64().unwrap(); + let p = app.run("frame.create", json!({"rect": [300, 200, 400, 260], "shape": "polygon", "sides": 4, "starInset": 0})).unwrap()["id"] + .as_u64() + .unwrap(); app.run("path.moveAnchors", json!({"id": p, "anchors": [[0, 0]], "dx": 5, "dy": 0})).unwrap(); let it = app.session.active().unwrap().doc.item(designcraft_doc::ItemId(p)).unwrap().clone(); assert_eq!(it.shape, designcraft_doc::Shape::Path); From b0fcb297ca3e38c56409996541a09314e7c78317 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 26 Oct 2026 04:28:46 +0000 Subject: [PATCH 05/16] Reject signed or out-of-range timezone offsets in parse_iso_date Found while testing crates/doc/src/vars.rs:~186 (parse_iso_date, the zone '+'/'-' branch: `z.get(0..2)?.parse::()` and the minutes parse) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/doc/src/vars.rs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/crates/doc/src/vars.rs b/crates/doc/src/vars.rs index c53eedb1a..f31a31002 100644 --- a/crates/doc/src/vars.rs +++ b/crates/doc/src/vars.rs @@ -195,8 +195,12 @@ pub fn parse_iso_date(s: &str) -> Option { None | Some('Z') => Some(secs), Some(sign @ ('+' | '-')) => { let z = zone.get(1..)?; - let zh: i64 = z.get(0..2)?.parse().ok()?; - let zm: i64 = z.get(2..).map(|r| r.trim_start_matches(':')).filter(|r| !r.is_empty()).map_or(Some(0), |r| r.parse().ok())?; + let digits = |t: &str| -> Option { t.bytes().all(|c| c.is_ascii_digit()).then(|| t.parse().ok()).flatten() }; + let zh = digits(z.get(0..2)?)?; + let zm = z.get(2..).map(|r| r.trim_start_matches(':')).filter(|r| !r.is_empty()).map_or(Some(0), digits)?; + if zh > 23 || zm > 59 { + return None; + } let off = zh * 3600 + zm * 60; Some(if sign == '+' { secs - off } else { secs + off }) } @@ -336,6 +340,13 @@ mod tests { } } + #[test] + fn iso_date_rejects_malformed_zone() { + for bad in ["2019-12-16T09:48:02+-5:00", "2019-12-16T09:48:02+05:+30", "2019-12-16T09:48:02+24:00", "2019-12-16T09:48:02+05:60"] { + assert_eq!(parse_iso_date(bad), None, "{bad}"); + } + } + #[test] fn chars() { assert_eq!(var_index(var_char(3).unwrap()), Some(3)); From 5aba8cb01747da9690b501401dcdbda314cd1c5b Mon Sep 17 00:00:00 2001 From: Zaxaerith Date: Thu, 29 Oct 2026 11:09:07 +0800 Subject: [PATCH 06/16] docs: complete README architecture layer table (#1979) --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index cd0f56479..47dbd6ee0 100644 --- a/README.md +++ b/README.md @@ -250,11 +250,11 @@ egui frontend is a separate crate, so the UI can be swapped without touching the |---|---| | L0 | `geom` (paths, units & measurement parsing, corner options) · `color` (CMYK/RGB/Lab, swatches, tints, gradients) | | L1 | `doc` (spreads, pages, parents, layers, frames, stories, styles) · `fonts` (font DB, shaping, outlines) | -| L2 | `compose` (the text engine) | -| L3 | `render` (vello_cpu) | +| L2 | `compose` (the text engine) · `images` | +| L3 | `render` (vello_cpu) · `pdf` · `idml` · `epub` · `pptx` · `textimport` · `format` | | L4 | `tools` (pointer events → commands + overlays) | | L5 | `engine` (session, history, command registry) | -| L6 | `ui-egui` (publishing UI, control channel) | +| L6 | `ui-egui` (publishing UI, control channel) · `mcp` | | L7 | `apps/designcraft`, `apps/designcraft-cli`, `apps/designcraft-web` | - **Status and milestones:** [ROADMAP.md](ROADMAP.md) (stage: pre-alpha); feature assessment: [`docs/target-app-parity.md`](docs/target-app-parity.md); known gaps: [`docs/gaps.md`](docs/gaps.md) From 53d4d9862a3193655907e9cd3e6eb4a51702e533 Mon Sep 17 00:00:00 2001 From: Zaxaerith Date: Thu, 29 Oct 2026 11:12:23 +0800 Subject: [PATCH 07/16] docs: correct sample document menu path (#1978) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index cd0f56479..33dde275d 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ ## The sample magazine Every page below was laid out by DesignCraft from code (`crates/engine/src/sample.rs`) and exported -by its own renderer. Try it yourself with `File → New → Sample Document`, or start the app with +by its own renderer. Try it yourself with `Help → Sample Document`, or start the app with `--sample`. From dccda2cd4ca67629cad35dd2c71debf715d0641a Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 13:31:57 +0100 Subject: [PATCH 08/16] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/tests.rs | 114 ++++++++++++++--------------- 1 file changed, 57 insertions(+), 57 deletions(-) diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 836fd37cd..b96133c76 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -86,65 +86,65 @@ fn bleed_and_slug_are_preview_modes_that_show_their_area() { } } - /// An empty graphic frame's X joins its corners: a rectangle's, and a frame slanted by - /// moving its top points, as in the Zero2Graphic tutorial's row of parallelograms. - #[test] - fn an_empty_frames_x_follows_its_corners() { - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - let id = app.run("frame.create", json!({"rect": [100, 100, 200, 160]})).unwrap()["id"].as_u64().unwrap(); - let x = |app: &DesignApp| { - let it = app.session.active().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().clone(); - let m = it.xf; - frame_x(&it).map(|(a, b)| { - let (a, b) = (m * a, m * b); - [(a.x.round(), a.y.round()), (b.x.round(), b.y.round())] - }) - }; - let mut lines = x(&app).to_vec(); - lines.iter_mut().for_each(|l| l.sort_by(|a, b| a.partial_cmp(b).unwrap())); - lines.sort_by(|a, b| a.partial_cmp(b).unwrap()); - assert_eq!(lines, vec![[(100.0, 100.0), (200.0, 160.0)], [(100.0, 160.0), (200.0, 100.0)]], "corner to corner"); - // Slant it: the two top points move 30 pt right. +/// An empty graphic frame's X joins its corners: a rectangle's, and a frame slanted by +/// moving its top points, as in the Zero2Graphic tutorial's row of parallelograms. +#[test] +fn an_empty_frames_x_follows_its_corners() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [100, 100, 200, 160]})).unwrap()["id"].as_u64().unwrap(); + let x = |app: &DesignApp| { let it = app.session.active().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().clone(); - let top: Vec = - it.path.subpaths[0].anchors.iter().enumerate().filter(|(_, a)| (it.xf * a.p).y < 130.0).map(|(k, _)| json!([0, k])).collect(); - assert_eq!(top.len(), 2); - app.run("path.moveAnchors", json!({"id": id, "anchors": top, "dx": 30, "dy": 0})).unwrap(); - let mut lines = x(&app).to_vec(); - lines.iter_mut().for_each(|l| l.sort_by(|a, b| a.partial_cmp(b).unwrap())); - lines.sort_by(|a, b| a.partial_cmp(b).unwrap()); - assert_eq!(lines, vec![[(100.0, 160.0), (230.0, 100.0)], [(130.0, 100.0), (200.0, 160.0)]], "the slanted corners"); - // An ellipse frame (four smooth anchors) keeps the X across its bounds, not a plus. - let e = app.run("frame.create", json!({"rect": [300, 100, 400, 160], "shape": "ellipse"})).unwrap()["id"].as_u64().unwrap(); - let it = app.session.active().unwrap().doc.item(designcraft_doc::ItemId(e)).unwrap().clone(); - let mut lines: Vec<[(f64, f64); 2]> = frame_x(&it) - .map(|(a, b)| { - let (a, b) = (it.xf * a, it.xf * b); - [(a.x.round(), a.y.round()), (b.x.round(), b.y.round())] - }) - .to_vec(); - lines.iter_mut().for_each(|l| l.sort_by(|a, b| a.partial_cmp(b).unwrap())); - lines.sort_by(|a, b| a.partial_cmp(b).unwrap()); - assert_eq!(lines, vec![[(300.0, 100.0), (400.0, 160.0)], [(300.0, 160.0), (400.0, 100.0)]], "the ellipse's bounds"); - // So does a four-sided polygon frame (a diamond): its corners would cross as a plus. - let mut diamond = it.clone(); - diamond.shape = designcraft_doc::Shape::Polygon; - for a in &mut diamond.path.subpaths[0].anchors { - (a.h_in, a.h_out) = (a.p, a.p); - } - let lines: Vec<_> = frame_x(&diamond).to_vec(); - assert!(lines.iter().all(|(a, b)| (a.x - b.x).abs() > 1e-6 && (a.y - b.y).abs() > 1e-6), "diagonals, not a plus: {lines:?}"); - // Still after one of its points moves (which makes it a path). - let p = app.run("frame.create", json!({"rect": [300, 200, 400, 260], "shape": "polygon", "sides": 4, "starInset": 0})).unwrap()["id"] - .as_u64() - .unwrap(); - app.run("path.moveAnchors", json!({"id": p, "anchors": [[0, 0]], "dx": 5, "dy": 0})).unwrap(); - let it = app.session.active().unwrap().doc.item(designcraft_doc::ItemId(p)).unwrap().clone(); - assert_eq!(it.shape, designcraft_doc::Shape::Path); - let lines: Vec<_> = frame_x(&it).to_vec(); - assert!(lines.iter().all(|(a, b)| (a.x - b.x).abs() > 1e-6 && (a.y - b.y).abs() > 1e-6), "an edited diamond: {lines:?}"); + let m = it.xf; + frame_x(&it).map(|(a, b)| { + let (a, b) = (m * a, m * b); + [(a.x.round(), a.y.round()), (b.x.round(), b.y.round())] + }) + }; + let mut lines = x(&app).to_vec(); + lines.iter_mut().for_each(|l| l.sort_by(|a, b| a.partial_cmp(b).unwrap())); + lines.sort_by(|a, b| a.partial_cmp(b).unwrap()); + assert_eq!(lines, vec![[(100.0, 100.0), (200.0, 160.0)], [(100.0, 160.0), (200.0, 100.0)]], "corner to corner"); + // Slant it: the two top points move 30 pt right. + let it = app.session.active().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().clone(); + let top: Vec = + it.path.subpaths[0].anchors.iter().enumerate().filter(|(_, a)| (it.xf * a.p).y < 130.0).map(|(k, _)| json!([0, k])).collect(); + assert_eq!(top.len(), 2); + app.run("path.moveAnchors", json!({"id": id, "anchors": top, "dx": 30, "dy": 0})).unwrap(); + let mut lines = x(&app).to_vec(); + lines.iter_mut().for_each(|l| l.sort_by(|a, b| a.partial_cmp(b).unwrap())); + lines.sort_by(|a, b| a.partial_cmp(b).unwrap()); + assert_eq!(lines, vec![[(100.0, 160.0), (230.0, 100.0)], [(130.0, 100.0), (200.0, 160.0)]], "the slanted corners"); + // An ellipse frame (four smooth anchors) keeps the X across its bounds, not a plus. + let e = app.run("frame.create", json!({"rect": [300, 100, 400, 160], "shape": "ellipse"})).unwrap()["id"].as_u64().unwrap(); + let it = app.session.active().unwrap().doc.item(designcraft_doc::ItemId(e)).unwrap().clone(); + let mut lines: Vec<[(f64, f64); 2]> = frame_x(&it) + .map(|(a, b)| { + let (a, b) = (it.xf * a, it.xf * b); + [(a.x.round(), a.y.round()), (b.x.round(), b.y.round())] + }) + .to_vec(); + lines.iter_mut().for_each(|l| l.sort_by(|a, b| a.partial_cmp(b).unwrap())); + lines.sort_by(|a, b| a.partial_cmp(b).unwrap()); + assert_eq!(lines, vec![[(300.0, 100.0), (400.0, 160.0)], [(300.0, 160.0), (400.0, 100.0)]], "the ellipse's bounds"); + // So does a four-sided polygon frame (a diamond): its corners would cross as a plus. + let mut diamond = it.clone(); + diamond.shape = designcraft_doc::Shape::Polygon; + for a in &mut diamond.path.subpaths[0].anchors { + (a.h_in, a.h_out) = (a.p, a.p); } + let lines: Vec<_> = frame_x(&diamond).to_vec(); + assert!(lines.iter().all(|(a, b)| (a.x - b.x).abs() > 1e-6 && (a.y - b.y).abs() > 1e-6), "diagonals, not a plus: {lines:?}"); + // Still after one of its points moves (which makes it a path). + let p = app.run("frame.create", json!({"rect": [300, 200, 400, 260], "shape": "polygon", "sides": 4, "starInset": 0})).unwrap()["id"] + .as_u64() + .unwrap(); + app.run("path.moveAnchors", json!({"id": p, "anchors": [[0, 0]], "dx": 5, "dy": 0})).unwrap(); + let it = app.session.active().unwrap().doc.item(designcraft_doc::ItemId(p)).unwrap().clone(); + assert_eq!(it.shape, designcraft_doc::Shape::Path); + let lines: Vec<_> = frame_x(&it).to_vec(); + assert!(lines.iter().all(|(a, b)| (a.x - b.x).abs() > 1e-6 && (a.y - b.y).abs() > 1e-6), "an edited diamond: {lines:?}"); +} /// Each preview mode's shown area: Preview stops at the trim, Bleed reaches the bleed box and /// Slug the slug box. A slug narrower than the bleed never pulls the edge back in. From cb036e06e28ecd7634182a815db4bf3fcaed4a4c Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 15:28:38 +0100 Subject: [PATCH 09/16] Frame X test: the corner-to-corner lines from main's split canvas frames module Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 2 +- crates/ui-egui/src/canvas/frames.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index de4331c7d..84b3cd0d6 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -30,7 +30,7 @@ mod view; use column_guides::{COLUMN_DRAG, ColumnDrag, column_guide_at, spread_x_across}; use frames::{draw_frames, draw_selection}; #[cfg(test)] -use frames::{item_canvas_xf, overset_frames, overset_marks, ported_frames, text_frames_in}; +use frames::{frame_x, item_canvas_xf, overset_frames, overset_marks, ported_frames, text_frames_in}; use guides::{GUIDE_DRAG, GUIDE_PRESS, GuideDrag, GuidePress, guide_readout, guide_spot, moving_guides, readout_at}; #[cfg(test)] use guides::{guide_at, guide_drag_delta, guide_measure}; diff --git a/crates/ui-egui/src/canvas/frames.rs b/crates/ui-egui/src/canvas/frames.rs index 1378ba255..4578b933b 100644 --- a/crates/ui-egui/src/canvas/frames.rs +++ b/crates/ui-egui/src/canvas/frames.rs @@ -135,7 +135,7 @@ fn draw_item_edges(painter: &egui::Painter, xf: &Xf, doc: &Document, it: &Item, /// The X of an empty graphic frame, in its own space: a four-cornered frame joins its opposite /// corners, so a frame slanted with Direct Selection keeps its X corner to corner, as InDesign /// draws it; other shapes span their bounds. -fn frame_x(it: &Item) -> [(Point, Point); 2] { +pub(super) fn frame_x(it: &Item) -> [(Point, Point); 2] { // A rectangle (slanted or not: four corner points, no handles). Ellipses, polygons (a // four-sided one is a diamond) and curved paths keep the X across their bounds. Edited points // make any of them a path: a four-cornered one whose corners would cross as a plus (a diamond, From 9ee25b1561b57644cc9f91e557b76f10f8e2bbac Mon Sep 17 00:00:00 2001 From: Zaxaerith Date: Fri, 30 Oct 2026 11:52:30 +0800 Subject: [PATCH 10/16] fix(fonts): find document fonts with bare file paths (#2014) --- ROADMAP.md | 1 + crates/engine/src/cmd/file/open.rs | 71 ++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index ad3f52a14..5bcc7b176 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,6 +12,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web. +- **2026-10-30** · Document Fonts lookup treats a bare document filename's empty parent as the current directory (#2014), preserving case-insensitive discovery and automation read-root checks. No readiness percentage change. - **2026-10-28** · Inline table rows leave room for column and spanning footnotes, continuing in the next column or frame when needed (#1901). Cell-local footnotes contribute to row height and sit inside the cell rather than outside its measuring frame. Vertical justification keeps diff --git a/crates/engine/src/cmd/file/open.rs b/crates/engine/src/cmd/file/open.rs index 75a7ce627..c7e8264ba 100644 --- a/crates/engine/src/cmd/file/open.rs +++ b/crates/engine/src/cmd/file/open.rs @@ -128,6 +128,8 @@ pub(crate) fn load_document_fonts(d: &mut Document, path: &str) -> (Option std::path::PathBuf { use designcraft_fonts::DOCUMENT_FONTS_FOLDER; + // A bare file name has an empty parent; directory traversal needs an explicit `.`. + let dir = if dir.as_os_str().is_empty() { std::path::Path::new(".") } else { dir }; let exact = dir.join(DOCUMENT_FONTS_FOLDER); if crate::io::is_dir(&exact) { return exact; @@ -286,6 +288,75 @@ mod document_fonts_tests { let _ = std::fs::remove_dir_all(&dir); } + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn document_fonts_are_found_for_bare_and_explicit_paths() { + // Only the child changes its working directory; parallel tests keep theirs. + let dir = tempfile::tempdir().unwrap(); + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "cmd::file::open::document_fonts_tests::document_font_paths_child", "--ignored", "--nocapture"]) + .env("DESIGNCRAFT_TEST_DOCUMENT_FONT_PATHS", "1") + .current_dir(dir.path()) + .output() + .unwrap(); + assert!(output.status.success(), "child failed: {}{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr)); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("test result: ok. 1 passed; 0 failed;"), "the subprocess helper must run exactly once: {stdout}"); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + #[ignore = "subprocess helper, executed by document_fonts_are_found_for_bare_and_explicit_paths"] + fn document_font_paths_child() { + const FAMILY: &str = "DocFont Bare Path Test"; + assert!(std::env::var_os("DESIGNCRAFT_TEST_DOCUMENT_FONT_PATHS").is_some(), "run through the parent test"); + let cwd = std::env::current_dir().unwrap(); + std::fs::create_dir("nested").unwrap(); + std::fs::create_dir("other").unwrap(); + for extension in ["idml", "designcraft"] { + let path = PathBuf::from(format!("t.{extension}")); + write_document(&cwd.join(&path), FAMILY); + write_document(&cwd.join("other").join(&path), FAMILY); + assert_eq!(open(&mut Session::new(), &path)["documentFonts"], 0, "no font folder: {path:?}"); + } + + let roots = crate::file_access::AutomationRoots::new(&[cwd.as_path()], &[]).unwrap(); + for folder in ["Document fonts", DOCUMENT_FONTS_FOLDER] { + std::fs::create_dir_all(folder).unwrap(); + std::fs::write(Path::new(folder).join("case.ttf"), font_with(FAMILY, &['H', 'e', 'l', 'o']).unwrap()).unwrap(); + if folder == DOCUMENT_FONTS_FOLDER { + // Both spellings exist on case-sensitive file systems; the exact name still wins. + assert_eq!(super::document_fonts_folder(Path::new("")), Path::new(".").join(DOCUMENT_FONTS_FOLDER)); + } + for extension in ["idml", "designcraft"] { + let name = format!("t.{extension}"); + for path in [PathBuf::from(&name), Path::new(".").join(&name), Path::new("nested/..").join(&name), cwd.join(&name)] { + let mut s = Session::new(); + let r = crate::file_access::confine(roots.as_ref(), || open(&mut s, &path)); + assert_eq!(r["documentFonts"], 1, "{folder}, {path:?}: {r}"); + let f = font_entry(&mut s, FAMILY); + assert_eq!((f["missing"].clone(), f["source"].clone()), (json!(false), json!("document")), "{path:?}: {f}"); + } + } + } + + // Opening a second document without the folder must not inherit the first one's font. + let mut s = Session::new(); + assert_eq!(open(&mut s, Path::new("t.idml"))["documentFonts"], 1); + assert_eq!(open(&mut s, Path::new("other/t.designcraft"))["documentFonts"], 0); + assert_eq!(font_entry(&mut s, FAMILY)["missing"], true); + s.execute("file.activate", &json!({"index": 0})).unwrap(); + assert_eq!(font_entry(&mut s, FAMILY)["source"], "document"); + + // Normalising the directory must not grant access outside the automation read root. + let restricted = crate::file_access::AutomationRoots::new(&[Path::new("other")], &[]).unwrap(); + let mut doc = s.doc().unwrap().doc.clone(); + let (scope, faces, _) = crate::file_access::confine(restricted.as_ref(), || super::load_document_fonts(&mut doc, "t.idml")); + assert!(scope.is_none()); + assert_eq!(faces, 0); + assert_eq!(doc.font_scope, 0); + } + #[test] fn a_document_without_the_folder_opens_as_before() { let dir = std::env::temp_dir().join(format!("dc-open-docfonts-none-{}", std::process::id())); From 8b9f2971817d83affe73da1140062630ba062340 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 1 Nov 2026 01:19:16 +0100 Subject: [PATCH 11/16] Require object params in compact script lines Found while testing crates/engine/src/script.rs:50-54 (parse, compact `command {json}` branch) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/script.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/script.rs b/crates/engine/src/script.rs index 4a66ff87f..8b414f423 100644 --- a/crates/engine/src/script.rs +++ b/crates/engine/src/script.rs @@ -51,7 +51,9 @@ pub fn parse(text: &str) -> Result, String> { let (id, rest) = l.split_at(split); let rest = rest.trim_start_matches(['=', ' ', '\t']).trim(); let params = if rest.is_empty() { Ok(json!({})) } else { serde_json::from_str(rest).map_err(|e| e.to_string()) }; - params.map(|params| Step { command: id.to_string(), params }) + params.and_then(|params: Value| { + if params.is_object() { Ok(Step { command: id.to_string(), params }) } else { Err("`params` must be an object".to_string()) } + }) }; out.push(step.map_err(|e| format!("line {}: {e}", n + 1))?); } @@ -195,6 +197,14 @@ mod tests { assert!(parse("frame.create {bad").unwrap_err().contains("line 1")); } + #[test] + fn compact_lines_require_object_params() { + for bad in ["a.b 5", "a.b [1]", "a.b \"x\"", "a.b=true", "a.b null"] { + assert_eq!(parse(bad), Err("line 1: `params` must be an object".to_string()), "{bad}"); + } + assert_eq!(parse("\na.b={\"x\":1}").unwrap()[0].params, json!({"x": 1})); + } + /// Windows editors and PowerShell 5.1 save "UTF-8 with BOM" with CRLF line endings; the BOM /// became part of the first command id (#654). #[test] From 41c59708eb5c3f6a677df74622ee3f61eb79bfc8 Mon Sep 17 00:00:00 2001 From: JonRC Date: Tue, 3 Nov 2026 01:05:17 -0300 Subject: [PATCH 12/16] cli: script --export prints the export's warnings, as run does (#2191) The script command threw away the result of each export, so a PDF with a drop shadow came out with nothing on stderr, while run --export printed "warning: 1 object(s) with soft effects ... exported as images". Each warning an export returns now goes to stderr as "warning: ...". The JSON report on stdout is unchanged. --- apps/designcraft-cli/src/main.rs | 7 ++++++- apps/designcraft-cli/tests/it/cli.rs | 29 ++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/apps/designcraft-cli/src/main.rs b/apps/designcraft-cli/src/main.rs index a9839fcd3..44948d54b 100644 --- a/apps/designcraft-cli/src/main.rs +++ b/apps/designcraft-cli/src/main.rs @@ -533,7 +533,7 @@ fn script(args: &[String]) -> Result<(), String> { } for (kind, out) in &export { let p = json!({"path": out}); - match kind { + let r = match kind { // PNG / JPEG of the first page. Export::Image => b.call("app.export", p)?, Export::Pdf => exec(b, "file.exportPdf", p)?, @@ -542,6 +542,11 @@ fn script(args: &[String]) -> Result<(), String> { Export::Pptx => exec(b, "file.exportPptx", p)?, Export::Command(cmd) => exec(b, cmd, p)?, }; + // What the export itself reports (effects rasterised, transparency flattened…) goes + // to stderr, as `run --export` prints it: the report on stdout covers only the steps. + for w in r["warnings"].as_array().into_iter().flatten() { + eprintln!("warning: {}", w.as_str().unwrap_or_default()); + } } } outln!("{}", serde_json::to_string_pretty(&report.to_json()).unwrap_or_default()); diff --git a/apps/designcraft-cli/tests/it/cli.rs b/apps/designcraft-cli/tests/it/cli.rs index 861cb514f..0b03ecd5d 100644 --- a/apps/designcraft-cli/tests/it/cli.rs +++ b/apps/designcraft-cli/tests/it/cli.rs @@ -95,6 +95,35 @@ fn export_to_an_unknown_file_type_is_an_error() { let _ = std::fs::remove_dir_all(&dir); } +/// `run --export` prints what the PDF export reports, and `script --export` dropped it: the same +/// PDF with a drop shadow came out of a script with nothing on stderr. +#[test] +fn script_export_prints_the_export_warnings_as_run_does() { + let dir = std::env::temp_dir().join(format!("dc-cli-warnings-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let steps = [ + r#"frame.create={"rect": [72, 72, 300, 200]}"#, + r#"object.fill={"ids": ["$0.id"], "swatch": "[Black]"}"#, + r#"object.dropShadow={"ids": ["$0.id"], "on": true, "distance": 8, "size": 6}"#, + ]; + let script = dir.join("shadow.dcs"); + std::fs::write(&script, steps.join("\n")).unwrap(); + let pdf = dir.join("shadow.pdf"); + let pdf = pdf.to_str().unwrap(); + let mut run = vec!["run"]; + for step in steps { + run.extend(["--cmd", step]); + } + run.extend(["--export", pdf]); + for args in [run, vec!["script", script.to_str().unwrap(), "--export", pdf]] { + let out = cli().args(&args).output().unwrap(); + let err = String::from_utf8_lossy(&out.stderr); + assert!(out.status.success(), "{}: {err}", args[0]); + assert!(err.contains("warning: 1 object(s) with soft effects"), "{}: {err}", args[0]); + } + let _ = std::fs::remove_dir_all(&dir); +} + /// With `--connect` the exports happen in the running app. `--export copy.designcraft` saves a /// copy there: Save As would make the copy the file the open document saves to from then on. #[test] From 7bc3a26e4a03b13b5dad46836b5918ca742dcb21 Mon Sep 17 00:00:00 2001 From: JonRC Date: Tue, 3 Nov 2026 01:51:54 -0300 Subject: [PATCH 13/16] type.char: warn about the font the text resolves to (follow-up to #2190) The warning from #2190 looked only at the attributes passed. A composite font made by style.compositeFont.set isn't an installed family, so applying one warned "Missing font: Mixed" even when its entries were installed. And a fontStyle-only call, or a family change that kept a style the new family lacks, said nothing. After a call that sets or clears fontFamily or fontStyle, type.char now tallies the fonts the formatted text resolves to, with the code font.list uses (composite entries per character), and warns about the missing ones and substitute styles. At a caret it checks the font the next typing gets. tally_story becomes tally_range over a byte range, and describe_fonts is the part of used_fonts that type.char reuses. --- crates/engine/src/cmd/fonts.rs | 35 ++++-- crates/engine/src/cmd/text.rs | 2 +- crates/engine/src/cmd/text/format.rs | 156 ++++++++++++++++++++------- 3 files changed, 144 insertions(+), 49 deletions(-) diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs index 77cebe369..8d78e4b4a 100644 --- a/crates/engine/src/cmd/fonts.rs +++ b/crates/engine/src/cmd/fonts.rs @@ -94,21 +94,30 @@ fn list(d: &Document) -> Vec { used_fonts(d).into_iter().map(|font| json!(font)).collect() } -type FontTally = BTreeMap<(String, String), (usize, std::collections::BTreeSet)>; +pub(super) type FontTally = BTreeMap<(String, String), (usize, std::collections::BTreeSet)>; /// Count the characters of every run of one story against the font each resolves to. fn tally_story(d: &Document, st: &Story, used: &mut FontTally) { - let ranges = st.para_ranges(); - for (pi, r) in ranges.iter().enumerate() { - let (_, base) = d.styles.resolve_para(&st.paras[pi]); + tally_range(d, st, 0..st.text.len(), used); +} + +/// Count the characters in the byte range `within` of a story against the font each resolves to; +/// a composite font counts each character under the font of its entry. An empty paragraph counts +/// the font of its mark, which still sets its line. +pub(super) fn tally_range(d: &Document, st: &Story, within: std::ops::Range, used: &mut FontTally) { + let to_end = within.end >= st.text.len(); + for (pi, r) in st.para_ranges().iter().enumerate() { + let Some(para) = st.paras.get(pi) else { continue }; + let (_, base) = d.styles.resolve_para(para); + let mark = r.is_empty() && within.start <= r.start && (r.start < within.end || to_end); for (rr, fmt) in st.runs() { - let n = st.text[rr.start.max(r.start)..rr.end.min(r.end).max(rr.start.max(r.start))].chars().count(); - if n == 0 && !(r.is_empty() && rr.contains(&r.start)) { + let start = rr.start.max(r.start).max(within.start); + let text = st.text.get(start..rr.end.min(r.end).min(within.end).max(start)).unwrap_or(""); + if text.is_empty() && !(mark && rr.contains(&r.start)) { continue; } let p = d.styles.resolve_char(&base, fmt); if let Some(f) = d.styles.composite_font(&p.font_family) { - let text = st.text.get(rr.start.max(r.start)..rr.end.min(r.end).max(rr.start.max(r.start))).unwrap_or(""); for c in text.chars() { if let Some(e) = f.entry(c) { let entry = used.entry((e.family.clone(), e.style.clone())).or_default(); @@ -118,17 +127,23 @@ fn tally_story(d: &Document, st: &Story, used: &mut FontTally) { } } else { let entry = used.entry((p.font_family, p.font_style)).or_default(); - entry.0 += n; - entry.1.extend(st.text.get(rr.start.max(r.start)..rr.end.min(r.end).max(rr.start.max(r.start))).unwrap_or("").chars()); + entry.0 += text.chars().count(); + entry.1.extend(text.chars()); } } } } pub(super) fn used_fonts(d: &Document) -> Vec { - let db = designcraft_fonts::FontDb::global().scoped(d.font_scope); let mut used = FontTally::new(); for_each_story(d, &mut |st| tally_story(d, st, &mut used)); + describe_fonts(d, used) +} + +/// The tallied fonts as this document's fonts resolve them: missing, a substitute style, or the +/// face itself (missing ones first). +pub(super) fn describe_fonts(d: &Document, used: FontTally) -> Vec { + let db = designcraft_fonts::FontDb::global().scoped(d.font_scope); let mut out: Vec = used .into_iter() .map(|((family, style), (n, characters))| { diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index f94826fef..eecdab5e3 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -211,7 +211,7 @@ pub fn specs() -> Vec { "Character Formatting", [], None, - "{attrs: {fontFamily?, fontStyle?, size?, leading?: {kind:auto}|{kind:points,value}, tracking?, kerning?, hScale?, vScale?, baselineShift?, fill?, capitalization?, underline?, …}} → null, or {warnings} naming a fontFamily or fontStyle that isn't available (still applied; a substitute draws the text)", + "{attrs: {fontFamily?, fontStyle?, size?, leading?: {kind:auto}|{kind:points,value}, tracking?, kerning?, hScale?, vScale?, baselineShift?, fill?, capitalization?, underline?, …}} → null, or, after a fontFamily or fontStyle change, {warnings} naming each font the text now resolves to that isn't available (still applied; a substitute draws the text)", has_text_or_frames, |s, p| format_chars(s, p.get("attrs").unwrap_or(p)) ), diff --git a/crates/engine/src/cmd/text/format.rs b/crates/engine/src/cmd/text/format.rs index edec044b9..d915da91d 100644 --- a/crates/engine/src/cmd/text/format.rs +++ b/crates/engine/src/cmd/text/format.rs @@ -162,9 +162,8 @@ pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result { } } validate_char_fill(&s.doc()?.doc, &a, "type.char")?; - let warnings = missing_font_warnings(&s.doc()?.doc, &a); - let result = if warnings.is_empty() { Value::Null } else { json!({ "warnings": warnings }) }; let cleared: Vec = attrs.as_object().map(|o| o.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect()).unwrap_or_default(); + let sets_font = a.font_family.is_some() || a.font_style.is_some() || cleared.iter().any(|k| k == "fontFamily" || k == "fontStyle"); let targets = format_targets(s); // A caret in text: the format is for the next typing. On an empty paragraph's hidden mark it // also formats the mark right away, since the mark alone sets that line's height; typing there @@ -187,31 +186,50 @@ pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result { })?; } s.typing_format = Some(f); - return Ok(result); + return if sets_font { font_warnings(s, &targets) } else { ok() }; } s.edit(|d, _| { apply_char_attrs(d, &targets, &a, &cleared); - Ok(result) - }) + ok() + })?; + if sets_font { font_warnings(s, &targets) } else { ok() } } /// A font family or style the app doesn't have is still applied, so the document keeps its name -/// and preflight and Find Font list it, but a substitute draws the text. Say so, in preflight's -/// words, instead of succeeding silently. -fn missing_font_warnings(document: &designcraft_doc::Document, attrs: &CharAttrs) -> Vec { - let Some(family) = attrs.font_family.as_deref().filter(|f| !f.trim().is_empty()) else { - return vec![]; - }; - let db = designcraft_fonts::FontDb::global().scoped(document.font_scope); - let style = attrs.font_style.as_deref().unwrap_or("Regular"); - let face = db.face(family, style); - if !db.has_family(family) { - vec![format!("Missing font: {family} (using {})", face.family)] - } else if attrs.font_style.is_some() && !db.has_style(family, style) { - vec![format!("Missing font style: {family} {style} (using {} {})", face.family, face.style)] - } else { - vec![] +/// and preflight and Find Font list it, but a substitute draws the text. After a change to the +/// family or style, say which of the fonts the formatted text now resolves to are missing, in +/// preflight's words: the text's own family with a new style, a new family with the text's style, +/// and a composite font's entries for the characters it sets, as `font.list` resolves them. At a +/// caret, the font the next typing gets (a composite font picks it per character typed). +fn font_warnings(s: &Session, targets: &[Target]) -> Result { + let d = &s.doc()?.doc; + let mut used = crate::cmd::fonts::FontTally::new(); + for t in targets { + let Some(story) = d.text_story(t.story, t.cell) else { continue }; + if !t.range.is_empty() { + crate::cmd::fonts::tally_range(d, story, t.range.clone(), &mut used); + continue; + } + let Some(para) = story.paras.get(story.para_at(t.range.start)) else { continue }; + let (_, base) = d.styles.resolve_para(para); + let next = d.styles.resolve_char(&base, &effective_char_format(s, t)?); + if d.styles.composite_font(&next.font_family).is_none() { + used.entry((next.font_family, next.font_style)).or_default(); + } } + let warnings: Vec = crate::cmd::fonts::describe_fonts(d, used) + .into_iter() + .filter_map(|f| { + if f.missing { + Some(format!("Missing font: {} (using {})", f.family, f.resolved_family)) + } else if f.style_missing { + Some(format!("Missing font style: {} {} (using {} {})", f.family, f.style, f.resolved_family, f.resolved_style)) + } else { + None + } + }) + .collect(); + Ok(if warnings.is_empty() { Value::Null } else { json!({ "warnings": warnings }) }) } /// Kerning spaces a character from the next one, so the pair at a caret is kerned through the @@ -1097,34 +1115,96 @@ mod size_bounds_tests { #[cfg(test)] mod missing_font_tests { - use serde_json::json; + use serde_json::{Value, json}; use crate::Session; /// A font the app doesn't have was applied with no word about it, so a script that asked for /// Courier Prime exported a PDF in the substitute and exited 0. It is still applied (preflight - /// and Find Font list it), and the command now says which font is missing. + /// and Find Font list it), and the command says which font the text now resolves to is + /// missing: the pair of family and style it ends up with, whichever of the two the call set, + /// and a composite font's entries rather than its name. #[test] - fn a_font_that_is_not_available_is_applied_with_a_warning() { - let mut s = Session::new(); - s.execute("file.new", &json!({"pages": 1})).unwrap(); - let f = s.execute("frame.create", &json!({"rect": [20, 20, 400, 300], "content": "text", "text": "Hello world"})).unwrap(); - for (attrs, warning) in [ - (json!({"fontFamily": "Source Serif 4", "fontStyle": "Bold"}), None), - (json!({"fontFamily": "Nonexistent Sans"}), Some("Missing font: Nonexistent Sans (using ")), - (json!({"fontFamily": "Source Serif 4", "fontStyle": "Nope"}), Some("Missing font style: Source Serif 4 Nope (using Source Serif 4 ")), - ] { + fn a_font_change_warns_about_the_font_the_text_resolves_to() { + let composite = |name: &str, family: &str| ("style.compositeFont.set", json!({"name": name, "entries": [{"family": family}]})); + let typed = |attrs: Value| ("type.char", json!({"attrs": attrs})); + let rows: [(&str, Vec<(&str, Value)>, bool, Value, Option<&str>); 11] = [ + ("family and style, both installed", vec![], false, json!({"fontFamily": "Source Serif 4", "fontStyle": "Bold"}), None), + ("family only, missing", vec![], false, json!({"fontFamily": "Nonexistent Sans"}), Some("Missing font: Nonexistent Sans (using ")), + ( + "family and a style it lacks", + vec![], + false, + json!({"fontFamily": "Source Serif 4", "fontStyle": "Nope"}), + Some("Missing font style: Source Serif 4 Nope (using Source Serif 4 "), + ), + ( + "style only, one the text's family has", + vec![typed(json!({"fontFamily": "Source Serif 4"}))], + false, + json!({"fontStyle": "Bold"}), + None, + ), + ( + "style only, one the text's family lacks", + vec![typed(json!({"fontFamily": "Source Serif 4"}))], + false, + json!({"fontStyle": "Nope"}), + Some("Missing font style: Source Serif 4 Nope (using "), + ), + ( + "family only, keeping a style the new family lacks", + vec![typed(json!({"fontFamily": "Source Serif 4", "fontStyle": "Nope"}))], + false, + json!({"fontFamily": "Source Sans 3"}), + Some("Missing font style: Source Sans 3 Nope (using "), + ), + ("a composite font of installed fonts", vec![composite("Mixed", "Source Serif 4")], false, json!({"fontFamily": "Mixed"}), None), + ( + "a composite font with a missing entry", + vec![composite("Gone", "Nonexistent Sans")], + false, + json!({"fontFamily": "Gone"}), + Some("Missing font: Nonexistent Sans (using "), + ), + ( + "family only at a caret, for the next typing", + vec![], + true, + json!({"fontFamily": "Nonexistent Sans"}), + Some("Missing font: Nonexistent Sans (using "), + ), + ( + "style only at a caret", + vec![typed(json!({"fontFamily": "Source Serif 4"}))], + true, + json!({"fontStyle": "Nope"}), + Some("Missing font style: Source Serif 4 Nope (using "), + ), + ("a change that leaves the font alone", vec![typed(json!({"fontFamily": "Nonexistent Sans"}))], false, json!({"size": 14}), None), + ]; + for (name, before, caret, attrs, warning) in rows { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1})).unwrap(); + let f = s.execute("frame.create", &json!({"rect": [20, 20, 400, 300], "content": "text", "text": "Hello world"})).unwrap(); s.execute("text.select", &json!({"story": f["story"].clone(), "anchor": 0, "focus": 5})).unwrap(); + for (command, params) in before { + s.execute(command, ¶ms).unwrap(); + } + if caret { + s.execute("text.select", &json!({"story": f["story"].clone(), "anchor": 5, "focus": 5})).unwrap(); + } let r = s.execute("type.char", &json!({"attrs": attrs})).unwrap(); match warning { - None => assert_eq!(r, json!(null), "{attrs}"), - Some(w) => assert!(r["warnings"][0].as_str().is_some_and(|m| m.starts_with(w)), "{attrs}: {r}"), + None => assert_eq!(r, json!(null), "{name}"), + Some(w) => { + assert!(r["warnings"].as_array().is_some_and(|ws| ws.iter().any(|m| m.as_str().is_some_and(|m| m.starts_with(w)))), "{name}: {r}") + } + } + // The font is applied whatever the warning says. + if let Some(family) = attrs.get("fontFamily").filter(|_| !caret) { + assert_eq!(&s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fontFamily"], family, "{name}"); } - assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fontFamily"], attrs["fontFamily"], "{attrs}"); } - // At a caret the font is for the next typing, and the warning is the same. - s.execute("text.select", &json!({"story": f["story"].clone(), "anchor": 5, "focus": 5})).unwrap(); - let r = s.execute("type.char", &json!({"attrs": {"fontFamily": "Nonexistent Sans"}})).unwrap(); - assert!(r["warnings"][0].as_str().is_some_and(|m| m.starts_with("Missing font: Nonexistent Sans")), "{r}"); } } From 1e3c5fb0ef9c22d3908f7526db4f8b4ae58f2e18 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Tue, 3 Nov 2026 17:56:11 +0000 Subject: [PATCH 14/16] Apply review fixes - crates/engine/src/cmd/file/open.rs --- crates/engine/src/cmd/file/open.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/file/open.rs b/crates/engine/src/cmd/file/open.rs index c7e8264ba..ab90f7e95 100644 --- a/crates/engine/src/cmd/file/open.rs +++ b/crates/engine/src/cmd/file/open.rs @@ -350,7 +350,7 @@ mod document_fonts_tests { // Normalising the directory must not grant access outside the automation read root. let restricted = crate::file_access::AutomationRoots::new(&[Path::new("other")], &[]).unwrap(); - let mut doc = s.doc().unwrap().doc.clone(); + let mut doc = s.doc().unwrap().doc.as_ref().clone(); let (scope, faces, _) = crate::file_access::confine(restricted.as_ref(), || super::load_document_fonts(&mut doc, "t.idml")); assert!(scope.is_none()); assert_eq!(faces, 0); From 6df8d6d93407877a4ce15619d78bea93ddbe480f Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Tue, 3 Nov 2026 19:03:59 +0000 Subject: [PATCH 15/16] Apply review fixes - crates/doc/src/vars.rs --- crates/doc/src/vars.rs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/doc/src/vars.rs b/crates/doc/src/vars.rs index f31a31002..9183a20e0 100644 --- a/crates/doc/src/vars.rs +++ b/crates/doc/src/vars.rs @@ -197,7 +197,11 @@ pub fn parse_iso_date(s: &str) -> Option { let z = zone.get(1..)?; let digits = |t: &str| -> Option { t.bytes().all(|c| c.is_ascii_digit()).then(|| t.parse().ok()).flatten() }; let zh = digits(z.get(0..2)?)?; - let zm = z.get(2..).map(|r| r.trim_start_matches(':')).filter(|r| !r.is_empty()).map_or(Some(0), digits)?; + let zm = match z.get(2..)? { + "" => 0, + r if r.len() == 2 => digits(r)?, + r => digits(r.strip_prefix(':').filter(|m| m.len() == 2)?)?, + }; if zh > 23 || zm > 59 { return None; } @@ -342,7 +346,16 @@ mod tests { #[test] fn iso_date_rejects_malformed_zone() { - for bad in ["2019-12-16T09:48:02+-5:00", "2019-12-16T09:48:02+05:+30", "2019-12-16T09:48:02+24:00", "2019-12-16T09:48:02+05:60"] { + for bad in [ + "2019-12-16T09:48:02+-5:00", + "2019-12-16T09:48:02+05:+30", + "2019-12-16T09:48:02+24:00", + "2019-12-16T09:48:02+05:60", + "2019-12-16T09:48:02+05::30", + "2019-12-16T09:48:02+05:", + "2019-12-16T09:48:02+053", + "2019-12-16T09:48:02+05:3", + ] { assert_eq!(parse_iso_date(bad), None, "{bad}"); } } From 6914f12209d550b15f5603fdbdf257c72a00d355 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Tue, 3 Nov 2026 19:08:01 +0000 Subject: [PATCH 16/16] Apply review fixes - crates/engine/src/script.rs --- crates/engine/src/script.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/script.rs b/crates/engine/src/script.rs index 8b414f423..b8793121e 100644 --- a/crates/engine/src/script.rs +++ b/crates/engine/src/script.rs @@ -51,7 +51,7 @@ pub fn parse(text: &str) -> Result, String> { let (id, rest) = l.split_at(split); let rest = rest.trim_start_matches(['=', ' ', '\t']).trim(); let params = if rest.is_empty() { Ok(json!({})) } else { serde_json::from_str(rest).map_err(|e| e.to_string()) }; - params.and_then(|params: Value| { + params.map(|p: Value| if p.is_null() { json!({}) } else { p }).and_then(|params: Value| { if params.is_object() { Ok(Step { command: id.to_string(), params }) } else { Err("`params` must be an object".to_string()) } }) }; @@ -199,10 +199,11 @@ mod tests { #[test] fn compact_lines_require_object_params() { - for bad in ["a.b 5", "a.b [1]", "a.b \"x\"", "a.b=true", "a.b null"] { + for bad in ["a.b 5", "a.b [1]", "a.b \"x\"", "a.b=true"] { assert_eq!(parse(bad), Err("line 1: `params` must be an object".to_string()), "{bad}"); } assert_eq!(parse("\na.b={\"x\":1}").unwrap()[0].params, json!({"x": 1})); + assert_eq!(parse("a.b null").unwrap()[0].params, json!({})); } /// Windows editors and PowerShell 5.1 save "UTF-8 with BOM" with CRLF line endings; the BOM