From 7c3e60b6c46db5fe3bf96a7b45929d80aa2f6780 Mon Sep 17 00:00:00 2001 From: Daniel Date: Tue, 20 Oct 2026 03:24:52 +0200 Subject: [PATCH 01/33] fix(linux): enable font-free Wayland window controls --- Cargo.lock | 47 +++++++++++++++++-- NOTICE | 31 ++++++++++++ apps/designcraft/Cargo.toml | 5 ++ packaging/env.sh | 2 +- .../flatpak/ai.storyteller.designcraft.yml | 1 + packaging/linux/package.sh | 2 + 6 files changed, 82 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3db056251..92345bf86 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2560,7 +2560,7 @@ dependencies = [ "skrifa 0.42.1", "smallvec", "subsetter", - "tiny-skia-path", + "tiny-skia-path 0.12.0", "xmp-writer", "yoke", "zune-jpeg", @@ -2578,7 +2578,7 @@ dependencies = [ "resvg", "skrifa 0.42.1", "smallvec", - "tiny-skia", + "tiny-skia 0.12.0", "usvg", ] @@ -3837,7 +3837,7 @@ dependencies = [ "pico-args", "rgb", "svgtypes", - "tiny-skia", + "tiny-skia 0.12.0", "usvg", "zune-jpeg", ] @@ -4009,6 +4009,17 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "log", + "smithay-client-toolkit 0.19.2", + "tiny-skia 0.11.4", +] + [[package]] name = "self_cell" version = "1.3.0" @@ -4444,6 +4455,20 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "tiny-skia-path 0.11.4", +] + [[package]] name = "tiny-skia" version = "0.12.0" @@ -4456,7 +4481,18 @@ dependencies = [ "cfg-if", "log", "png", - "tiny-skia-path", + "tiny-skia-path 0.12.0", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", ] [[package]] @@ -4714,7 +4750,7 @@ dependencies = [ "siphasher", "strict-num", "svgtypes", - "tiny-skia-path", + "tiny-skia-path 0.12.0", "ttf-parser", "unicode-bidi", "unicode-script", @@ -5651,6 +5687,7 @@ dependencies = [ "raw-window-handle", "redox_syscall 0.4.1", "rustix 0.38.44", + "sctk-adwaita", "smithay-client-toolkit 0.19.2", "smol_str", "tracing", diff --git a/NOTICE b/NOTICE index f4e3184f6..0a875190e 100644 --- a/NOTICE +++ b/NOTICE @@ -90,3 +90,34 @@ Rust dependencies (vello_cpu, kurbo, skrifa, harfrust, image, egui, eframe, …) licensed (MIT/Apache-2.0/BSD/ISC/Zlib) and fetched through crates.io. No Adobe icons, images or artwork are included. + +Linux Wayland window decorations +------------------------------- +sctk-adwaita 0.10.1 (https://github.com/PolyMeilex/sctk-adwaita, +commit d5f343c45c04d4a026db57f76760819b7325db00) is used by winit on Linux +for vector window controls. Its title-font features are disabled: no Cantarell +font is embedded by this dependency and no font file is added to this repo. + +sctk-adwaita's MIT licence: + +MIT License + +Copyright (c) 2022 Bartłomiej Maryńczak + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/designcraft/Cargo.toml b/apps/designcraft/Cargo.toml index 7dc83bc55..300ab225c 100644 --- a/apps/designcraft/Cargo.toml +++ b/apps/designcraft/Cargo.toml @@ -50,3 +50,8 @@ objc2-app-kit = { version = "0.3", default-features = false, features = ["std", # PdfCraft and WordCraft) registers the handler behind a safe API (Objective-C interop needs unsafe, # which the workspace forbids). Pinned exactly: review the diff before bumping. fmv-macos-events = "=0.1.0" + +[target.'cfg(target_os = "linux")'.dependencies] +# Native GNOME/Wayland controls without a bundled title font. +winit = { version = "0.30", default-features = false, features = ["wayland-csd-adwaita-notitle"] } + diff --git a/packaging/env.sh b/packaging/env.sh index 0eb58029b..60ae3b0cb 100644 --- a/packaging/env.sh +++ b/packaging/env.sh @@ -47,7 +47,7 @@ warn() { # Copy licence and readme files that exist into a package directory. copy_docs() { local dest="$1" f - for f in README.md LICENSE LICENSE-MIT LICENSE-APACHE COPYRIGHT; do + for f in README.md LICENSE LICENSE-MIT LICENSE-APACHE COPYRIGHT NOTICE; do if [ -f "$ROOT/$f" ]; then cp "$ROOT/$f" "$dest/"; fi done copy_font_licences "$dest" diff --git a/packaging/linux/flatpak/ai.storyteller.designcraft.yml b/packaging/linux/flatpak/ai.storyteller.designcraft.yml index aabee2ebb..5335d7a66 100644 --- a/packaging/linux/flatpak/ai.storyteller.designcraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.designcraft.yml @@ -46,6 +46,7 @@ modules: build-commands: - cargo --offline build --release --locked -p designcraft - install -Dm755 target/release/designcraft /app/bin/designcraft + - install -Dm644 NOTICE /app/share/doc/designcraft/NOTICE - install -Dm644 packaging/linux/ai.storyteller.designcraft.desktop /app/share/applications/ai.storyteller.designcraft.desktop - install -Dm644 packaging/linux/ai.storyteller.designcraft.mime.xml /app/share/mime/packages/ai.storyteller.designcraft.xml - mkdir -p /app/share/icons && cp -R assets/app-icon/hicolor /app/share/icons/ diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index 2d7b80ee8..43cf7dae3 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -138,6 +138,8 @@ if has appimage; then APPDIR="$WORK/DesignCraft.AppDir" cp -R "$STAGE" "$APPDIR" mv "$APPDIR/usr/share/doc" "$WORK/doc-unused" + # Keep sctk-adwaita's MIT notice in the AppImage. + install -Dm644 "$ROOT/NOTICE" "$APPDIR/usr/share/doc/designcraft/NOTICE" ln -s usr/bin/designcraft "$APPDIR/AppRun" cp "$HERE/$APP_ID.desktop" "$APPDIR/$APP_ID.desktop" cp "$ROOT/assets/app-icon/hicolor/256x256/apps/$APP_ID.png" "$APPDIR/$APP_ID.png" From 4f45a0127a0a12f0c04699b3e8463d493ec5cd59 Mon Sep 17 00:00:00 2001 From: makesomethingshit Date: Fri, 23 Oct 2026 20:58:19 +0900 Subject: [PATCH 02/33] #1620: Resize selected parent pages from page controls --- ROADMAP.md | 4 +- crates/doc/src/selection.rs | 5 +- crates/engine/src/cmd/layout.rs | 218 +++++++++----- crates/engine/src/cmd/liquid.rs | 4 +- crates/engine/src/cmd/parent_resize_tests.rs | 299 +++++++++++++++++++ crates/engine/src/tests.rs | 56 ++++ crates/tools/src/layout.rs | 46 ++- crates/tools/src/nav.rs | 83 +++-- crates/tools/src/tests.rs | 35 +++ crates/ui-egui/src/canvas.rs | 30 +- crates/ui-egui/src/chrome.rs | 122 +++++++- crates/ui-egui/src/control.rs | 21 +- crates/ui-egui/src/dialogs.rs | 30 ++ crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/panels/properties.rs | 48 ++- docs/roadmap.md | 4 +- 16 files changed, 855 insertions(+), 152 deletions(-) create mode 100644 crates/engine/src/cmd/parent_resize_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index d3ef17e47..349f68fe2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -3,7 +3,7 @@ **Stage: pre-alpha** · next: alpha, ~5–7 h away; beta ~28 points and ~420–750 h beyond that (the core-workflow gate, not the percentage: ready for real work is already ~47%) - FID1: PDF export normalizes mixed emitted gradient color spaces to RGB with an explicit warning; homogeneous device-space gradients retain their components. Linear/radial, midpoint, archival and print policies have synthetic regression coverage. -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; the alpha gate waits on Primary Text Frame page fixes; Japanese print, import and table controls localized; Live corners (#186); Optical kerning from outlines (#181); window and menu quit confirmation; failed writes preserve live save and collection state; spring-loaded Zoom navigation; orderly native exit recovery; Pages panel parents; cancelable macOS Dock Quit; text stroke behind fill; Based on Parent; object styles carry effects; table anchors keep their own paragraphs; grouped HTML/EPUB reading order; Links source paths; XLSX cached cell values retain numeric precision and text) · **Target:** Adobe InDesign 2026 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** minor (independent parent page resizing, #1620; File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; the alpha gate waits on Primary Text Frame page fixes; Japanese print, import and table controls localized; Live corners (#186); Optical kerning from outlines (#181); window and menu quit confirmation; failed writes preserve live save and collection state; spring-loaded Zoom navigation; orderly native exit recovery; Pages panel parents; cancelable macOS Dock Quit; text stroke behind fill; Based on Parent; object styles carry effects; table anchors keep their own paragraphs; grouped HTML/EPUB reading order; Links source paths; XLSX cached cell values retain numeric precision and text) · **Target:** Adobe InDesign 2026 - AR1.1: Kashida justification tests verify space fallback without Tatweel fonts using an isolated font database, while retaining elongation assertions when fonts support it. - AR1: Arabic IDML controls now retain character direction, kashida switches, diacritic offsets/forms, paragraph policy values and independent story/table directions. Shaping preserves joining context and script/language selection; paragraph-level bidi, contextual digit conversion, mark-safe elongation, RTL columns/tables and physical column insertion have regression coverage. Vendor-specific justification and diacritic presets remain explicit preflight warnings; see docs/arabic-typography.md. @@ -348,6 +348,8 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-23** · Parent page size edits target the selected parent page from Properties, the Page Tool Control panel and page handles (#1620), preserving independent document page sizes, Undo/Redo and the existing preview/Alt-drag behavior. No readiness percentage change. + - **2026-10-16** · Edit › Keyboard Shortcuts imports a set saved by InDesign's Show Set (.txt, Windows or Mac key names, UTF-8 or UTF-16): matched commands take its Default shortcuts or lose theirs, other commands give up keys it takes, and a report lists what it changed and the items it could not match (`window.importShortcuts`, #1180). Tool keys stay fixed. No readiness percentage change. - **2026-10-22** · A ruler guide dragged with the Selection tools, out of a ruler, on its own or with other selected guides, snaps along its axis as objects do (#1221): to other ruler guides, margins, columns, bleed, page and object edges and centers (with the smart-guide mark) and the document grid, within the snap zone and following Snap to Guides and Smart Guides. Shift snaps to the ruler's ticks and Cmd suspends snapping. The preview line, the position readout and the drop use the same snapped position. No readiness percentage change. - **2026-10-22** · macOS: the app menu has a Language submenu after Settings… with each interface language in its own name and the current one checked, as in the suite's other apps; Preferences › General › Interface Language stays. No readiness percentage change. diff --git a/crates/doc/src/selection.rs b/crates/doc/src/selection.rs index 11c4e2ab1..a2dcf29da 100644 --- a/crates/doc/src/selection.rs +++ b/crates/doc/src/selection.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; use crate::edit::SpreadRef; -use crate::ids::{ItemId, StoryId}; +use crate::ids::{ItemId, SpreadId, StoryId}; use crate::table::CellRange; /// A selected ruler guide: index `index` in the guides of page `page` of spread `spread`. @@ -93,6 +93,9 @@ pub struct Selection { /// The page selected with the Page tool (absolute index). #[serde(default, skip_serializing_if = "Option::is_none")] pub page: Option, + /// Parent selected with the Page tool: stable spread id and page index within it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_page: Option<(SpreadId, usize)>, /// Selected ruler guides. Never together with items, text or cells: selecting one clears /// the other. #[serde(default, skip_serializing_if = "Vec::is_empty")] diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 047bd90ba..a53fed36b 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -622,6 +622,30 @@ pub fn specs() -> Vec { has_doc, page_size ), + cmd!( + "layout.parentPageSize", + "Parent Page Size", + [], + None, + "{parent: spread id, page: 0-based parent page index, width?, height?, anchor?: 0–8} — resize only this parent page, keeping document page sizes independent", + has_doc, + parent_page_size + ), + cmd!( + noundo "layout.selectParentPage", "Select Parent Page", [], None, + "{parent: spread id|null, page?: 0-based parent page index} — select a parent page with the Page tool; null clears the selection", + has_doc, |s, p| { + let target = match p.get("parent") { + Some(Value::Null) => None, + _ => Some(parent_page_target(&s.doc()?.doc, p, "layout.selectParentPage")?), + }; + let st = s.doc_mut()?; + st.selection = Default::default(); + st.selection.parent_page = target.map(|(si, pi)| (st.doc.parents[si].id, pi)); + st.bump_revision(); + ok() + } + ), cmd!( "layout.pageOptions", "Page Options", @@ -656,6 +680,7 @@ pub fn specs() -> Vec { st.selection = designcraft_doc::Selection::items(ids); } st.selection.page = page; + st.selection.parent_page = None; st.bump_revision(); Ok(json!({"page": page.map(|p| p + 1)})) } @@ -1021,6 +1046,43 @@ fn page_size(s: &mut crate::Session, p: &Value) -> Result { }) } +fn parent_page_target(d: &designcraft_doc::Document, p: &Value, cmd: &str) -> Result<(usize, usize)> { + let parent = p.get("parent").and_then(Value::as_u64).map(SpreadId).ok_or_else(|| bad(cmd, "give a parent spread id"))?; + let si = d.parent_index(parent).ok_or_else(|| bad(cmd, "no such parent"))?; + let pi = p + .get("page") + .and_then(Value::as_u64) + .and_then(|n| usize::try_from(n).ok()) + .filter(|i| *i < d.parents[si].pages.len()) + .ok_or_else(|| bad(cmd, "no such parent page"))?; + Ok((si, pi)) +} + +fn parent_page_size(s: &mut Session, p: &Value) -> Result { + const ID: &str = "layout.parentPageSize"; + let (si, pi) = parent_page_target(&s.doc()?.doc, p, ID)?; + let size = |key| { + p.get(key) + .map(|v| { + v.as_f64().filter(|v| v.is_finite() && *v > 0.0 && *v <= 15552.0).ok_or_else(|| bad(ID, "page size out of range (0 < size ≤ 216 in)")) + }) + .transpose() + }; + let (w, h) = (size("width")?, size("height")?); + if w.is_none() && h.is_none() { + return Err(bad(ID, "give width or height")); + } + let anchor = match p.get("anchor") { + None => 0, + Some(v) => v.as_u64().filter(|n| *n <= 8).ok_or_else(|| bad(ID, "anchor: 0–8"))? as usize, + }; + s.edit(|d, _| { + resize_spread_page(d, designcraft_doc::SpreadRef::Parent(si), pi, w, h, anchor); + d.sync_parent_margins(); + Ok(json!({"parent": d.parents[si].id.0, "page": pi})) + }) +} + fn page_options(s: &mut crate::Session, p: &Value) -> Result { let pages: Vec = 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(); @@ -1050,84 +1112,86 @@ pub(crate) fn resize_pages(d: &mut designcraft_doc::Document, pages: &[usize], w /// [`resize_pages`] keeping reference point `anchor` (0–8, row-major from the top-left) in place: /// the page grows away from it, so objects keep their place relative to it. pub(crate) fn resize_pages_from(d: &mut designcraft_doc::Document, pages: &[usize], w: Option, h: Option, anchor: usize) { + for page in pages { + let Some((si, pi)) = page.checked_sub(1).and_then(|i| d.page_loc(i)) else { continue }; + resize_spread_page(d, designcraft_doc::SpreadRef::Doc(si), pi, w.map(|v| v.max(1.0)), h.map(|v| v.max(1.0)), anchor); + } +} + +fn resize_spread_page(d: &mut designcraft_doc::Document, sr: designcraft_doc::SpreadRef, pi: usize, w: Option, h: Option, anchor: usize) { let (fx, fy) = ((anchor % 3) as f64 / 2.0, (anchor / 3).min(2) as f64 / 2.0); - { - 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 = sp.pages.iter().map(|x| x.x).collect(); - let old_bounds = sp.pages[pi].bounds(); - // The page's own objects (outer pages take the pasteboard beside them). - let last = sp.pages.len() - 1; - let (lo, hi) = (if pi == 0 { f64::NEG_INFINITY } else { old_bounds.x0 }, if pi == last { f64::INFINITY } else { old_bounds.x1 }); - let own: Vec = sp - .items - .iter() - .enumerate() - .filter(|(_, it)| { - let c = it.bounds().center().x; - c >= lo && c < hi - }) - .map(|(i, _)| i) - .collect(); - let pg = &mut sp.pages[pi]; - let dw = w.map_or(0.0, |w| w.max(1.0) - pg.width); - let dh = h.map_or(0.0, |h| h.max(1.0) - pg.height); - if let Some(w) = w { - set_page_width(pg, 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 = 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; - } - } - // Page guides move with their page. - for (k, q) in sp.pages.iter_mut().enumerate() { - let dx = new[k] - old[k]; - if dx != 0.0 { - for g in q.guides.iter_mut().filter(|g| g.orientation == designcraft_doc::Orientation::Vertical) { - g.position += dx; - } - } - } - // Growing away from the anchor: the page's objects and guides keep their place - // relative to it. - let shift = designcraft_geom::Vec2::new(fx * dw, fy * dh); - if shift != designcraft_geom::Vec2::ZERO { - for i in &own { - if let Some(it) = sp.items.get_mut(*i) { - let it = std::sync::Arc::make_mut(it); - it.xf = designcraft_geom::Affine::translate(shift) * it.xf; - } - } - for g in &mut sp.pages[pi].guides { - g.position += if g.orientation == designcraft_doc::Orientation::Vertical { shift.x } else { shift.y }; - } - } - // Liquid rules: objects were carried along with the page's left edge (and the anchor). - let moved = old_bounds + designcraft_geom::Vec2::new(new[pi] - old[pi], 0.0) + shift; - super::liquid::apply(d, designcraft_doc::SpreadRef::Doc(si), pi, moved); + let Some(sp) = d.spread_mut(sr) else { return }; + let origin = sp.bounds().x0; + let old: Vec = sp.pages.iter().map(|x| x.x).collect(); + let old_bounds = sp.pages[pi].bounds(); + // The page's own objects (outer pages take the pasteboard beside them). + let Some((leftmost, rightmost)) = sp.outer_page_indices() else { return }; + let (lo, hi) = (if pi == leftmost { f64::NEG_INFINITY } else { old_bounds.x0 }, if pi == rightmost { f64::INFINITY } else { old_bounds.x1 }); + let own: Vec = sp + .items + .iter() + .enumerate() + .filter(|(_, it)| { + let c = it.bounds().center().x; + c >= lo && c < hi + }) + .map(|(i, _)| i) + .collect(); + let pg = &mut sp.pages[pi]; + let dw = w.map_or(0.0, |w| w - pg.width); + let dh = h.map_or(0.0, |h| h - pg.height); + if let Some(w) = w { + set_page_width(pg, w); + } + if let Some(h) = h { + pg.height = h; + } + let left = pg.side == designcraft_doc::PageSide::Left; + sp.relayout(); + // Relayout resets the origin; retain prior edits and grow left pages away from the spine. + let offset = origin - if left { dw } else { 0.0 }; + for q in &mut sp.pages { + q.x += offset; + } + let new: Vec = 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; } } + // Page guides move with their page. + for (k, q) in sp.pages.iter_mut().enumerate() { + let dx = new[k] - old[k]; + if dx != 0.0 { + for g in q.guides.iter_mut().filter(|g| g.orientation == designcraft_doc::Orientation::Vertical) { + g.position += dx; + } + } + } + // Growing away from the anchor: the page's objects and guides keep their place + // relative to it. + let shift = designcraft_geom::Vec2::new(fx * dw, fy * dh); + if shift != designcraft_geom::Vec2::ZERO { + for i in &own { + if let Some(it) = sp.items.get_mut(*i) { + let it = std::sync::Arc::make_mut(it); + it.xf = designcraft_geom::Affine::translate(shift) * it.xf; + } + } + for g in &mut sp.pages[pi].guides { + g.position += if g.orientation == designcraft_doc::Orientation::Vertical { shift.x } else { shift.y }; + } + } + // Liquid rules: objects were carried along with the page's left edge (and the anchor). + let moved = old_bounds + designcraft_geom::Vec2::new(new[pi] - old[pi], 0.0) + shift; + super::liquid::apply(d, sr, pi, moved); } /// File › Document Setup values. @@ -2497,3 +2561,7 @@ mod parent_base_tests { #[cfg(test)] #[path = "parent_delete_tests.rs"] mod parent_delete_tests; + +#[cfg(test)] +#[path = "parent_resize_tests.rs"] +mod parent_resize_tests; diff --git a/crates/engine/src/cmd/liquid.rs b/crates/engine/src/cmd/liquid.rs index 8c8d4be23..5f09a0c89 100644 --- a/crates/engine/src/cmd/liquid.rs +++ b/crates/engine/src/cmd/liquid.rs @@ -70,8 +70,8 @@ pub(crate) fn apply(d: &mut Document, sr: SpreadRef, pi: usize, old: Rect) { let liquid_guides = |o: Orientation| pg.guides.iter().filter(|g| g.liquid && g.orientation == o).map(|g| g.position).collect::>(); let (vg, hg) = (liquid_guides(Orientation::Vertical), liquid_guides(Orientation::Horizontal)); // The page's objects: centres within its old horizontal extent (outer pages take the pasteboard). - let last = sp.pages.len() - 1; - let (lo, hi) = (if pi == 0 { f64::NEG_INFINITY } else { old.x0 }, if pi == last { f64::INFINITY } else { old.x1 }); + let Some((leftmost, rightmost)) = sp.outer_page_indices() else { return }; + let (lo, hi) = (if pi == leftmost { f64::NEG_INFINITY } else { old.x0 }, if pi == rightmost { f64::INFINITY } else { old.x1 }); let strokes = false; let Some(sp) = d.spread_mut(sr) else { return }; for it in &mut sp.items { diff --git a/crates/engine/src/cmd/parent_resize_tests.rs b/crates/engine/src/cmd/parent_resize_tests.rs new file mode 100644 index 000000000..55ba22976 --- /dev/null +++ b/crates/engine/src/cmd/parent_resize_tests.rs @@ -0,0 +1,299 @@ +use std::sync::Arc; + +use designcraft_doc::{Columns, LiquidRule, PageSide, SpreadId}; +use designcraft_geom::Rect; +use serde_json::json; + +use crate::Session; + +fn parent_id(s: &Session, index: usize) -> u64 { + s.doc().unwrap().doc.parents[index].id.0 +} + +fn parent_page(s: &Session, index: usize, page: usize) -> designcraft_doc::Page { + s.doc().unwrap().doc.parents[index].pages[page].clone() +} + +fn parent_frame(s: &mut Session, x: f64) -> u64 { + s.execute( + "frame.create", + &json!({ + "spread": {"kind": "parent", "index": 0}, + "rect": [x, 80.0, x + 100.0, 180.0] + }), + ) + .unwrap()["id"] + .as_u64() + .unwrap() +} + +fn bounds(s: &Session, id: u64) -> Rect { + s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds() +} + +fn guide_positions(s: &Session, page: usize) -> Vec { + s.doc().unwrap().doc.parents[0].pages[page].guides.iter().map(|g| g.position).collect() +} + +fn prepare_parent_pages(s: &mut Session, binding: &str, rule: LiquidRule) -> (u64, u64, u64, usize, usize) { + s.execute("file.new", &json!({"pages": 2, "facingPages": true, "width": 400.0, "height": 400.0})).unwrap(); + s.execute("layout.documentSetup", &json!({"binding": binding})).unwrap(); + assert_eq!(s.doc().unwrap().doc.settings.right_to_left_binding, binding == "rightToLeft"); + assert_eq!(s.doc().unwrap().doc.parents[0].pages[0].side, if binding == "rightToLeft" { PageSide::Right } else { PageSide::Left }); + let target = s.doc().unwrap().doc.parents[0].pages.iter().position(|p| p.side == PageSide::Right).unwrap(); + let other = 1 - target; + let target_page = parent_page(s, 0, target); + let other_page = parent_page(s, 0, other); + let target_item = parent_frame(s, target_page.x + 80.0); + let other_item = parent_frame(s, other_page.x + 80.0); + s.execute( + "guide.add", + &json!({"spread": {"kind": "parent", "index": 0}, "page": target, "orientation": "vertical", "position": target_page.x + 120.0}), + ) + .unwrap(); + s.execute( + "guide.add", + &json!({"spread": {"kind": "parent", "index": 0}, "page": target, "orientation": "horizontal", "position": 120.0, "at": target_page.x + 10.0}), + ) + .unwrap(); + s.execute( + "guide.add", + &json!({"spread": {"kind": "parent", "index": 0}, "page": other, "orientation": "vertical", "position": other_page.x + 120.0}), + ) + .unwrap(); + s.execute( + "guide.add", + &json!({"spread": {"kind": "parent", "index": 0}, "page": other, "orientation": "horizontal", "position": 120.0, "at": other_page.x + 10.0}), + ) + .unwrap(); + s.edit(|d, _| { + let sp = Arc::make_mut(&mut d.parents[0]); + let positions = |page: &designcraft_doc::Page| { + let w = page.margin_rect().width(); + vec![0.0, 100.0, 120.0, w] + }; + for page in &mut sp.pages { + page.columns = Columns { count: 2, gutter: 20.0, positions: Some(positions(page)) }; + } + sp.pages[target].liquid = rule; + Ok(()) + }) + .unwrap(); + (parent_id(s, 0), target_item, other_item, target, other) +} + +#[test] +fn parent_resize_changes_only_the_target_page_in_ltr_and_rtl() { + for binding in ["leftToRight", "rightToLeft"] { + for rule in [ + LiquidRule::Off, + LiquidRule::Scale, + LiquidRule::ReCenter, + LiquidRule::ObjectBased, + LiquidRule::GuideBased, + LiquidRule::ControlledByParent, + ] { + let mut s = Session::new(); + let (parent, target_item, other_item, target, other) = prepare_parent_pages(&mut s, binding, rule); + let before_target = parent_page(&s, 0, target); + let before_other = parent_page(&s, 0, other); + let before_target_item = bounds(&s, target_item); + let before_other_item = bounds(&s, other_item); + let before_target_guides = guide_positions(&s, target); + let before_other_guides = guide_positions(&s, other); + let before_target_columns = before_target.columns.positions.clone(); + let before_other_columns = before_other.columns.positions.clone(); + + s.execute("layout.selectParentPage", &json!({"parent": parent, "page": target})).unwrap(); + assert_eq!(s.doc().unwrap().selection.parent_page, Some((SpreadId(parent), target))); + assert!(s.doc().unwrap().selection.items.is_empty()); + s.execute("layout.parentPageSize", &json!({"parent": parent, "page": target, "width": 500.0, "height": 500.0, "anchor": 8})).unwrap(); + + let after_target = parent_page(&s, 0, target); + let after_other = parent_page(&s, 0, other); + assert_eq!((after_target.width, after_target.height), (500.0, 500.0), "{binding} {rule:?}"); + assert_eq!((after_other.width, after_other.height), (before_other.width, before_other.height), "{binding} {rule:?}"); + assert_ne!(bounds(&s, target_item), before_target_item, "target object did not follow the resize: {binding} {rule:?}"); + assert_eq!(bounds(&s, other_item), before_other_item, "other page object moved: {binding} {rule:?}"); + assert_ne!(guide_positions(&s, target), before_target_guides, "target guides did not follow the resize: {binding} {rule:?}"); + assert_eq!(guide_positions(&s, other), before_other_guides, "other page guides moved: {binding} {rule:?}"); + assert_ne!(after_target.columns.positions, before_target_columns, "target columns did not scale: {binding} {rule:?}"); + assert_eq!(after_other.columns.positions, before_other_columns, "other page columns changed: {binding} {rule:?}"); + assert_eq!(s.doc().unwrap().doc.settings.page_width, 400.0); + assert_eq!(s.doc().unwrap().doc.settings.page_height, 400.0); + s.doc().unwrap().doc.check().unwrap(); + } + } +} + +#[test] +fn parent_resize_is_independent_of_applied_and_based_on_parents_and_undoable() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1, "facingPages": false, "width": 400.0, "height": 400.0})).unwrap(); + let a = parent_id(&s, 0); + let b = s.execute("layout.parents.new", &json!({"prefix": "B", "name": "Child"})).unwrap()["id"].as_u64().unwrap(); + s.execute("layout.parents.options", &json!({"parent": b, "basedOn": a})).unwrap(); + s.execute("layout.pages.applyParent", &json!({"pages": [0], "parent": b})).unwrap(); + let before = s.doc().unwrap().doc.as_ref().clone(); + let undo_before = s.doc().unwrap().history.undo.len(); + + s.execute("layout.parentPageSize", &json!({"parent": a, "page": 0, "width": 0.5, "anchor": 0})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.parents[0].pages[0].width, 0.5); + assert_eq!(d.parents[1].pages[0].width, before.parents[1].pages[0].width); + assert_eq!(d.spreads[0].pages[0].width, before.spreads[0].pages[0].width); + assert_eq!(d.parents[1].parent.as_ref().unwrap().based_on, Some(SpreadId(a))); + assert_eq!(d.settings.page_width, before.settings.page_width); + d.check().unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_before + 1); + + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.parents[0].pages[0].width, before.parents[0].pages[0].width); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.parents[0].pages[0].width, 0.5); + s.doc().unwrap().doc.check().unwrap(); +} + +#[test] +fn parent_resize_survives_native_and_idml_round_trips() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1, "facingPages": false, "width": 400.0, "height": 400.0})).unwrap(); + let a = parent_id(&s, 0); + s.execute("layout.parentPageSize", &json!({"parent": a, "page": 0, "width": 0.5, "height": 0.75})).unwrap(); + let d = s.doc().unwrap().doc.as_ref().clone(); + d.check().unwrap(); + + let native = crate::cmd::from_bytes(&crate::cmd::to_bytes(&d).unwrap()).unwrap(); + assert_eq!((native.parents[0].pages[0].width, native.parents[0].pages[0].height), (0.5, 0.75)); + native.check().unwrap(); + + let idml = designcraft_idml::export_idml(&d); + let reopened = designcraft_idml::import_idml(&idml).unwrap(); + assert_eq!((reopened.parents[0].pages[0].width, reopened.parents[0].pages[0].height), (0.5, 0.75)); + reopened.check().unwrap(); +} + +#[test] +fn parent_resize_rejects_invalid_inputs_atomically() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap(); + let parent = parent_id(&s, 0); + let before = s.doc().unwrap().doc.as_ref().clone(); + let undo_before = s.doc().unwrap().history.undo.len(); + let invalid = [ + json!({"parent": parent, "page": 0, "width": null}), + json!({"parent": parent, "page": 0, "width": "400"}), + json!({"parent": parent, "page": 0, "width": "NaN"}), + json!({"parent": parent, "page": 0, "width": "Infinity"}), + json!({"parent": parent, "page": 0, "width": 0}), + json!({"parent": parent, "page": 0, "width": -1}), + json!({"parent": parent, "page": 0, "width": 15552.1}), + json!({"parent": parent, "page": 0, "width": 400, "anchor": 9}), + json!({"parent": parent, "page": 1, "width": 400}), + json!({"parent": 999_999, "page": 0, "width": 400}), + ]; + for params in invalid { + assert!(s.execute("layout.parentPageSize", ¶ms).is_err(), "accepted {params}"); + assert_eq!(s.doc().unwrap().doc.as_ref(), &before, "mutated after {params}"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_before, "added undo after {params}"); + } + assert!(s.execute("layout.parentPageSize", &json!({"parent": parent, "page": 0})).is_err()); + assert_eq!(s.doc().unwrap().doc.as_ref(), &before); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_before); + s.doc().unwrap().doc.check().unwrap(); +} + +#[test] +fn parent_and_document_page_selection_validate_and_clear_each_other() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap(); + let parent = parent_id(&s, 0); + let item = s.execute("frame.create", &json!({"rect": [40, 40, 100, 100]})).unwrap()["id"].as_u64().unwrap(); + assert!(!s.doc().unwrap().selection.items.is_empty()); + s.execute("layout.selectParentPage", &json!({"parent": parent, "page": 0})).unwrap(); + assert_eq!(s.doc().unwrap().selection.parent_page, Some((SpreadId(parent), 0))); + assert!(s.doc().unwrap().selection.items.is_empty()); + assert!(s.execute("layout.selectParentPage", &json!({"parent": parent, "page": 1})).is_err()); + assert_eq!(s.doc().unwrap().selection.parent_page, Some((SpreadId(parent), 0))); + s.execute("layout.selectPage", &json!({"page": 1, "ids": [item]})).unwrap(); + assert_eq!(s.doc().unwrap().selection.page, Some(0)); + assert_eq!(s.doc().unwrap().selection.parent_page, None); + s.execute("layout.selectParentPage", &json!({"parent": null})).unwrap(); + assert_eq!(s.doc().unwrap().selection, Default::default()); +} + +#[test] +fn parent_resize_repeated_edits_keep_the_spine_and_other_page_objects() { + for binding in ["leftToRight", "rightToLeft"] { + for pi in 0..2 { + let mut s = Session::new(); + s.execute("file.new", &json!({"width":400,"height":600,"pages":3})).unwrap(); + s.execute("layout.documentSetup", &json!({"binding":binding})).unwrap(); + let id = parent_id(&s, 0); + let before = s.doc().unwrap().doc.clone(); + let spine = before.parents[0].spine_x(); + let other = 1 - pi; + let ox = before.parents[0].pages[other].x + 80.0; + let object = parent_frame(&mut s, ox); + let old_object = bounds(&s, object); + for params in + [json!({"parent":id,"page":pi,"width":500}), json!({"parent":id,"page":pi,"height":700}), json!({"parent":id,"page":pi,"width":550})] + { + s.execute("layout.parentPageSize", ¶ms).unwrap(); + assert_eq!(s.doc().unwrap().doc.parents[0].spine_x(), spine, "spine moved: {binding}, page {pi}, {params}"); + assert_eq!(bounds(&s, object), old_object, "other-page object moved: {binding}, page {pi}"); + } + } + } +} + +#[test] +fn parent_resize_keeps_margin_and_column_inheritance_without_resizing_applied_pages() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width":400,"height":600,"pages":3})).unwrap(); + let id = parent_id(&s, 0); + s.edit(|d, _| { + let pg = &mut Arc::make_mut(&mut d.parents[0]).pages[1]; + pg.columns = Columns { count: 2, gutter: 20.0, positions: Some(vec![0.0, 100.0, 120.0, 328.0]) }; + d.sync_parent_margins(); + Arc::make_mut(&mut d.spreads[1]).pages[1].local_margins = true; + Ok(()) + }) + .unwrap(); + let before = s.doc().unwrap().doc.clone(); + s.execute("layout.parentPageSize", &json!({"parent":id,"page":1,"width":500})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.page(0).unwrap().columns, d.parents[0].pages[1].columns); + assert_eq!(d.page(0).unwrap().margins, d.parents[0].pages[1].margins); + assert_eq!((d.page(0).unwrap().width, d.page(0).unwrap().height), (400.0, 600.0)); + assert_eq!(d.page(2).unwrap(), before.page(2).unwrap()); + d.check().unwrap(); + s.execute("layout.pages.applyParent", &json!({"pages":[0],"parent":null})).unwrap(); + s.execute("layout.pages.applyParent", &json!({"pages":[0],"parent":id})).unwrap(); + assert_eq!((s.doc().unwrap().doc.page(0).unwrap().width, s.doc().unwrap().doc.page(0).unwrap().height), (400.0, 600.0)); +} + +#[test] +fn parent_resize_facing_sizes_survive_idml_in_both_bindings() { + for binding in ["leftToRight", "rightToLeft"] { + let mut s = Session::new(); + s.execute("file.new", &json!({"width":400,"height":600,"pages":3,"facingPages":true})).unwrap(); + s.execute("layout.documentSetup", &json!({"binding":binding})).unwrap(); + let id = parent_id(&s, 0); + for (pi, w, h) in [(0, 500, 700), (1, 300, 550)] { + s.execute("layout.parentPageSize", &json!({"parent":id,"page":pi,"width":w,"height":h})).unwrap(); + } + let d = &s.doc().unwrap().doc; + let reopened = designcraft_idml::import_idml(&designcraft_idml::export_idml(d)).unwrap(); + assert_eq!(reopened.settings.right_to_left_binding, d.settings.right_to_left_binding); + for page in &d.parents[0].pages { + let restored = reopened.parents[0].pages.iter().find(|p| p.side == page.side).unwrap(); + assert_eq!((restored.width, restored.height), (page.width, page.height), "{binding}, {:?}", page.side); + } + for abs in 0..d.page_count() { + let (old, new) = (d.page(abs).unwrap(), reopened.page(abs).unwrap()); + assert_eq!((new.width, new.height), (old.width, old.height)); + } + reopened.check().unwrap(); + } +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 42f57b3a1..3e1773853 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1651,3 +1651,59 @@ fn a_line_fits_when_its_baseline_does() { assert!(!overset(&mut s, 78.5), "the line is set; its descenders hang below the frame"); assert!(overset(&mut s, 50.0), "a baseline below the frame still oversets the line"); } + +#[test] +fn parent_resize_tool_previews_cancels_commits_and_undoes_on_the_selected_spread() { + use designcraft_tools::{Mods, Overlay, PointerEvent, PointerKind}; + let mut s = Session::new(); + s.execute("file.new", &json!({"width":400,"height":600,"pages":3,"facingPages":true})).unwrap(); + s.execute("layout.parents.new", &json!({"prefix":"B"})).unwrap(); + s.execute("layout.parents.edit", &json!({"on":true})).unwrap(); + s.set_tool("page"); + let v = ViewInfo::at_zoom(1.0); + let before = s.doc().unwrap().doc.clone(); + for si in 0..2 { + for pi in 0..2 { + let sr = designcraft_doc::SpreadRef::Parent(si); + let rect = |s: &Session| s.layout().spread_page_rect(&s.doc().unwrap().doc, sr, pi).unwrap(); + let c = rect(&s).center(); + s.pointer(&PointerEvent::new(PointerKind::Down, c.x, c.y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, c.x, c.y), v).unwrap(); + assert_eq!(s.doc().unwrap().selection.parent_page, Some((before.parents[si].id, pi))); + assert_eq!(s.doc().unwrap().selection.page, None); + assert!(s.overlays(v).iter().any(|o| matches!(o,Overlay::PageHandles(r) if *r==rect(&s)))); + for alt in [false, true] { + let original = s.doc().unwrap().doc.clone(); + let undo = s.doc().unwrap().history.undo.len(); + let r = rect(&s); + let p = designcraft_geom::Point::new(r.x1, r.center().y); + let ev = |kind, x| PointerEvent::new(kind, x, p.y).with_mods(Mods { alt, ..Default::default() }); + s.pointer(&ev(PointerKind::Down, p.x), v).unwrap(); + s.pointer(&ev(PointerKind::Drag, p.x + 80.0), v).unwrap(); + assert_eq!(s.doc().unwrap().doc.parents[si].pages[pi].width, 480.0); + assert_eq!(s.doc().unwrap().doc.spreads, before.spreads); + assert_eq!(s.doc().unwrap().doc.parents[1 - si], before.parents[1 - si]); + s.pointer(&ev(PointerKind::Up, p.x + 80.0), v).unwrap(); + if alt { + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + assert_eq!(s.doc().unwrap().doc.parents[si].pages[pi].width, 480.0); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.parents, original.parents); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.parents[si].pages[pi].width, 480.0); + s.execute("edit.undo", &json!({})).unwrap(); + } else { + assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &original)); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + } + } + let c = rect(&s).center(); + s.pointer(&PointerEvent::new(PointerKind::DoubleClick, c.x, c.y), v).unwrap(); + let req = std::mem::take(&mut s.ui_requests); + assert!( + matches!(req.last(),Some(crate::UiRequest::Dialog{id,params}) if id=="cmd:layout.parentPageSize" && params["parent"]==before.parents[si].id.0 && params["page"]==pi) + ); + s.doc().unwrap().doc.check().unwrap(); + } + } +} diff --git a/crates/tools/src/layout.rs b/crates/tools/src/layout.rs index 9f000fcd7..d5ea24b85 100644 --- a/crates/tools/src/layout.rs +++ b/crates/tools/src/layout.rs @@ -1,9 +1,10 @@ //! Where spreads sit on the canvas (the pasteboard): stacked vertically, spines aligned at x = 0, //! parents shown on their own canvas when a parent spread is being edited. -use designcraft_doc::{Document, SpreadRef}; +use designcraft_doc::{Document, Selection, SpreadRef}; use designcraft_geom::{Affine, Point, Rect, Vec2}; use serde::Serialize; +use serde_json::{Value, json}; #[derive(Clone, Copy, Debug, PartialEq, Serialize)] pub struct SpreadSlot { @@ -100,6 +101,31 @@ impl CanvasLayout { (m * Point::new(d.x, d.y)) - (m * Point::ORIGIN) } + /// The Page tool selection on the canvas currently shown. + pub fn selected_page(&self, doc: &Document, selection: &Selection) -> Option<(SpreadRef, usize)> { + let (sr, pi) = if self.slots.first().is_some_and(|s| matches!(s.spread, SpreadRef::Parent(_))) { + let (id, pi) = selection.parent_page?; + (SpreadRef::Parent(doc.parent_index(id)?), pi) + } else { + let (si, pi) = doc.page_loc(selection.page?)?; + (SpreadRef::Doc(si), pi) + }; + self.slot(sr)?; + doc.spread(sr)?.pages.get(pi)?; + Some((sr, pi)) + } + + /// A page on the visible canvas, including parent pages. + pub fn page_at(&self, doc: &Document, p: Point) -> Option<(SpreadRef, usize)> { + self.slots + .iter() + .find_map(|slot| doc.spread(slot.spread)?.pages.iter().position(|pg| pg.bounds().contains(slot.to_spread(p))).map(|pi| (slot.spread, pi))) + } + + pub fn spread_page_rect(&self, doc: &Document, sr: SpreadRef, pi: usize) -> Option { + Some(self.slot(sr)?.xf.transform_rect_bbox(doc.spread(sr)?.pages.get(pi)?.bounds())) + } + /// Canvas rect of a document page. pub fn page_rect(&self, doc: &Document, abs: usize) -> Option { let (si, pi) = doc.page_loc(abs)?; @@ -107,6 +133,24 @@ impl CanvasLayout { } } +/// The same resize contract for panels, handles and the exact-size dialog. +pub fn page_size_command(doc: &Document, sr: SpreadRef, page: usize, mut params: Value) -> Option<(&'static str, Value)> { + let sp = doc.spread(sr)?; + sp.pages.get(page)?; + let command = match sr { + SpreadRef::Doc(si) => { + params["pages"] = json!([doc.first_page_of_spread(si) + page + 1]); + "layout.pageSize" + } + SpreadRef::Parent(_) => { + params["parent"] = json!(sp.id.0); + params["page"] = json!(page); + "layout.parentPageSize" + } + }; + Some((command, params)) +} + fn dist_y(r: Rect, y: f64) -> f64 { if y < r.y0 - SPREAD_GAP / 2.0 { r.y0 - y diff --git a/crates/tools/src/nav.rs b/crates/tools/src/nav.rs index f77c5d0bb..bd6c6b2f4 100644 --- a/crates/tools/src/nav.rs +++ b/crates/tools/src/nav.rs @@ -249,7 +249,9 @@ fn liquid_control_points(cx: &ToolContext, r: designcraft_geom::Rect) -> [(Liqui /// A page handle being dragged. #[derive(Clone, Copy, Debug)] struct PageDrag { - /// Absolute page index. + spread: designcraft_doc::SpreadRef, + parent: Option, + /// Page index within the spread. page: usize, /// Reference-point index of the handle (0–8, row-major; 4 is the centre and has none). handle: usize, @@ -297,6 +299,9 @@ impl PageTool { fn liquid_target( cx: &ToolContext, ) -> Option<(designcraft_doc::ItemId, designcraft_doc::ObjectLiquid, designcraft_geom::Rect, designcraft_geom::Rect)> { + if cx.layout.slots.first().is_some_and(|s| matches!(s.spread, designcraft_doc::SpreadRef::Parent(_))) { + return None; + } let page = cx.selection.page?; let [id] = cx.selection.items.as_slice() else { return None }; let (si, _) = cx.doc.page_loc(page)?; @@ -316,17 +321,16 @@ impl PageTool { } /// The selected page's handle under `p`. - fn handle_at(&self, cx: &ToolContext, p: Point) -> Option<(usize, usize)> { - let page = cx.selection.page?; - let r = cx.layout.page_rect(cx.doc, page)?; - page_handles(r).find(|(_, h)| (h.x - p.x).abs() <= cx.tol(5.0) && (h.y - p.y).abs() <= cx.tol(5.0)).map(|(i, _)| (page, i)) + fn handle_at(&self, cx: &ToolContext, p: Point) -> Option<(designcraft_doc::SpreadRef, usize, usize)> { + let (sr, page) = cx.layout.selected_page(cx.doc, cx.selection)?; + let r = cx.layout.spread_page_rect(cx.doc, sr, page)?; + page_handles(r).find(|(_, h)| (h.x - p.x).abs() <= cx.tol(5.0) && (h.y - p.y).abs() <= cx.tol(5.0)).map(|(i, _)| (sr, page, i)) } /// The new page size for the drag at `p`: the handle's edges follow the pointer (Shift: in /// proportion). fn size_at(cx: &ToolContext, d: &PageDrag, p: Point, shift: bool) -> (f64, f64) { - let si = cx.doc.page_loc(d.page).map_or(0, |l| l.0); - let v = cx.layout.delta_to_spread(designcraft_doc::SpreadRef::Doc(si), p - d.start); + let v = cx.layout.delta_to_spread(d.spread, p - d.start); let (col, row) = (d.handle % 3, d.handle / 3); let dx = match col { 0 => -v.x, @@ -361,10 +365,21 @@ impl Tool for PageTool { fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { match ev.kind { PointerKind::Down => { - if let Some((page, handle)) = self.handle_at(cx, ev.pos) { - let Some((si, pi)) = cx.doc.page_loc(page) else { return vec![] }; - let pg = &cx.doc.spreads[si].pages[pi]; - self.drag = Some(PageDrag { page, handle, start: ev.pos, cur: ev.pos, size: (pg.width, pg.height), alt: ev.mods.alt }); + if let Some((spread, page, handle)) = self.handle_at(cx, ev.pos) { + let Some(pg) = cx.doc.spread(spread).and_then(|sp| sp.pages.get(page)) else { return vec![] }; + self.drag = Some(PageDrag { + spread, + parent: match spread { + designcraft_doc::SpreadRef::Parent(si) => cx.doc.parents.get(si).map(|sp| sp.id), + _ => None, + }, + page, + handle, + start: ev.pos, + cur: ev.pos, + size: (pg.width, pg.height), + alt: ev.mods.alt, + }); return vec![Action::Begin("Resize Page".into())]; } // An object-based page's object: a click on a pin or lock toggles it. @@ -373,7 +388,18 @@ impl Tool for PageTool { } // A click selects the page under the pointer (none on the pasteboard), and on an // object-based page the object clicked too. - let page = (0..cx.doc.page_count()).find(|p| cx.layout.page_rect(cx.doc, *p).is_some_and(|r| r.contains(ev.pos))); + let target = cx.layout.page_at(cx.doc, ev.pos); + if let Some((designcraft_doc::SpreadRef::Parent(si), page)) = target { + let Some(sp) = cx.doc.parents.get(si) else { return vec![] }; + return vec![Action::Exec("layout.selectParentPage".into(), json!({"parent": sp.id.0, "page": page}))]; + } + if cx.layout.slots.first().is_some_and(|s| matches!(s.spread, designcraft_doc::SpreadRef::Parent(_))) { + return vec![Action::Exec("layout.selectParentPage".into(), json!({"parent": null}))]; + } + let page = target.and_then(|(sr, pi)| match sr { + designcraft_doc::SpreadRef::Doc(si) => Some(cx.doc.first_page_of_spread(si) + pi), + _ => None, + }); let object = page .filter(|p| object_based(cx.doc, *p)) .and_then(|p| cx.hit(ev.pos).map(|(_, id)| id).filter(|id| cx.doc.page_of_item(*id) == Some(p))); @@ -387,9 +413,21 @@ impl Tool for PageTool { let Some(d) = self.drag.as_mut() else { return vec![] }; d.cur = ev.pos; d.alt |= ev.mods.alt; - let d = *d; + let mut d = *d; + if let Some(id) = d.parent { + let Some(si) = cx.doc.parent_index(id) else { + self.drag = None; + return vec![Action::Cancel]; + }; + d.spread = designcraft_doc::SpreadRef::Parent(si); + } let (w, h) = Self::size_at(cx, &d, ev.pos, ev.mods.shift); - vec![Action::Preview("layout.pageSize".into(), json!({"pages": [d.page + 1], "width": w, "height": h, "anchor": 8 - d.handle}))] + let Some((cmd, params)) = + crate::layout::page_size_command(cx.doc, d.spread, d.page, json!({"width": w, "height": h, "anchor": 8 - d.handle})) + else { + return vec![]; + }; + vec![Action::Preview(cmd.into(), params)] } PointerKind::Up => match self.drag.take() { // Alt-drag resizes; a plain drag previews the liquid rule and springs back. @@ -399,12 +437,12 @@ impl Tool for PageTool { }, PointerKind::DoubleClick => { // Exact sizes: the Page Size dialog for the page. - let Some(page) = (0..cx.doc.page_count()).find(|p| cx.layout.page_rect(cx.doc, *p).is_some_and(|r| r.contains(ev.pos))) else { + let Some((sr, pi)) = cx.layout.page_at(cx.doc, ev.pos) else { return vec![] }; + let Some(pg) = cx.doc.spread(sr).and_then(|sp| sp.pages.get(pi)) else { return vec![] }; + let Some((cmd, params)) = crate::layout::page_size_command(cx.doc, sr, pi, json!({"width": pg.width, "height": pg.height})) else { return vec![]; }; - let Some((si, pi)) = cx.doc.page_loc(page) else { return vec![] }; - let pg = &cx.doc.spreads[si].pages[pi]; - vec![Action::Dialog("cmd:layout.pageSize".into(), json!({"pages": [page + 1], "width": pg.width, "height": pg.height}))] + vec![Action::Dialog(format!("cmd:{cmd}"), params)] } PointerKind::Move => { self.hover = Self::control_at(cx, ev.pos).map(|(_, _, c)| (c, ev.pos)); @@ -413,10 +451,9 @@ impl Tool for PageTool { } } fn overlays(&self, cx: &ToolContext) -> Vec { - let Some(page) = cx.selection.page else { return vec![] }; - let Some(r) = cx.layout.page_rect(cx.doc, page) else { return vec![] }; - let Some((si, pi)) = cx.doc.page_loc(page) else { return vec![] }; - let pg = &cx.doc.spreads[si].pages[pi]; + let Some((sr, pi)) = cx.layout.selected_page(cx.doc, cx.selection) else { return vec![] }; + let Some(r) = cx.layout.spread_page_rect(cx.doc, sr, pi) else { return vec![] }; + let Some(pg) = cx.doc.spread(sr).and_then(|sp| sp.pages.get(pi)) else { return vec![] }; let mut v = vec![Overlay::PageHandles(r), Overlay::Tag { p: Point::new(r.x0, r.y0 - cx.tol(6.0)), text: rule_label(pg.liquid).into() }]; if let Some((_, l, object, page)) = Self::liquid_target(cx) { v.push(Overlay::LiquidPins { @@ -445,7 +482,7 @@ impl Tool for PageTool { if self.drag.is_none() && Self::control_at(cx, p).is_some() { return Cursor::Hand; } - let handle = self.drag.map(|d| d.handle).or_else(|| self.handle_at(cx, p).map(|(_, h)| h)); + let handle = self.drag.map(|d| d.handle).or_else(|| self.handle_at(cx, p).map(|(_, _, h)| h)); match handle { Some(1 | 7) => Cursor::ResizeV, Some(3 | 5) => Cursor::ResizeH, diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index ee3b362f1..555ea9e1e 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -1936,3 +1936,38 @@ fn loaded_text_cursor_threads_into_a_frame_inside_a_group() { assert_eq!(cx2.hit_text_frame(q).map(|(_, i)| i), None, "no text frame in that group"); assert_eq!(t2.cursor(&cx2, q, Mods::default()), Cursor::NotAllowed); } + +#[test] +fn parent_resize_page_tool_targets_the_visible_parent() { + let d = Document::new(&NewDocument { width: 400.0, height: 600.0, pages: 3, facing_pages: true, ..Default::default() }); + let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, true)); + let cx = ctx(&d, &s, &c, &l); + let p = l.to_canvas(SpreadRef::Parent(0), d.parents[0].pages[0].bounds().center()); + let mut t = create("page"); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::DoubleClick, p.x, p.y)); + assert_eq!( + a, + vec![Action::Dialog( + "cmd:layout.parentPageSize".into(), + serde_json::json!({"parent": d.parents[0].id.0, "page": 0, "width": 400.0, "height": 600.0}) + )], + "double click must target the visible parent instead of document page zero" + ); +} + +#[test] +fn parent_resize_selection_tracks_ids_and_never_crosses_canvas_modes() { + let mut d = Document::new(&NewDocument::default()); + let second = d.add_parent("B", "Parent", 1, 12.0, designcraft_doc::Margins::uniform(36.0)); + let selection = Selection { page: Some(0), parent_page: Some((second, 1)), ..Default::default() }; + let l = CanvasLayout::new(&d, true); + assert_eq!(l.selected_page(&d, &selection), Some((SpreadRef::Parent(1), 1))); + d.parents.swap(0, 1); + let l = CanvasLayout::new(&d, true); + assert_eq!(l.selected_page(&d, &selection), Some((SpreadRef::Parent(0), 1))); + assert_eq!(CanvasLayout::new(&d, false).selected_page(&d, &selection), Some((SpreadRef::Doc(0), 0))); + d.parents.remove(0); + assert_eq!(CanvasLayout::new(&d, true).selected_page(&d, &selection), None); + let selection = Selection { page: Some(0), ..Default::default() }; + assert_eq!(CanvasLayout::new(&d, true).selected_page(&d, &selection), None); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 318f82388..496d17bdd 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -198,7 +198,7 @@ pub fn current_page(app: &DesignApp) -> Option { let layout = CanvasLayout::new(&st.doc, st.editing_parents); let i = current_slot(app, &layout)?; let slot = layout.slots.get(i)?; - let SpreadRef::Doc(si) = slot.spread else { return Some(0) }; + let SpreadRef::Doc(si) = slot.spread else { return None }; let v = app.view()?; let r = app.canvas_rect?; let c = slot.to_spread(Point::new(v.origin.x + r.width() as f64 / 2.0 / v.zoom, slot.bounds.center().y)); @@ -206,6 +206,16 @@ pub fn current_page(app: &DesignApp) -> Option { Some(st.doc.first_page_of_spread(si) + pi) } +/// The Page tool selection, or the page in view, without crossing canvas modes. +pub fn page_target(app: &DesignApp) -> Option<(SpreadRef, usize)> { + let st = app.session.active()?; + let selected = CanvasLayout::new(&st.doc, st.editing_parents).selected_page(&st.doc, &st.selection); + if st.editing_parents && st.selection.parent_page.is_some() { + return selected; + } + selected.or_else(|| current_spread_page(app)) +} + /// The spread (document or parent) under the view centre and the page index within it: where /// commands that act "on the page I am working on" should go. pub fn current_spread_page(app: &DesignApp) -> Option<(SpreadRef, usize)> { @@ -220,22 +230,10 @@ pub fn current_spread_page(app: &DesignApp) -> Option<(SpreadRef, usize)> { Some((slot.spread, pg)) } -/// While parent spreads are being edited: (parent spread index, page index within it) under the -/// view centre. `None` when document pages are shown. +/// The selected parent page, or the parent page under the view centre. pub fn current_parent_page(app: &DesignApp) -> Option<(usize, usize)> { - let st = app.session.active()?; - if !st.editing_parents { - return None; - } - let layout = CanvasLayout::new(&st.doc, true); - let i = current_slot(app, &layout)?; - let slot = layout.slots.get(i)?; - let SpreadRef::Parent(pi) = slot.spread else { return None }; - let v = app.view()?; - let r = app.canvas_rect?; - let c = slot.to_spread(Point::new(v.origin.x + r.width() as f64 / 2.0 / v.zoom, slot.bounds.center().y)); - let qi = st.doc.parents.get(pi)?.page_at_x(c.x).unwrap_or(0); - Some((pi, qi)) + let (SpreadRef::Parent(si), pi) = page_target(app)? else { return None }; + Some((si, pi)) } /// Scroll so absolute page `abs` is centred (keeps zoom). diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index e853e6793..5235180e0 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -484,12 +484,20 @@ fn control_page(app: &mut DesignApp, ui: &mut egui::Ui) { let lang = app.ui.language.clone(); let Some(st) = app.session.active() else { return }; let units = st.doc.settings.horizontal_units; - let Some(page) = st.selection.page.or_else(|| crate::canvas::current_page(app)) else { return }; + let Some((spread, page)) = crate::canvas::page_target(app) else { return }; let Some(st) = app.session.active() else { return }; let d = st.doc.clone(); - let Some((si, pi)) = d.page_loc(page) else { return }; - let pg = d.spreads[si].pages[pi].clone(); - let n = page + 1; + let Some(sp) = d.spread(spread) else { return }; + let Some(pg) = sp.pages.get(page).cloned() else { return }; + let n = match spread { + designcraft_doc::SpreadRef::Doc(si) => Some(d.first_page_of_spread(si) + page + 1), + _ => None, + }; + let resize = |app: &mut DesignApp, params| { + if let Some((cmd, params)) = designcraft_tools::layout::page_size_command(&d, spread, page, params) { + let _ = app.run(cmd, params); + } + }; // Reference point: W/H edits keep it in place. let (r, _) = ui.allocate_exact_size(vec2(30.0, 44.0), Sense::hover()); if let Some(p) = panels::properties::ref_point_proxy(ui, egui::Rect::from_center_size(r.center(), vec2(27.0, 27.0)), app.ui.ref_point) { @@ -498,21 +506,21 @@ fn control_page(app: &mut DesignApp, ui: &mut egui::Ui) { let anchor = app.ui.ref_point.min(8); let (fx, fy) = ((anchor % 3) as f64 / 2.0, (anchor / 3) as f64 / 2.0); // The reference point's position: from the spine across, from the top down. - let (x, y) = (pg.x - d.spreads[si].spine_x() + fx * pg.width, fy * pg.height); + let (x, y) = (pg.x - sp.spine_x() + fx * pg.width, fy * pg.height); egui::Grid::new("ctl_page_xywh").num_columns(4).spacing(vec2(4.0, 4.0)).show(ui, |ui| { caption(ui, crate::i18n::tr(&lang, "X:")); // Pages don't move within their spread yet. ui.add_enabled_ui(false, |ui| measure(ui, "cpx", Some(x), units, 64.0)); caption(ui, crate::i18n::tr(&lang, "W:")); if let Some(v) = measure(ui, "cpw", Some(pg.width), units, 64.0) { - let _ = app.run("layout.pageSize", json!({"pages": [n], "width": v, "anchor": anchor})); + resize(app, json!({"width": v, "anchor": anchor})); } ui.end_row(); caption(ui, crate::i18n::tr(&lang, "Y:")); ui.add_enabled_ui(false, |ui| measure(ui, "cpy", Some(y), units, 64.0)); caption(ui, crate::i18n::tr(&lang, "H:")); if let Some(v) = measure(ui, "cph", Some(pg.height), units, 64.0) { - let _ = app.run("layout.pageSize", json!({"pages": [n], "height": v, "anchor": anchor})); + resize(app, json!({"height": v, "anchor": anchor})); } ui.end_row(); }); @@ -527,7 +535,7 @@ fn control_page(app: &mut DesignApp, ui: &mut egui::Ui) { for (name, w, h) in panels::properties::PAGE_PRESETS { if ui.selectable_label(cur == *name, crate::rtl::widget(ui, crate::i18n::tr(&lang, name))).clicked() { let (w, h) = if landscape { (h, w) } else { (w, h) }; - let _ = app.run("layout.pageSize", json!({"pages": [n], "width": w, "height": h, "anchor": anchor})); + resize(app, json!({"width": w, "height": h, "anchor": anchor})); } } }, @@ -535,20 +543,23 @@ fn control_page(app: &mut DesignApp, ui: &mut egui::Ui) { ui.horizontal(|ui| { for (icon, tip, want) in [("orient-portrait", "Portrait", false), ("orient-landscape", "Landscape", true)] { if crate::widgets::icon_toggle(ui, icon, landscape == want, crate::i18n::tr(&lang, tip)).clicked() && landscape != want { - let _ = app.run("layout.pageSize", json!({"pages": [n], "width": pg.height, "height": pg.width, "anchor": anchor})); + resize(app, json!({"width": pg.height, "height": pg.width, "anchor": anchor})); } } }); }); vsep(ui); - ui.vertical(|ui| { + ui.add_enabled_ui(n.is_some(), |ui| { caption(ui, crate::i18n::tr(&lang, "Liquid Page Rule:")); let label = panels::interactive::liquid_rule_label(pg.liquid); egui::ComboBox::from_id_salt("ctl_page_rule").selected_text(crate::rtl::widget(ui, crate::i18n::tr(&lang, label))).width(150.0).show_ui( ui, |ui| { for (r, l) in panels::interactive::LIQUID_RULES { - if ui.selectable_label(pg.liquid == r, crate::rtl::widget(ui, crate::i18n::tr(&lang, l))).clicked() && pg.liquid != r { + if ui.selectable_label(pg.liquid == r, crate::rtl::widget(ui, crate::i18n::tr(&lang, l))).clicked() + && pg.liquid != r + && let Some(n) = n + { let _ = app.run("liquid.pageRule", json!({"rule": r, "pages": [n]})); } } @@ -556,9 +567,11 @@ fn control_page(app: &mut DesignApp, ui: &mut egui::Ui) { ); }); vsep(ui); - ui.vertical(|ui| { + ui.add_enabled_ui(n.is_some(), |ui| { let mut overlay = pg.show_parent_overlay; - if ui.checkbox(&mut overlay, crate::rtl::widget(ui, crate::i18n::tr(&lang, "Show Parent Page Overlay"))).changed() { + if ui.checkbox(&mut overlay, crate::rtl::widget(ui, crate::i18n::tr(&lang, "Show Parent Page Overlay"))).changed() + && let Some(n) = n + { let _ = app.run("layout.pageOptions", json!({"pages": [n], "showParentOverlay": overlay})); } }); @@ -1304,6 +1317,89 @@ mod tests { assert!(short.min.x - first.min.x <= 2.0 * super::DOC_TAB_MAX_W, "the last tab {short:?} starts too far right"); } + #[test] + fn parent_resize_properties_changes_the_parent_not_document_page_zero() { + parent_resize_panel(true); + } + + #[test] + fn parent_resize_control_changes_the_parent_not_document_page_zero() { + parent_resize_panel(false); + } + + fn parent_resize_panel(properties: bool) { + use egui_kittest::Harness; + { + let mut app = app(); + app.run("file.new", serde_json::json!({"width": 400, "height": 600, "pages": 3, "facingPages": true})).unwrap(); + app.run("layout.parents.new", serde_json::json!({"prefix": "B"})).unwrap(); + app.run("layout.pages.applyParent", serde_json::json!({"pages": [0], "parent": "A"})).unwrap(); + app.run("layout.parents.edit", serde_json::json!({"on": true, "parent": "A"})).unwrap(); + app.session.set_tool("page"); + app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(800.0, 600.0))); + *app.view_mut().unwrap() = crate::View { zoom: 1.0, origin: designcraft_geom::Point::new(-600.0, 0.0), fitted: true, rotation: 0 }; + let before = app.session.doc().unwrap().doc.clone(); + let mut h = Harness::builder().with_size(vec2(1600.0, 900.0)).build_ui_state( + move |ui, app: &mut DesignApp| { + let id = egui::Id::new("parent_resize_fonts"); + if ui.data(|d| d.get_temp::(id)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(id, true)); + return; + } + if properties { + panels::properties::show(app, ui); + } else { + control_page(app, ui); + } + }, + app, + ); + h.run_steps(3); + edit_parent_resize_field(&mut h, "33p4", "500 pt"); + let d = &h.state().session.doc().unwrap().doc; + assert_eq!(d.parents[0].pages[0].width, 500.0, "the parent page in view must change"); + assert_eq!(d.spreads, before.spreads); + assert_eq!(d.parents[1], before.parents[1]); + let b = before.parents[1].id.0; + h.state_mut().run("layout.selectParentPage", json!({"parent": b, "page": 1})).unwrap(); + h.run_steps(3); + assert_eq!(crate::canvas::current_parent_page(h.state()), Some((1, 1))); + if properties { + h.state_mut().session.set_tool("selection"); + } + h.run_steps(3); + edit_parent_resize_field(&mut h, "50p0", "700 pt"); + edit_parent_resize_field(&mut h, "33p4", "560 pt"); + let d = &h.state().session.doc().unwrap().doc; + assert_eq!((d.parents[1].pages[1].width, d.parents[1].pages[1].height), (560.0, 700.0)); + assert_eq!(d.parents[1].pages[0].width, 400.0); + assert_eq!(d.parents[0].pages[0].width, 500.0); + assert_eq!(d.spreads, before.spreads); + let parents = d.parents.clone(); + h.state_mut().run("layout.parents.edit", json!({"on": false})).unwrap(); + h.state_mut().session.set_tool("page"); + h.state_mut().run("layout.selectPage", json!({"page": 1})).unwrap(); + h.run_steps(3); + edit_parent_resize_field(&mut h, "33p4", "530 pt"); + let d = &h.state().session.doc().unwrap().doc; + assert_eq!(d.spreads[0].pages[0].width, 530.0); + assert_eq!(d.parents, parents); + } + } + + fn edit_parent_resize_field(h: &mut egui_kittest::Harness<'_, DesignApp>, value: &str, input: &str) { + h.query_all_by(|n| n.role() == egui::accesskit::Role::TextInput && n.value().as_deref() == Some(value)) + .next() + .expect("page size field") + .click(); + h.run_steps(2); + h.event(egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }); + h.event(egui::Event::Text(input.into())); + h.key_press(egui::Key::Enter); + h.run_steps(3); + } + #[test] fn the_page_tool_has_its_own_control_panel() { // Chris Cicchino's Page Tool and Liquid Layout video (1:02): with the Page tool, the diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 1cb7395c0..0714a93b2 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -413,7 +413,12 @@ fn handle_request(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest } "ui.render" => { let Some(st) = app.session.active() else { return err("no document") }; - let page = match page_index(p, crate::canvas::current_page(app).unwrap_or(0), st.doc.page_count()) { + let current = match crate::canvas::current_page(app) { + Some(page) => page, + None if p.get("page").is_some() => 0, + None => return err("select a document page or give an explicit page"), + }; + let page = match page_index(p, current, st.doc.page_count()) { Ok(page) => page, Err(e) => return err(e), }; @@ -683,6 +688,20 @@ mod automation_contract_tests { assert_eq!(app.ui.palette.as_deref(), Some("field")); assert_eq!(app.session.execute("story.get", &json!({"story":f["story"]})).unwrap()["text"], "Hello"); } + #[test] + fn parent_resize_mode_does_not_default_document_actions_to_page_zero() { + let mut app = app(); + let ctx = egui::Context::default(); + app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))); + app.run("layout.parents.edit", json!({"on":true,"parent":"A"})).unwrap(); + let before = app.session.doc().unwrap().doc.clone(); + assert!(app.run("app.deletePage", json!({})).is_err()); + assert!(app.run("app.duplicateSpread", json!({})).is_err()); + assert_eq!(call(&mut app, &ctx, "ui.render", json!({"scale":0.1}))["ok"], false); + assert_eq!(call(&mut app, &ctx, "ui.render", json!({"page":0,"scale":0.1}))["ok"], true); + assert_eq!(app.session.doc().unwrap().doc, before); + } + #[test] fn control_pages_validate_before_writer_and_preserve_omission() { let mut app = app(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 40787954f..258eee509 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -7695,6 +7695,36 @@ mod tests { } } + #[test] + fn parent_resize_exact_dialog_keeps_its_target_and_rejects_invalid_input() { + use designcraft_tools::{PointerEvent, PointerKind}; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width":400,"height":600,"pages":3,"facingPages":true})).unwrap(); + app.run("layout.parents.new", json!({"prefix":"B"})).unwrap(); + app.run("layout.parents.edit", json!({"on":true})).unwrap(); + app.session.set_tool("page"); + let before = app.session.doc().unwrap().doc.clone(); + let sr = designcraft_doc::SpreadRef::Parent(1); + let p = app.session.layout().spread_page_rect(&before, sr, 1).unwrap().center(); + app.session.pointer(&PointerEvent::new(PointerKind::DoubleClick, p.x, p.y), designcraft_engine::ViewInfo::at_zoom(1.0)).unwrap(); + let request = app.session.ui_requests.pop().unwrap(); + let designcraft_engine::UiRequest::Dialog { id, params } = request else { panic!("expected exact-size dialog") }; + app.ui.dialog = Some(Dialog::new(&id, params)); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("width".into(), json!("530")); + d.fields.insert("height".into(), json!("710")); + confirm(&mut app).unwrap(); + let doc = &app.session.doc().unwrap().doc; + assert_eq!((doc.parents[1].pages[1].width, doc.parents[1].pages[1].height), (530.0, 710.0)); + assert_eq!(doc.parents[0], before.parents[0]); + assert_eq!(doc.spreads, before.spreads); + let saved = doc.clone(); + app.ui.dialog = Some(Dialog::new("cmd:layout.parentPageSize", json!({"parent":saved.parents[1].id.0,"page":1,"width":"NaN"}))); + assert!(confirm(&mut app).is_err()); + assert!(app.ui.dialog.is_some()); + assert!(std::sync::Arc::ptr_eq(&saved, &app.session.doc().unwrap().doc)); + } + fn command_app(id: &str, fields: Value) -> DesignApp { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); app.ui.dialog = Some(Dialog::new(&format!("cmd:{id}"), fields)); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 87537abd7..a50bdbdec 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1335,7 +1335,7 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option { let Some(st) = app.session.active() else { return Some(Err("no document open".into())) }; - let page = crate::canvas::current_page(app).unwrap_or(0); + let Some(page) = crate::canvas::current_page(app) else { return Some(Err("select a document page".into())) }; let r = if id == "app.deletePage" { crate::dialogs::delete_pages(app, vec![page]) } else { diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index fd0094f9b..6f1c0e02e 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -115,12 +115,11 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { return; } // The Page tool with no objects selected: the page's own settings, as InDesign's. - if info.is_none() && app.session.tool_id() == "page" { - let page = app.session.active().and_then(|st| st.selection.page).or_else(|| crate::canvas::current_page(app)); - if let Some(page) = page { - page_sections(app, ui, page); - return; + if info.is_none() && (app.session.tool_id() == "page" || app.session.active().is_some_and(|st| st.editing_parents)) { + if let Some((spread, page)) = crate::canvas::page_target(app) { + page_sections(app, ui, spread, page); } + return; } let Some(i) = info else { document_sections(app, ui); @@ -214,14 +213,13 @@ pub(crate) fn preset_name(w: f64, h: f64) -> &'static str { /// Properties with the Page tool (Properties › Edit Page), as InDesign's: a "Page" band with Back, /// Page Size for this page alone (which page, preset, orientation, W/H), Margins and Columns and /// Quick Actions. Back returns to the Selection tool and the document's properties. -fn page_sections(app: &mut DesignApp, ui: &mut Ui, page: usize) { +fn page_sections(app: &mut DesignApp, ui: &mut Ui, spread: designcraft_doc::SpreadRef, page: usize) { let u = units(app); let lang = app.ui.language.clone(); let Some(st) = app.session.active() else { return }; let d = st.doc.clone(); - let Some((si, pi)) = d.page_loc(page) else { return }; - let Some(pg) = d.spreads.get(si).and_then(|s| s.pages.get(pi)).cloned() else { return }; - let n = page + 1; + let Some(sp) = d.spread(spread) else { return }; + let Some(pg) = sp.pages.get(page).cloned() else { return }; let t = Tokens::get(ui.ctx()); // The band: "Page", and Back at its end. let band = block(ui, 33.0); @@ -245,13 +243,25 @@ fn page_sections(app: &mut DesignApp, ui: &mut Ui, page: usize) { section(ui, crate::i18n::tr(&lang, "Page Size"), true); // Which page. let b = block(ui, FIELD_H); - let names: Vec = (0..d.page_count()).map(|i| app.session.page_label(i)).collect(); + let (names, selected): (Vec, usize) = match spread { + designcraft_doc::SpreadRef::Doc(si) => ((0..d.page_count()).map(|i| app.session.page_label(i)).collect(), d.first_page_of_spread(si) + page), + designcraft_doc::SpreadRef::Parent(_) => { + ((0..sp.pages.len()).map(|i| format!("{} · {}", sp.parent.as_ref().map(|p| p.label()).unwrap_or_default(), i + 1)).collect(), page) + } + }; let picked = place(ui, sub(b, 0.0, 0.0, 93.0, FIELD_H), |ui| { - widgets::dropdown_list(ui, names.get(page).map_or("", String::as_str), 93.0, &names, Some(page)) + widgets::dropdown_list(ui, names.get(selected).map_or("", String::as_str), 93.0, &names, Some(selected)) }); if let Some(p) = picked { - let _ = app.run("layout.selectPage", json!({"page": p + 1})); - crate::canvas::go_to_page(app, p); + match spread { + designcraft_doc::SpreadRef::Doc(_) => { + let _ = app.run("layout.selectPage", json!({"page": p + 1})); + crate::canvas::go_to_page(app, p); + } + designcraft_doc::SpreadRef::Parent(_) => { + let _ = app.run("layout.selectParentPage", json!({"parent": sp.id.0, "page": p})); + } + } return; } ui.add_space(3.0); @@ -293,13 +303,19 @@ fn page_sections(app: &mut DesignApp, ui: &mut Ui, page: usize) { size = Some(if key == "width" { (nv, h) } else { (w, nv) }); } } - if let Some((nw, nh)) = size { - let _ = app.run("layout.pageSize", json!({"pages": [n], "width": nw, "height": nh, "anchor": app.ui.ref_point.min(8)})); + if let Some((nw, nh)) = size + && let Some((cmd, params)) = + designcraft_tools::layout::page_size_command(&d, spread, page, json!({"width": nw, "height": nh, "anchor": app.ui.ref_point.min(8)})) + { + let _ = app.run(cmd, params); } ui.add_space(6.0); let fw = full_width(ui); if outline_button(ui, crate::i18n::tr(&lang, "Margins and Columns"), fw).clicked() { - crate::canvas::go_to_page(app, page); + match spread { + designcraft_doc::SpreadRef::Doc(si) => crate::canvas::go_to_page(app, d.first_page_of_spread(si) + page), + designcraft_doc::SpreadRef::Parent(_) => crate::canvas::show_spread(app, spread), + } crate::menus::activate(app, "layout.marginsAndColumns", &Value::Null); } divider(ui); diff --git a/docs/roadmap.md b/docs/roadmap.md index 9223ca701..7d471566f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # DesignCraft roadmap (detail) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (.indd and .indt open through indd-utils; text threads by hand; the gate waits on the Primary Text Frame page fixes) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (independent parent page resizing, #1620; .indd and .indt open through indd-utils; text threads by hand; the gate waits on the Primary Text Frame page fixes) · **Target:** Adobe InDesign 2026 (21.6) Forward-looking plan. The one-page summary is [ROADMAP.md](../ROADMAP.md); numbers are in [target-app-parity.md](target-app-parity.md); the work list is [gaps.md](gaps.md). Hours are @@ -17,7 +17,7 @@ but are next in line. | Core workflow | Works end to end? | Evidence | Hours to pass | |---|---|---|---| -| Set up a document (size, margins, columns, parent pages, page numbers, sections); save and reopen | yes | New Document, Document Setup, parent pages and overrides, Numbering & Section Options; `.designcraft` round-trip tests; recovery | 0 | +| Set up a document (size, margins, columns, parent pages, page numbers, sections); save and reopen | yes | New Document, Document Setup, independently sized parent pages (Properties and Page Tool, #1620), parent overrides, Numbering & Section Options; `.designcraft` round-trip tests; recovery | 0 | | Flow a long story across frames and pages: thread, unthread, autoflow | **no** | threading by hand works (ports, loaded text cursor, `text.thread`/`text.unthread`/`text.removeFromThread`); Primary Text Frame autoflow adds pages users can't delete (#165) | 5–7 | | Format text with paragraph and character styles | partial (not blocking) | Paragraph/Character Style Options, Quick Apply, based-on, nested/GREP styles work; style edit/delete from the panel reported (#169); formatting set at a caret is discarded (#289) | 6–12 | | Place, crop and fit images; wrap text around them | yes | place gun, frame fitting and Fitting Options, content grabber, text wrap, Links panel | 0 | From b6f82deb11606d0022f428c77f36d6e4863d015f Mon Sep 17 00:00:00 2001 From: makesomethingshit Date: Fri, 23 Oct 2026 21:24:01 +0900 Subject: [PATCH 03/33] #1620: Restore Properties Back from parent editing --- crates/ui-egui/src/chrome.rs | 18 ++++++++++++++++++ crates/ui-egui/src/panels/properties.rs | 5 ++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 5235180e0..d9d40e98a 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -1377,6 +1377,15 @@ mod tests { assert_eq!(d.parents[0].pages[0].width, 500.0); assert_eq!(d.spreads, before.spreads); let parents = d.parents.clone(); + if properties { + let saved = d.clone(); + h.get_by_label("Back").click(); + h.run_steps(3); + assert!(!h.state().session.doc().unwrap().editing_parents, "Back returns to document properties"); + assert_eq!(h.state().session.tool_id(), "selection"); + assert!(h.query_by_label("Back").is_none(), "the next frame must leave the page properties screen"); + assert!(std::sync::Arc::ptr_eq(&saved, &h.state().session.doc().unwrap().doc)); + } h.state_mut().run("layout.parents.edit", json!({"on": false})).unwrap(); h.state_mut().session.set_tool("page"); h.state_mut().run("layout.selectPage", json!({"page": 1})).unwrap(); @@ -1385,6 +1394,15 @@ mod tests { let d = &h.state().session.doc().unwrap().doc; assert_eq!(d.spreads[0].pages[0].width, 530.0); assert_eq!(d.parents, parents); + if properties { + let saved = d.clone(); + h.get_by_label("Back").click(); + h.run_steps(3); + assert_eq!(h.state().session.tool_id(), "selection"); + assert!(h.query_by_label("Back").is_none()); + assert_eq!(h.state().session.doc().unwrap().selection.page, None); + assert!(std::sync::Arc::ptr_eq(&saved, &h.state().session.doc().unwrap().doc)); + } } } diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 6f1c0e02e..779e1e159 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -212,7 +212,7 @@ pub(crate) fn preset_name(w: f64, h: f64) -> &'static str { /// Properties with the Page tool (Properties › Edit Page), as InDesign's: a "Page" band with Back, /// Page Size for this page alone (which page, preset, orientation, W/H), Margins and Columns and -/// Quick Actions. Back returns to the Selection tool and the document's properties. +/// Quick Actions. Back leaves parent editing and returns to the Selection tool and document properties. fn page_sections(app: &mut DesignApp, ui: &mut Ui, spread: designcraft_doc::SpreadRef, page: usize) { let u = units(app); let lang = app.ui.language.clone(); @@ -236,6 +236,9 @@ fn page_sections(app: &mut DesignApp, ui: &mut Ui, spread: designcraft_doc::Spre }); rule(ui); if back { + if matches!(spread, designcraft_doc::SpreadRef::Parent(_)) { + let _ = app.run("layout.parents.edit", json!({"on": false})); + } let _ = app.run("layout.selectPage", json!({"page": null})); app.select_tool("selection"); return; From 7ad7f22f4df3298ac4f28bad12f2c745fbdffd74 Mon Sep 17 00:00:00 2001 From: Hamza Date: Mon, 26 Oct 2026 17:12:09 +0500 Subject: [PATCH 04/33] Pin the indd dependency by rev, as every other git dependency is (#1599) `indd` was the one git dependency taken from a branch: indd = { git = "https://github.com/storytold/indd-utils", branch = "main" } `Cargo.lock` pinned a commit, but any `cargo update` -- and `.github/dependabot.yml` runs them -- pulls whatever is on that branch into a parser of untrusted INDD files, with no review of what changed. The craft-libs dependencies beside it are all `rev = "b926b5c0..."`. Pinned to `7f3060e5bce78d21ea70e0d5eacb96edce15dfbe`, which is the commit the lock already held: the only change in `Cargo.lock` is the source string, `?branch=main` to `?rev=7f3060e5...`, so no code moves with this. Verified: `cargo check --locked -p designcraft-engine` passes, and `indd_files_go_through_the_converter_and_its_errors_are_open_errors` still passes, so the converter is the same one. Audit DC-18. --- Cargo.lock | 2 +- Cargo.toml | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index eabe9e52e..0a0688da5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3120,7 +3120,7 @@ checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" [[package]] name = "indd" version = "0.1.0" -source = "git+https://github.com/storytold/indd-utils?branch=main#7f3060e5bce78d21ea70e0d5eacb96edce15dfbe" +source = "git+https://github.com/storytold/indd-utils?rev=7f3060e5bce78d21ea70e0d5eacb96edce15dfbe#7f3060e5bce78d21ea70e0d5eacb96edce15dfbe" [[package]] name = "indexmap" diff --git a/Cargo.toml b/Cargo.toml index 195f39e67..2924c8275 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -75,9 +75,12 @@ flo_curves = "0.8" qrcodegen = "1.8" quick-xml = "0.41" zip = { version = "3", default-features = false, features = ["deflate"] } -# INDD/INDT → IDML conversion for File › Open: storytold/indd-utils, tracking its main branch +# INDD/INDT → IDML conversion for File › Open: storytold/indd-utils, pinned by rev as every other +# git dependency here is. It parses untrusted INDD files, and `branch = "main"` meant any +# `cargo update` -- Dependabot runs them -- pulled whatever was on that branch into the parser +# without review (#1599). # (MIT OR Apache-2.0, no dependencies, builds for wasm). -indd = { git = "https://github.com/storytold/indd-utils", branch = "main" } +indd = { git = "https://github.com/storytold/indd-utils", rev = "7f3060e5bce78d21ea70e0d5eacb96edce15dfbe" } # Which paths stored in documents may be followed (craft-libs). craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7" } From 2f403698e0e2a8db6b404afe30292f3e3d15ce81 Mon Sep 17 00:00:00 2001 From: Hamza Date: Wed, 28 Oct 2026 18:53:45 +0500 Subject: [PATCH 05/33] An export says what text its pages had no room for (#1465) A page-based export leaves out the text its frames cannot fit, and said nothing about it: an imported 58-page form lost whole sentences -- a data-consent clause, a statutory rule, the last row of two tables -- with `warnings` empty while preflight reported 19 overset stories. The PDF and deck exports now report it, in the words preflight uses and with the same count, naming the page and the frame. A reflowable export carries the whole story, so it has nothing to report. Carries the review fix: a spread export puts the whole spread on the sheet, so choosing page 2 of a facing-pages document exports page 3 as well and page 3's overset is as absent from the file as page 2's would be. The selection is widened to the spreads it touches first. A page on another spread is still not reported, so the filter keeps doing what it is for. Rebased onto the post-split main: the PDF command is in export/pdf.rs, the deck one in export/formats.rs, and preflight.rs is unchanged by the split. The helper sits before export_pdf rather than between first_page_ref's doc comment and its function, which is where the pre-split patch had put it. --- crates/engine/src/cmd/export/formats.rs | 2 + crates/engine/src/cmd/export/pdf.rs | 123 +++++++++++++++++++++++- crates/engine/src/cmd/preflight.rs | 45 +++++++++ crates/engine/src/cmd/text.rs | 7 +- 4 files changed, 175 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/export/formats.rs b/crates/engine/src/cmd/export/formats.rs index 025bcdac4..dab0bb8d1 100644 --- a/crates/engine/src/cmd/export/formats.rs +++ b/crates/engine/src/cmd/export/formats.rs @@ -110,6 +110,8 @@ pub(super) fn export_pptx(s: &mut Session, p: &Value) -> Result { if rasterized > 0 { r.warnings.push(format!("{rasterized} object(s) went in as images")); } + // A slide has no room for the overset text either (#1465). + r.warnings.extend(crate::cmd::preflight::overset_warnings(s, opts.pages.as_deref())); match str_param(p, "path") { Some(path) => { #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/engine/src/cmd/export/pdf.rs b/crates/engine/src/cmd/export/pdf.rs index 64011fa3c..12d5577ec 100644 --- a/crates/engine/src/cmd/export/pdf.rs +++ b/crates/engine/src/cmd/export/pdf.rs @@ -87,8 +87,27 @@ fn first_page_ref(pdf: &[u8]) -> Option { s[i..].split_whitespace().next()?.parse().ok() } +/// Every page sharing a spread with one of `pages` (0-based), for an export that puts whole +/// spreads on a sheet. The pages come back in document order. +fn with_their_spreads(s: &Session, pages: &[usize]) -> Vec { + let Some(st) = s.active() else { return pages.to_vec() }; + let d = &st.doc; + let spreads: std::collections::BTreeSet = pages.iter().filter_map(|p| d.page_loc(*p)).map(|(si, _)| si).collect(); + if spreads.is_empty() { + return pages.to_vec(); + } + (0..d.page_count()).filter(|p| d.page_loc(*p).is_some_and(|(si, _)| spreads.contains(&si))).collect() +} + pub(super) fn export_pdf(s: &mut Session, p: &Value) -> Result { - let r = pdf_export(s, p)?; + let mut r = pdf_export(s, p)?; + // Text the pages have no room for is not in the file: say so, as preflight does (#1465). + // A spread export puts the whole spread on the sheet, so choosing page 2 exports page 3 as + // well and page 3's overset is as absent from the file as page 2's would be. The selection + // is widened to the spreads it touches before the warnings are collected (noted in review). + let opts = options(p, s.doc()?.doc.page_count())?; + let warn_pages = opts.pages.as_deref().map(|pages| if opts.spreads { with_their_spreads(s, pages) } else { pages.to_vec() }); + r.warnings.extend(crate::cmd::preflight::overset_warnings(s, warn_pages.as_deref())); match str_param(p, "path") { Some(path) => { write_file(path, &r.bytes)?; @@ -461,3 +480,105 @@ mod page_list_tests { assert!(s.execute("file.exportPdf", &json!({"pages": []})).is_err()); } } + +#[cfg(test)] +mod overset_tests { + use serde_json::json; + + use crate::Session; + + /// A page-based export leaves out the text its frames have no room for, and said nothing + /// about it: an imported 58-page form lost whole sentences — a data-consent clause, a + /// statutory rule, the last row of two tables — with `warnings` empty while preflight + /// reported 19 overset stories (#1465). + #[test] + fn an_export_warns_about_the_text_the_pages_had_no_room_for() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 200, "pages": 2, "facingPages": false, "margins": 0})).unwrap(); + let text = "First line is visible.\nSecond paragraph does not fit: this sentence carries the legal notice and must not vanish from the PDF without a warning."; + let frame = s.execute("frame.create", &json!({"rect": [20, 20, 280, 60], "content": "text", "text": text})).unwrap(); + let (id, sid) = (frame["id"].as_u64().unwrap(), frame["story"].clone()); + + // What preflight says about the same frame, for the warning to agree with. + let issues = s.execute("preflight.run", &json!({})).unwrap(); + let overset = issues["issues"].as_array().unwrap().iter().find(|i| i["kind"] == "overset").expect("preflight reports it").clone(); + assert_eq!(overset["message"], "Overset text: 76 characters"); + + for command in ["file.exportPdf", "file.exportPptx"] { + let r = s.execute(command, &json!({})).unwrap(); + let warnings = r["warnings"].as_array().expect("warnings").clone(); + let about: Vec<&str> = warnings.iter().filter_map(|w| w.as_str()).filter(|w| w.contains("not in the export")).collect(); + assert_eq!(about.len(), 1, "{command}: {warnings:?}"); + let w = about[0]; + assert!(w.contains("page 1"), "{command}: the page it is on: {w}"); + assert!(w.contains("76 character"), "{command}: the count preflight gives: {w}"); + assert!(w.contains(&format!("frame {id}")), "{command}: the frame: {w}"); + } + + // The pages being exported are the ones reported on: page 2 has no overset frame. + let r = s.execute("file.exportPdf", &json!({"pages": [2]})).unwrap(); + assert_eq!(r["warnings"], json!([]), "{r}"); + + // A reflowable export carries the whole story, so it has nothing to report. + let r = s.execute("file.exportHtml", &json!({})).unwrap(); + assert_eq!(r["warnings"], json!([]), "{r}"); + assert!(r["text"].as_str().unwrap().contains("must not vanish"), "the HTML has the text the PDF left out"); + + // `overset` is the index where the text stops fitting; `oversetChars` is how much is left, + // which is the number preflight reports. The two were read as the same thing. + let story = s.execute("story.get", &json!({"story": sid})).unwrap(); + assert_eq!(story["overset"], json!(68), "{story}"); + assert_eq!(story["oversetChars"], json!(76), "{story}"); + + // A frame the text fits: nothing is reported anywhere. + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 200, "pages": 1, "facingPages": false, "margins": 0})).unwrap(); + let frame = s.execute("frame.create", &json!({"rect": [20, 20, 280, 180], "content": "text", "text": "Short."})).unwrap(); + for command in ["file.exportPdf", "file.exportPptx"] { + assert_eq!(s.execute(command, &json!({})).unwrap()["warnings"], json!([]), "{command}"); + } + let story = s.execute("story.get", &json!({"story": frame["story"].clone()})).unwrap(); + assert!(story["overset"].is_null() && story["oversetChars"].is_null(), "{story}"); + } + + /// A spread export puts the whole spread on the sheet, so choosing one page of it exports its + /// partner too -- and that partner's overset is just as absent from the file. Reporting only + /// the pages that were named left it unmentioned (noted in review of #1465). + #[test] + fn a_spread_export_warns_about_the_pages_it_takes_with_it() { + let mut s = Session::new(); + // Facing pages: 2 and 3 share a spread, 1 is the cover on its own. + s.execute("file.new", &json!({"width": 200, "height": 200, "pages": 4, "facingPages": true, "margins": 0})).unwrap(); + let text = "First line is visible.\nSecond paragraph does not fit: this sentence carries the legal notice and must not vanish from the PDF without a warning."; + // A frame on page 3, whose text does not fit it. `frame.create` takes a spread and + // spread coordinates, so page 3's own bounds say where to put it. + let (si, b) = { + let d = &s.active().expect("a document").doc; + let (si, pi) = d.page_loc(2).expect("page 3 is there"); + (si, d.spreads[si].pages[pi].bounds()) + }; + let rect = json!([b.x0 + 20.0, b.y0 + 20.0, b.x0 + 180.0, b.y0 + 60.0]); + let frame = s.execute("frame.create", &json!({"spread": si, "rect": rect, "content": "text", "text": text})).unwrap(); + let id = frame["id"].as_u64().unwrap(); + let about = |r: &serde_json::Value| -> Vec { + r["warnings"] + .as_array() + .map(|w| w.iter().filter_map(|w| w.as_str()).filter(|w| w.contains("not in the export")).map(str::to_string).collect()) + .unwrap_or_default() + }; + + // Page by page, choosing page 2 reports nothing: page 2 holds no overset frame. + let r = s.execute("file.exportPdf", &json!({"pages": [2]})).unwrap(); + assert!(about(&r).is_empty(), "page 2 alone has nothing to report: {r}"); + + // As spreads, choosing page 2 exports page 3 with it, so page 3's overset is reported. + let r = s.execute("file.exportPdf", &json!({"pages": [2], "spreads": true})).unwrap(); + let w = about(&r); + assert_eq!(w.len(), 1, "the page that came along is reported too: {r}"); + assert!(w[0].contains("page 3") && w[0].contains(&format!("frame {id}")), "{}", w[0]); + + // And a page on another spread entirely is still not reported. + let r = s.execute("file.exportPdf", &json!({"pages": [1], "spreads": true})).unwrap(); + assert!(about(&r).is_empty(), "the cover's spread holds no overset frame: {r}"); + } +} diff --git a/crates/engine/src/cmd/preflight.rs b/crates/engine/src/cmd/preflight.rs index 390dc7118..b3a22d6ad 100644 --- a/crates/engine/src/cmd/preflight.rs +++ b/crates/engine/src/cmd/preflight.rs @@ -113,6 +113,51 @@ fn overset_location(s: &Session, target: designcraft_doc::ItemId) -> Option<(des None } +/// Text that is in the document but not in a page-based export (PDF, PowerPoint): the overset +/// part of a story, and a story with no frame at all. The export used to write the file and +/// report nothing, so whole sentences left an imported document without a hint anywhere (#1465). +/// +/// One line per story, naming the page and the frame as the preflight issue does. `pages` are the +/// 0-based pages being exported; a story whose overset is on a page that is not being exported is +/// not reported, and one with no page of its own always is. A reflowable export (EPUB, HTML) or a +/// text export carries the whole story, so neither calls this. +pub fn overset_warnings(s: &Session, pages: Option<&[usize]>) -> Vec { + let Some(st) = s.active() else { return vec![] }; + let d = &st.doc; + let mut out = Vec::new(); + for story in d.stories.values() { + let cs = s.cache.get(d, story.id, None); + if !cs.is_overset() { + continue; + } + let n = story.text.get(cs.overset_at.unwrap_or(0)..).unwrap_or("").chars().count(); + if n == 0 { + continue; + } + if story.frames.is_empty() { + out.push(format!("a story with no text frame: {n} character(s) of it are not in the export")); + continue; + } + let last = story.frames.last().copied(); + let location = last.and_then(|f| overset_location(s, f)); + let page = location.and_then(|(_, page)| page); + if let (Some(page), Some(pages)) = (page, pages) + && !pages.contains(&page) + { + continue; + } + let item = location.map(|(item, _)| item).or(last); + out.push(match (page, item) { + (Some(page), Some(item)) => { + format!("page {}: {n} character(s) of the story in frame {} do not fit it and are not in the export", page + 1, item.0) + } + (None, Some(item)) => format!("the story in frame {}: {n} character(s) do not fit and are not in the export", item.0), + _ => format!("{n} character(s) of a story do not fit their frame and are not in the export"), + }); + } + out +} + pub fn check(s: &Session, min_ppi: f64) -> Vec { let Some(st) = s.active() else { return vec![] }; let d = &st.doc; diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 8e6038741..3e6dce9d5 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -191,12 +191,17 @@ pub fn specs() -> Vec { }) } ), - cmd!(query "story.get", "Get Story", [], None, "{story? | frame?} → text, frames, paragraphs, vertical, overset", has_doc, |s, p| { + cmd!(query "story.get", "Get Story", [], None, "{story? | frame?} → text, frames, paragraphs, vertical, lines, overset (the index in `text` where it stops fitting its frames, null when it all fits) and oversetChars (how many characters that leaves, as preflight reports them)", has_doc, |s, p| { let st = s.doc()?; let sid = story_of(s, p).ok_or_else(|| bad("story.get", "no story"))?; let mut v = st.doc.story_summary(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?; let cs = s.cache.get(&st.doc, sid, None); + // `overset` is where the text stops fitting, not how much is left over: the two were + // read as the same number and disagreed with preflight's count (#1465). v["overset"] = json!(cs.overset_at); + v["oversetChars"] = json!(cs + .overset_at + .map(|at| st.doc.stories.get(&sid).and_then(|story| story.text.get(at..)).unwrap_or("").chars().count())); v["lines"] = json!(cs.line_count()); Ok(v) }), From 4d88c0899e88a4b05d554985acfdf8ccb2f7ce4e Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:21:20 +0100 Subject: [PATCH 06/33] Keep stroke styles from joining the subpaths of a compound path Found while testing crates/geom/src/stroke_style.rs:24-31 (offset_path: `(MoveTo(p), 0, true) => out.line_to(p)`; also samples/wavy at :58-98) Co-Authored-By: Claude Opus 5.5 --- crates/geom/src/stroke_style.rs | 63 +++++++++++++++++++++++++++------ 1 file changed, 53 insertions(+), 10 deletions(-) diff --git a/crates/geom/src/stroke_style.rs b/crates/geom/src/stroke_style.rs index 63c632ea2..d018667f2 100644 --- a/crates/geom/src/stroke_style.rs +++ b/crates/geom/src/stroke_style.rs @@ -9,6 +9,28 @@ pub fn offset_path(bp: &BezPath, d: f64, tol: f64) -> BezPath { return bp.clone(); } let mut out = BezPath::new(); + for sub in subpaths(bp) { + offset_subpath(&sub, d, tol, &mut out); + } + out +} + +/// `bp` split at each `MoveTo` into its subpaths. +fn subpaths(bp: &BezPath) -> Vec { + let mut out: Vec = Vec::new(); + for el in bp.elements() { + if matches!(el, kurbo::PathEl::MoveTo(_)) || out.is_empty() { + out.push(BezPath::new()); + } + if let Some(sub) = out.last_mut() { + sub.push(*el); + } + } + out +} + +/// One subpath offset by `d`, its segments joined into one line, appended to `out`. +fn offset_subpath(bp: &BezPath, d: f64, tol: f64, out: &mut BezPath) { let mut started = false; for seg in bp.segments() { let c = match seg { @@ -30,7 +52,6 @@ pub fn offset_path(bp: &BezPath, d: f64, tol: f64) -> BezPath { } started = true; } - out } /// Bands (start, width) across a stroke, as fractions of the weight from its left edge. @@ -54,8 +75,13 @@ pub fn stripes(bp: &BezPath, weight: f64, bands: &Bands, tol: f64) -> BezPath { out } -/// Points along `bp` every `step` of arc length, with the unit tangent there. -fn samples(bp: &BezPath, step: f64) -> Vec<(Point, Vec2)> { +/// Points along each subpath of `bp` every `step` of arc length, with the unit tangent there. +fn samples(bp: &BezPath, step: f64) -> Vec> { + subpaths(bp).iter().map(|sub| subpath_samples(sub, step)).collect() +} + +/// Points along one subpath every `step` of arc length, with the unit tangent there. +fn subpath_samples(bp: &BezPath, step: f64) -> Vec<(Point, Vec2)> { let mut out = Vec::new(); let mut carry = 0.0; for seg in bp.segments() { @@ -85,13 +111,14 @@ fn samples(bp: &BezPath, step: f64) -> Vec<(Point, Vec2)> { /// A wavy stroke: the line swings `weight` wide, drawn a third of the weight thick. pub fn wavy(bp: &BezPath, weight: f64, tol: f64) -> BezPath { let wavelength = (weight * 4.0).max(2.0); - let pts = samples(bp, wavelength / 16.0); let mut line = BezPath::new(); - for (i, (p, t)) in pts.iter().enumerate() { - let normal = Vec2::new(-t.y, t.x); - let phase = i as f64 / 16.0 * std::f64::consts::TAU; - let q = *p + normal * (phase.sin() * weight * 0.33); - if i == 0 { line.move_to(q) } else { line.line_to(q) } + for pts in samples(bp, wavelength / 16.0) { + for (i, (p, t)) in pts.iter().enumerate() { + let normal = Vec2::new(-t.y, t.x); + let phase = i as f64 / 16.0 * std::f64::consts::TAU; + let q = *p + normal * (phase.sin() * weight * 0.33); + if i == 0 { line.move_to(q) } else { line.line_to(q) } + } } let s = Stroke::new(weight / 3.0).with_join(kurbo::Join::Round); kurbo::stroke(line.iter(), &s, &kurbo::StrokeOpts::default(), tol) @@ -100,7 +127,7 @@ pub fn wavy(bp: &BezPath, weight: f64, tol: f64) -> BezPath { /// A straight-hash stroke: short ticks across the path, as wide as the weight. pub fn hashed(bp: &BezPath, weight: f64, tol: f64) -> BezPath { let mut ticks = BezPath::new(); - for (p, t) in samples(bp, (weight * 0.6).max(1.0)) { + for (p, t) in samples(bp, (weight * 0.6).max(1.0)).into_iter().flatten() { let normal = Vec2::new(-t.y, t.x) * (weight / 2.0); ticks.move_to(p - normal); ticks.line_to(p + normal); @@ -141,4 +168,20 @@ mod tests { let h = hashed(&line(), 8.0, 0.01).bounding_box(); assert!((h.height() - 8.0).abs() < 0.5 && h.width() > 90.0, "{h:?}"); } + + #[test] + fn styled_strokes_keep_subpaths_apart() { + // Two separate lines, 50 pt apart, in one compound path. + let mut b = line(); + b.move_to((0.0, 50.0)); + b.line_to((100.0, 50.0)); + let moves = |p: &BezPath| p.elements().iter().filter(|e| matches!(e, kurbo::PathEl::MoveTo(_))).count(); + assert_eq!(moves(&offset_path(&b, 3.0, 0.01)), 2, "one offset subpath per input subpath"); + let s = stripes(&b, 12.0, &[(0.0, 0.5), (0.75, 0.25)], 0.01); + assert!(!s.contains(Point::new(50.0, 25.0)), "stripe bridges the subpaths: {:?}", s.bounding_box()); + assert!(s.contains(Point::new(50.0, 46.0)), "second line is still striped"); + let w = wavy(&b, 4.0, 0.01); + let bridged = (0..=2000).map(|i| f64::from(i) * 0.05).filter(|&x| w.contains(Point::new(x, 25.0))).count(); + assert_eq!(bridged, 0, "wavy bridges the subpaths: {:?}", w.bounding_box()); + } } From 5a53a3807c08d156722ba8dce3f034d03b411181 Mon Sep 17 00:00:00 2001 From: Hamza Date: Thu, 29 Oct 2026 14:17:21 +0500 Subject: [PATCH 07/33] Check a parent page's frame on the pages that show it The warnings composed every story once with no page. For a frame on a parent page that is wrong twice over, and I measured both: exportPdf {"pages": [2]} ["the story in frame 9: 76 character(s) do not fit and are not in the export"] Page 2 used no parent at all, so nothing of that frame was in its export to be missing -- and the warning named no page, because a parent frame has no document page of its own. It now resolves the pages that draw the parent page and reports only those: {"pages": [1]} ["page 1: 76 character(s) ... frame 9 ..."] {"pages": [2]} [] The second half is page-dependent text. A parent frame 112 pt wide holding "Continued on page " fits while the number is one digit and does not once it is two, so composing it once could not tell: {"pages": [1]} [] {"pages": [9]} [] {"pages": [10]} ["page 10: 1 character(s) ..."] {} ["pages 10, 11, 12: 1 character(s) ..."] Each page that shows the parent is composed as that page, and the pages are grouped by how much they lose, so a story still gets one line. Also from review: `story.get` now says `overset` is a UTF-8 byte offset into `text`, not a character index. A test measures the difference on French text, where the offset runs ahead of the character count. --- crates/engine/src/cmd/export/pdf.rs | 70 +++++++++++++++++++++++++++++ crates/engine/src/cmd/preflight.rs | 63 +++++++++++++++++++++++++- crates/engine/src/cmd/text.rs | 2 +- 3 files changed, 132 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/export/pdf.rs b/crates/engine/src/cmd/export/pdf.rs index 12d5577ec..b5f16c110 100644 --- a/crates/engine/src/cmd/export/pdf.rs +++ b/crates/engine/src/cmd/export/pdf.rs @@ -541,6 +541,76 @@ mod overset_tests { assert!(story["overset"].is_null() && story["oversetChars"].is_null(), "{story}"); } + /// `overset` indexes `text` in UTF-8 bytes, not characters: a client that slices a JavaScript + /// string at it cuts the wrong place once the text is not ASCII (noted in review). + #[test] + fn overset_is_a_byte_offset_into_the_text() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 200, "pages": 1, "facingPages": false, "margins": 0})).unwrap(); + // Accented letters are two bytes each, so the byte offset runs ahead of the character index. + let text = "Réservé à l'évaluation détaillée des éléments.\nCette phrase dépasse le cadre et n'est pas dans le PDF exporté du tout."; + let frame = s.execute("frame.create", &json!({"rect": [20, 20, 280, 40], "content": "text", "text": text})).unwrap(); + let story = s.execute("story.get", &json!({"story": frame["story"].clone()})).unwrap(); + let at = story["overset"].as_u64().expect("it oversets") as usize; + assert!(text.is_char_boundary(at), "a byte offset that splits no character: {at}"); + assert!(at > text[..at].chars().count(), "ahead of the character index: {at} bytes, {} characters", text[..at].chars().count()); + // And `oversetChars` counts characters, which is why the two numbers differ. + assert_eq!(story["oversetChars"], json!(text[at..].chars().count()), "{story}"); + } + + /// A parent page's frame is composed once per page that shows it: its page number marker is + /// that page's, so the same frame fits on page 1 and oversets on page 10 -- and a parent page + /// no exported page uses puts nothing in the file to be missing. Composing it once with no + /// page reported neither: every selection got the same unpaged warning (noted in review). + #[test] + fn a_parent_frame_is_checked_on_the_pages_that_show_it() { + let about = |r: &serde_json::Value| -> Vec { + r["warnings"] + .as_array() + .map(|w| w.iter().filter_map(|w| w.as_str()).filter(|w| w.contains("not in the export")).map(str::to_string).collect()) + .unwrap_or_default() + }; + + // A parent page that only page 1 uses. + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 200, "pages": 2, "facingPages": false, "margins": 0})).unwrap(); + s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": null})).unwrap(); + let text = "First line is visible.\nSecond paragraph does not fit: this sentence carries the legal notice and must not vanish from the PDF without a warning."; + let frame = s + .execute("frame.create", &json!({"spread": {"kind": "parent", "index": 0}, "rect": [20, 20, 280, 60], "content": "text", "text": text})) + .unwrap(); + let id = frame["id"].as_u64().unwrap(); + + let w = about(&s.execute("file.exportPdf", &json!({"pages": [1]})).unwrap()); + assert_eq!(w.len(), 1, "page 1 shows the parent: {w:?}"); + assert!(w[0].contains("page 1") && w[0].contains(&format!("frame {id}")) && w[0].contains("76 character"), "{}", w[0]); + + // Page 2 has no parent, so nothing of that frame is in its export to be missing. + let r = s.execute("file.exportPdf", &json!({"pages": [2]})).unwrap(); + assert!(about(&r).is_empty(), "page 2 does not use the parent: {r}"); + + // Page-dependent text: the marker is one digit up to page 9 and two from page 10, and the + // wider line no longer fits. 112 pt is the width where that is the only difference. + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 200, "pages": 12, "facingPages": false, "margins": 0})).unwrap(); + let text = format!("Continued on page {}", designcraft_doc::story::PAGE_NUMBER); + s.execute("frame.create", &json!({"spread": {"kind": "parent", "index": 0}, "rect": [20, 20, 132, 36], "content": "text", "text": text})) + .unwrap(); + + for page in [1, 9] { + let r = s.execute("file.exportPdf", &json!({"pages": [page]})).unwrap(); + assert!(about(&r).is_empty(), "page {page} fits one digit: {r}"); + } + let w = about(&s.execute("file.exportPdf", &json!({"pages": [10]})).unwrap()); + assert_eq!(w.len(), 1, "two digits do not fit: {w:?}"); + assert!(w[0].contains("page 10"), "{}", w[0]); + + // The whole document: one line naming every page it happens on, not one line per page. + let w = about(&s.execute("file.exportPdf", &json!({})).unwrap()); + assert_eq!(w.len(), 1, "one line for the story: {w:?}"); + assert!(w[0].starts_with("pages 10, 11, 12:"), "{}", w[0]); + } + /// A spread export puts the whole spread on the sheet, so choosing one page of it exports its /// partner too -- and that partner's overset is just as absent from the file. Reporting only /// the pages that were named left it unmentioned (noted in review of #1465). diff --git a/crates/engine/src/cmd/preflight.rs b/crates/engine/src/cmd/preflight.rs index b3a22d6ad..bf98f9ab9 100644 --- a/crates/engine/src/cmd/preflight.rs +++ b/crates/engine/src/cmd/preflight.rs @@ -121,16 +121,76 @@ fn overset_location(s: &Session, target: designcraft_doc::ItemId) -> Option<(des /// 0-based pages being exported; a story whose overset is on a page that is not being exported is /// not reported, and one with no page of its own always is. A reflowable export (EPUB, HTML) or a /// text export carries the whole story, so neither calls this. +/// Pages a single warning names before "and N more". +const MAX_WARNED_PAGES: usize = 10; + +/// How the pages one warning covers are named: "page 3", "pages 3, 4", "pages 3, 4 and 6 more". +fn page_list(pages: &[usize]) -> String { + let shown: Vec = pages.iter().take(MAX_WARNED_PAGES).map(|p| (p + 1).to_string()).collect(); + let rest = pages.len() - shown.len(); + let list = shown.join(", "); + match (pages.len(), rest) { + (1, _) => format!("page {list}"), + (_, 0) => format!("pages {list}"), + _ => format!("pages {list} and {rest} more"), + } +} + +/// The parent spread and the page of it a frame sits on, or `None` for a frame on a document page. +fn parent_page_of(d: &Document, target: designcraft_doc::ItemId) -> Option<(usize, usize)> { + let loc = d.find(target)?; + let SpreadRef::Parent(pi) = loc.spread else { return None }; + let bounds = d.parent_xf(&loc).transform_rect_bbox(d.item(target)?.bounds()); + Some((pi, d.spread(loc.spread)?.page_at_x(bounds.center().x).unwrap_or(0))) +} + +/// Document pages that draw parent page `pp` of parent spread `pi`, in document order. +fn pages_showing(d: &Document, (pi, pp): (usize, usize)) -> Vec { + (0..d.page_count()) + .filter(|p| d.page_loc(*p).is_some_and(|(si, pgi)| d.shown_parent_pages(SpreadRef::Doc(si), pgi).contains(&(pi, pp)))) + .collect() +} + +/// Characters of `story` that `cs` had no room for. +fn overset_chars(story: &designcraft_doc::Story, cs: &ComposedStory) -> usize { + story.text.get(cs.overset_at.unwrap_or(0)..).unwrap_or("").chars().count() +} + pub fn overset_warnings(s: &Session, pages: Option<&[usize]>) -> Vec { let Some(st) = s.active() else { return vec![] }; let d = &st.doc; let mut out = Vec::new(); for story in d.stories.values() { + let last = story.frames.last().copied(); + // A story on a parent page is composed once per page that shows it: its page number + // markers and variables are that page's, so the same frame can fit on one page and + // overset on another, and a parent page no exported page uses puts nothing in the file + // to be missing. Composing it once with no page reported neither (noted in review). + if let Some(parent) = last.and_then(|f| parent_page_of(d, f)) { + let mut by_count: std::collections::BTreeMap> = std::collections::BTreeMap::new(); + for page in pages_showing(d, parent) { + if pages.is_some_and(|sel| !sel.contains(&page)) { + continue; + } + let cs = s.cache.get(d, story.id, Some(page)); + let n = overset_chars(story, &cs); + if cs.is_overset() && n > 0 { + by_count.entry(n).or_default().push(page); + } + } + let item = last.map_or(0, |f| f.0); + out.extend( + by_count.into_iter().map(|(n, ps)| { + format!("{}: {n} character(s) of the story in frame {item} do not fit it and are not in the export", page_list(&ps)) + }), + ); + continue; + } let cs = s.cache.get(d, story.id, None); if !cs.is_overset() { continue; } - let n = story.text.get(cs.overset_at.unwrap_or(0)..).unwrap_or("").chars().count(); + let n = overset_chars(story, &cs); if n == 0 { continue; } @@ -138,7 +198,6 @@ pub fn overset_warnings(s: &Session, pages: Option<&[usize]>) -> Vec { out.push(format!("a story with no text frame: {n} character(s) of it are not in the export")); continue; } - let last = story.frames.last().copied(); let location = last.and_then(|f| overset_location(s, f)); let page = location.and_then(|(_, page)| page); if let (Some(page), Some(pages)) = (page, pages) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 3e6dce9d5..01abf0e6e 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -191,7 +191,7 @@ pub fn specs() -> Vec { }) } ), - cmd!(query "story.get", "Get Story", [], None, "{story? | frame?} → text, frames, paragraphs, vertical, lines, overset (the index in `text` where it stops fitting its frames, null when it all fits) and oversetChars (how many characters that leaves, as preflight reports them)", has_doc, |s, p| { + cmd!(query "story.get", "Get Story", [], None, "{story? | frame?} → text, frames, paragraphs, vertical, lines, overset (the UTF-8 byte offset into `text` where it stops fitting its frames, not a character index, null when it all fits) and oversetChars (how many characters that leaves, as preflight reports them)", has_doc, |s, p| { let st = s.doc()?; let sid = story_of(s, p).ok_or_else(|| bad("story.get", "no story"))?; let mut v = st.doc.story_summary(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?; From ec059c042b34dcac6db4452a27169e441968e73e Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Sat, 24 Oct 2026 23:11:57 +0200 Subject: [PATCH 08/33] Linux: file managers show a .designcraft file's first page through a freedesktop thumbnailer (#1782) --- Cargo.lock | 1 + ROADMAP.md | 1 + apps/designcraft-cli/Cargo.toml | 1 + apps/designcraft-cli/src/main.rs | 40 ++++++++++++- apps/designcraft-cli/tests/it/cli.rs | 60 +++++++++++++++++++ .../ai.storyteller.designcraft.thumbnailer | 8 +++ packaging/linux/package.sh | 1 + 7 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 packaging/linux/ai.storyteller.designcraft.thumbnailer diff --git a/Cargo.lock b/Cargo.lock index 1c361aef2..13a009d2f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1340,6 +1340,7 @@ dependencies = [ "designcraft-mcp", "designcraft-pdf", "designcraft-render", + "image", "serde_json", ] diff --git a/ROADMAP.md b/ROADMAP.md index ad3f52a14..807aa5dbf 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -405,6 +405,7 @@ Newest first. - **2026-10-24** · Renaming a paragraph or character style, or moving it into a style group, keeps its Export Tagging under the new name, and undo puts it back under the old one. Deleting a tagged style drops its tagging and leaves the replacement style's own tagging as it was. No readiness percentage change. - **2026-10-24** · Quick Apply and the application bar's search leave out the interface commands that failed or reset a setting when chosen there: those that need a workspace, a command and shortcut, a menu item, a panel, a window edge, an app page, a recent file or a swatch they can't give them, a Contextual Task Bar place, and Interface Color Theme, UI Scaling, Reset Shortcuts, the flattener preset, Screen Mode and Zoom To, which without a value switched to the dark theme, set 100%, cleared every custom shortcut or the preset, or went to Normal mode or 100% zoom. Each stays in the menu, panel or dialog that gives it the value. No readiness percentage change. - **2026-10-24** · EPUB Export Options › Split Document, a dialog before File › Export EPUB… saves (`document.epubSplit`, stored with the document): off, a single paragraph style, or based on paragraph style export tags, with Paragraph Style Options › Export Tagging's Split Document flag marking those styles. A reflowable EPUB is one content file unless Split Document is on; the split before each top-level h1 from #791 is gone. A split before a list item or preformatted line no longer leaves an unclosed list or block in a content file. IDML import and export keep the setting and the flag, and Load Styles the flag. No readiness percentage change. +- **2026-10-24** · Linux file managers show a `.designcraft` file's first page instead of a blank icon: the packages install a freedesktop thumbnailer (`share/thumbnailers/ai.storyteller.designcraft.thumbnailer`) that runs `designcraft-cli thumbnail FILE OUT.png SIZE`, which writes the `preview.png` saving already puts in the file, fitted to the size asked for and never enlarged. A file without a preview fails, so the file manager keeps the file type's icon (#1782). No readiness percentage change. - **2026-10-18** · Repeated placed-PDF layer variants are prepared once per asset and hidden-name set, with recovery warnings retained. All-visible named layers keep their original source bytes, including PDFs whose cross-reference tables the reader repairs, in plain, PDF/X-4, booklet and book-merge output. Layer changes that require writing still reject an unusable cross-reference table with a file-named damage error; the first placement error is retained (#906). No readiness percentage change. - **2026-10-18** · Placed PDF optional-content defaults and per-frame overrides survive plain and PDF/X-4 vector export, including repeated placements, booklet output and book merging (#906). Source usage-application arrays are accepted; groups without a catalog configuration stay visible. Missing or unreadable default configurations and unsupported base states use all-ON defaults with a file-named export warning, retaining explicit frame overrides. Readable groups omitted from the catalog list are added with a distinct file-named warning, preserving their default ON/OFF states. Concurrent recovery warnings are combined. Malformed ON data is recoverable when a known ON base and empty OFF list prove it cannot hide artwork; missing display names are generated without changing known visibility when no override needs name matching. Contradictory states, unresolved references and ambiguous name overrides still fail export. Print-usage adjustments and IDML layer overrides remain follow-ups. No readiness percentage change. diff --git a/apps/designcraft-cli/Cargo.toml b/apps/designcraft-cli/Cargo.toml index 7aaa454cb..ab2464147 100644 --- a/apps/designcraft-cli/Cargo.toml +++ b/apps/designcraft-cli/Cargo.toml @@ -22,4 +22,5 @@ designcraft-compose = { workspace = true } designcraft-format = { workspace = true } designcraft-pdf = { workspace = true } designcraft-mcp = { workspace = true } +image = { workspace = true } serde_json = { workspace = true } diff --git a/apps/designcraft-cli/src/main.rs b/apps/designcraft-cli/src/main.rs index 9ce15674c..bf7b6b351 100644 --- a/apps/designcraft-cli/src/main.rs +++ b/apps/designcraft-cli/src/main.rs @@ -16,6 +16,8 @@ //! designcraft-cli bench FILE [--runs N] # the same measurements on one document //! designcraft-cli validate-pdf FILE.pdf [--standard x4|x1a] # the built-in PDF/X-4 or PDF/X-1a checks (default: the //! # file's own GTS_PDFXVersion) +//! designcraft-cli thumbnail FILE.designcraft OUT.png [SIZE] # the file's first-page preview, fitted to SIZE +//! # (the freedesktop thumbnailer for Linux file managers) //! designcraft-cli links # Discord, website, app page and GitHub links //! designcraft-cli --version # print the version //! ``` @@ -40,7 +42,7 @@ use designcraft_engine::Session; use serde_json::{Value, json}; /// The usage block. Shared by `--help` (stdout, success) and an unknown command (stderr, failure). -const USAGE: &str = "usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n (--page, --scale and --pdf-options apply to the exports that follow them)\n designcraft-cli commands [FILTER]\n designcraft-cli describe COMMAND\n designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]\n designcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON]\n designcraft-cli mcp [--connect PORT] [--sample] [--automation-read-root DIR]... [--automation-write-root DIR]...\n the roots confine every file the agent's commands read (open, placed images, data sources, libraries) and\n write (save, export, package) to those folders, links followed. Each may be given more than once; a kind\n left out grants none of its access.\n They run the in-process server, so they conflict with --connect (a running app is not confined by them).\n designcraft-cli perf [--pages N] [--runs N] [--strict]\n designcraft-cli bench FILE [--runs N]\n designcraft-cli validate-pdf FILE.pdf # built-in PDF/X-4 checks (including visible unembedded fonts)\n designcraft-cli links\n designcraft-cli --version"; +const USAGE: &str = "usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n (--page, --scale and --pdf-options apply to the exports that follow them)\n designcraft-cli commands [FILTER]\n designcraft-cli describe COMMAND\n designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]\n designcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON]\n designcraft-cli mcp [--connect PORT] [--sample] [--automation-read-root DIR]... [--automation-write-root DIR]...\n the roots confine every file the agent's commands read (open, placed images, data sources, libraries) and\n write (save, export, package) to those folders, links followed. Each may be given more than once; a kind\n left out grants none of its access.\n They run the in-process server, so they conflict with --connect (a running app is not confined by them).\n designcraft-cli perf [--pages N] [--runs N] [--strict]\n designcraft-cli bench FILE [--runs N]\n designcraft-cli validate-pdf FILE.pdf # built-in PDF/X-4 checks (including visible unembedded fonts)\n designcraft-cli thumbnail FILE.designcraft OUT.png [SIZE] # the saved first-page preview, at most SIZE px\n designcraft-cli links\n designcraft-cli --version"; /// `script --help`: the script syntax, otherwise only in `describe script.run` and docs/agents.md. const SCRIPT_HELP: &str = r#"usage: designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going] @@ -124,6 +126,7 @@ fn main() -> ExitCode { Some("perf") => report(perf::perf(&args[1..])), Some("bench") => report(perf::bench(&args[1..])), Some("validate-pdf") => report(validate_pdf(args.get(1..).unwrap_or_default())), + Some("thumbnail") => report(thumbnail(&args[1..])), Some("links") => { use designcraft_engine::links::{APP_PAGE, DISCORD, GITHUB, ISSUES, WEBSITE}; outln!("Discord {DISCORD}\nWebsite {WEBSITE}\nApp page {APP_PAGE}\nGitHub {GITHUB}\nIssues {ISSUES}"); @@ -188,6 +191,41 @@ fn validate_pdf(args: &[String]) -> Result<(), String> { if valid { Ok(()) } else { Err(format!("{path} failed {name} validation")) } } +/// `thumbnail FILE OUT [SIZE]`: the first-page preview saved in a `.designcraft` file, as a PNG +/// whose longer side is at most SIZE pixels (never enlarged). It is the freedesktop thumbnailer +/// (`packaging/linux/ai.storyteller.designcraft.thumbnailer`), so it only reads the preview: a file +/// without one fails, and the file manager keeps the file type's icon. +fn thumbnail(args: &[String]) -> Result<(), String> { + const USAGE: &str = "usage: designcraft-cli thumbnail FILE.designcraft OUT.png [SIZE]"; + let [input, out, rest @ ..] = args else { return Err(USAGE.into()) }; + let size = match rest { + [] => None, + [s] => Some(s.parse::().ok().filter(|s| *s > 0).ok_or_else(|| format!("bad SIZE `{s}`: expected a positive number of pixels"))?), + _ => return Err(USAGE.into()), + }; + let file = std::fs::File::open(input).map_err(|e| format!("{input}: {e}"))?; + let preview = designcraft_format::read_preview(std::io::BufReader::new(file)) + .ok_or_else(|| format!("{input} has no preview (a DesignCraft file saved before they had one, or not a DesignCraft file)"))?; + let png = fit_png(preview, size).map_err(|e| format!("{input}: the preview can't be read: {e}"))?; + designcraft_engine::atomic::write_atomic(std::path::Path::new(out), &png).map_err(|e| format!("{out}: {e}")) +} + +/// `png` with its longer side brought down to `size`. A picture already that small is returned +/// as it is; it is decoded anyway, so a broken one is an error rather than an unreadable thumbnail. +fn fit_png(png: Vec, size: Option) -> Result, String> { + // The preview comes from the file: it may claim any size. + let mut limits = image::Limits::default(); + limits.max_image_width = Some(4096); + limits.max_image_height = Some(4096); + let mut reader = image::ImageReader::with_format(std::io::Cursor::new(&png), image::ImageFormat::Png); + reader.limits(limits); + let img = reader.decode().map_err(|e| e.to_string())?; + let Some(size) = size.filter(|s| img.width().max(img.height()) > *s) else { return Ok(png) }; + let mut out = std::io::Cursor::new(Vec::new()); + img.resize(size, size, image::imageops::FilterType::Triangle).write_to(&mut out, image::ImageFormat::Png).map_err(|e| e.to_string())?; + Ok(out.into_inner()) +} + /// `mcp` (headless, in-process engine) or `mcp --connect PORT|HOST:PORT` (drive a running app /// started with `designcraft --control PORT`). JSON-RPC on stdin/stdout; logs on stderr. fn mcp(args: &[String]) -> Result<(), String> { diff --git a/apps/designcraft-cli/tests/it/cli.rs b/apps/designcraft-cli/tests/it/cli.rs index 861cb514f..98b4f5edf 100644 --- a/apps/designcraft-cli/tests/it/cli.rs +++ b/apps/designcraft-cli/tests/it/cli.rs @@ -556,3 +556,63 @@ fn usage_lists_the_automation_root_flags() { let usage = String::from_utf8_lossy(&out.stdout); assert!(usage.contains("--automation-read-root DIR") && usage.contains("--automation-write-root DIR"), "{usage}"); } + +/// Width and height from a PNG's IHDR chunk. +fn png_size(png: &[u8]) -> (u32, u32) { + assert!(png.starts_with(b"\x89PNG\r\n\x1a\n"), "not a PNG"); + let be = |at: usize| u32::from_be_bytes(png[at..at + 4].try_into().unwrap()); + (be(16), be(20)) +} + +/// Linux file managers (Dolphin, Nautilus, Thunar) draw a `.designcraft` file's icon through the +/// freedesktop thumbnailer, which runs `designcraft-cli thumbnail %i %o %s` (#1782). The picture is +/// the first-page preview the file already holds, fitted to the size asked for and never enlarged. +#[test] +fn thumbnail_writes_the_saved_preview_fitted_to_the_size() { + let dir = std::env::temp_dir().join(format!("dc-cli-thumb-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let doc = dir.join("a.designcraft"); + make_document(&doc); + let preview = designcraft_format::read_preview(std::fs::File::open(&doc).unwrap()).unwrap(); + let (pw, ph) = png_size(&preview); + assert!(pw.max(ph) > 128, "the saved preview is {pw}×{ph}"); + + let small = dir.join("small.png"); + let out = cli().arg("thumbnail").arg(&doc).arg(&small).arg("128").output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + let (w, h) = png_size(&std::fs::read(&small).unwrap()); + assert_eq!(w.max(h), 128, "{w}×{h} from a {pw}×{ph} preview"); + // The page's proportions stay. + let ratio = |a: u32, b: u32| f64::from(a) / f64::from(b); + assert!((ratio(w, h) - ratio(pw, ph)).abs() < 0.02, "{w}×{h} from a {pw}×{ph} preview"); + + let large = dir.join("large.png"); + let out = cli().arg("thumbnail").arg(&doc).arg(&large).arg("1024").output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + assert_eq!(std::fs::read(&large).unwrap(), preview, "a preview smaller than the size is written as saved"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A file saved without a preview (by an older version, or not by DesignCraft) has no picture: the +/// thumbnailer fails and writes nothing, so the file manager keeps the file type's icon. +#[test] +fn thumbnail_of_a_file_without_a_preview_fails_and_writes_nothing() { + let dir = std::env::temp_dir().join(format!("dc-cli-thumb-none-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let with = dir.join("with.designcraft"); + make_document(&with); + let doc = designcraft_format::load(&std::fs::read(&with).unwrap()).unwrap(); + let without = dir.join("without.designcraft"); + std::fs::write(&without, designcraft_format::save(&doc).unwrap()).unwrap(); + let text = dir.join("notes.designcraft"); + std::fs::write(&text, "not a document").unwrap(); + for input in [&without, &text] { + let png = dir.join("out.png"); + let out = cli().arg("thumbnail").arg(input).arg(&png).arg("256").output().unwrap(); + let err = String::from_utf8_lossy(&out.stderr); + assert!(!out.status.success(), "{}", input.display()); + assert!(err.contains("no preview"), "{}: {err}", input.display()); + assert!(!png.exists(), "{}", input.display()); + } + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/packaging/linux/ai.storyteller.designcraft.thumbnailer b/packaging/linux/ai.storyteller.designcraft.thumbnailer new file mode 100644 index 000000000..4642996b3 --- /dev/null +++ b/packaging/linux/ai.storyteller.designcraft.thumbnailer @@ -0,0 +1,8 @@ +# Installed as share/thumbnailers/ai.storyteller.designcraft.thumbnailer (package.sh). File managers +# that read freedesktop thumbnailers (Nautilus, Thunar, Dolphin through KIO) run it for .designcraft +# files: %i is the file, %o the PNG to write, %s the size in pixels. It writes the first-page preview +# saved in the file; a file without one keeps the file type's icon. +[Thumbnailer Entry] +TryExec=designcraft-cli +Exec=designcraft-cli thumbnail %i %o %s +MimeType=application/x-designcraft; diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index fa24385d5..7b120c525 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -61,6 +61,7 @@ install -Dm755 "$BIN/designcraft-cli" "$STAGE/usr/bin/designcraft-cli" "${CROSS_COMPILE:-}strip" "$STAGE/usr/bin/designcraft" "$STAGE/usr/bin/designcraft-cli" 2>/dev/null || true install -Dm644 "$HERE/$APP_ID.desktop" "$STAGE/usr/share/applications/$APP_ID.desktop" install -Dm644 "$HERE/$APP_ID.mime.xml" "$STAGE/usr/share/mime/packages/$APP_ID.xml" +install -Dm644 "$HERE/$APP_ID.thumbnailer" "$STAGE/usr/share/thumbnailers/$APP_ID.thumbnailer" mkdir -p "$STAGE/usr/share/metainfo" sed -e "s/@VERSION@/$VERSION/g" -e "s/@DATE@/$DESIGNCRAFT_BUILD_DATE/g" \ "$HERE/$APP_ID.metainfo.xml.in" >"$STAGE/usr/share/metainfo/$APP_ID.metainfo.xml" From 8e8865bba18922e29dd57ed9c62fa910ab701c41 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 30 Oct 2026 03:08:39 +0100 Subject: [PATCH 09/33] thumbnail: refuse an output that names the input document (#1782) designcraft-cli thumbnail a.designcraft a.designcraft replaced the document with PNG bytes. The output is now refused before anything is read or written when it is the input by the same path, through a symlink, or on Unix as a hard link (same device and inode). --- apps/designcraft-cli/src/main.rs | 20 ++++++++++++ apps/designcraft-cli/tests/it/cli.rs | 47 ++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/apps/designcraft-cli/src/main.rs b/apps/designcraft-cli/src/main.rs index bf7b6b351..a26c3d977 100644 --- a/apps/designcraft-cli/src/main.rs +++ b/apps/designcraft-cli/src/main.rs @@ -203,6 +203,9 @@ fn thumbnail(args: &[String]) -> Result<(), String> { [s] => Some(s.parse::().ok().filter(|s| *s > 0).ok_or_else(|| format!("bad SIZE `{s}`: expected a positive number of pixels"))?), _ => return Err(USAGE.into()), }; + if names_the_same_file(input.as_ref(), out.as_ref()) { + return Err(format!("{out} is the document {input}: the thumbnail would replace it")); + } let file = std::fs::File::open(input).map_err(|e| format!("{input}: {e}"))?; let preview = designcraft_format::read_preview(std::io::BufReader::new(file)) .ok_or_else(|| format!("{input} has no preview (a DesignCraft file saved before they had one, or not a DesignCraft file)"))?; @@ -210,6 +213,23 @@ fn thumbnail(args: &[String]) -> Result<(), String> { designcraft_engine::atomic::write_atomic(std::path::Path::new(out), &png).map_err(|e| format!("{out}: {e}")) } +/// Whether `out` is the file `input` names: the same path, a symlink to it or, on Unix, a hard +/// link (the same device and inode). An `out` that doesn't exist yet can't be the input. +fn names_the_same_file(input: &std::path::Path, out: &std::path::Path) -> bool { + let (Ok(a), Ok(b)) = (std::fs::canonicalize(input), std::fs::canonicalize(out)) else { return false }; + if a == b { + return true; + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if let (Ok(a), Ok(b)) = (std::fs::metadata(&a), std::fs::metadata(&b)) { + return (a.dev(), a.ino()) == (b.dev(), b.ino()); + } + } + false +} + /// `png` with its longer side brought down to `size`. A picture already that small is returned /// as it is; it is decoded anyway, so a broken one is an error rather than an unreadable thumbnail. fn fit_png(png: Vec, size: Option) -> Result, String> { diff --git a/apps/designcraft-cli/tests/it/cli.rs b/apps/designcraft-cli/tests/it/cli.rs index 98b4f5edf..f19551a71 100644 --- a/apps/designcraft-cli/tests/it/cli.rs +++ b/apps/designcraft-cli/tests/it/cli.rs @@ -616,3 +616,50 @@ fn thumbnail_of_a_file_without_a_preview_fails_and_writes_nothing() { } let _ = std::fs::remove_dir_all(&dir); } + +/// `thumbnail a.designcraft a.designcraft` would replace the document with a PNG. An output that +/// names the input, by the same path, through a symlink or (on Unix) as a hard link, is refused +/// before anything is read or written, and the document keeps its bytes. +#[test] +fn thumbnail_refuses_an_output_that_names_the_input() { + let dir = std::env::temp_dir().join(format!("dc-cli-thumb-self-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let doc = dir.join("a.designcraft"); + make_document(&doc); + let saved = std::fs::read(&doc).unwrap(); + let mut outs = vec![doc.clone(), dir.join(".").join("a.designcraft")]; + #[cfg(unix)] + { + let link = dir.join("link.png"); + std::os::unix::fs::symlink(&doc, &link).unwrap(); + outs.push(link); + let hard = dir.join("hard.png"); + std::fs::hard_link(&doc, &hard).unwrap(); + outs.push(hard); + } + for out_path in &outs { + let out = cli().arg("thumbnail").arg(&doc).arg(out_path).arg("128").output().unwrap(); + let err = String::from_utf8_lossy(&out.stderr); + assert_eq!(out.status.code(), Some(1), "{}: {err}", out_path.display()); + assert!(err.contains("is the document"), "{}: {err}", out_path.display()); + assert!(std::fs::read(&doc).unwrap() == saved, "{} changed the document", out_path.display()); + assert!(std::fs::read(out_path).unwrap() == saved, "{} was written", out_path.display()); + } + let _ = std::fs::remove_dir_all(&dir); +} + +/// Another existing file at the output path is still replaced by the thumbnail. +#[test] +fn thumbnail_replaces_another_existing_file() { + let dir = std::env::temp_dir().join(format!("dc-cli-thumb-other-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let doc = dir.join("a.designcraft"); + make_document(&doc); + let png = dir.join("old.png"); + std::fs::write(&png, b"an older thumbnail").unwrap(); + let out = cli().arg("thumbnail").arg(&doc).arg(&png).arg("128").output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + let (w, h) = png_size(&std::fs::read(&png).unwrap()); + assert_eq!(w.max(h), 128, "{w}×{h}"); + let _ = std::fs::remove_dir_all(&dir); +} From 90bbec39d1a0b3cf9986541e4dc1d900b82a9632 Mon Sep 17 00:00:00 2001 From: makesomethingshit Date: Sun, 1 Nov 2026 19:44:44 +0900 Subject: [PATCH 10/33] #1620: Restore parent margins and pre-canvas page defaults --- ROADMAP.md | 2 +- crates/ui-egui/src/chrome/tests.rs | 14 ++- crates/ui-egui/src/control.rs | 79 ++++++++++-- crates/ui-egui/src/menus/run.rs | 6 +- .../ui-egui/src/panels/properties/document.rs | 115 ++++++++++-------- docs/control-protocol.md | 2 +- 6 files changed, 154 insertions(+), 64 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index dbf91dc8e..b5c4a15e2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -388,7 +388,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. -- **2026-11-01** · Parent page resizing (#1620): Properties, the Page Tool Control panel and on-page handles resize the selected parent page independently of document pages. Stable parent IDs, repeated resizing, anchors and liquid rules are preserved on the current command modules. No readiness percentage change. +- **2026-11-01** · Parent page resizing (#1620): Properties, the Page Tool Control panel and on-page handles resize the selected parent page independently of document pages. Stable parent IDs, repeated resizing, anchors and liquid rules are preserved on the current command modules. Inline parent margin editing remains available; document commands retain their first-page default before the canvas is initialized. No readiness percentage change. - **2026-10-30** · Import warnings about a story have a Show button: it closes the warnings (the status line keeps them) and selects the story's first frame, fitted in the window. `file.open` returns `warningItems`, the frame each IDML or InDesign warning is about (or null), matching the converter's INDD story numbers (`story 30892`, written to IDML as `u78ac`) and IDML `Self` names to the imported stories. No readiness percentage change. - **2026-10-30** · Parent pages: a running-header variable shows its name in angle brackets on the parent itself (it was blank), as InDesign shows it; on the document pages, and in text with no page, it shows its text as before. No readiness percentage change. diff --git a/crates/ui-egui/src/chrome/tests.rs b/crates/ui-egui/src/chrome/tests.rs index 73a943330..99704c1d7 100644 --- a/crates/ui-egui/src/chrome/tests.rs +++ b/crates/ui-egui/src/chrome/tests.rs @@ -913,7 +913,7 @@ fn caption_buttons_fit_the_minimum_window_width() { } #[test] -fn parent_resize_properties_changes_the_parent_not_document_page_zero() { +fn parent_resize_properties_keep_size_and_inline_parent_margins() { parent_resize_panel(true); } @@ -974,6 +974,18 @@ fn parent_resize_panel(properties: bool) { let parents = d.parents.clone(); if properties { let saved = d.clone(); + assert_eq!(h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Top").value().as_deref(), Some("3p0")); + edit_parent_resize_field(&mut h, "3p0", "72 pt"); + let edited = &h.state().session.doc().unwrap().doc; + for page in &edited.parents[1].pages { + assert_eq!(page.margins, designcraft_doc::Margins { top: 72.0, bottom: 72.0, inside: 72.0, outside: 72.0 }); + } + assert_eq!(edited.parents[0], saved.parents[0]); + assert_eq!(edited.spreads, saved.spreads); + assert_eq!(edited.settings, saved.settings); + h.state_mut().run("edit.undo", json!({})).unwrap(); + h.run_steps(3); + assert_eq!(h.state().session.doc().unwrap().doc, saved); h.get_by_label("Back").click(); h.run_steps(3); assert!(!h.state().session.doc().unwrap().editing_parents, "Back returns to document properties"); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 7032f6810..d70a79695 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -421,7 +421,7 @@ fn handle_request(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest let Some(st) = app.session.active() else { return err("no document") }; let current = match crate::canvas::current_page(app) { Some(page) => page, - None if p.get("page").is_some() => 0, + None if !st.editing_parents || p.get("page").is_some() => 0, None => return err("select a document page or give an explicit page"), }; let page = match page_index(p, current, st.doc.page_count()) { @@ -695,19 +695,82 @@ mod automation_contract_tests { assert_eq!(app.session.execute("story.get", &json!({"story":f["story"]})).unwrap()["text"], "Hello"); } #[test] - fn parent_resize_mode_does_not_default_document_actions_to_page_zero() { + fn parent_resize_no_canvas_render_keeps_document_page_zero_default() { let mut app = app(); let ctx = egui::Context::default(); - app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))); - app.run("layout.parents.edit", json!({"on":true,"parent":"A"})).unwrap(); + app.run("layout.pageSize", json!({"pages": [2], "width": 700})).unwrap(); + assert!(app.canvas_rect.is_none()); let before = app.session.doc().unwrap().doc.clone(); - assert!(app.run("app.deletePage", json!({})).is_err()); - assert!(app.run("app.duplicateSpread", json!({})).is_err()); - assert_eq!(call(&mut app, &ctx, "ui.render", json!({"scale":0.1}))["ok"], false); - assert_eq!(call(&mut app, &ctx, "ui.render", json!({"page":0,"scale":0.1}))["ok"], true); + let omitted = call(&mut app, &ctx, "ui.render", json!({"scale": 0.1})); + assert_eq!(omitted["ok"], true, "{omitted}"); + let first = call(&mut app, &ctx, "ui.render", json!({"page": 0, "scale": 0.1})); + let second = call(&mut app, &ctx, "ui.render", json!({"page": 1, "scale": 0.1})); + assert_eq!(omitted, first); + assert_ne!(omitted, second); assert_eq!(app.session.doc().unwrap().doc, before); } + #[test] + fn parent_resize_no_canvas_delete_uses_first_document_page() { + let mut app = app(); + assert!(app.canvas_rect.is_none()); + let before = app.session.doc().unwrap().doc.clone(); + app.run("app.deletePage", json!({})).unwrap(); + let after = &app.session.doc().unwrap().doc; + assert_eq!(after.page_count(), 1); + assert_eq!(after.page(0).unwrap().id, before.page(1).unwrap().id); + assert_eq!(after.parents, before.parents); + assert!(app.ui.dialog.is_none()); + } + + #[test] + fn parent_resize_no_canvas_delete_keeps_the_object_warning() { + let mut app = app(); + app.run("frame.create", json!({"rect": [36, 36, 100, 100]})).unwrap(); + assert!(app.canvas_rect.is_none()); + let before = app.session.doc().unwrap().doc.clone(); + app.run("app.deletePage", json!({})).unwrap(); + let dialog = app.ui.dialog.as_ref().unwrap(); + assert_eq!(dialog.id, "deletePages"); + assert_eq!(dialog.fields["pages"], json!([0])); + assert_eq!(app.session.doc().unwrap().doc, before); + } + + #[test] + fn parent_resize_no_canvas_duplicate_uses_first_document_spread() { + let mut app = app(); + assert!(app.canvas_rect.is_none()); + let before = app.session.doc().unwrap().doc.clone(); + let ids: Vec<_> = before.spreads.iter().flat_map(|sp| sp.pages.iter().map(|p| p.id)).collect(); + let result = app.run("app.duplicateSpread", json!({})).unwrap(); + let after = &app.session.doc().unwrap().doc; + let first = result["page"].as_u64().unwrap() as usize; + assert_eq!(first, before.spreads[0].pages.len()); + assert_eq!(after.page_count(), before.page_count() + first); + for i in first..first * 2 { + assert!(!ids.contains(&after.page(i).unwrap().id)); + } + let retained: Vec<_> = after.spreads.iter().flat_map(|sp| sp.pages.iter().map(|p| p.id)).filter(|id| ids.contains(id)).collect(); + assert_eq!(retained, ids); + assert_eq!(after.parents, before.parents); + } + + #[test] + fn parent_resize_mode_does_not_default_document_actions_to_page_zero() { + for canvas in [None, Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0)))] { + let mut app = app(); + let ctx = egui::Context::default(); + app.canvas_rect = canvas; + app.run("layout.parents.edit", json!({"on":true,"parent":"A"})).unwrap(); + let before = app.session.doc().unwrap().doc.clone(); + assert!(app.run("app.deletePage", json!({})).is_err()); + assert!(app.run("app.duplicateSpread", json!({})).is_err()); + assert_eq!(call(&mut app, &ctx, "ui.render", json!({"scale":0.1}))["ok"], false); + assert_eq!(call(&mut app, &ctx, "ui.render", json!({"page":0,"scale":0.1}))["ok"], true); + assert_eq!(app.session.doc().unwrap().doc, before); + } + } + #[test] fn control_pages_validate_before_writer_and_preserve_omission() { let mut app = app(); diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index d2a87be3c..f26532ebf 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -462,7 +462,11 @@ fn run_ui_part2(app: &mut DesignApp, id: &str, p: &Value) -> Option { let Some(st) = app.session.active() else { return Some(Err("no document open".into())) }; - let Some(page) = crate::canvas::current_page(app) else { return Some(Err("select a document page".into())) }; + let page = match crate::canvas::current_page(app) { + Some(page) => page, + None if !st.editing_parents => 0, + None => return Some(Err("select a document page".into())), + }; let r = if id == "app.deletePage" { crate::dialogs::delete_pages(app, vec![page]) } else { diff --git a/crates/ui-egui/src/panels/properties/document.rs b/crates/ui-egui/src/panels/properties/document.rs index f8ea15451..bb7111e48 100644 --- a/crates/ui-egui/src/panels/properties/document.rs +++ b/crates/ui-egui/src/panels/properties/document.rs @@ -136,6 +136,9 @@ pub(super) fn page_sections(app: &mut DesignApp, ui: &mut Ui, spread: designcraf { let _ = app.run(cmd, params); } + if matches!(spread, designcraft_doc::SpreadRef::Parent(_)) { + margin_section(app, ui); + } ui.add_space(6.0); let fw = full_width(ui); if outline_button(ui, crate::i18n::tr(&lang, "Margins and Columns"), fw).clicked() { @@ -201,6 +204,65 @@ fn page_box_field(app: &mut DesignApp, ui: &mut Ui, b: Rect) { } } +fn margin_section(app: &mut DesignApp, ui: &mut Ui) { + let u = units(app); + let Some(st) = app.session.active() else { return }; + let d = st.doc.clone(); + let t = Tokens::get(ui.ctx()); + section(ui, crate::i18n::tr(&app.ui.language, "Margins"), true); + let cur = crate::canvas::current_page(app).unwrap_or(0); + // While parent spreads are edited the fields belong to the parent page in view; they used to + // show (and change) the document pages instead, so nothing changed on the parent. + let parent_target = crate::canvas::current_parent_page(app); + let shown = match parent_target { + Some((pi, qi)) => d.parents.get(pi).and_then(|s| s.pages.get(qi)).cloned(), + None => d.page(cur).cloned(), + }; + if let Some(p) = shown { + let linked = temp_bool(ui, "margins_linked", true); + let b = block(ui, 2.0 * FIELD_H + 5.0); + let mut set: Option = None; + let fields = [ + ("margin-top", "Top", p.margins.top, 0.0, 0.0), + ("margin-bottom", "Bottom", p.margins.bottom, 0.0, 26.0), + ("margin-outside", if d.settings.facing_pages { "Outside" } else { "Right" }, p.margins.outside, 127.0, 0.0), + ("margin-inside", if d.settings.facing_pages { "Inside" } else { "Left" }, p.margins.inside, 127.0, 26.0), + ]; + for (k, (icon, tip, v, x, y)) in fields.into_iter().enumerate() { + let ir = sub(b, x, y + 2.0, 17.0, 17.0); + icons::paint(ui.painter(), ir, icon, t.icon); + ui.interact(ir, ui.scope_id().with(("micon", k)), Sense::hover()).on_hover_ui(|ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, tip)); + }); + let nv = place(ui, sub(b, x + 20.0, y, 73.0, FIELD_H), |ui| { + NumField::measure(tip, Some(v), u).width(73.0).spinner().range(0.0, 8000.0).show(ui) + }); + if let Some(nv) = nv { + set = Some(edited_margins(p.margins, linked, k, nv)); + } + } + let lr = sub(b, 99.0, 12.5, 22.0, FIELD_H); + let clicked = place(ui, lr, |ui| { + widgets::icon_toggle_sized(ui, if linked { "link" } else { "link-broken" }, linked, "Make all settings the same", vec2(22.0, 21.0)) + .clicked() + }); + if clicked { + set_temp_bool(ui, "margins_linked", !linked); + } + if let Some(m) = set { + let params = match parent_target { + // Both pages of a facing parent spread, so the margins show wherever the parent is used. + Some((pi, _)) => { + let n = app.session.active().and_then(|st| st.doc.parents.get(pi)).map_or(1, |sp| sp.pages.len()); + json!({"margins": m, "parent": pi, "pages": (0..n).collect::>()}) + } + None => json!({"margins": m}), + }; + let _ = app.run("layout.marginsAndColumns", params); + } + } +} + pub(super) fn document_sections(app: &mut DesignApp, ui: &mut Ui) { let u = units(app); let Some(st) = app.session.active() else { return }; @@ -254,59 +316,8 @@ pub(super) fn document_sections(app: &mut DesignApp, ui: &mut Ui) { if changed { let _ = app.run("layout.documentSetup", json!({"facingPages": facing})); } - // Margins. - section(ui, crate::i18n::tr(&app.ui.language, "Margins"), true); + margin_section(app, ui); let cur = crate::canvas::current_page(app).unwrap_or(0); - // While parent spreads are edited the fields belong to the parent page in view; they used to - // show (and change) the document pages instead, so nothing changed on the parent. - let parent_target = crate::canvas::current_parent_page(app); - let shown = match parent_target { - Some((pi, qi)) => d.parents.get(pi).and_then(|s| s.pages.get(qi)).cloned(), - None => d.page(cur).cloned(), - }; - if let Some(p) = shown { - let linked = temp_bool(ui, "margins_linked", true); - let b = block(ui, 2.0 * FIELD_H + 5.0); - let mut set: Option = None; - let fields = [ - ("margin-top", "Top", p.margins.top, 0.0, 0.0), - ("margin-bottom", "Bottom", p.margins.bottom, 0.0, 26.0), - ("margin-outside", if d.settings.facing_pages { "Outside" } else { "Right" }, p.margins.outside, 127.0, 0.0), - ("margin-inside", if d.settings.facing_pages { "Inside" } else { "Left" }, p.margins.inside, 127.0, 26.0), - ]; - for (k, (icon, tip, v, x, y)) in fields.into_iter().enumerate() { - let ir = sub(b, x, y + 2.0, 17.0, 17.0); - icons::paint(ui.painter(), ir, icon, t.icon); - ui.interact(ir, ui.scope_id().with(("micon", k)), Sense::hover()).on_hover_ui(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, tip)); - }); - let nv = place(ui, sub(b, x + 20.0, y, 73.0, FIELD_H), |ui| { - NumField::measure(tip, Some(v), u).width(73.0).spinner().range(0.0, 8000.0).show(ui) - }); - if let Some(nv) = nv { - set = Some(edited_margins(p.margins, linked, k, nv)); - } - } - let lr = sub(b, 99.0, 12.5, 22.0, FIELD_H); - let clicked = place(ui, lr, |ui| { - widgets::icon_toggle_sized(ui, if linked { "link" } else { "link-broken" }, linked, "Make all settings the same", vec2(22.0, 21.0)) - .clicked() - }); - if clicked { - set_temp_bool(ui, "margins_linked", !linked); - } - if let Some(m) = set { - let params = match parent_target { - // Both pages of a facing parent spread, so the margins show wherever the parent is used. - Some((pi, _)) => { - let n = app.session.active().and_then(|st| st.doc.parents.get(pi)).map_or(1, |sp| sp.pages.len()); - json!({"margins": m, "parent": pi, "pages": (0..n).collect::>()}) - } - None => json!({"margins": m}), - }; - let _ = app.run("layout.marginsAndColumns", params); - } - } ui.add_space(6.0); let fw = full_width(ui); if outline_button(ui, crate::i18n::tr(&app.ui.language, "Adjust Layout"), fw).clicked() { diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 2d8fb7718..e886c07ab 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -49,7 +49,7 @@ so only enable it while you use it. Transport: `apps/designcraft/src/control_ser | `ui.dialog.set` / `ui.dialog.confirm` / `ui.dialog.cancel` | `{field, value}` | Fill and confirm the open dialog | | `ui.resize`, `ui.focus` | | Window control | | `ui.screenshot` | `{path?}` | PNG of the whole window | -| `ui.render` | `{path?, page?, scale?, bleed?}` | Render a document page headlessly (PNG; base64 if no path); parent editing mode requires an explicit 0-based `page` | +| `ui.render` | `{path?, page?, scale?, bleed?}` | Render a document page headlessly (PNG; base64 if no path); without a canvas, document mode defaults to page 0; parent editing mode requires an explicit 0-based `page` | | `app.open` / `app.save` / `app.export` | | Files | | `app.quit` | | Quit without asking about unsaved changes (the `app.quit` command, like ⌘Q, asks) | From 20925111eb5701e2b31d965446ec6086821d1885 Mon Sep 17 00:00:00 2001 From: makesomethingshit Date: Mon, 2 Nov 2026 00:05:23 +0900 Subject: [PATCH 11/33] #1620: Limit inline margins to the selected parent page --- ROADMAP.md | 2 +- crates/ui-egui/src/chrome/tests.rs | 55 +++++++++++++++---- .../ui-egui/src/panels/properties/document.rs | 6 +- 3 files changed, 45 insertions(+), 18 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b5c4a15e2..3523a83be 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -388,7 +388,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. -- **2026-11-01** · Parent page resizing (#1620): Properties, the Page Tool Control panel and on-page handles resize the selected parent page independently of document pages. Stable parent IDs, repeated resizing, anchors and liquid rules are preserved on the current command modules. Inline parent margin editing remains available; document commands retain their first-page default before the canvas is initialized. No readiness percentage change. +- **2026-11-01** · Parent page resizing (#1620): Properties, the Page Tool Control panel and on-page handles resize the selected parent page independently of document pages. Stable parent IDs, repeated resizing, anchors and liquid rules are preserved on the current command modules. Inline parent margin editing targets only the selected parent page and preserves opposite-page margins and document overrides; document commands retain their first-page default before the canvas is initialized. No readiness percentage change. - **2026-10-30** · Import warnings about a story have a Show button: it closes the warnings (the status line keeps them) and selects the story's first frame, fitted in the window. `file.open` returns `warningItems`, the frame each IDML or InDesign warning is about (or null), matching the converter's INDD story numbers (`story 30892`, written to IDML as `u78ac`) and IDML `Self` names to the imported stories. No readiness percentage change. - **2026-10-30** · Parent pages: a running-header variable shows its name in angle brackets on the parent itself (it was blank), as InDesign shows it; on the document pages, and in text with no page, it shows its text as before. No readiness percentage change. diff --git a/crates/ui-egui/src/chrome/tests.rs b/crates/ui-egui/src/chrome/tests.rs index 99704c1d7..f0449c2d0 100644 --- a/crates/ui-egui/src/chrome/tests.rs +++ b/crates/ui-egui/src/chrome/tests.rs @@ -929,6 +929,16 @@ fn parent_resize_panel(properties: bool) { app.run("file.new", serde_json::json!({"width": 400, "height": 600, "pages": 3, "facingPages": true})).unwrap(); app.run("layout.parents.new", serde_json::json!({"prefix": "B"})).unwrap(); app.run("layout.pages.applyParent", serde_json::json!({"pages": [0], "parent": "A"})).unwrap(); + if properties { + app.run("layout.marginsAndColumns", json!({"parent": 1, "pages": [0], "margins": 24})).unwrap(); + app.run( + "layout.marginsAndColumns", + json!({"parent": 1, "pages": [1], "margins": {"top": 36, "bottom": 48, "inside": 60, "outside": 72}}), + ) + .unwrap(); + app.run("layout.pages.applyParent", json!({"pages": [0, 1, 2], "parent": "B"})).unwrap(); + app.run("layout.marginsAndColumns", json!({"pages": [0], "margins": 90})).unwrap(); + } app.run("layout.parents.edit", serde_json::json!({"on": true, "parent": "A"})).unwrap(); app.session.set_tool("page"); app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(800.0, 600.0))); @@ -974,18 +984,7 @@ fn parent_resize_panel(properties: bool) { let parents = d.parents.clone(); if properties { let saved = d.clone(); - assert_eq!(h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Top").value().as_deref(), Some("3p0")); - edit_parent_resize_field(&mut h, "3p0", "72 pt"); - let edited = &h.state().session.doc().unwrap().doc; - for page in &edited.parents[1].pages { - assert_eq!(page.margins, designcraft_doc::Margins { top: 72.0, bottom: 72.0, inside: 72.0, outside: 72.0 }); - } - assert_eq!(edited.parents[0], saved.parents[0]); - assert_eq!(edited.spreads, saved.spreads); - assert_eq!(edited.settings, saved.settings); - h.state_mut().run("edit.undo", json!({})).unwrap(); - h.run_steps(3); - assert_eq!(h.state().session.doc().unwrap().doc, saved); + check_parent_margin_edits(&mut h); h.get_by_label("Back").click(); h.run_steps(3); assert!(!h.state().session.doc().unwrap().editing_parents, "Back returns to document properties"); @@ -1013,6 +1012,38 @@ fn parent_resize_panel(properties: bool) { } } +fn check_parent_margin_edits(h: &mut egui_kittest::Harness<'_, DesignApp>) { + let saved = h.state().session.doc().unwrap().doc.clone(); + assert_eq!(h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Top").value().as_deref(), Some("3p0")); + edit_parent_resize_field(h, "3p0", "72 pt"); + let edited = &h.state().session.doc().unwrap().doc; + assert_eq!(edited.parents[1].pages[1].margins, designcraft_doc::Margins::uniform(72.0)); + assert_eq!(edited.parents[1].pages[0], saved.parents[1].pages[0]); + assert_eq!(edited.parents[0], saved.parents[0]); + assert_eq!(edited.page(0), saved.page(0), "local document margins stay unchanged"); + assert_eq!(edited.page(1), saved.page(1), "the other parent page's inheritor stays unchanged"); + assert_eq!(edited.page(2).unwrap().margins, designcraft_doc::Margins::uniform(72.0)); + assert_eq!(edited.settings, saved.settings); + h.state_mut().run("edit.undo", json!({})).unwrap(); + h.run_steps(3); + assert_eq!(h.state().session.doc().unwrap().doc, saved); + h.get_by_label("Make all settings the same").click(); + h.run_steps(3); + edit_parent_resize_field(h, "3p0", "84 pt"); + let edited = &h.state().session.doc().unwrap().doc; + let margins = designcraft_doc::Margins { top: 84.0, bottom: 48.0, inside: 60.0, outside: 72.0 }; + assert_eq!(edited.parents[1].pages[1].margins, margins); + assert_eq!(edited.parents[1].pages[0], saved.parents[1].pages[0]); + assert_eq!(edited.parents[0], saved.parents[0]); + assert_eq!(edited.page(0), saved.page(0)); + assert_eq!(edited.page(1), saved.page(1)); + assert_eq!(edited.page(2).unwrap().margins, margins); + assert_eq!(edited.settings, saved.settings); + h.state_mut().run("edit.undo", json!({})).unwrap(); + h.run_steps(3); + assert_eq!(h.state().session.doc().unwrap().doc, saved); +} + fn edit_parent_resize_field(h: &mut egui_kittest::Harness<'_, DesignApp>, value: &str, input: &str) { h.query_all_by(|n| n.role() == egui::accesskit::Role::TextInput && n.value().as_deref() == Some(value)).next().expect("page size field").click(); h.run_steps(2); diff --git a/crates/ui-egui/src/panels/properties/document.rs b/crates/ui-egui/src/panels/properties/document.rs index bb7111e48..450123568 100644 --- a/crates/ui-egui/src/panels/properties/document.rs +++ b/crates/ui-egui/src/panels/properties/document.rs @@ -251,11 +251,7 @@ fn margin_section(app: &mut DesignApp, ui: &mut Ui) { } if let Some(m) = set { let params = match parent_target { - // Both pages of a facing parent spread, so the margins show wherever the parent is used. - Some((pi, _)) => { - let n = app.session.active().and_then(|st| st.doc.parents.get(pi)).map_or(1, |sp| sp.pages.len()); - json!({"margins": m, "parent": pi, "pages": (0..n).collect::>()}) - } + Some((pi, qi)) => json!({"margins": m, "parent": pi, "pages": [qi]}), None => json!({"margins": m}), }; let _ = app.run("layout.marginsAndColumns", params); From 60584e32217c264482601d1b2f1777eb0f7c8f5a Mon Sep 17 00:00:00 2001 From: Tom Main Date: Sun, 1 Nov 2026 15:08:46 -0500 Subject: [PATCH 12/33] #2108: prefer document content over InDesign filename extensions Keep native ZIP, legacy JSON and IDML bytes out of the INDD converter even when a browser supplies a misleading .indd or .indt name. Preserve signature and unknown-data routing to the converter. Check that every browser delivery adds a document before asserting its title. Co-Authored-By: OpenAI Codex --- ROADMAP.md | 2 +- crates/engine/src/cmd/file/open.rs | 25 +++++++++++++++++++++++++ crates/engine/src/cmd/interchange.rs | 6 ++++-- crates/ui-egui/src/app/files.rs | 14 +++++++++++--- 4 files changed, 41 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 054d0f28a..3e4e571a8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -388,7 +388,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. -- **2026-10-31** · A `.designcraft` file opened from bytes (`file.openBytes` with its file name, as a browser or host passes it) is titled without the extension, as an IDML file opened that way is. Saving it in the browser downloaded `Brochure.designcraft.designcraft`, and the File Name variable showed the doubled name. Browser Open and Drop pass the full file name, so only the engine removes the extension and a repeated suffix is kept in the title. No readiness percentage change. +- **2026-10-31** · A `.designcraft` file opened from bytes (`file.openBytes` with its file name, as a browser or host passes it) is titled without the extension, as an IDML file opened that way is. Saving it in the browser downloaded `Brochure.designcraft.designcraft`, and the File Name variable showed the doubled name. Browser Open and Drop pass the full file name, so only the engine removes the extension and a repeated suffix is kept in the title. Native and IDML content takes priority over a misleading `.indd` or `.indt` extension. No readiness percentage change. - **2026-10-30** · Import warnings about a story have a Show button: it closes the warnings (the status line keeps them) and selects the story's first frame, fitted in the window. `file.open` returns `warningItems`, the frame each IDML or InDesign warning is about (or null), matching the converter's INDD story numbers (`story 30892`, written to IDML as `u78ac`) and IDML `Self` names to the imported stories. No readiness percentage change. - **2026-10-30** · Parent pages: a running-header variable shows its name in angle brackets on the parent itself (it was blank), as InDesign shows it; on the document pages, and in text with no page, it shows its text as before. No readiness percentage change. - **2026-10-30** · Without facing pages, a page of a multi-page spread shows the parent page in its place, as in InDesign: the second page of a two-page spread shows the parent's second page. Every single-sided page showed the parent's first page, so an InDesign book set up without facing pages but laid out in two-page spreads repeated the left parent page's items (a black heading bar) on its right pages and lost the right parent page's. No readiness percentage change. diff --git a/crates/engine/src/cmd/file/open.rs b/crates/engine/src/cmd/file/open.rs index 4e90f30fb..6d1aee7ce 100644 --- a/crates/engine/src/cmd/file/open.rs +++ b/crates/engine/src/cmd/file/open.rs @@ -420,6 +420,29 @@ mod open_bytes_tests { use crate::Session; + #[test] + fn legacy_and_idml_bytes_named_as_indesign_files_open_without_conversion() { + let mut maker = Session::new(); + maker.execute("file.new", &json!({})).unwrap(); + let idml = designcraft_idml::export_idml(&maker.doc().unwrap().doc); + let base64 = super::super::bytes::base64_encode(&idml); + let legacy = super::super::bytes::base64_encode(&serde_json::to_vec(&maker.doc().unwrap().doc).unwrap()); + for name in ["Brochure.indd", "Brochure.INDT"] { + let mut s = Session::new(); + s.execute("file.openBytes", &json!({"name": name, "base64": legacy})).unwrap(); + assert_eq!(s.documents().len(), 1, "legacy: {name}"); + assert_eq!(s.doc().unwrap().doc.title, name, "legacy: {name}"); + } + for command in ["file.openBytes", "file.openIdml"] { + for name in ["Brochure.indd", "Brochure.INDT"] { + let mut s = Session::new(); + s.execute(command, &json!({"name": name, "base64": base64})).unwrap(); + assert_eq!(s.documents().len(), 1, "{command}: {name}"); + assert_eq!(s.doc().unwrap().doc.title, "Brochure", "{command}: {name}"); + } + } + } + /// A `.designcraft` file opened from bytes under its file name (a browser or host passes the /// name it has) is titled without the extension, as an IDML file is, so saving it gives /// `Brochure.designcraft` and not `Brochure.designcraft.designcraft`. @@ -433,6 +456,8 @@ mod open_bytes_tests { ("Brochure.DesignCraft", "Brochure"), ("Brochure", "Brochure"), ("Brochure.v2", "Brochure.v2"), + ("Brochure.indd", "Brochure.indd"), + ("Brochure.INDT", "Brochure.INDT"), ("Brochure.designcraft.designcraft", "Brochure.designcraft"), (".designcraft", ".designcraft"), // Names that aren't ASCII: cutting the extension's length off the end of the last one diff --git a/crates/engine/src/cmd/interchange.rs b/crates/engine/src/cmd/interchange.rs index 4ca22309c..0d0824f06 100644 --- a/crates/engine/src/cmd/interchange.rs +++ b/crates/engine/src/cmd/interchange.rs @@ -455,9 +455,11 @@ fn warning_item( } /// An InDesign document or template (.indd, .indt): known by its signature, or by its name when -/// the bytes are something else (the converter then says why it can't read them). +/// the bytes are neither a recognized DesignCraft document nor IDML. The converter explains +/// unrecognized data with an InDesign extension. pub(crate) fn is_indd(name: &str, bytes: &[u8]) -> bool { - bytes.starts_with(&indd::header::SIGNATURE) || has_indd_extension(name) + bytes.starts_with(&indd::header::SIGNATURE) + || (has_indd_extension(name) && !super::file::is_document(bytes) && !bytes.trim_ascii_start().starts_with(b"{")) } pub(crate) fn has_indd_extension(name: &str) -> bool { diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index 68155d884..cef32eae9 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -435,20 +435,28 @@ pub(crate) mod browser_import_tests { ("Brochure.designcraft", "Brochure"), ("Brochure.designcraft.designcraft", "Brochure.designcraft"), ("Brochure.v2.designcraft", "Brochure.v2"), + ("Brochure.indd", "Brochure.indd"), + ("Brochure.INDT", "Brochure.INDT"), ("日本.DESIGNCRAFT", "日本"), (".designcraft", ".designcraft"), ("Brochure", "Brochure"), ] { + let before = app.session.documents().len(); let request = app.import_request(purpose); deliver(&mut app, request, name, native.clone()); + assert_eq!(app.session.documents().len(), before + 1, "{purpose}: {name}"); assert_eq!(app.session.doc().unwrap().doc.title, title, "{purpose}: {name}"); } } let idml = designcraft_idml::export_idml(&app.session.doc().unwrap().doc); for purpose in ["open", "drop"] { - let request = app.import_request(purpose); - deliver(&mut app, request, "Brochure.idml", idml.clone()); - assert_eq!(app.session.doc().unwrap().doc.title, "Brochure", "{purpose}: IDML"); + for name in ["Brochure.idml", "Brochure.indd", "Brochure.INDT"] { + let before = app.session.documents().len(); + let request = app.import_request(purpose); + deliver(&mut app, request, name, idml.clone()); + assert_eq!(app.session.documents().len(), before + 1, "{purpose}: {name}"); + assert_eq!(app.session.doc().unwrap().doc.title, "Brochure", "{purpose}: {name}"); + } } } fn image() -> Vec { From 633bdb66bf2449b79055c7e3ab77ae531ee40bf9 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 2 Nov 2026 05:10:06 +0100 Subject: [PATCH 13/33] Reject signed record numbers in data merge record ranges Found while testing crates/engine/src/cmd/datamerge/parse.rs:212 (parse_record_no, used by select_range) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/datamerge/parse.rs | 4 ++++ crates/engine/src/cmd/datamerge/tests.rs | 8 ++++++++ 2 files changed, 12 insertions(+) diff --git a/crates/engine/src/cmd/datamerge/parse.rs b/crates/engine/src/cmd/datamerge/parse.rs index 6cc838c3c..dfc49e842 100644 --- a/crates/engine/src/cmd/datamerge/parse.rs +++ b/crates/engine/src/cmd/datamerge/parse.rs @@ -210,6 +210,10 @@ fn select_range(range: &str, count: usize, idx: &mut Vec, seen: &mut std: } fn parse_record_no(s: &str) -> Result { + // `parse` alone would accept a leading '+'. + if !s.bytes().all(|b| b.is_ascii_digit()) { + return Err(format!("bad record \"{s}\"")); + } s.parse::().map_err(|_| format!("bad record \"{s}\"")) } diff --git a/crates/engine/src/cmd/datamerge/tests.rs b/crates/engine/src/cmd/datamerge/tests.rs index 9af882653..212175d78 100644 --- a/crates/engine/src/cmd/datamerge/tests.rs +++ b/crates/engine/src/cmd/datamerge/tests.rs @@ -1179,6 +1179,14 @@ fn select_records_dedupes_large_ranges_in_first_seen_order() { assert_eq!(&idx[..3], &[4, 0, 1]); } +#[test] +fn select_records_rejects_signed_record_numbers() { + for (tok, bad) in [("+2", "+2"), ("1-+3", "+3"), ("+1,+4", "+1")] { + let err = parse::select_records(5, "range", 1, tok, None).unwrap_err(); + assert!(err.contains(&format!("bad record \"{bad}\"")), "{tok}: {err}"); + } +} + #[test] fn oversized_data_file_is_refused_before_reading() { let dir = scratch("oversized"); From 485a94673f817f826af047689ac658d0ad6bc264 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Mon, 2 Nov 2026 02:40:43 -0500 Subject: [PATCH 14/33] M3: a Custom text variable defined with a number shows its digits (#2093) Co-Authored-By: Claude Fable 5.1 --- ROADMAP.md | 1 + crates/engine/src/cmd/variables.rs | 16 ++++++++++++++-- crates/engine/src/cmd/variables/tests.rs | 21 +++++++++++++++++++++ docs/ui-parity.md | 5 +++-- 4 files changed, 39 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 58a1913e5..a00624c9a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -382,6 +382,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m ## Progress log +- **2026-11-02** · A Custom text variable defined with a number (a typed `2026` reaches the command as one) shows its digits instead of nothing (#2093, first item). No readiness percentage change. - **2026-11-02** · Windows: the custom title bar window stays borderless, so the content is no longer drawn offset by the caption and border (black bars at the top and left, clicks landing off target). The workaround goes when winit carries the fix (storytold/winit#17). No readiness percentage change. - **2026-10-30** · The Color Picker takes an eyedropper: dragged from the picker onto the page, it takes the colour the canvas shows (proofed under Proof Colors). No readiness percentage change. - **2026-10-30** · The Color Picker is laid out as the reference app's: a field and slider for the component chosen, HSB, Lab, RGB and CMYK values, the old colour, and Add CMYK Swatch; Enter and Esc close it once. No readiness percentage change. diff --git a/crates/engine/src/cmd/variables.rs b/crates/engine/src/cmd/variables.rs index 682ccebde..3b85c18df 100644 --- a/crates/engine/src/cmd/variables.rs +++ b/crates/engine/src/cmd/variables.rs @@ -28,7 +28,7 @@ pub fn specs() -> Vec { "Define Text Variable…", ["Type", "Text Variables"], None, - "{name, type: custom|lastPageNumber|chapterNumber|fileName|creationDate|modificationDate|outputDate|runningHeader, text?, format?, style?, use?: firstOnPage|lastOnPage, character?, section?, extension?, before?, after?} — creates or replaces by name", + "{name, type: custom|lastPageNumber|chapterNumber|fileName|creationDate|modificationDate|outputDate|runningHeader, text? (a string; a number is shown as its digits), format?, style?, use?: firstOnPage|lastOnPage, character?, section?, extension?, before?, after?} — creates or replaces by name", has_doc, define ), @@ -391,11 +391,23 @@ fn unvar(st: &mut designcraft_doc::Story, i: usize, n: usize, value: &str, depth } } +/// A parameter's text: a string as it is; a number or a boolean spelled out. A Custom variable's +/// text typed as `2026` reaches the command as a number (the dialog and JSON callers type their +/// values), and it is still the text the variable shows (#2093). +fn text_param(p: &Value, key: &str) -> Option { + match p.get(key)? { + Value::String(s) => Some(s.clone()), + Value::Number(n) => Some(n.to_string()), + Value::Bool(b) => Some(b.to_string()), + _ => None, + } +} + fn define(s: &mut Session, p: &Value) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("variables.define", "missing name"))?.to_string(); let fmt = || str_param(p, "format").unwrap_or("MM/dd/yy").to_string(); let kind = match str_param(p, "type").unwrap_or("custom") { - "custom" => VarKind::Custom { text: str_param(p, "text").unwrap_or("").into() }, + "custom" => VarKind::Custom { text: text_param(p, "text").unwrap_or_default() }, "lastPageNumber" => VarKind::LastPageNumber { section: p.get("section").and_then(Value::as_bool).unwrap_or(false) }, "chapterNumber" => VarKind::ChapterNumber, "fileName" => VarKind::FileName { extension: p.get("extension").and_then(Value::as_bool).unwrap_or(false) }, diff --git a/crates/engine/src/cmd/variables/tests.rs b/crates/engine/src/cmd/variables/tests.rs index 9093f31dd..6345a11a4 100644 --- a/crates/engine/src/cmd/variables/tests.rs +++ b/crates/engine/src/cmd/variables/tests.rs @@ -434,3 +434,24 @@ fn running_header_orders_stories_by_their_first_heading() { assert_eq!(idx.value("Heading", false, designcraft_doc::vars::Use::FirstOnPage, 0), "Beta"); assert_eq!(idx.value("Heading", false, designcraft_doc::vars::Use::LastOnPage, 0), "Gamma"); } + +/// #2093: a Custom variable whose text arrives as a number (the dialog types a typed `2026` as +/// one) shows those digits; it showed nothing at all before. +#[test] +fn a_custom_variable_defined_with_a_number_shows_its_digits() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("variables.define", &json!({"name": "Year", "type": "custom", "text": 2026})).unwrap(); + s.execute("variables.define", &json!({"name": "Half", "type": "custom", "text": 0.5})).unwrap(); + s.execute("variables.define", &json!({"name": "Word", "type": "custom", "text": "year"})).unwrap(); + let list = s.execute("variables.list", &json!({})).unwrap(); + let value = |name: &str| list.as_array().unwrap().iter().find(|v| v["name"] == name).unwrap()["value"].as_str().unwrap().to_string(); + assert_eq!((value("Year"), value("Half"), value("Word")), ("2026".to_string(), "0.5".to_string(), "year".to_string())); + // Inserted into a story, the instance composes as four digits. + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": ""})).unwrap(); + let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); + s.execute("variables.insert", &json!({"name": "Year"})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + assert_eq!(shown_on_parent(&s, &d, sid), 4, "2026 shows"); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 3e6cac78a..14d11a9c4 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Table of Contents dialog: title field keeps its width, More Options toggles without losing hidden settings; Story panel; text Control panel Character/Paragraph modes; Properties ••• expands Character and Paragraph; live corners work; document views row: scrollbars, Alt pans while editing text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; paragraph style override feedback and clearing) · **Target:** Adobe InDesign 2026 (21.6, Medium Dark, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** patch (a Custom text variable defined with a number shows its digits, #2093; Table of Contents dialog: title field keeps its width, More Options toggles without losing hidden settings; Story panel; text Control panel Character/Paragraph modes; Properties ••• expands Character and Paragraph; live corners work; document views row: scrollbars, Alt pans while editing text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; paragraph style override feedback and clearing) · **Target:** Adobe InDesign 2026 (21.6, Medium Dark, macOS) Tools, handles, snapping, nudging, modifiers, shortcuts, panels, menus, context menus and feel, against InDesign 2026. The look was measured from the running app @@ -48,7 +48,7 @@ basics of editing fail for too many users on Windows and Linux. | Numeric fields: units, arithmetic, scrubbing | ✓ | 21 pt spinner fields with arithmetic | ✓ | | | Colour fields: hex, picker from any swatch field | ✓ | ✓ | ~ | hex entry and picker issues (#323, #190) | | Panels: Pages, Layers, Swatches, Color, Stroke, Character, Paragraph, Styles, Links, Effects, Text Wrap, Align, Pathfinder, Table, Index, TOC, Hyperlinks, Bookmarks, Articles, Tags, Notes, Track Changes, Object States, Buttons and Forms, Preflight, Data Merge, Library, Scripts, Glyphs, Info, Tabs, Story | ✓ | all present | ~ | Navigator, Trap Presets, Animation, Timing missing; pull-down panels can't scroll on Linux (#267) | -| Dialogs: New Document, Document Setup, Preferences (all pages), Paragraph/Character/Object/Table/Cell Style Options, Export PDF, Print, Package, Find/Change | ✓ | present | ~ | generated command dialogs fill the long tail; depth varies | +| Dialogs: New Document, Document Setup, Preferences (all pages), Paragraph/Character/Object/Table/Cell Style Options, Export PDF, Print, Package, Find/Change | ✓ | present | ~ | a Custom text variable whose text is typed as a number shows its digits (`variables.define` takes a string or a number for `text`; #2093); generated command dialogs fill the long tail; depth varies | | Workspaces | Essentials, Essentials Classic, Advanced, Book, Digital Publishing, Interactive for PDF, Printing and Proofing, Review, Typography, Touch | ✓ named workspaces (Essentials Classic: Control panel and iconic panel groups) + custom; Window › Workspace; no Review or Touch | ~ | tab bar overlaps menus in Book/Advanced (#182) | | Story Editor | ✓ | ✓ | ~ | depth ruler and View › Story Editor › Show/Hide Depth Ruler (`story_editor.rs` `depth_ruler_shows_and_hides_from_the_view_menu`); hiding or resizing the style-name column missing | | Undo granularity (a drag = one step) | ✓ | ✓ (Begin/Preview/Commit) | ✓ | | @@ -134,6 +134,7 @@ covers checking the sampled and redefined settings in the styles tutorial (2:45 | Date | Change | Summary | |---|---|---| +| 2026-11-02 | patch | Text variables: a Custom variable defined with a number (a typed `2026` reaches `variables.define` as one) shows its digits instead of nothing (#2093, first item); `text` takes a string, a number or a boolean. Test: `variables::tests::a_custom_variable_defined_with_a_number_shows_its_digits` | | 2026-10-30 | minor | Dialogs: Table of Contents title field keeps its edit width; More Options toggles without losing hidden settings | | 2026-10-24 | minor | Panels row: Story panel (Optical Margin Alignment and its size) | | 2026-10-21 | minor | Chrome row: the text Control panel's A/¶ modes; Properties row: ••• expands Character and Paragraph in place | From 5de0a0b806246fe5b783d4e5ec8ac1dfc60a08b6 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Mon, 2 Nov 2026 04:13:40 -0500 Subject: [PATCH 15/33] M3: variables.define refuses a text that is neither a string, a number nor a boolean (#2093) Co-Authored-By: Claude Fable 5.1 --- crates/engine/src/cmd/variables.rs | 11 +++++++++-- crates/engine/src/cmd/variables/tests.rs | 6 ++++++ docs/ui-parity.md | 2 +- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/variables.rs b/crates/engine/src/cmd/variables.rs index 3b85c18df..33c3c8821 100644 --- a/crates/engine/src/cmd/variables.rs +++ b/crates/engine/src/cmd/variables.rs @@ -28,7 +28,7 @@ pub fn specs() -> Vec { "Define Text Variable…", ["Type", "Text Variables"], None, - "{name, type: custom|lastPageNumber|chapterNumber|fileName|creationDate|modificationDate|outputDate|runningHeader, text? (a string; a number is shown as its digits), format?, style?, use?: firstOnPage|lastOnPage, character?, section?, extension?, before?, after?} — creates or replaces by name", + "{name, type: custom|lastPageNumber|chapterNumber|fileName|creationDate|modificationDate|outputDate|runningHeader, text? (a string; a number or a boolean is shown as typed, anything else is refused), format?, style?, use?: firstOnPage|lastOnPage, character?, section?, extension?, before?, after?} — creates or replaces by name", has_doc, define ), @@ -407,7 +407,14 @@ fn define(s: &mut Session, p: &Value) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("variables.define", "missing name"))?.to_string(); let fmt = || str_param(p, "format").unwrap_or("MM/dd/yy").to_string(); let kind = match str_param(p, "type").unwrap_or("custom") { - "custom" => VarKind::Custom { text: text_param(p, "text").unwrap_or_default() }, + "custom" => VarKind::Custom { + // No `text` is empty text; an object or an array is refused rather than stored as + // empty text in place of what a variable of that name holds. + text: match p.get("text") { + None | Some(Value::Null) => String::new(), + Some(_) => text_param(p, "text").ok_or_else(|| bad("variables.define", "`text` must be a string, a number or a boolean"))?, + }, + }, "lastPageNumber" => VarKind::LastPageNumber { section: p.get("section").and_then(Value::as_bool).unwrap_or(false) }, "chapterNumber" => VarKind::ChapterNumber, "fileName" => VarKind::FileName { extension: p.get("extension").and_then(Value::as_bool).unwrap_or(false) }, diff --git a/crates/engine/src/cmd/variables/tests.rs b/crates/engine/src/cmd/variables/tests.rs index 6345a11a4..6a2291ccb 100644 --- a/crates/engine/src/cmd/variables/tests.rs +++ b/crates/engine/src/cmd/variables/tests.rs @@ -444,9 +444,15 @@ fn a_custom_variable_defined_with_a_number_shows_its_digits() { s.execute("variables.define", &json!({"name": "Year", "type": "custom", "text": 2026})).unwrap(); s.execute("variables.define", &json!({"name": "Half", "type": "custom", "text": 0.5})).unwrap(); s.execute("variables.define", &json!({"name": "Word", "type": "custom", "text": "year"})).unwrap(); + s.execute("variables.define", &json!({"name": "Flag", "type": "custom", "text": true})).unwrap(); + // Anything else is refused, and a variable of that name keeps its text; no text is empty text. + assert!(s.execute("variables.define", &json!({"name": "Year", "type": "custom", "text": {"y": 2026}})).is_err()); + assert!(s.execute("variables.define", &json!({"name": "Year", "type": "custom", "text": [2026]})).is_err()); + s.execute("variables.define", &json!({"name": "Blank", "type": "custom"})).unwrap(); let list = s.execute("variables.list", &json!({})).unwrap(); let value = |name: &str| list.as_array().unwrap().iter().find(|v| v["name"] == name).unwrap()["value"].as_str().unwrap().to_string(); assert_eq!((value("Year"), value("Half"), value("Word")), ("2026".to_string(), "0.5".to_string(), "year".to_string())); + assert_eq!((value("Flag"), value("Blank")), ("true".to_string(), String::new())); // Inserted into a story, the instance composes as four digits. let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": ""})).unwrap(); let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 14d11a9c4..608df8059 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -48,7 +48,7 @@ basics of editing fail for too many users on Windows and Linux. | Numeric fields: units, arithmetic, scrubbing | ✓ | 21 pt spinner fields with arithmetic | ✓ | | | Colour fields: hex, picker from any swatch field | ✓ | ✓ | ~ | hex entry and picker issues (#323, #190) | | Panels: Pages, Layers, Swatches, Color, Stroke, Character, Paragraph, Styles, Links, Effects, Text Wrap, Align, Pathfinder, Table, Index, TOC, Hyperlinks, Bookmarks, Articles, Tags, Notes, Track Changes, Object States, Buttons and Forms, Preflight, Data Merge, Library, Scripts, Glyphs, Info, Tabs, Story | ✓ | all present | ~ | Navigator, Trap Presets, Animation, Timing missing; pull-down panels can't scroll on Linux (#267) | -| Dialogs: New Document, Document Setup, Preferences (all pages), Paragraph/Character/Object/Table/Cell Style Options, Export PDF, Print, Package, Find/Change | ✓ | present | ~ | a Custom text variable whose text is typed as a number shows its digits (`variables.define` takes a string or a number for `text`; #2093); generated command dialogs fill the long tail; depth varies | +| Dialogs: New Document, Document Setup, Preferences (all pages), Paragraph/Character/Object/Table/Cell Style Options, Export PDF, Print, Package, Find/Change | ✓ | present | ~ | a Custom text variable whose text is typed as a number shows its digits (`variables.define` takes a string, a number or a boolean for `text` and refuses anything else; #2093); generated command dialogs fill the long tail; depth varies | | Workspaces | Essentials, Essentials Classic, Advanced, Book, Digital Publishing, Interactive for PDF, Printing and Proofing, Review, Typography, Touch | ✓ named workspaces (Essentials Classic: Control panel and iconic panel groups) + custom; Window › Workspace; no Review or Touch | ~ | tab bar overlaps menus in Book/Advanced (#182) | | Story Editor | ✓ | ✓ | ~ | depth ruler and View › Story Editor › Show/Hide Depth Ruler (`story_editor.rs` `depth_ruler_shows_and_hides_from_the_view_menu`); hiding or resizing the style-name column missing | | Undo granularity (a drag = one step) | ✓ | ✓ (Begin/Preview/Commit) | ✓ | | From c065cec5171d253d8b0c626a2f4c811c79ce553c Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:43:09 +0100 Subject: [PATCH 16/33] Include single-anchor subpaths in PathData::bounds Found while testing crates/geom/src/path.rs:~335 (PathData::bounds) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/geom/src/path.rs | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/crates/geom/src/path.rs b/crates/geom/src/path.rs index adfc4a2e4..946b347b6 100644 --- a/crates/geom/src/path.rs +++ b/crates/geom/src/path.rs @@ -342,13 +342,21 @@ impl PathData { if self.is_empty() { return None; } - let bp = self.to_bezpath(); - if self.anchor_count() == 1 - && let Some(a) = self.subpaths.iter().find_map(|s| s.anchors.first()) - { - return Some(Rect::from_points(a.p, a.p)); + // `bounding_box` skips a lone MoveTo, so single-anchor subpaths are folded in by point. + let mut bp = BezPath::new(); + let mut lone: Option = None; + for sp in &self.subpaths { + if let [a] = sp.anchors.as_slice() { + lone = Some(lone.map_or(Rect::from_points(a.p, a.p), |r| r.union_pt(a.p))); + } else { + sp.to_bezpath_into(&mut bp); + } } - Some(bp.bounding_box()) + if bp.elements().is_empty() { + return lone; + } + let bb = bp.bounding_box(); + Some(lone.map_or(bb, |r| r.union(bb))) } /// Bounds including control handles (used for quick culling). pub fn control_bounds(&self) -> Option { @@ -421,6 +429,17 @@ mod tests { PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(10.0, 10.0), Point::new(0.0, 10.0)], true)) } + #[test] + fn bounds_cover_single_anchor_subpaths() { + let mut pd = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(10.0, 10.0)], true)); + pd.subpaths.push(SubPath::polyline(&[Point::new(50.0, 50.0)], false)); + assert_eq!(pd.bounds(), Some(Rect::new(0.0, 0.0, 50.0, 50.0))); + + let mut two = PathData::single(SubPath::polyline(&[Point::new(5.0, 7.0)], false)); + two.subpaths.push(SubPath::polyline(&[Point::new(50.0, 50.0)], false)); + assert_eq!(two.bounds(), Some(Rect::new(5.0, 7.0, 50.0, 50.0))); + } + #[test] fn from_bezpath_detects_smooth_on_every_open_subpath() { let mut bp = BezPath::new(); From 2d5af1bd0d540995ad3f9892c2d71d7c1e23b847 Mon Sep 17 00:00:00 2001 From: Hamza Date: Mon, 2 Nov 2026 18:40:39 +0500 Subject: [PATCH 17/33] A frame that crosses into an exported page is checked on it A sheet draws its spread's items without asking which page each one sits on, so a frame straddling two pages is drawn, in part, on both. The warning chose the page from the frame's centre alone (`page_at_x(bounds.center().x)`), so a frame centred on a page the export leaves out had its warning dropped while the part on an exported page went into the file (noted in review). `pages_of` gives every page of the spread the frame's bounds overlap, and the story is kept when any of them is exported. The page named is one the export has: the centre page when that is exported, so an ordinary frame reads exactly as before, otherwise the first exported page the frame reaches. A frame wholly on the pasteboard overlaps no page's span and falls back to `page_of`, so the two cannot disagree about where such a frame is. `overset_location` returns the reached pages beside the centre page, which is what made it worth a named type. The test exports a frame centred on page 3 and reaching back into page 2, four ways: the whole document, page 3 alone, page 2 alone -- which used to report nothing -- and page 4, which the frame never reaches and which still reports nothing. --- crates/engine/src/cmd/export/pdf.rs | 44 +++++++++++++++++++ crates/engine/src/cmd/preflight.rs | 68 +++++++++++++++++++++++------ 2 files changed, 99 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/cmd/export/pdf.rs b/crates/engine/src/cmd/export/pdf.rs index b5f16c110..3466a18a3 100644 --- a/crates/engine/src/cmd/export/pdf.rs +++ b/crates/engine/src/cmd/export/pdf.rs @@ -541,6 +541,50 @@ mod overset_tests { assert!(story["overset"].is_null() && story["oversetChars"].is_null(), "{story}"); } + /// A sheet draws its spread's items without asking which page each one sits on, so a frame + /// straddling two pages is drawn, in part, on both. Choosing the page by the frame's centre + /// alone meant a frame centred on a page the export leaves out had its warning dropped while + /// the part on an exported page went into the file (#1493 review). + #[test] + fn an_export_warns_about_a_frame_that_only_crosses_into_one_of_its_pages() { + let mut s = Session::new(); + // Facing pages, so pages 2 and 3 are one spread and a frame can straddle their boundary. + s.execute("file.new", &json!({"width": 300, "height": 200, "pages": 4, "facingPages": true, "margins": 0})).unwrap(); + let text = "First line is visible.\nSecond paragraph does not fit: this sentence carries the legal notice and must not vanish from the PDF without a warning."; + // Spread 1 is pages 2-3; its page 2 runs x 0..300 and page 3 runs x 300..600. The frame + // is centred on page 3 and reaches back over the boundary into page 2. + let frame = json!({"spread": 1, "rect": [260, 20, 420, 60], "content": "text", "text": text}); + let id = s.execute("frame.create", &frame).unwrap()["id"].as_u64().unwrap(); + + let about = |s: &mut Session, pages: serde_json::Value| { + let params = if pages.is_null() { json!({}) } else { json!({"pages": pages}) }; + let r = s.execute("file.exportPdf", ¶ms).unwrap(); + let ws = r["warnings"].as_array().cloned().unwrap_or_default(); + ws.iter().filter_map(|w| w.as_str()).filter(|w| w.contains("not in the export")).map(str::to_owned).collect::>() + }; + + // The whole document: reported once, at the page the frame is centred on. + let all = about(&mut s, serde_json::Value::Null); + assert_eq!(all.len(), 1, "{all:?}"); + assert!(all[0].contains("page 3") && all[0].contains(&format!("frame {id}")), "{}", all[0]); + + // Exporting page 3 alone: the centre page, reported as before. + let three = about(&mut s, json!([3])); + assert_eq!(three.len(), 1, "{three:?}"); + assert!(three[0].contains("page 3"), "{}", three[0]); + + // Exporting page 2 alone: the frame's centre is on page 3, which is not in the export -- + // but part of the frame is drawn on page 2, so it is still reported, and the page named is + // one the export has. + let two = about(&mut s, json!([2])); + assert_eq!(two.len(), 1, "a frame crossing into page 2 is reported: {two:?}"); + assert!(two[0].contains("page 2"), "and the page it names is an exported one: {}", two[0]); + assert!(two[0].contains(&format!("frame {id}")), "{}", two[0]); + + // A page the frame does not reach at all: nothing to say. + assert_eq!(about(&mut s, json!([4])), Vec::::new()); + } + /// `overset` indexes `text` in UTF-8 bytes, not characters: a client that slices a JavaScript /// string at it cuts the wrong place once the text is not ASCII (noted in review). #[test] diff --git a/crates/engine/src/cmd/preflight.rs b/crates/engine/src/cmd/preflight.rs index bf98f9ab9..45fb8bc40 100644 --- a/crates/engine/src/cmd/preflight.rs +++ b/crates/engine/src/cmd/preflight.rs @@ -38,6 +38,25 @@ fn page_of(d: &Document, sr: SpreadRef, it: &Item, parent: Affine) -> Option Vec { + let SpreadRef::Doc(si) = sr else { return vec![] }; + let Some(sp) = d.spreads.get(si) else { return vec![] }; + let bounds = parent.transform_rect_bbox(it.bounds()); + let first = d.first_page_of_spread(si); + let touched: Vec = + sp.pages.iter().enumerate().filter(|(_, pg)| bounds.x1 > pg.x && bounds.x0 < pg.x + pg.width).map(|(i, _)| first + i).collect(); + // A frame wholly on the pasteboard touches no page's span; it still belongs to the page + // `page_at_x` nominates, so the two functions agree about where it is. + if touched.is_empty() { page_of(d, sr, it, parent).into_iter().collect() } else { touched } +} + /// Characters a missing-glyph report names before "and N more". const MISSING_GLYPHS_LISTED: usize = 20; @@ -74,11 +93,19 @@ fn missing_glyphs( } } -/// Locate an embedded frame at the outer page frame that actually renders it. -fn overset_location(s: &Session, target: designcraft_doc::ItemId) -> Option<(designcraft_doc::ItemId, Option)> { +/// Locate an embedded frame at the outer page frame that actually renders it: the frame, the page +/// its centre is on, and every page it reaches (see [`pages_of`]). +type Located = (designcraft_doc::ItemId, Option, Vec); + +fn overset_location(s: &Session, target: designcraft_doc::ItemId) -> Option { let d = &s.active()?.doc; + let at = |id: designcraft_doc::ItemId, loc: &designcraft_doc::ItemLoc| { + let it = d.item(id); + let xf = d.parent_xf(loc); + (id, it.and_then(|it| page_of(d, loc.spread, it, xf)), it.map(|it| pages_of(d, loc.spread, it, xf)).unwrap_or_default()) + }; if let Some(loc) = d.find(target) { - return Some((target, d.item(target).and_then(|it| page_of(d, loc.spread, it, d.parent_xf(&loc))))); + return Some(at(target, &loc)); } fn contains(root: &designcraft_compose::FrameText, target: designcraft_doc::ItemId) -> bool { let mut frames = vec![root]; @@ -107,7 +134,7 @@ fn overset_location(s: &Session, target: designcraft_doc::ItemId) -> Option<(des let frame = cs.frames.iter().find(|f| contains(f, current))?; current = frame.frame; if let Some(loc) = d.find(current) { - return Some((current, d.item(current).and_then(|it| page_of(d, loc.spread, it, d.parent_xf(&loc))))); + return Some(at(current, &loc)); } } None @@ -199,13 +226,28 @@ pub fn overset_warnings(s: &Session, pages: Option<&[usize]>) -> Vec { continue; } let location = last.and_then(|f| overset_location(s, f)); - let page = location.and_then(|(_, page)| page); - if let (Some(page), Some(pages)) = (page, pages) - && !pages.contains(&page) - { - continue; - } - let item = location.map(|(item, _)| item).or(last); + let centre = location.as_ref().and_then(|(_, page, _)| *page); + let touched: &[usize] = location.as_ref().map(|(_, _, ps)| ps.as_slice()).unwrap_or_default(); + // A sheet draws its spread's items without asking which page they are on, so a frame + // straddling two pages is drawn in part on both. Testing the centre page alone dropped + // the warning for a frame centred on a page the export leaves out while the part on an + // exported page went into the file (noted in review). + // + // The page named is one the export has: the centre page when that is exported, so an + // ordinary frame reads as before, otherwise the first exported page the frame reaches. + let page = match pages { + None => centre, + Some(sel) => { + let exported: Vec = touched.iter().copied().filter(|p| sel.contains(p)).collect(); + match centre.filter(|p| sel.contains(p)).or_else(|| exported.first().copied()) { + Some(p) => Some(p), + // Nothing of this frame is on an exported page. + None if centre.is_some() || !touched.is_empty() => continue, + None => None, + } + } + }; + let item = location.as_ref().map(|(item, _, _)| *item).or(last); out.push(match (page, item) { (Some(page), Some(item)) => { format!("page {}: {n} character(s) of the story in frame {} do not fit it and are not in the export", page + 1, item.0) @@ -242,8 +284,8 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec { } else { format!("Overset text: {n} characters") }, - item: location.map(|(item, _)| item.0).or(last.map(|i| i.0)), - page: location.and_then(|(_, page)| page), + item: location.as_ref().map(|(item, _, _)| item.0).or(last.map(|i| i.0)), + page: location.as_ref().and_then(|(_, page, _)| *page), }); } } From a207f13f73a0b323d875fdb977e9b08f5e9b3939 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:47:11 +0100 Subject: [PATCH 18/33] Keep the graphics backend when the window closes before its first frame Fixes #2116 Co-Authored-By: Claude Opus 5.5 --- apps/designcraft/src/gpu.rs | 7 ++++++- apps/designcraft/src/gpu/backend.rs | 22 ++++++++++++++++++++-- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/apps/designcraft/src/gpu.rs b/apps/designcraft/src/gpu.rs index 907117b0c..93586e585 100644 --- a/apps/designcraft/src/gpu.rs +++ b/apps/designcraft/src/gpu.rs @@ -678,7 +678,12 @@ fn restart_without<'a>(graphics_failed: bool, frames: u64, picked: Option<&'a st /// ended it. pub fn finish(outcome: Result, startup: &Startup) -> Result<(), String> { let (why, graphics_failed) = match outcome { - Ok(Ok(())) => return Ok(()), + // The window closed normally, perhaps before its first frame or before the app was + // created (so `on_exit` never ran): no driver fault took this start down (#2116). + Ok(Ok(())) => { + startup.presented(); + return Ok(()); + } Ok(Err(e @ eframe::Error::Wgpu(_))) => (e.to_string(), true), Ok(Err(e)) => (e.to_string(), false), Err(panic) => (panic, GRAPHICS_PANIC.load(Ordering::Relaxed)), diff --git a/apps/designcraft/src/gpu/backend.rs b/apps/designcraft/src/gpu/backend.rs index 6aa250dd4..06c477064 100644 --- a/apps/designcraft/src/gpu/backend.rs +++ b/apps/designcraft/src/gpu/backend.rs @@ -14,8 +14,9 @@ //! fallback off. //! //! The fallback: before the window is created, `gpu.json` in the settings directory records the -//! backend being tried; once a frame has been presented, or the graphics failed with an error the -//! app caught (see `gpu::finish`), that entry is cleared. An entry still there at the next start +//! backend being tried; once a frame has been presented, the window closed normally (even before +//! its first frame), or the graphics failed with an error the app caught (see `gpu::finish`), that +//! entry is cleared. An entry still there at the next start //! means the previous start never got that far — the driver crashed, or the app was killed //! meanwhile — so that backend joins `failed`, the next candidate is tried and the status bar says //! so. Backends a restart left out (`--gpu-skip=`, see `gpu`) aren't chosen for that start but @@ -538,6 +539,23 @@ mod tests { let _ = std::fs::remove_dir_all(path.parent().expect("dir")); } + /// A window closed before its first frame (#2116): the app was created slowly or not at all, + /// so neither a frame nor `on_exit` cleared the record, but eframe ended cleanly. No driver + /// fault took that start down, so the next start keeps the backend. + #[test] + fn a_start_that_ends_cleanly_before_its_first_frame_does_not_fail_its_backend() { + let path = temp_path("clean-end"); + let fallback = Fallback::begin_unforced(Some(path.clone()), V, &[]).expect("candidates"); + let first = fallback.backend; + assert_eq!(read(&path).trying, Some(first)); + let startup = super::super::Startup::new(Some(fallback), Vec::new()); + assert_eq!(super::super::finish(Ok(Ok(())), &startup), Ok(())); + assert_eq!(read(&path).trying, None, "a clean end leaves no backend being tried"); + let c = choose(candidates(), &read(&path), V, &[]).expect("candidates"); + assert_eq!((c.backend, c.record.failed, c.switched_from), (first, Vec::new(), None)); + let _ = std::fs::remove_dir_all(path.parent().expect("dir")); + } + #[test] fn without_a_path_nothing_is_written_and_a_bad_path_is_not_fatal() { let mut s = Fallback::begin_unforced(None, V, &[]).expect("candidates"); From 5f86f33c4274bd14f52a6f8f2a50a43e34e4ff11 Mon Sep 17 00:00:00 2001 From: Paul Gemignani <4waymedia@gmail.com> Date: Mon, 2 Nov 2026 08:09:15 -0800 Subject: [PATCH 19/33] Dialogs: the Color Picker opens at its full height (#2129) A centred dialog window offers its body only last frame's height, and the Color Picker lays itself out on a fixed 566 x 334 canvas, so it opened clipped to its top strip behind a scrollbar: the colour field cut off, no value fields, no hex code, OK and Cancel visible but blind, and the window not resizable. The body's minimum scrolled height now covers the picker's canvas, as it already did for Numbering & Section Options. Verified locally on a288a87b: fmt, clippy, the picker's own tests; opened on macOS at full height. --- crates/ui-egui/src/dialogs.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d546cd108..132d80a24 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3016,9 +3016,16 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { result = Some(false); } } - // A centred window only offers its body last frame's height: let this one grow to its - // options' height (the screen still caps it). - let min_body = if side_actions { body_height.min(crate::section_options::BODY_H) } else { 1.0 }; + // A centred window only offers its body last frame's height: let these grow to their + // content's height (the screen still caps it). Numbering & Section Options has its + // options beside its buttons; the Color Picker lays itself out on a fixed canvas, which + // otherwise shows only its top strip (the field cut off, no values, no hex) behind a + // scrollbar. + let min_body = match d.id.as_str() { + "numberingSection" => body_height.min(crate::section_options::BODY_H), + "colorPicker" => body_height.min(picker_layout::SIZE.y + 2.0 * ui.spacing().item_spacing.y), + _ => 1.0, + }; egui::ScrollArea::both() .id_salt(("dialog_body", &d.id)) .min_scrolled_width(1.0) From 885793f763527ef47d696b998b3638e7e8dbb683 Mon Sep 17 00:00:00 2001 From: Paul Gemignani <4waymedia@gmail.com> Date: Mon, 2 Nov 2026 08:31:14 -0800 Subject: [PATCH 20/33] Color Picker: reopens on the component it was left on The picker opened with R selected every time, so anyone who works in H (the hue slider with the saturation/brightness field) chose it again on every open. The component now lives in the UI preferences (colorPickerComponent, default r, so existing preference files and openers that name a component behave as before) and the picker reads it on open and writes it when a radio button is clicked, as Photoshop's picker keeps its last mode. Verified locally on a288a87b: fmt, clippy, the picker's tests; tried on macOS. --- crates/ui-egui/src/dialogs.rs | 3 +++ crates/ui-egui/src/dialogs/tests.rs | 36 +++++++++++++++++++++++++++++ crates/ui-egui/src/state.rs | 4 ++++ 3 files changed, 43 insertions(+) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d546cd108..8f50c1990 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -9021,6 +9021,8 @@ fn color_picker(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { let tr = |s: &str| crate::i18n::tr(&lang, s).to_string(); let old = d.s("hex"); d.fields.entry("old").or_insert_with(|| json!(old)); + // The component the picker was last left on, unless the opener chose one. + d.fields.entry("component").or_insert_with(|| json!(app.ui.color_picker_component)); let comp = PickComp::from_key(&d.s("component")); let mut p = picked_of(d); let before = p; @@ -9215,6 +9217,7 @@ fn color_picker(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { } if comp_now != comp { d.fields.insert("component".into(), json!(comp_now.key())); + app.ui.color_picker_component = comp_now.key().to_string(); } // The hex code, and the eyedropper beside it. let cy = area.min.y + at::ROWS[7]; diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 1cdce534f..3641a0e7d 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3698,6 +3698,42 @@ fn the_color_picker_picks_on_its_field_and_slider_and_adds_a_cmyk_swatch() { assert_eq!(st.doc.resolve_color(&fill, 1.0).unwrap().to_hex(), "#7c1315"); } +/// The Color Picker reopens on the component it was left on, and the choice survives the +/// preferences file; an opener that names a component still gets it. +#[test] +fn the_color_picker_remembers_its_component() { + use egui_kittest::kittest::Queryable; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect": [72, 72, 200, 200], "shape": "rectangle"})).unwrap(); + assert_eq!(app.ui.color_picker_component, "r", "R at first, as before"); + app.ui.dialog = Some(Dialog::new("colorPicker", json!({"target": "fill", "hex": "#7c1315", "text": false}))); + let mut h = crate::test_window::open(app, egui::vec2(1400.0, 900.0)); + h.run_steps(3); + assert_eq!(h.state().app.ui.dialog.as_ref().unwrap().s("component"), "r"); + h.get_by_role_and_label(egui::accesskit::Role::RadioButton, "H:").click(); + h.run_steps(2); + assert_eq!(h.state().app.ui.dialog.as_ref().unwrap().s("component"), "h"); + assert_eq!(h.state().app.ui.color_picker_component, "h", "the choice is kept outside the dialog"); + h.get_by_label("Cancel").click(); + h.run_steps(2); + assert!(h.state().app.ui.dialog.is_none()); + // Reopened: H, not R. + h.state_mut().app.ui.dialog = Some(Dialog::new("colorPicker", json!({"target": "fill", "hex": "#7c1315", "text": false}))); + h.run_steps(3); + assert_eq!(h.state().app.ui.dialog.as_ref().unwrap().s("component"), "h"); + h.get_by_label("Cancel").click(); + h.run_steps(2); + // An opener's own choice wins. + h.state_mut().app.ui.dialog = Some(Dialog::new("colorPicker", json!({"target": "fill", "hex": "#7c1315", "text": false, "component": "l"}))); + h.run_steps(3); + assert_eq!(h.state().app.ui.dialog.as_ref().unwrap().s("component"), "l"); + // Saved with the preferences, and read back; a file without it is R. + let saved = serde_json::to_vec(&h.state().app.ui).unwrap(); + assert_eq!(crate::UiState::from_preferences_json(&saved).unwrap().color_picker_component, "h"); + assert_eq!(crate::UiState::from_preferences_json(b"{}").unwrap().color_picker_component, "r"); +} + #[test] fn create_guides_dialog_covers_the_spread_in_view() { let mut app = facing_app(6); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index bd038090a..63100f614 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -226,6 +226,9 @@ pub struct UiState { pub story_editor_size: f32, /// View › Story Editor › Show/Hide Depth Ruler. pub story_editor_depth_ruler: bool, + /// The Color Picker's component (its radio button, as `PickComp::key`: `h`, `s`, `b`, `l`, + /// `la`, `lb`, `r`, `g`, `bl`), kept between openings and sessions, as Photoshop's picker is. + pub color_picker_component: String, /// Preferences › General › Interface Language: supported codes are listed in `i18n::LANGUAGES`. pub language: String, /// Edit › Transparency Flattener Presets: "" (none), "high", "medium" or "low" for PDF export. @@ -399,6 +402,7 @@ impl Default for UiState { show_full_menus: false, story_editor_size: 14.0, story_editor_depth_ruler: true, + color_picker_component: "r".into(), scripts: vec![ ("Number the pages".into(), "# A page-number frame at the bottom of the current page.\nframe.create {\"rect\": [288, 740, 324, 760], \"content\": \"text\", \"text\": \"\"}\ntext.select {\"story\": \"$0.story\", \"anchor\": 0, \"focus\": 0}\ntext.insert {\"text\": \"\\ue000\", \"raw\": true}".into()), ("Two-column grid".into(), "# Four text frames in a 2 × 2 grid.\nframe.grid {\"rect\": [36, 36, 576, 756], \"cols\": 2, \"rows\": 2, \"gutter\": 12}".into()), From 8bbf375118bd5a6f1e361f3f5fa64b27f5554e75 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Mon, 2 Nov 2026 19:10:52 +0100 Subject: [PATCH 21/33] flatpak: drop duplicate NOTICE install line --- packaging/linux/flatpak/ai.storyteller.designcraft.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/packaging/linux/flatpak/ai.storyteller.designcraft.yml b/packaging/linux/flatpak/ai.storyteller.designcraft.yml index 56edb388e..fd56910ce 100644 --- a/packaging/linux/flatpak/ai.storyteller.designcraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.designcraft.yml @@ -101,7 +101,6 @@ modules: - cargo --offline build --release --locked -p designcraft - install -Dm755 target/release/designcraft /app/bin/designcraft - install -Dm644 NOTICE /app/share/doc/designcraft/NOTICE - - install -Dm644 NOTICE /app/share/doc/designcraft/NOTICE - install -Dm644 packaging/linux/ai.storyteller.designcraft.desktop /app/share/applications/ai.storyteller.designcraft.desktop - install -Dm644 packaging/linux/ai.storyteller.designcraft.mime.xml /app/share/mime/packages/ai.storyteller.designcraft.xml - mkdir -p /app/share/icons && cp -R assets/app-icon/hicolor /app/share/icons/ From 7e3d0fe2f5a8fe3703b5d30b7794fa3c3bb30924 Mon Sep 17 00:00:00 2001 From: ljkx <177671075+ljkx@users.noreply.github.com> Date: Mon, 2 Nov 2026 20:07:09 +0100 Subject: [PATCH 22/33] Text: Indent to Here indents the paragraph's later lines to the character (#2080) The composer treated Indent to Here as an invisible zero-width character and nothing else, so inserting it changed nothing. A line holding one now sets the start-side indent of the paragraph's following lines to the character's position, and those lines are broken again with the narrower measure. The indent holds into the next columns and frames; a later Indent to Here in the paragraph replaces it. --- crates/compose/src/lib.rs | 49 ++++++++++++++++++++++++++++++++++--- crates/compose/src/tests.rs | 28 +++++++++++++++++++++ 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 227dad654..9d383d270 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -1849,7 +1849,7 @@ fn place_paragraph_lines(shared: PlacementShared<'_, '_>, paragraph: ParagraphLi let ParagraphLines { paragraph: pi, range: prange, - props: pp, + props: para_props, glyphs, hyph_after, drop_cap, @@ -1878,7 +1878,10 @@ fn place_paragraph_lines(shared: PlacementShared<'_, '_>, paragraph: ParagraphLi let mut hyphen_at: Option = None; let has_tabs = glyphs.iter().any(|g| g.ch == '\t'); let mut first_line_rect: Option<(usize, f64, f64, f64)> = None; // frame, baseline, ascent, x-span - let rtl = pp.direction == designcraft_doc::TextDirection::RightToLeft; + let rtl = para_props.direction == designcraft_doc::TextDirection::RightToLeft; + // The paragraph's settings with the start-side indent an Indent to Here character set for + // the lines after it (InDesign's ^i): those lines start where the character sits. + let mut indented: Option = None; // Paragraph gyoudori: the (frame, column) of the block and the grid line after it. let mut para_block: Option<((usize, usize), f64)> = None; // Segments of a line already placed (beside an object with text on both sides) that the @@ -1912,6 +1915,7 @@ fn place_paragraph_lines(shared: PlacementShared<'_, '_>, paragraph: ParagraphLi no_hyph, }); } + let pp = indented.as_ref().unwrap_or(para_props); let f = &frames[cur.fi]; let base = cols[cur.fi][cur.col.min(cols[cur.fi].len() - 1)]; let col = cur.clip(base); @@ -1945,6 +1949,8 @@ fn place_paragraph_lines(shared: PlacementShared<'_, '_>, paragraph: ParagraphLi spacing, }); let mut moved = false; + // Set by a line with an Indent to Here character, for the lines broken again after it. + let mut reindented = None; for (k, b) in breaks.iter().enumerate() { let (s, e) = (g0 + b.start, g0 + b.end); let line_glyphs = &glyphs[s..e.max(s)]; @@ -2112,6 +2118,7 @@ fn place_paragraph_lines(shared: PlacementShared<'_, '_>, paragraph: ParagraphLi } ruby::annotate(db, styles_tab, &story.text, &mut placed, doc.settings.glyph_fallback, f.vertical); kenten::annotate(db, styles_tab, &mut placed, doc.settings.glyph_fallback); + let here_props = indent_here(line_glyphs, &placed, pp, x0, x1, rtl); let range_end = if last { prange.end } else { glyphs.get(g0 + b.next).map(|g| g.byte).unwrap_or(prange.end) }; let range_start = glyphs.get(s).map(|g| g.byte).unwrap_or(prange.start).min(range_end); let range_start = if line_no == 0 { prange.start } else { range_start }; @@ -2170,6 +2177,10 @@ fn place_paragraph_lines(shared: PlacementShared<'_, '_>, paragraph: ParagraphLi hyphen_at = e.checked_sub(1).filter(|&i| b.hyphen && hyph_after.get(i) == Some(&true)).and_then(|i| glyphs.get(i)).map(|g| g.byte + g.len); line_no += 1; + // An Indent to Here character in this line moves the start of the lines after it: + // break them again with that indent (it holds into the next columns too). + let reindent = here_props.is_some(); + reindented = here_props.or(reindented); // A column / frame / page break inside the paragraph: the rest of it continues in the // new column/frame/page, re-broken there. (A break that ends the paragraph moves the // next paragraph instead.) @@ -2183,12 +2194,13 @@ fn place_paragraph_lines(shared: PlacementShared<'_, '_>, paragraph: ParagraphLi } // An own-story anchored object anchored in this line wraps the lines after it, // which were broken without it: break them again. - if k + 1 < breaks.len() && line_has_wrapping_exclusion(f, range_start..range_end, col, baseline) { + if k + 1 < breaks.len() && (reindent || line_has_wrapping_exclusion(f, range_start..range_end, col, baseline)) { g0 += b.next; moved = true; break; } } + indented = reindented.or(indented); if !moved { break; } @@ -4949,6 +4961,37 @@ fn distribute(line: &[Glyph], spaces: &[usize], extra: f64, sp: &Spacing, add: & } } +/// The paragraph settings for the lines after `line` when it holds an Indent to Here character +/// (the last one, if several): the start-side indent reaches the character, measured from the +/// start edge of the line's segment `x0..x1`. `None` when there is none or the indent is unchanged. +fn indent_here( + line: &[Glyph], + placed: &[PlacedGlyph], + pp: &designcraft_doc::ParaProps, + x0: f64, + x1: f64, + rtl: bool, +) -> Option { + let byte = line.iter().rev().find(|g| g.ch == story::INDENT_HERE)?.byte; + let x = placed.iter().find(|g| g.byte == byte && g.len > 0)?.x; + let indent = if rtl { x1 - x } else { x - x0 }; + if !indent.is_finite() { + return None; + } + let indent = indent.max(0.0); + let current = if rtl { pp.right_indent } else { pp.left_indent }; + if (indent - current).abs() < 0.01 { + return None; + } + let mut props = pp.clone(); + if rtl { + props.right_indent = indent; + } else { + props.left_indent = indent; + } + Some(props) +} + fn place(g: &Glyph, x: f64) -> PlacedGlyph { let visible = !(g.ch == '\t' || g.ch == '\n' diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 7b5e1024f..3aff83ae6 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1906,6 +1906,34 @@ fn tab_leaders_fill_the_gap() { assert!(last_dot < num_x, "leaders stop before the page number"); } +#[test] +fn indent_to_here_starts_the_paragraphs_later_lines_at_the_character() { + // "1.", Indent to Here, then text long enough to wrap: every line after the first starts + // where the character sits, in this frame and the next one (#2080). + let text = format!("1.\u{e005}{}", [LOREM; 3].join(" ")); + let para = ParaAttrs { left_indent: Some(6.0), ..Default::default() }; + let (mut d, sid, a) = doc_with(&text, Rect::new(72.0, 36.0, 272.0, 96.0), para.clone()); + let lid = d.default_layer(); + let (b, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(72.0, 200.0, 272.0, 800.0), lid, "", ParaFormat::default()).unwrap(); + d.thread(a, b).unwrap(); + let cs = compose_story(&d, sid, &Default::default()); + assert!(!cs.is_overset()); + assert!(cs.frames[0].lines.len() > 1 && !cs.frames[1].lines.is_empty(), "the text wraps into the second frame"); + let lines = all_lines(&cs); + let here = lines[0].glyphs.iter().find(|g| g.byte == 2 && g.len > 0).unwrap().x; + assert!(here > 72.0 + 6.0 + 1.0, "the character follows the number"); + for (i, line) in lines.iter().enumerate().skip(1) { + let start = line.glyphs.iter().find(|g| g.visible).unwrap().x; + assert!((start - here).abs() < 0.01, "line {i} starts at {start}, not at the indent {here}"); + assert!(line.end_x <= 272.0 + 0.01, "line {i} fits the narrower measure"); + } + // Without the character the later lines keep the paragraph's own indent. + let (plain, psid, _) = doc_with(&text.replace('\u{e005}', " "), Rect::new(72.0, 36.0, 272.0, 400.0), para); + let pcs = compose_story(&plain, psid, &Default::default()); + let start = pcs.frames[0].lines[1].glyphs.iter().find(|g| g.visible).unwrap().x; + assert!((start - 78.0).abs() < 0.01, "plain second line starts at {start}"); +} + #[test] fn right_indent_tab_leaders_follow_stop_precedence_without_moving_text() { let text = "Intro\u{e006}12"; From 7d1340a3dff69266870e92428345d51605a29ae6 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 3 Nov 2026 00:09:48 +0100 Subject: [PATCH 23/33] Keep style export tags and classes on IDML import and export Fixes #2173 Co-Authored-By: Claude Opus 5.5 --- crates/idml/src/export/styles.rs | 27 +++++++++++++++++++++--- crates/idml/src/import/styles.rs | 15 ++++++++++++++ crates/idml/src/tests.rs | 35 ++++++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 3 deletions(-) diff --git a/crates/idml/src/export/styles.rs b/crates/idml/src/export/styles.rs index bf156c680..8977af787 100644 --- a/crates/idml/src/export/styles.rs +++ b/crates/idml/src/export/styles.rs @@ -28,7 +28,11 @@ impl<'a> Ex<'a> { } self.char_attrs(&mut el, &mut props, &cs.chars); } - cg.insert(&cs.name, with_props(el, props)); + let mut el = with_props(el, props); + if let Some(t) = styles.export_tag(&cs.name, true) { + el.push(self.export_tag_map(t)); + } + cg.insert(&cs.name, el); } let mut rc = El::new("RootCharacterStyleGroup").attr("Self", self.fresh()); cg.emit(&mut rc, "CharacterStyleGroup", ""); @@ -65,10 +69,15 @@ impl<'a> Ex<'a> { let pa = with_list_tab(pa, || (styles.resolve_para_style(&ps.name).0, parent_props)); self.para_attrs(&mut el, &mut props, &pa, parent); self.char_attrs(&mut el, &mut props, &ca); - if styles.export_tag(&ps.name, false).is_some_and(|t| t.split) { + let tag = styles.export_tag(&ps.name, false); + if tag.is_some_and(|t| t.split) { el.set("SplitDocument", "true"); } - pg.insert(&ps.name, with_props(el, props)); + let mut el = with_props(el, props); + if let Some(t) = tag { + el.push(self.export_tag_map(t)); + } + pg.insert(&ps.name, el); } let mut rp = El::new("RootParagraphStyleGroup").attr("Self", self.fresh()); pg.emit(&mut rp, "ParagraphStyleGroup", ""); @@ -159,6 +168,18 @@ impl<'a> Ex<'a> { document(&root) } + /// A style's Export Tagging as its EPUB export tag map. + fn export_tag_map(&mut self, t: &designcraft_doc::ExportTag) -> El { + El::new("StyleExportTagMap") + .attr("Self", self.fresh()) + .attr("ExportType", "EPUB") + .attr("ExportTag", &t.tag) + .attr("ExportClass", &t.class) + .attr("ExportAttributes", "") + .attr("SplitDocument", bool_s(t.split)) + .attr("EmitCss", "true") + } + fn append_cell_styles(&mut self, root: &mut El, styles: &designcraft_doc::Styles) { let mut cells = El::new("RootCellStyleGroup") .attr("Self", self.fresh()) diff --git a/crates/idml/src/import/styles.rs b/crates/idml/src/import/styles.rs index fd40a6d96..4519d8931 100644 --- a/crates/idml/src/import/styles.rs +++ b/crates/idml/src/import/styles.rs @@ -86,6 +86,7 @@ impl<'r> Importer<'r> { let split = e.get("SplitDocument") == Some("true") || e.elements() .any(|m| m.local() == "StyleExportTagMap" && m.get("ExportType") == Some("EPUB") && m.get("SplitDocument") == Some("true")); + self.export_tag(e, &format!("p:{name}")); if split { self.styles.export_tags.entry(format!("p:{name}")).or_default().split = true; } @@ -99,6 +100,7 @@ impl<'r> Importer<'r> { Some(slot) => *slot = s, None => self.styles.character.push(s), } + self.export_tag(e, &format!("c:{name}")); } // Keep built-ins as well: [Basic Table] is editable, and [No table style] // supplies the defaults used by named styles in ordinary IDML files. @@ -201,6 +203,19 @@ impl<'r> Importer<'r> { } } + /// Export Tagging (tag and class) of style `e` from its EPUB export tag map, under `key`. + fn export_tag(&mut self, e: &El, key: &str) { + let Some(m) = e.elements().find(|m| m.local() == "StyleExportTagMap" && m.get("ExportType") == Some("EPUB")) else { return }; + let tag = m.get("ExportTag").unwrap_or("").to_string(); + let class = m.get("ExportClass").unwrap_or("").to_string(); + if tag.is_empty() && class.is_empty() { + return; + } + let t = self.styles.export_tags.entry(key.to_string()).or_default(); + t.tag = tag; + t.class = class; + } + fn based_on(&self, e: &El, para: bool) -> Option { let b = e.prop("BasedOn")?; let b = b.trim(); diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index d9e00975d..050c07fa2 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -4894,6 +4894,41 @@ fn split_document_round_trips() { assert!(split(&back, "Part") && split(&back, "Chapter") && !split(&back, "Body")); } +/// Export Tagging (tag and class) of paragraph and character styles comes from the style's EPUB +/// export tag map, not another export type's, and is written back. +#[test] +fn export_tags_round_trip() { + let xml = r#" + + + + + + + + + + + + + + + + +"#; + let d = import_idml_with(&fixture_with_styles(xml), &|_| None).unwrap(); + let tag = |d: &designcraft_doc::Document, name: &str, character: bool| { + d.styles.export_tag(name, character).map(|t| (t.tag.clone(), t.class.clone(), t.split)) + }; + let check = |d: &designcraft_doc::Document| { + assert_eq!(tag(d, "Chapter", false), Some(("h1".into(), "title".into(), false))); + assert_eq!(tag(d, "Lead", true), Some(("em".into(), "lead".into(), false))); + assert_eq!(tag(d, "Body", false), None); + }; + check(&d); + check(&import_idml(&export_idml(&d)).unwrap()); +} + /// EPUB Export Options › Split Document: off, one style (built in or not) or the styles' export /// tags, read from the file's `EPubExportPreference` and written back. #[test] From b4c9cb7d3a11ce868877bc36f01933ee68c884a4 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 3 Nov 2026 00:24:50 +0100 Subject: [PATCH 24/33] Space dotted paragraph border dots evenly Fixes #2175 Co-Authored-By: Claude Opus 5.5 --- crates/compose/src/parabox.rs | 147 +++++++++++++++++++++++++++++++++- 1 file changed, 143 insertions(+), 4 deletions(-) diff --git a/crates/compose/src/parabox.rs b/crates/compose/src/parabox.rs index 09c5b19e8..4a5b0e8d1 100644 --- a/crates/compose/src/parabox.rs +++ b/crates/compose/src/parabox.rs @@ -358,10 +358,10 @@ fn stroke_outline(path: &BezPath, w: f64, kind: &StrokeType, cap: Cap, join: Joi let p: Vec = pattern.iter().copied().filter(|v| v.is_finite() && *v >= 0.0).collect(); Some(if p.iter().sum::() > 1e-3 { p } else { vec![3.0 * w, 2.0 * w] }) } - StrokeType::Dotted => { - st = st.with_caps(kurbo::Cap::Round); - Some(vec![0.0, 2.0 * w]) - } + StrokeType::Dotted => match dots(path, w) { + Some(d) => return d, + None => None, + }, _ => None, }; if let Some(d) = dashes { @@ -372,3 +372,142 @@ fn stroke_outline(path: &BezPath, w: f64, kind: &StrokeType, cap: Cap, join: Joi } kurbo::stroke(path.iter(), &st, &kurbo::StrokeOpts::default(), TOL) } + +/// Round dots `w` wide along `path`, about `2w` apart and evenly spaced by arc length: around a +/// closed subpath the spacing divides its length exactly, an open one has a dot at each end. +/// `None` (stroke solid) when there would be more than [`MAX_DASHES`] dots. +fn dots(path: &BezPath, w: f64) -> Option { + use kurbo::{ParamCurve, ParamCurveArclen, PathEl, PathSeg}; + // Subpaths as (segments, closed). + let mut subs: Vec<(Vec, bool)> = Vec::new(); + let (mut start, mut cur) = (kurbo::Point::ZERO, kurbo::Point::ZERO); + for el in path.elements() { + let seg = match *el { + PathEl::MoveTo(p) => { + subs.push((Vec::new(), false)); + (start, cur) = (p, p); + continue; + } + PathEl::LineTo(p) => PathSeg::Line(kurbo::Line::new(cur, p)), + PathEl::QuadTo(a, p) => PathSeg::Quad(kurbo::QuadBez::new(cur, a, p)), + PathEl::CurveTo(a, b, p) => PathSeg::Cubic(kurbo::CubicBez::new(cur, a, b, p)), + PathEl::ClosePath => { + if let Some(s) = subs.last_mut() { + s.1 = true; + if cur.distance(start) > 1e-9 { + s.0.push(PathSeg::Line(kurbo::Line::new(cur, start))); + } + } + cur = start; + continue; + } + }; + cur = seg.end(); + if let Some(s) = subs.last_mut() { + s.0.push(seg); + } + } + const ACC: f64 = 1e-4; + let mut out = BezPath::new(); + let mut count = 0.0; + for (segs, closed) in subs { + let lens: Vec = segs.iter().map(|s| s.arclen(ACC)).collect(); + let total: f64 = lens.iter().sum(); + if !(total.is_finite() && total > 1e-9) { + continue; + } + let n = (total / (2.0 * w)).round().max(1.0); + count += n + 1.0; + if count > MAX_DASHES { + return None; + } + let step = total / n; + let dots = if closed { n as usize } else { n as usize + 1 }; + let (mut k, mut before) = (0, 0.0); + for i in 0..dots { + let s = i as f64 * step; + while k + 1 < segs.len() && before + lens.get(k).copied().unwrap_or(0.0) < s { + before += lens.get(k).copied().unwrap_or(0.0); + k += 1; + } + let (Some(seg), Some(&l)) = (segs.get(k), lens.get(k)) else { continue }; + let t = if l > 1e-12 { seg.inv_arclen((s - before).clamp(0.0, l), ACC) } else { 0.0 }; + out.extend(kurbo::Circle::new(seg.eval(t), w / 2.0).path_elements(TOL)); + } + } + Some(out) +} + +#[cfg(test)] +mod dotted_tests { + use super::*; + + /// The centres of the dots (one subpath each) of a dotted outline. + fn dot_centres(p: &BezPath) -> Vec { + let mut out = Vec::new(); + let mut cur = BezPath::new(); + for el in p.elements() { + if matches!(el, kurbo::PathEl::MoveTo(_)) && !cur.elements().is_empty() { + out.push(cur.bounding_box().center()); + cur = BezPath::new(); + } + cur.push(*el); + } + if !cur.elements().is_empty() { + out.push(cur.bounding_box().center()); + } + out + } + + /// Distance along the outline of `r`, clockwise from its top left corner, of point `q` on it. + fn along(r: Rect, q: kurbo::Point) -> f64 { + let (w, h) = (r.width(), r.height()); + if (q.y - r.y0).abs() < 1e-6 { + q.x - r.x0 + } else if (q.x - r.x1).abs() < 1e-6 { + w + q.y - r.y0 + } else if (q.y - r.y1).abs() < 1e-6 { + w + h + r.x1 - q.x + } else { + 2.0 * w + h + r.y1 - q.y + } + } + + fn assert_even(gaps: &[f64], expected: f64) { + for g in gaps { + assert!((g - expected).abs() < 0.05, "uneven dot gaps {gaps:?}, expected {expected}"); + } + } + + #[test] + fn dotted_box_border_dots_are_evenly_spaced() { + let r = Rect::new(0.0, 0.0, 100.3, 37.7); + let path = box_outline(r, &[Corner::default(); 4], [true; 4]); + let len = 2.0 * (r.width() + r.height()); + let out = stroke_outline(&path, 1.0, &StrokeType::Dotted, Cap::Butt, Join::Miter, len); + let mut s: Vec = dot_centres(&out).into_iter().map(|q| along(r, q)).collect(); + s.sort_by(f64::total_cmp); + let n = (len / 2.0).round() as usize; + assert_eq!(s.len(), n, "dot count"); + let mut gaps: Vec = s.windows(2).map(|p| p[1] - p[0]).collect(); + gaps.push(s[0] + len - s[n - 1]); + assert_even(&gaps, len / n as f64); + } + + #[test] + fn dotted_edge_dots_are_evenly_spaced_and_reach_both_ends() { + for (w, l) in [(1.0, 50.3), (2.5, 37.7), (0.7, 100.3)] { + let mut p = BezPath::new(); + p.move_to((0.0, 0.0)); + p.line_to((l, 0.0)); + let out = stroke_outline(&p, w, &StrokeType::Dotted, Cap::Butt, Join::Miter, l); + let mut xs: Vec = dot_centres(&out).into_iter().map(|q| q.x).collect(); + xs.sort_by(f64::total_cmp); + let n = (l / (2.0 * w)).round() as usize; + assert_eq!(xs.len(), n + 1, "dot count for w {w}, length {l}"); + assert!(xs[0].abs() < 0.05 && (xs[n] - l).abs() < 0.05, "ends {xs:?}"); + let gaps: Vec = xs.windows(2).map(|p| p[1] - p[0]).collect(); + assert_even(&gaps, l / n as f64); + } + } +} From e79d682e02dc66f14370958b193bfa46072b2f41 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 2 Nov 2026 19:26:33 -0500 Subject: [PATCH 25/33] Release 0.17.0 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 42 +++++++++++++++++++++--------------------- Cargo.toml | 2 +- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6db00efc7..5900424b0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1306,7 +1306,7 @@ dependencies = [ [[package]] name = "designcraft" -version = "0.16.0" +version = "0.17.0" dependencies = [ "arboard", "craft-update", @@ -1330,7 +1330,7 @@ dependencies = [ [[package]] name = "designcraft-cli" -version = "0.16.0" +version = "0.17.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1346,7 +1346,7 @@ dependencies = [ [[package]] name = "designcraft-color" -version = "0.16.0" +version = "0.17.0" dependencies = [ "craft-cms", "kurbo", @@ -1358,7 +1358,7 @@ dependencies = [ [[package]] name = "designcraft-compose" -version = "0.16.0" +version = "0.17.0" dependencies = [ "designcraft-color", "designcraft-doc", @@ -1378,7 +1378,7 @@ dependencies = [ [[package]] name = "designcraft-doc" -version = "0.16.0" +version = "0.17.0" dependencies = [ "chrono", "designcraft-color", @@ -1391,7 +1391,7 @@ dependencies = [ [[package]] name = "designcraft-engine" -version = "0.16.0" +version = "0.17.0" dependencies = [ "caseless", "craft-atomic", @@ -1431,7 +1431,7 @@ dependencies = [ [[package]] name = "designcraft-epub" -version = "0.16.0" +version = "0.17.0" dependencies = [ "chrono", "designcraft-color", @@ -1441,7 +1441,7 @@ dependencies = [ [[package]] name = "designcraft-fonts" -version = "0.16.0" +version = "0.17.0" dependencies = [ "core-text", "harfrust 0.13.3", @@ -1454,7 +1454,7 @@ dependencies = [ [[package]] name = "designcraft-format" -version = "0.16.0" +version = "0.17.0" dependencies = [ "designcraft-doc", "designcraft-images", @@ -1466,7 +1466,7 @@ dependencies = [ [[package]] name = "designcraft-geom" -version = "0.16.0" +version = "0.17.0" dependencies = [ "flo_curves", "kurbo", @@ -1479,7 +1479,7 @@ dependencies = [ [[package]] name = "designcraft-idml" -version = "0.16.0" +version = "0.17.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1494,7 +1494,7 @@ dependencies = [ [[package]] name = "designcraft-images" -version = "0.16.0" +version = "0.17.0" dependencies = [ "craft-cms", "designcraft-color", @@ -1512,7 +1512,7 @@ dependencies = [ [[package]] name = "designcraft-mcp" -version = "0.16.0" +version = "0.17.0" dependencies = [ "designcraft-engine", "designcraft-geom", @@ -1525,7 +1525,7 @@ dependencies = [ [[package]] name = "designcraft-pdf" -version = "0.16.0" +version = "0.17.0" dependencies = [ "chrono", "craft-cms", @@ -1551,7 +1551,7 @@ dependencies = [ [[package]] name = "designcraft-pptx" -version = "0.16.0" +version = "0.17.0" dependencies = [ "chrono", "designcraft-color", @@ -1569,7 +1569,7 @@ dependencies = [ [[package]] name = "designcraft-render" -version = "0.16.0" +version = "0.17.0" dependencies = [ "craft-pdf", "designcraft-color", @@ -1588,7 +1588,7 @@ dependencies = [ [[package]] name = "designcraft-textimport" -version = "0.16.0" +version = "0.17.0" dependencies = [ "designcraft-doc", "quick-xml 0.41.0", @@ -1597,7 +1597,7 @@ dependencies = [ [[package]] name = "designcraft-tools" -version = "0.16.0" +version = "0.17.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1609,7 +1609,7 @@ dependencies = [ [[package]] name = "designcraft-ui-egui" -version = "0.16.0" +version = "0.17.0" dependencies = [ "craft-dialogs", "craft-keymap", @@ -1641,7 +1641,7 @@ dependencies = [ [[package]] name = "designcraft-web" -version = "0.16.0" +version = "0.17.0" dependencies = [ "designcraft-engine", "designcraft-format", @@ -7642,7 +7642,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.16.0" +version = "0.17.0" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 138a07985..70675a5fc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/designcraft", "apps/designcraft-cli", "xtask"] [workspace.package] -version = "0.16.0" +version = "0.17.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" From 7d0f141b52af42a1164740a50559496624c40689 Mon Sep 17 00:00:00 2001 From: Paul Gemignani <4waymedia@gmail.com> Date: Mon, 2 Nov 2026 10:33:07 -0800 Subject: [PATCH 26/33] Links: an unchanged linked image is not read again on every poll (#2164) links.list decided a placed graphic's status by reading the whole file and comparing it with the asset's bytes whenever the sizes matched, and the Links panel polls it twice a second, so a document of press-size images read every one of them in full on every poll. Image links now have the size-and-time fast path linked text files already had: a file of the remembered size and time isn't read, a new time is read once and remembered (unless the file changed in the last two seconds), a different size is modified without a read. The answer is keyed by the asset's bytes' identity, so a relink, update or undo can never be served a stale one; status() stays as the exact check for callers that want it. Verified locally on a288a87b: fmt, clippy, engine link tests including the new unchanged_linked_images_are_not_read_again. --- crates/engine/src/cmd/interchange.rs | 16 +++--- crates/engine/src/cmd/links.rs | 81 ++++++++++++++++++++-------- crates/engine/src/cmd/links/tests.rs | 74 ++++++++++++++++++++++++- crates/engine/src/lib.rs | 4 ++ 4 files changed, 145 insertions(+), 30 deletions(-) diff --git a/crates/engine/src/cmd/interchange.rs b/crates/engine/src/cmd/interchange.rs index 4ca22309c..32c87d9f5 100644 --- a/crates/engine/src/cmd/interchange.rs +++ b/crates/engine/src/cmd/interchange.rs @@ -525,7 +525,7 @@ mod tests { assert_eq!(*asset.data, png, "the packaged graphic must render even when its author-machine path no longer exists"); let resolved = std::path::absolute(&path).unwrap(); assert_eq!(asset.link.as_deref(), resolved.to_str(), "remember the resolved link so update and preflight use the packaged file"); - assert_eq!(super::super::links::status(asset, None), "ok"); + assert_eq!(super::super::links::status_cached(asset, None, &mut Default::default()), "ok"); std::fs::remove_dir_all(dir).unwrap(); } @@ -542,7 +542,7 @@ mod tests { let asset = doc.assets.values().next().unwrap(); assert_eq!(*asset.data, png); assert_eq!(asset.link.as_deref(), std::path::absolute(&path).unwrap().to_str()); - assert_eq!(super::super::links::status(asset, None), "ok"); + assert_eq!(super::super::links::status_cached(asset, None, &mut Default::default()), "ok"); std::fs::remove_dir_all(dir).unwrap(); } @@ -558,7 +558,7 @@ mod tests { let asset = doc.assets.values().next().unwrap(); assert_eq!(*asset.data, png); assert_eq!(asset.link.as_deref(), std::path::absolute(&path).unwrap().to_str()); - assert_eq!(super::super::links::status(asset, None), "ok"); + assert_eq!(super::super::links::status_cached(asset, None, &mut Default::default()), "ok"); std::fs::remove_dir_all(dir).unwrap(); } @@ -578,7 +578,7 @@ mod tests { let asset = doc.assets.values().next().unwrap(); assert_eq!(std::path::Path::new(asset.link.as_deref().unwrap()), original.as_path(), "an existing original stays authoritative"); assert_eq!(*asset.data, png); - assert_eq!(super::super::links::status(asset, None), "modified"); + assert_eq!(super::super::links::status_cached(asset, None, &mut Default::default()), "modified"); std::fs::remove_file(&original).unwrap(); std::fs::write(©, b"modified packaged copy").unwrap(); @@ -586,7 +586,7 @@ mod tests { let asset = doc.assets.values().next().unwrap(); assert_eq!(asset.link.as_deref(), copy.to_str()); assert_eq!(*asset.data, png, "rebinding must not replace embedded pixels"); - assert_eq!(super::super::links::status(asset, None), "modified"); + assert_eq!(super::super::links::status_cached(asset, None, &mut Default::default()), "modified"); std::fs::remove_file(©).unwrap(); let packaged = package.join("x.idml"); @@ -595,7 +595,7 @@ mod tests { let asset = doc.assets.values().next().unwrap(); assert_eq!(std::path::Path::new(asset.link.as_deref().unwrap()), original.as_path(), "keep a missing link when there is no candidate"); assert_eq!(*asset.data, png); - assert_eq!(super::super::links::status(asset, None), "missing"); + assert_eq!(super::super::links::status_cached(asset, None, &mut Default::default()), "missing"); } let oversized = "a/".repeat(MAX_LINK_PATH_BYTES); assert!(packaged_link_candidates(&oversized, Some(&packaged)).is_empty()); @@ -643,7 +643,7 @@ mod tests { assert!(seen.iter().all(|p| p.starts_with(&dir) || itself.as_ref() == Some(p)), "{link}: {seen:?}"); let asset = doc.assets.values().next().unwrap(); assert!(asset.data.is_empty(), "{link}"); - assert_eq!(super::super::links::status(asset, None), "missing", "{link}"); + assert_eq!(super::super::links::status_cached(asset, None, &mut Default::default()), "missing", "{link}"); } // An image elsewhere on this computer is read, and so is one beside the package. let away = outside.join("logo.png"); @@ -652,7 +652,7 @@ mod tests { let doc = import(&idml, Some(&package)).unwrap(); let asset = doc.assets.values().next().unwrap(); assert_eq!((&*asset.data, asset.link.as_deref()), (&png, away.to_str())); - assert_eq!(super::super::links::status(asset, None), "ok"); + assert_eq!(super::super::links::status_cached(asset, None, &mut Default::default()), "ok"); std::fs::write(dir.join("Links").join("logo.png"), &png).unwrap(); let (idml, _) = linked_idml("Links/logo.png", false).unwrap(); let doc = import(&idml, Some(&package)).unwrap(); diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 4f5368af6..1ad60b30f 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -27,8 +27,10 @@ pub fn specs() -> Vec { cmd!(query "links.list", "Links", [], None, "{} → [{asset, name, path, status: ok|modified|missing|embedded, refused?: why a missing link isn't followed (a path on another computer, a device), pixels, pdfCrop (placed PDFs: the box shown), uses: [{id, page, ppi}]}], then linked text files: [{story, name, path, status (also tooLarge: unchanged size, too big to check), edited (the story was changed in the layout), uses: [{id, page}]}]", has_doc, |s, _| { // Taken out for the call so the document needn't be cloned on every poll. let mut checks = std::mem::take(&mut s.text_link_checks); - let links = s.doc().map(|d| list(&d.doc, &s.cache, d.path.as_deref().map(Path::new), &mut checks)); + let mut images = std::mem::take(&mut s.image_link_checks); + let links = s.doc().map(|d| list(&d.doc, &s.cache, d.path.as_deref().map(Path::new), &mut checks, &mut images)); s.text_link_checks = checks; + s.image_link_checks = images; Ok(Value::Array(links?)) }), cmd!( @@ -137,7 +139,7 @@ pub fn refused(link: &str, document: Option<&Path>) -> Option) -> bool { @@ -155,7 +157,18 @@ pub fn link_missing(a: &designcraft_doc::Asset, document: Option<&Path>) -> bool /// Link status of an asset in the document at `document`. Only a regular file is compared, so a /// link to a device (`/dev/zero`) or a FIFO is missing rather than read. -pub fn status(a: &designcraft_doc::Asset, document: Option<&Path>) -> &'static str { +/// Linked images checked this session: (asset, the identity of its bytes, link) → (the file's size +/// and time when it was read, whether it matched the bytes). The bytes' identity (their `Arc`'s +/// address, as the render cache keys them) changes with every relink, update or undo, so a +/// remembered answer can never describe other bytes. +pub(crate) type ImageChecks = std::collections::HashMap<(AssetId, usize, String), (Fingerprint, bool)>; + +/// Link status of a placed graphic (`ok`, `modified`, `missing`, `embedded`), reading the file +/// only when its size is the asset's and its time isn't the one remembered from the last read +/// (`checks`): the Links panel polls twice a second, and a document +/// of press-size images would otherwise be read whole on every poll. A file changed in the last +/// two seconds is never remembered (see [`settled`]). +pub(crate) fn status_cached(a: &designcraft_doc::Asset, document: Option<&Path>, checks: &mut ImageChecks) -> &'static str { let Some(link) = &a.link else { return "embedded" }; // Nothing was read from the file (a package's link that wasn't followed): missing, as in // Preflight. @@ -168,20 +181,32 @@ pub fn status(a: &designcraft_doc::Asset, document: Option<&Path>) -> &'static s if !crate::io::is_file(&path) { return "missing"; } - match crate::io::metadata(&path) { - Err(_) => "missing", - Ok(m) if m.len() as usize != a.data.len() => "modified", - // Same size: compare the bytes (cheap for the sizes involved, and exact). - Ok(_) => match crate::io::read_capped(&path, a.data.len() as u64) { - Ok(b) if b == *a.data => "ok", - Ok(_) => "modified", - Err(_) => "missing", - }, + let now = match crate::io::metadata(&path) { + Err(_) => return "missing", + Ok(m) => super::datamerge::fingerprint_from(&m), + }; + if now.size != a.data.len() as u64 { + return "modified"; } + let key = (a.id, Arc::as_ptr(&a.data) as usize, link.clone()); + if let Some((at, same)) = checks.get(&key) + && *at == now + { + return if *same { "ok" } else { "modified" }; + } + let same = match crate::io::read_capped(&path, a.data.len() as u64) { + Ok(b) => b == *a.data, + Err(_) => return "missing", + }; + // The time from before the read: a change during it is seen next time. + if settled(&now) { + checks.insert(key, (now, same)); + } + if same { "ok" } else { "modified" } } #[cfg(target_arch = "wasm32")] { - let _ = (link, document); + let _ = (link, document, checks); "ok" } } @@ -317,7 +342,9 @@ fn shown<'a>(d: &'a Document, cache: &Cache, aid: AssetId) -> Vec> { out } -fn list(d: &Document, cache: &Cache, document: Option<&Path>, checks: &mut TextChecks) -> Vec { +/// `links.list`: every used placed graphic, then every linked text file, each with its status +/// (`images` and `checks` remember what was read this session, so a poll reads nothing unchanged). +fn list(d: &Document, cache: &Cache, document: Option<&Path>, checks: &mut TextChecks, images: &mut ImageChecks) -> Vec { let texts = d.stories.values().filter_map(|st| { let l = st.file_link.as_ref()?; let uses: Vec = st.frames.iter().map(|id| json!({"id": id.0, "page": d.page_of_item(*id).map(|p| d.page_name(p))})).collect(); @@ -350,7 +377,7 @@ fn list(d: &Document, cache: &Cache, document: Option<&Path>, checks: &mut TextC .collect(); let pdf_crop = (a.mime == "application/pdf").then(|| a.pdf_crop.name()); let why = a.link.as_deref().and_then(|l| refused(l, document)).map(|r| r.to_string()); - json!({"asset": a.id.0, "name": a.name, "path": a.link, "status": status(a, document), "refused": why, "pixels": a.pixels, "pdfCrop": pdf_crop, "uses": uses}) + json!({"asset": a.id.0, "name": a.name, "path": a.link, "status": status_cached(a, document, images), "refused": why, "pixels": a.pixels, "pdfCrop": pdf_crop, "uses": uses}) }) .chain(texts) .collect() @@ -450,6 +477,7 @@ fn relinked_asset(a: &designcraft_doc::Asset, read: &Path, link: &str) -> Result Ok(Arc::new(a)) } +/// `links.relink`: a placed graphic or linked text file takes its bytes from `path` from now on. fn relink(s: &mut Session, p: &Value) -> Result { let path = str_param(p, "path").ok_or_else(|| bad("links.relink", "missing path"))?.to_string(); if let Some(sid) = story_param(p) { @@ -464,13 +492,17 @@ fn relink(s: &mut Session, p: &Value) -> Result { let a = s.doc()?.doc.assets.get(&aid).ok_or_else(|| bad("links.relink", "no such graphic"))?; // Build the replacement before the edit: a file that cannot be read leaves the document as it was. let fresh = relinked_asset(a, Path::new(&path), &path)?; - s.edit(|d, _| { + let r = s.edit(|d, _| { let name = fresh.name.clone(); d.assets.insert(aid, fresh); Ok(json!({"name": name})) - }) + }); + s.image_link_checks.clear(); + r } +/// `links.relinkFolder`: relink graphics to same-named files in a folder and/or with another +/// extension (the missing ones by default). fn relink_folder(s: &mut Session, p: &Value) -> Result { let dir = str_param(p, "dir").map(std::path::PathBuf::from); if let Some(dir) = &dir { @@ -486,12 +518,13 @@ fn relink_folder(s: &mut Session, p: &Value) -> Result { } let missing_only = p.get("missingOnly").and_then(Value::as_bool).unwrap_or(dir.is_some() && ext.is_none()); let only: Option> = p.get("assets").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).collect()); + let mut images = std::mem::take(&mut s.image_link_checks); let st = s.doc()?; let (d, document) = (&st.doc, st.path.as_deref().map(Path::new)); let mut todo: Vec<(AssetId, std::path::PathBuf)> = Vec::new(); let mut not_found = Vec::new(); for a in d.assets.values() { - if only.as_ref().is_some_and(|o| !o.contains(&a.id.0)) || (missing_only && status(a, document) != "missing") { + if only.as_ref().is_some_and(|o| !o.contains(&a.id.0)) || (missing_only && status_cached(a, document, &mut images) != "missing") { continue; } let old = std::path::PathBuf::from(a.link.clone().unwrap_or_else(|| a.name.clone())); @@ -538,10 +571,13 @@ fn relink_folder(s: &mut Session, p: &Value) -> Result { } Ok(()) })?; + images.clear(); } + s.image_link_checks = images; Ok(json!({"relinked": n, "notFound": not_found})) } +/// `links.update`: re-read the modified links (one, or every one) from where they resolve. fn update(s: &mut Session, p: &Value) -> Result { let (d, document) = { let st = s.doc()?; @@ -549,6 +585,7 @@ fn update(s: &mut Session, p: &Value) -> Result { }; let document = document.as_deref().map(Path::new); let checks = &mut s.text_link_checks; + let images = &mut s.image_link_checks; let (only_asset, only_story) = (p.get("asset").and_then(Value::as_u64), story_param(p)); let all = only_asset.is_none() && only_story.is_none(); // A link craft-docpath refuses is missing, so it is never read or refreshed. @@ -563,7 +600,7 @@ fn update(s: &mut Session, p: &Value) -> Result { }) .collect(); let mut fresh = Vec::new(); - for a in d.assets.values().filter(|a| all || only_asset == Some(a.id.0)).filter(|a| status(a, document) == "modified") { + for a in d.assets.values().filter(|a| all || only_asset == Some(a.id.0)).filter(|a| status_cached(a, document, images) == "modified") { // The bytes come from where the stored link resolves; the link itself stays as the // document stored it, so refreshing never rewrites a relative link to an absolute one. if let Some((link, at)) = a.link.as_deref().and_then(|l| resolved(l).map(|at| (l, at))) { @@ -579,7 +616,7 @@ fn update(s: &mut Session, p: &Value) -> Result { if n == 0 { return Ok(json!({"updated": 0})); } - s.edit(|d, _| { + let r = s.edit(|d, _| { for (aid, a) in fresh { d.assets.insert(aid, a); } @@ -588,7 +625,9 @@ fn update(s: &mut Session, p: &Value) -> Result { warnings.extend(reimport(d, *sid, path, bytes, *before)?); } Ok(json!({"updated": n, "warnings": warnings})) - }) + }); + s.image_link_checks.clear(); + r } #[cfg(test)] diff --git a/crates/engine/src/cmd/links/tests.rs b/crates/engine/src/cmd/links/tests.rs index 8089b24ce..c3391b333 100644 --- a/crates/engine/src/cmd/links/tests.rs +++ b/crates/engine/src/cmd/links/tests.rs @@ -4,6 +4,78 @@ fn png(w: u32, h: u32) -> Vec { designcraft_render::Rendered { width: w, height: h, pixels: vec![200u8; (w * h * 4) as usize] }.to_png() } +/// The Links panel polls `links.list` twice a second: a linked image whose file keeps its size +/// and time is not read again (press-size images would otherwise be read whole on every poll). +/// A new time is read once; changed bytes show as modified; an update is remembered. +#[cfg(all(unix, not(target_arch = "wasm32")))] +#[test] +fn unchanged_linked_images_are_not_read_again() { + use std::os::unix::fs::PermissionsExt; + let dir = std::env::temp_dir().join(format!("dc-image-link-poll-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("photo.png"); + // Two valid images of the same byte length (a uniform fill compresses to the same size + // whatever its value), so a change shows only in the bytes. + let a = png(40, 40); + let b = designcraft_render::Rendered { width: 40, height: 40, pixels: vec![100u8; 40 * 40 * 4] }.to_png(); + assert_eq!(a.len(), b.len(), "the test needs two files of one size"); + assert_ne!(a, b); + std::fs::write(&path, &a).unwrap(); + // Unreadable: a status that reads the file would say missing. + let readable = |on: bool| std::fs::set_permissions(&path, std::fs::Permissions::from_mode(if on { 0o644 } else { 0o000 })).unwrap(); + // A time `secs` ago (a file changed in the last two seconds is always read). + let touch = |secs: u64| { + let f = std::fs::File::options().write(true).open(&path).unwrap(); + f.set_modified(std::time::SystemTime::now() - std::time::Duration::from_secs(secs)).unwrap(); + }; + touch(60); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("file.place", &json!({"path": path.to_string_lossy(), "link": true})).unwrap(); + let status = |s: &mut Session| { + let l = s.execute("links.list", &json!({})).unwrap(); + l.as_array().unwrap().iter().find(|r| r.get("asset").is_some()).unwrap()["status"].as_str().unwrap().to_string() + }; + + // First poll reads and remembers; the second doesn't read. + assert_eq!(status(&mut s), "ok"); + readable(false); + if std::fs::read(&path).is_ok() { + // Running as root: permissions don't stop reads, so this can't be observed. + let _ = std::fs::remove_dir_all(&dir); + return; + } + assert_eq!(status(&mut s), "ok", "same size and time: not read"); + // Saved again unchanged (a new time): read once, then not again. + readable(true); + touch(30); + assert_eq!(status(&mut s), "ok"); + readable(false); + assert_eq!(status(&mut s), "ok", "the second poll doesn't read it"); + // Changed, same size, new time: modified — and remembered, not re-read. + readable(true); + std::fs::write(&path, &b).unwrap(); + touch(20); + assert_eq!(status(&mut s), "modified"); + readable(false); + assert_eq!(status(&mut s), "modified", "a modified answer is remembered too"); + // Updated: the document's bytes are the file's, so the next poll reads once and says ok. + readable(true); + assert_eq!(s.execute("links.update", &json!({})).unwrap()["updated"], 1); + assert_eq!(status(&mut s), "ok"); + readable(false); + assert_eq!(status(&mut s), "ok", "remembered after the update"); + // A different size needs no read at all. + readable(true); + std::fs::write(&path, png(41, 41)).unwrap(); + touch(10); + readable(false); + assert_eq!(status(&mut s), "modified", "size differs: decided from metadata"); + readable(true); + let _ = std::fs::remove_dir_all(&dir); +} + /// A linked file's size is the document's choice, so a link is read under a cap (#1596). #[cfg(not(target_arch = "wasm32"))] #[test] @@ -154,7 +226,7 @@ fn eps_status_tracks_source_bytes_across_save_update_relink_and_embed() { std::fs::remove_file(&replacement).unwrap(); let embedded = designcraft_format::load(&designcraft_format::save(&s.doc().unwrap().doc).unwrap()).unwrap(); // The asset is embedded, so no link is followed and the document is irrelevant. - assert_eq!(status(&embedded.assets[&aid], None), "embedded"); + assert_eq!(status_cached(&embedded.assets[&aid], None, &mut Default::default()), "embedded"); assert_eq!(embedded.assets[&aid].data.as_slice(), changed); assert!(designcraft_render::decode_pixmap(&embedded.assets[&aid].data).is_some()); let _ = std::fs::remove_dir_all(dir); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f42dc5b13..aee87f2ee 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -435,6 +435,9 @@ pub struct Session { /// Linked text files checked by the Links panel: path → the size and modification time the /// file had and the hash of its contents then, so a file touched but not changed is read once. pub(crate) text_link_checks: std::collections::HashMap, + /// Linked images checked against their files this session (`links.list` polls twice a second): + /// a file of the recorded size and time isn't read again. + pub(crate) image_link_checks: std::collections::HashMap<(designcraft_doc::AssetId, usize, String), (designcraft_doc::Fingerprint, bool)>, } impl Default for Session { @@ -467,6 +470,7 @@ impl Session { recovery_dir: None, text_clipboard: None, text_link_checks: Default::default(), + image_link_checks: Default::default(), ui_requests: vec![], loaded: None, transforms: Default::default(), From d0271a562ffaf8b9293715a97520c9cfaee3fc85 Mon Sep 17 00:00:00 2001 From: ljkx <177671075+ljkx@users.noreply.github.com> Date: Tue, 3 Nov 2026 01:59:37 +0100 Subject: [PATCH 27/33] Text: Indent to Here beside a drop cap isn't indented by the drop cap again (#2080) The lines after an Indent to Here start at the character, which already sits past the drop cap, but the lines beside the drop cap still added its width on top. They no longer do. Adds tests for the drop cap, for Indent to Here after a tab (the usual hanging number) and for a right-to-left paragraph, and documents how the indent behaves beside a text wrap. --- crates/compose/src/lib.rs | 13 ++++++--- crates/compose/src/tests.rs | 54 +++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 9d383d270..0e32881d9 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -1817,6 +1817,8 @@ fn place_sub_objects( } } +/// Sets one paragraph's lines, column after column and frame after frame. It stops when the +/// paragraph ends, when the frames run out, or when an earlier paragraph has to be set again. fn place_paragraph_lines(shared: PlacementShared<'_, '_>, paragraph: ParagraphLines<'_>) -> LinePlacement { let PlacementShared { doc, @@ -1916,6 +1918,9 @@ fn place_paragraph_lines(shared: PlacementShared<'_, '_>, paragraph: ParagraphLi }); } let pp = indented.as_ref().unwrap_or(para_props); + // The Indent to Here character sits past the drop cap, so the lines that start at it + // aren't indented by the drop cap again. + let beside_cap = drop_cap.filter(|_| indented.is_none()); let f = &frames[cur.fi]; let base = cols[cur.fi][cur.col.min(cols[cur.fi].len() - 1)]; let col = cur.clip(base); @@ -1941,7 +1946,7 @@ fn place_paragraph_lines(shared: PlacementShared<'_, '_>, paragraph: ParagraphLi props: pp, slots: &slots, column: col, - drop_cap, + drop_cap: beside_cap, line_no, col_first_line, rtl, @@ -2066,7 +2071,7 @@ fn place_paragraph_lines(shared: PlacementShared<'_, '_>, paragraph: ParagraphLi let col_w = notes.area(&cols[cur.fi], cur.col).map_or(0.0, |r| r.width()); let note_column = NoteColumn { frame_index: cur.fi, column_index: cur.col, width: col_w, frame: f }; // A drop cap sits at the start-side indent; the first-line indent doesn't move it. - let beside = drop_cap.filter(|dc| line_no < dc.lines && col_first_line == 0).map(|dc| dc.indent()); + let beside = beside_cap.filter(|dc| line_no < dc.lines && col_first_line == 0).map(|dc| dc.indent()); let first_line = if line_no == 0 { pp.first_line_indent } else { 0.0 }; let (ind_l, ind_r) = match beside { Some(w) if rtl => (pp.left_indent, pp.right_indent + w), @@ -4963,7 +4968,9 @@ fn distribute(line: &[Glyph], spaces: &[usize], extra: f64, sp: &Spacing, add: & /// The paragraph settings for the lines after `line` when it holds an Indent to Here character /// (the last one, if several): the start-side indent reaches the character, measured from the -/// start edge of the line's segment `x0..x1`. `None` when there is none or the indent is unchanged. +/// start edge of the line's segment `x0..x1`. Like the paragraph's own indents it then applies +/// from each later line's segment, so beside a text wrap it starts at the wrap's edge. `None` +/// when there is none or the indent is unchanged. fn indent_here( line: &[Glyph], placed: &[PlacedGlyph], diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 3aff83ae6..7867d2ec8 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1934,6 +1934,60 @@ fn indent_to_here_starts_the_paragraphs_later_lines_at_the_character() { assert!((start - 78.0).abs() < 0.01, "plain second line starts at {start}"); } +/// Where each line after the first starts, and the Indent to Here character's x on the first. +fn indent_to_here_starts(text: &str, para: ParaAttrs) -> (f64, Vec) { + let (d, sid, _) = doc_with(text, Rect::new(72.0, 36.0, 272.0, 600.0), para); + let cs = compose_story(&d, sid, &Default::default()); + let lines = all_lines(&cs); + assert!(lines.len() > 4, "the text wraps"); + let byte = text.find('\u{e005}').unwrap(); + let here = lines[0].glyphs.iter().find(|g| g.byte == byte && g.len > 0).unwrap().x; + let starts = lines.iter().skip(1).map(|l| l.glyphs.iter().find(|g| g.visible).unwrap().x).collect(); + (here, starts) +} + +#[test] +fn indent_to_here_after_a_tab_hangs_the_text_after_the_number() { + // The usual hanging number: "1.", a tab to the next stop, then Indent to Here. + let tabs = vec![designcraft_doc::TabStop { position: 24.0, align: TabAlign::Left, leader: String::new(), align_on: String::new() }]; + let (here, starts) = indent_to_here_starts(&format!("1.\t\u{e005}{LOREM}"), ParaAttrs { tabs: Some(tabs), ..Default::default() }); + assert!((here - (72.0 + 24.0)).abs() < 0.01, "the character sits at the tab stop: {here}"); + for (i, start) in starts.iter().enumerate() { + assert!((start - here).abs() < 0.01, "line {} starts at {start}, not at {here}", i + 1); + } +} + +#[test] +fn indent_to_here_beside_a_drop_cap_is_not_indented_by_the_drop_cap_again() { + // A three-line drop cap, then Indent to Here on the first line: the lines beside the drop + // cap and the lines below it all start at the character, none further in. + let (here, starts) = indent_to_here_starts(&format!("Drop \u{e005}{LOREM}"), drop_cap(3, 1)); + for (i, start) in starts.iter().enumerate() { + assert!((start - here).abs() < 0.01, "line {} starts at {start}, not at {here}", i + 1); + } +} + +#[test] +fn indent_to_here_in_a_right_to_left_paragraph_indents_from_the_right() { + // Hebrew words (the bidi classes decide the order; no Hebrew font has to be present): the + // lines after the first end where the character sits, measured from the right edge. + let words = "שלום עולם ".repeat(40); + let text = format!("שלום \u{e005}{words}"); + let para = ParaAttrs { direction: Some(designcraft_doc::TextDirection::RightToLeft), align: Some(Align::Right), ..Default::default() }; + let (d, sid, _) = doc_with(&text, Rect::new(72.0, 36.0, 272.0, 600.0), para); + let cs = compose_story(&d, sid, &Default::default()); + let lines = all_lines(&cs); + assert!(lines.len() > 3, "the text wraps"); + let byte = text.find('\u{e005}').unwrap(); + let here = lines[0].glyphs.iter().find(|g| g.byte == byte && g.len > 0).unwrap().x; + assert!(here < 272.0 - 1.0, "the character follows the first word: {here}"); + let right_edge = |l: &Line| l.glyphs.iter().filter(|g| g.visible).map(|g| g.x + g.adv).fold(f64::MIN, f64::max); + for (i, line) in lines.iter().enumerate().skip(1) { + let end = right_edge(line); + assert!((end - here).abs() < 0.01, "line {i} ends at {end}, not at {here}"); + } +} + #[test] fn right_indent_tab_leaders_follow_stop_precedence_without_moving_text() { let text = "Intro\u{e006}12"; From 8013118a44774ed5649446028e0643485fb704fd Mon Sep 17 00:00:00 2001 From: ljkx <177671075+ljkx@users.noreply.github.com> Date: Tue, 3 Nov 2026 03:53:41 +0100 Subject: [PATCH 28/33] Text: Indent to Here in a drop cap or a list label indents the later lines too (#2080) The character was looked up among the line's body glyphs, so one inside the drop cap was missed, and one in a list number's ^i matched the first body glyph (after the label's tab) instead of itself. Find it among the placed glyphs, which include the drop cap's and the label's. --- crates/compose/src/lib.rs | 26 ++++++++++---------------- crates/compose/src/tests.rs | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 16 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 0e32881d9..c84cb6e27 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -2123,7 +2123,7 @@ fn place_paragraph_lines(shared: PlacementShared<'_, '_>, paragraph: ParagraphLi } ruby::annotate(db, styles_tab, &story.text, &mut placed, doc.settings.glyph_fallback, f.vertical); kenten::annotate(db, styles_tab, &mut placed, doc.settings.glyph_fallback); - let here_props = indent_here(line_glyphs, &placed, pp, x0, x1, rtl); + let here_props = indent_here(&placed, pp, x0, x1, rtl); let range_end = if last { prange.end } else { glyphs.get(g0 + b.next).map(|g| g.byte).unwrap_or(prange.end) }; let range_start = glyphs.get(s).map(|g| g.byte).unwrap_or(prange.start).min(range_end); let range_start = if line_no == 0 { prange.start } else { range_start }; @@ -4966,21 +4966,15 @@ fn distribute(line: &[Glyph], spaces: &[usize], extra: f64, sp: &Spacing, add: & } } -/// The paragraph settings for the lines after `line` when it holds an Indent to Here character -/// (the last one, if several): the start-side indent reaches the character, measured from the -/// start edge of the line's segment `x0..x1`. Like the paragraph's own indents it then applies -/// from each later line's segment, so beside a text wrap it starts at the wrap's edge. `None` -/// when there is none or the indent is unchanged. -fn indent_here( - line: &[Glyph], - placed: &[PlacedGlyph], - pp: &designcraft_doc::ParaProps, - x0: f64, - x1: f64, - rtl: bool, -) -> Option { - let byte = line.iter().rev().find(|g| g.ch == story::INDENT_HERE)?.byte; - let x = placed.iter().find(|g| g.byte == byte && g.len > 0)?.x; +/// The paragraph settings for the lines after the `placed` line when it holds an Indent to Here +/// character (the last one, if several): the start-side indent reaches the character, measured +/// from the start edge of the line's segment `x0..x1`. Like the paragraph's own indents it then +/// applies from each later line's segment, so beside a text wrap it starts at the wrap's edge. +/// `None` when there is none or the indent is unchanged. +fn indent_here(placed: &[PlacedGlyph], pp: &designcraft_doc::ParaProps, x0: f64, x1: f64, rtl: bool) -> Option { + // The placed glyphs stay in text order (bidi only moves them) and include the drop cap's + // and a list label's, so a character in either counts too. + let x = placed.iter().rev().find(|g| g.rendered_char == story::INDENT_HERE)?.x; let indent = if rtl { x1 - x } else { x - x0 }; if !indent.is_finite() { return None; diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 7867d2ec8..9e9dd354a 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1967,6 +1967,39 @@ fn indent_to_here_beside_a_drop_cap_is_not_indented_by_the_drop_cap_again() { } } +#[test] +fn indent_to_here_inside_the_drop_cap_indents_the_lines_below_it() { + // A two-character drop cap whose second character is Indent to Here: the lines beside the + // drop cap and the ones below it all start at the character, at the drop cap's edge. + let (here, starts) = indent_to_here_starts(&format!("A\u{e005}{LOREM}"), drop_cap(3, 2)); + assert!(here > 72.0 + 10.0, "the character sits past the drop cap: {here}"); + assert!(starts.len() > 3, "lines below the drop cap"); + for (i, start) in starts.iter().enumerate() { + assert!((start - here).abs() < 0.01, "line {} starts at {start}, not at {here}", i + 1); + } +} + +#[test] +fn indent_to_here_in_a_list_number_indents_to_the_generated_character() { + // "^#.^i^t": the paragraph's later lines start where the number's Indent to Here sits, + // right after "1.", not at the text after the tab. + let mut para = numbered(designcraft_doc::NumberStyle::Arabic, "^#.^i^t"); + para.list_tab = Some(Some(36.0)); + let (d, sid, _) = doc_with(LOREM, Rect::new(72.0, 36.0, 272.0, 600.0), para); + let cs = compose_story(&d, sid, &Default::default()); + let lines = all_lines(&cs); + assert!(lines.len() > 3, "the text wraps"); + let label = |c: char| lines[0].glyphs.iter().find(|g| g.len == 0 && g.rendered_char == c).unwrap(); + let (dot, here) = (label('.'), label('\u{e005}').x); + assert!((here - (dot.x + dot.adv)).abs() < 0.01, "the character follows the number: {here}"); + let text = lines[0].glyphs.iter().find(|g| g.len > 0).unwrap().x; + assert!((text - (72.0 + 36.0)).abs() < 0.01 && here < text - 1.0, "the text after the tab is further in: {text}"); + for (i, line) in lines.iter().enumerate().skip(1) { + let start = line.glyphs.iter().find(|g| g.visible).unwrap().x; + assert!((start - here).abs() < 0.01, "line {i} starts at {start}, not at {here}"); + } +} + #[test] fn indent_to_here_in_a_right_to_left_paragraph_indents_from_the_right() { // Hebrew words (the bidi classes decide the order; no Hebrew font has to be present): the From 70a76b15c51b0ca5d40c8a5642500872a3553f68 Mon Sep 17 00:00:00 2001 From: ljkx <177671075+ljkx@users.noreply.github.com> Date: Tue, 3 Nov 2026 04:44:04 +0100 Subject: [PATCH 29/33] Text: test that a list number's Indent to Here wins over one in the drop cap (#2080) The drop cap is set at the indent and the list number beside it, so the number's character is the later one on the line and sets the indent. Pin that with a test, and say so where the character is looked up. --- crates/compose/src/lib.rs | 5 +++-- crates/compose/src/tests.rs | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index c84cb6e27..f110c0ecc 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -4972,8 +4972,9 @@ fn distribute(line: &[Glyph], spaces: &[usize], extra: f64, sp: &Spacing, add: & /// applies from each later line's segment, so beside a text wrap it starts at the wrap's edge. /// `None` when there is none or the indent is unchanged. fn indent_here(placed: &[PlacedGlyph], pp: &designcraft_doc::ParaProps, x0: f64, x1: f64, rtl: bool) -> Option { - // The placed glyphs stay in text order (bidi only moves them) and include the drop cap's - // and a list label's, so a character in either counts too. + // The placed glyphs are in the line's order (bidi only moves them): the drop cap's, then a + // list label's, which is set beside the drop cap, then the text's. A character in the drop + // cap or the label counts too, and one in the label comes after one in the drop cap. let x = placed.iter().rev().find(|g| g.rendered_char == story::INDENT_HERE)?.x; let indent = if rtl { x1 - x } else { x - x0 }; if !indent.is_finite() { diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 9e9dd354a..4d056dcc2 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -2000,6 +2000,26 @@ fn indent_to_here_in_a_list_number_indents_to_the_generated_character() { } } +#[test] +fn indent_to_here_in_a_list_number_after_one_in_the_drop_cap_wins() { + // The drop cap "A^i" sits at the indent and the number "1.^i" follows it on the first line, + // so the number's character is the line's last one: the later lines start there, not at + // the drop cap's. + let mut para = ParaAttrs { drop_cap_lines: Some(3), drop_cap_chars: Some(2), ..numbered(designcraft_doc::NumberStyle::Arabic, "^#.^i^t") }; + para.list_tab = Some(Some(36.0)); + let (d, sid, _) = doc_with(&format!("A\u{e005}{LOREM}"), Rect::new(72.0, 36.0, 272.0, 600.0), para); + let cs = compose_story(&d, sid, &Default::default()); + let lines = all_lines(&cs); + assert!(lines.len() > 4, "the text wraps below the drop cap"); + let marker = |label: bool| lines[0].glyphs.iter().find(|g| g.rendered_char == '\u{e005}' && (g.len == 0) == label).unwrap().x; + let (in_cap, in_label) = (marker(false), marker(true)); + assert!(in_label > in_cap + 1.0, "the number follows the drop cap: {in_cap} then {in_label}"); + for (i, line) in lines.iter().enumerate().skip(1) { + let start = line.glyphs.iter().find(|g| g.visible).unwrap().x; + assert!((start - in_label).abs() < 0.01, "line {i} starts at {start}, not at {in_label}"); + } +} + #[test] fn indent_to_here_in_a_right_to_left_paragraph_indents_from_the_right() { // Hebrew words (the bidi classes decide the order; no Hebrew font has to be present): the From 74eab18d1676618234b8c8dc97cfdd46e6fa73f5 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 2 Nov 2026 22:51:17 -0500 Subject: [PATCH 30/33] Assets: index placed files by content so importing many graphics stays linear (#2181) --- crates/doc/src/assets.rs | 100 ++++++++++++++++++++++++++++++++- crates/doc/src/lib.rs | 2 +- crates/idml/src/import.rs | 3 + crates/idml/src/import/item.rs | 2 +- 4 files changed, 104 insertions(+), 3 deletions(-) diff --git a/crates/doc/src/assets.rs b/crates/doc/src/assets.rs index 1533b8b4b..6ddd52b1e 100644 --- a/crates/doc/src/assets.rs +++ b/crates/doc/src/assets.rs @@ -112,8 +112,13 @@ pub fn find_same_asset(assets: &BTreeMap>, asset: &Asset) -> /// Add `asset` to `assets` unless they already hold the same file; returns the id placements /// should reference. A new asset with the bytes of an existing one shares its buffer. +/// +/// This scans `assets`; an importer adding many files uses an [`AssetIndex`] instead. pub fn insert_asset(assets: &mut BTreeMap>, mut asset: Asset) -> AssetId { - if let Some(id) = find_same_asset(assets, &asset) { + // An empty, unlinked asset never holds the same file as another: nothing to search for. + if (!asset.data.is_empty() || asset.link.is_some()) + && let Some(id) = find_same_asset(assets, &asset) + { return id; } if !asset.data.is_empty() @@ -126,6 +131,51 @@ pub fn insert_asset(assets: &mut BTreeMap>, mut asset: Asset id } +/// A lookup of the assets added through it, by content, so that adding many files (an import) +/// costs time in proportion to their number rather than its square. It only knows the assets +/// it inserted: use one index per asset map, and add every asset through it. +#[derive(Debug, Default)] +pub struct AssetIndex { + fingerprint: std::collections::hash_map::RandomState, + /// Candidates for [`Asset::same_file`]: (bytes fingerprint, type, page, link). + files: HashMap<(u64, String, u32, Option), Vec>, + /// One asset per distinct non-empty byte content, for buffer sharing. + bytes: HashMap>, +} + +impl AssetIndex { + pub fn new() -> Self { + Self::default() + } + + /// [`insert_asset`], looking up candidates by fingerprint instead of scanning `assets`. + /// A fingerprint only selects candidates; equality still compares the actual files. + pub fn insert_asset(&mut self, assets: &mut BTreeMap>, mut asset: Asset) -> AssetId { + // Empty, unlinked assets never compare equal: don't search for or index them. + if asset.data.is_empty() && asset.link.is_none() { + let id = asset.id; + assets.insert(id, Arc::new(asset)); + return id; + } + let hash = self.fingerprint.hash_one(asset.data.as_slice()); + let key = (hash, asset.mime.clone(), asset.page, asset.link.clone()); + if let Some(id) = self.files.get(&key).and_then(|ids| ids.iter().copied().find(|id| assets.get(id).is_some_and(|a| a.same_file(&asset)))) { + return id; + } + if !asset.data.is_empty() { + let bucket = self.bytes.entry(hash).or_default(); + match bucket.iter().find_map(|id| assets.get(id).filter(|a| a.same_bytes(&asset))) { + Some(a) => asset.data = Arc::clone(&a.data), + None => bucket.push(asset.id), + } + } + let id = asset.id; + self.files.entry(key).or_default().push(id); + assets.insert(id, Arc::new(asset)); + id + } +} + /// Calls `f` with every asset reference in `it` and its children; false when nesting exceeded /// [`MAX_DEPTH`] (some references weren't visited). fn item_refs(it: &Item, f: &mut impl FnMut(AssetId), depth: usize) -> bool { @@ -463,6 +513,54 @@ mod tests { assert_ne!(m3, m4); } + /// The index dedupes exactly as the scan does, and skips empty unlinked assets. + #[test] + fn indexed_insert_matches_scanning_insert() { + let linked = |id, path: &str, data: &[u8]| Asset { link: Some(path.into()), ..asset(id, data) }; + let cases = vec![ + asset(1, b"same"), + asset(2, b"same"), + asset(3, b"diff"), + Asset { page: 1, ..asset(4, b"same") }, + linked(5, "/p/a.png", b"same"), + linked(6, "/q/b.png", b"same"), + linked(7, "/p/a.png", b"same"), + linked(8, "/m/1.png", b""), + linked(9, "/m/1.png", b""), + asset(10, b""), + asset(11, b""), + ]; + let mut scanned = BTreeMap::new(); + let mut indexed = BTreeMap::new(); + let mut index = AssetIndex::new(); + for a in cases { + let s = insert_asset(&mut scanned, a.clone()); + let i = index.insert_asset(&mut indexed, a); + assert_eq!(s, i); + } + assert_eq!(scanned.keys().collect::>(), indexed.keys().collect::>()); + assert!(Arc::ptr_eq(&indexed[&AssetId(5)].data, &indexed[&AssetId(6)].data), "identical bytes share one buffer"); + } + + /// Many empty unlinked placements (graphics with neither bytes nor a link) insert without + /// comparing against each other. + #[test] + fn many_empty_unlinked_assets_insert_quickly() { + let mut assets = BTreeMap::new(); + let mut index = AssetIndex::new(); + for id in 1..=200_000u64 { + assert_eq!(insert_asset(&mut assets, asset(id, b"")), AssetId(id)); + } + assert_eq!(assets.len(), 200_000); + let mut indexed = BTreeMap::new(); + for id in 1..=20_000u64 { + let mut bytes = vec![7; 64]; + bytes[56..].copy_from_slice(&id.to_le_bytes()); + assert_eq!(index.insert_asset(&mut indexed, asset(id, &bytes)), AssetId(id)); + } + assert_eq!(indexed.len(), 20_000); + } + #[test] fn files_at_different_paths_stay_separate_assets() { let mut d = Document::new(&Default::default()); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 8861dd924..cb0e4a020 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -44,7 +44,7 @@ use std::collections::BTreeMap; use std::sync::Arc; pub use anchored::{AnchorPosition, AnchoredObject, OBJECT_MARK}; -pub use assets::{find_same_asset, insert_asset, remap_story_assets, story_asset_refs}; +pub use assets::{AssetIndex, find_same_asset, insert_asset, remap_story_assets, story_asset_refs}; pub use attrs::*; pub use datamerge::{ DataField, DataFieldKind, DataMerge, DataSource, Delimiter, Fingerprint, MergeOptions, Placeholder, PlaceholderAnchor, PlaceholderRole, diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 87f84a7db..3f708b7cd 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -195,6 +195,8 @@ struct Importer<'r> { page_index: HashMap, item_ids: HashMap, assets: BTreeMap>, + /// Content lookup for `assets`, so each placed graphic doesn't compare against every earlier one. + asset_index: designcraft_doc::AssetIndex, /// Assets read from a link path at a page and crop, so later placements of that path, page /// and crop reuse them unread. linked_assets: HashMap<(String, u32, designcraft_doc::PdfCrop), AssetId>, @@ -277,6 +279,7 @@ impl<'r> Importer<'r> { page_index: HashMap::new(), item_ids: HashMap::new(), assets: BTreeMap::new(), + asset_index: designcraft_doc::AssetIndex::new(), linked_assets: HashMap::new(), ctx: ItemCtx { threads: Vec::new() }, sections: Vec::new(), diff --git a/crates/idml/src/import/item.rs b/crates/idml/src/import/item.rs index d25044dc6..ac27a833e 100644 --- a/crates/idml/src/import/item.rs +++ b/crates/idml/src/import/item.rs @@ -371,7 +371,7 @@ impl<'r> Importer<'r> { uri.as_deref().and_then(|p| p.rsplit(['/', '\\']).next()).filter(|n| !n.is_empty()).map(str::to_string).unwrap_or_else(|| "image".into()); let fresh = AssetId(self.alloc()); let link_path = uri.filter(|p| p.contains('/') || p.contains('\\')); - let id = designcraft_doc::insert_asset( + let id = self.asset_index.insert_asset( &mut self.assets, Asset { page, From 6abeb8a1cad0219eccdf6fdc2482e183df60325a Mon Sep 17 00:00:00 2001 From: JonRC Date: Tue, 3 Nov 2026 01:00:19 -0300 Subject: [PATCH 31/33] type.char: warn when the font family or style isn't available (#2189) A fontFamily the app doesn't have was applied with a null result, so a script that asked for Courier Prime exported the PDF in the substitute and exited 0. The font is still applied, since documents can carry missing fonts and Find Font and preflight list them, but type.char now returns {"warnings": ["Missing font: Courier Prime (using Source Sans 3)"]}, or "Missing font style: ..." for a style the family doesn't have, in preflight's words. Other calls still return null. --- ROADMAP.md | 1 + crates/engine/src/cmd/text.rs | 2 +- crates/engine/src/cmd/text/format.rs | 59 +++++++++++++++++++++++++++- 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d88e8fc36..ce2c0c25e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -389,6 +389,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-11-03** · `type.char` says when the font it applies isn't available (#2189): it returns `{"warnings": ["Missing font: Courier Prime (using Source Sans 3)"]}`, or `Missing font style: …` for a style the family doesn't have, in preflight's words. The font is still applied, as before, so Find Font and preflight list it. A script that asked for a font the machine doesn't have exported in the substitute with no word about it. No readiness percentage change. - **2026-11-01** · Parent page resizing (#1620): Properties, the Page Tool Control panel and on-page handles resize the selected parent page independently of document pages. Stable parent IDs, repeated resizing, anchors and liquid rules are preserved on the current command modules. Inline parent margin editing targets only the selected parent page and preserves opposite-page margins and document overrides; document commands retain their first-page default before the canvas is initialized. No readiness percentage change. - **2026-10-31** · `font.list` reports the embedding licence of the face that sets each font's text: its OS/2 `fsType` and what the bits mean (`restrictedLicence`, `bitmapOnly`, `noSubsetting`, `editable`, `previewPrint`; null when the font is missing or has no OS/2 table). A script or host that makes PDFs can check every face, bundled and installed ones included, without reading font files. Export behaviour is unchanged. No readiness percentage change. - **2026-10-31** · A `.designcraft` file opened from bytes (`file.openBytes` with its file name, as a browser or host passes it) is titled without the extension, as an IDML file opened that way is. Saving it in the browser downloaded `Brochure.designcraft.designcraft`, and the File Name variable showed the doubled name. Browser Open and Drop pass the full file name, so only the engine removes the extension and a repeated suffix is kept in the title. No readiness percentage change. diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index b50850553..89b9e35a1 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -206,7 +206,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?, …}}", + "{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)", 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 7e1833c0a..edec044b9 100644 --- a/crates/engine/src/cmd/text/format.rs +++ b/crates/engine/src/cmd/text/format.rs @@ -162,6 +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 targets = format_targets(s); // A caret in text: the format is for the next typing. On an empty paragraph's hidden mark it @@ -185,14 +187,33 @@ pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result { })?; } s.typing_format = Some(f); - return ok(); + return Ok(result); } s.edit(|d, _| { apply_char_attrs(d, &targets, &a, &cleared); - ok() + Ok(result) }) } +/// 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![] + } +} + /// Kerning spaces a character from the next one, so the pair at a caret is kerned through the /// character before it. With no such pair (story or paragraph start, a selection) this is /// `type.char`. @@ -1073,3 +1094,37 @@ mod size_bounds_tests { } } } + +#[cfg(test)] +mod missing_font_tests { + use serde_json::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. + #[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 ")), + ] { + s.execute("text.select", &json!({"story": f["story"].clone(), "anchor": 0, "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}"), + } + 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 c5c7d413209f8f67b13c0691af11bd425eee77f8 Mon Sep 17 00:00:00 2001 From: JonRC Date: Tue, 3 Nov 2026 01:00:19 -0300 Subject: [PATCH 32/33] Contributors: credit JonRC as Jonathan Cardoso --- contributors/people.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 3e1b92d94..5049c9493 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -83,3 +83,7 @@ sync_github_name = true [people.Tom-R-Main] sync_github_name = true + +[people.JonRC] +real_name = "Jonathan Cardoso" +display_name = "Jonathan Cardoso" From 4c2296491eddb85909afd563c8b189c0be34099b Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 2 Nov 2026 23:08:25 -0500 Subject: [PATCH 33/33] Links: key the image check cache on the resolved file, not the link text A relative link names another file once the document is saved elsewhere, so a status remembered for the old file must not answer for the new one. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/links.rs | 16 ++++++++-------- crates/engine/src/lib.rs | 3 ++- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 1ad60b30f..ec7bf6ba8 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -155,13 +155,13 @@ pub fn link_missing(a: &designcraft_doc::Asset, document: Option<&Path>) -> bool } } -/// Link status of an asset in the document at `document`. Only a regular file is compared, so a -/// link to a device (`/dev/zero`) or a FIFO is missing rather than read. -/// Linked images checked this session: (asset, the identity of its bytes, link) → (the file's size -/// and time when it was read, whether it matched the bytes). The bytes' identity (their `Arc`'s -/// address, as the render cache keys them) changes with every relink, update or undo, so a -/// remembered answer can never describe other bytes. -pub(crate) type ImageChecks = std::collections::HashMap<(AssetId, usize, String), (Fingerprint, bool)>; +/// Linked images checked this session: (asset, the identity of its bytes, the file its link +/// resolves to) → (the file's size and time when it was read, whether it matched the bytes). The +/// resolved file, not the link text, is the key: a relative link names another file once the +/// document is saved elsewhere. The bytes' identity (their `Arc`'s address, as the render cache +/// keys them) changes with every relink, update or undo, so a remembered answer can never +/// describe other bytes. +pub(crate) type ImageChecks = std::collections::HashMap<(AssetId, usize, std::path::PathBuf), (Fingerprint, bool)>; /// Link status of a placed graphic (`ok`, `modified`, `missing`, `embedded`), reading the file /// only when its size is the asset's and its time isn't the one remembered from the last read @@ -188,7 +188,7 @@ pub(crate) fn status_cached(a: &designcraft_doc::Asset, document: Option<&Path>, if now.size != a.data.len() as u64 { return "modified"; } - let key = (a.id, Arc::as_ptr(&a.data) as usize, link.clone()); + let key = (a.id, Arc::as_ptr(&a.data) as usize, path.clone()); if let Some((at, same)) = checks.get(&key) && *at == now { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index aee87f2ee..d0922e484 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -437,7 +437,8 @@ pub struct Session { pub(crate) text_link_checks: std::collections::HashMap, /// Linked images checked against their files this session (`links.list` polls twice a second): /// a file of the recorded size and time isn't read again. - pub(crate) image_link_checks: std::collections::HashMap<(designcraft_doc::AssetId, usize, String), (designcraft_doc::Fingerprint, bool)>, + pub(crate) image_link_checks: + std::collections::HashMap<(designcraft_doc::AssetId, usize, std::path::PathBuf), (designcraft_doc::Fingerprint, bool)>, } impl Default for Session {