diff --git a/Cargo.lock b/Cargo.lock index 37b91be9..6b405ed6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -896,7 +896,7 @@ dependencies = [ [[package]] name = "designcraft" -version = "0.7.1" +version = "0.8.0" dependencies = [ "arboard", "designcraft-engine", @@ -914,7 +914,7 @@ dependencies = [ [[package]] name = "designcraft-cli" -version = "0.7.1" +version = "0.8.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -930,7 +930,7 @@ dependencies = [ [[package]] name = "designcraft-color" -version = "0.7.1" +version = "0.8.0" dependencies = [ "kurbo", "moxcms", @@ -942,7 +942,7 @@ dependencies = [ [[package]] name = "designcraft-compose" -version = "0.7.1" +version = "0.8.0" dependencies = [ "designcraft-color", "designcraft-doc", @@ -960,7 +960,7 @@ dependencies = [ [[package]] name = "designcraft-doc" -version = "0.7.1" +version = "0.8.0" dependencies = [ "designcraft-color", "designcraft-geom", @@ -972,7 +972,7 @@ dependencies = [ [[package]] name = "designcraft-engine" -version = "0.7.1" +version = "0.8.0" dependencies = [ "caseless", "craft-print", @@ -1007,7 +1007,7 @@ dependencies = [ [[package]] name = "designcraft-epub" -version = "0.7.1" +version = "0.8.0" dependencies = [ "designcraft-color", "designcraft-doc", @@ -1016,7 +1016,7 @@ dependencies = [ [[package]] name = "designcraft-fonts" -version = "0.7.1" +version = "0.8.0" dependencies = [ "harfrust 0.13.3", "kurbo", @@ -1028,7 +1028,7 @@ dependencies = [ [[package]] name = "designcraft-format" -version = "0.7.1" +version = "0.8.0" dependencies = [ "designcraft-doc", "serde", @@ -1039,7 +1039,7 @@ dependencies = [ [[package]] name = "designcraft-geom" -version = "0.7.1" +version = "0.8.0" dependencies = [ "flo_curves", "kurbo", @@ -1052,7 +1052,7 @@ dependencies = [ [[package]] name = "designcraft-idml" -version = "0.7.1" +version = "0.8.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1065,7 +1065,7 @@ dependencies = [ [[package]] name = "designcraft-images" -version = "0.7.1" +version = "0.8.0" dependencies = [ "designcraft-color", "designcraft-fonts", @@ -1076,7 +1076,7 @@ dependencies = [ [[package]] name = "designcraft-mcp" -version = "0.7.1" +version = "0.8.0" dependencies = [ "designcraft-engine", "designcraft-geom", @@ -1089,7 +1089,7 @@ dependencies = [ [[package]] name = "designcraft-pdf" -version = "0.7.1" +version = "0.8.0" dependencies = [ "craft-pdf", "designcraft-color", @@ -1111,7 +1111,7 @@ dependencies = [ [[package]] name = "designcraft-render" -version = "0.7.1" +version = "0.8.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1129,7 +1129,7 @@ dependencies = [ [[package]] name = "designcraft-textimport" -version = "0.7.1" +version = "0.8.0" dependencies = [ "designcraft-doc", "quick-xml 0.38.4", @@ -1138,7 +1138,7 @@ dependencies = [ [[package]] name = "designcraft-tools" -version = "0.7.1" +version = "0.8.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1150,7 +1150,7 @@ dependencies = [ [[package]] name = "designcraft-ui-egui" -version = "0.7.1" +version = "0.8.0" dependencies = [ "craft-ui", "designcraft-color", @@ -1178,7 +1178,7 @@ dependencies = [ [[package]] name = "designcraft-web" -version = "0.7.1" +version = "0.8.0" dependencies = [ "designcraft-engine", "designcraft-format", @@ -2016,13 +2016,13 @@ dependencies = [ [[package]] name = "hayro" version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4caa128ab87fd48ffb7490617cf93f77f606820dffbc9fd9ef6ab0ed077f56d" +source = "git+https://github.com/storytold/pdfcraft?rev=b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9#b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" dependencies = [ "bytemuck", "hayro-interpret", "image", "kurbo", + "log", "pic-scale", "vello_cpu 0.0.8", ] @@ -2046,8 +2046,7 @@ dependencies = [ [[package]] name = "hayro-interpret" version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2613d0406898995042d0794c4245b2f2fba1246490c8ac593769bba6551129d" +source = "git+https://github.com/storytold/pdfcraft?rev=b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9#b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" dependencies = [ "bitflags 2.13.2", "hayro-cmap", @@ -2057,7 +2056,7 @@ dependencies = [ "phf", "rustc-hash 2.1.3", "siphasher", - "skrifa 0.42.1", + "skrifa 0.47.0", "smallvec", "yoke", ] @@ -2065,8 +2064,7 @@ dependencies = [ [[package]] name = "hayro-jbig2" version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69374b3668dd45aeb3d3145cda68f2c7b4f223aaa2511e67d076f1c7d741388d" +source = "git+https://github.com/storytold/pdfcraft?rev=b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9#b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" dependencies = [ "fearless_simd", "hayro-ccitt", @@ -2090,8 +2088,7 @@ checksum = "885c5ef0654933139a9b9546fc2c69e18d37f38aa2520f079092b1be18f1fcaa" [[package]] name = "hayro-syntax" version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0edeafd70aa2db743de8ede8637d07ec87db05efe69cde371d03f1b185fcef27" +source = "git+https://github.com/storytold/pdfcraft?rev=b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9#b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" dependencies = [ "flate2", "hayro-ccitt", @@ -2100,6 +2097,7 @@ dependencies = [ "memchr", "rustc-hash 2.1.3", "smallvec", + "stringprep", "zune-jpeg", ] @@ -4210,6 +4208,17 @@ dependencies = [ "float-cmp", ] +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + [[package]] name = "subsetter" version = "0.2.6" @@ -5696,7 +5705,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.7.1" +version = "0.8.0" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 14655b20..efd3b617 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.7.1" +version = "0.8.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -103,3 +103,8 @@ debug = 0 [patch.crates-io] krilla = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8", version = "=0.8.2" } winit = { git = "https://github.com/skylartaylor/winit.git", rev = "3d51077fd1a966f84df9ef287839dc84fd5b4ca7" } +# The hayro crates from pdfcraft's vendor/ directory, with the patches its README lists. +hayro = { git = "https://github.com/storytold/pdfcraft", rev = "b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" } +hayro-interpret = { git = "https://github.com/storytold/pdfcraft", rev = "b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" } +hayro-syntax = { git = "https://github.com/storytold/pdfcraft", rev = "b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" } +hayro-jbig2 = { git = "https://github.com/storytold/pdfcraft", rev = "b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" } diff --git a/NOTICE b/NOTICE index 087bb619..21123eea 100644 --- a/NOTICE +++ b/NOTICE @@ -58,6 +58,11 @@ list (author, source, license) is in ASSETS.md. Shippori Mincho), under the SIL Open Font License 1.1. Their licence texts ship with the packages as OFL-.txt. Builds without CRAFT_FONTS_DIR do not include them. - All UI icons are drawn in code (crates/ui-egui/src/icons.rs) and are original work, MIT OR Apache-2.0. +- hayro PDF crates (placed PDFs): hayro, hayro-interpret, hayro-syntax and hayro-jbig2 + (Apache-2.0 OR MIT) are pdfcraft's patched copies, fetched by git from its vendor/ directory at + the commit that Cargo.toml pins (https://github.com/storytold/pdfcraft, MIT OR Apache-2.0). + hayro-interpret embeds PDFium's Foxit standard-14 fonts under PDFium's BSD-style license + (vendor/hayro-interpret/assets/LICENSE_FOXIT) and a CMYK profile under CC0-1.0. Rust dependencies (vello_cpu, kurbo, skrifa, harfrust, image, egui, eframe, …) are permissively licensed (MIT/Apache-2.0/BSD/ISC/Zlib) and fetched through crates.io. diff --git a/ROADMAP.md b/ROADMAP.md index b599045c..bddf5add 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -3,6 +3,8 @@ **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%) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (text threads by hand; the alpha gate waits on Primary Text Frame page fixes) · **Target:** Adobe InDesign 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (parent canvas refresh) · **Target:** Adobe InDesign 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (bounded control click/drag input) · **Target:** Adobe InDesign 2026 DesignCraft aims at full Adobe InDesign parity, and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web. @@ -126,8 +128,22 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m ## Progress log +- **2026-10-13** · Switching between document and parent pages refreshes the canvas immediately, preventing black parent pages and stale document objects when the zoom stays the same. Ordinary edits retain partial repainting. No readiness percentage change. + Newest first. +- **2026-10-13** · Object › Fitting has InDesign's shortcuts: Fill Frame Proportionally ⌘⌥⇧C, Fit Content Proportionally ⌘⌥⇧E, Fit Frame to Content ⌘⌥C, Fit Content to Frame ⌘⌥E, Center Content ⌘⇧E. No readiness percentage change. +- **2026-10-13** · Gradients between process colours blend as inks, as InDesign shows them: magenta to cyan passes through C 50 M 50 (a light lilac) on screen instead of the darker RGB mix, and a midpoint between CMYK or grey stops no longer turns the PDF shading RGB. No readiness percentage change. +- **2026-10-14** · The Swatches panel has InDesign's header: a Fill/Stroke proxy, Formatting Affects Container/Text and the Tint of the colour in front (a tint alone keeps each object's own swatch). Clicking a swatch applies it to the fill or stroke in front (of the text, when formatting affects text), and the bottom row has New Color Group, New Swatch and Delete Swatch. No readiness percentage change. +- **2026-10-14** · With the Page tool, the Control panel shows the page as InDesign's does: W/H from a reference point, page-size preset (US Business Card added), orientation, Liquid Page Rule (now with Controlled by Parent) and Show Parent Page Overlay, which draws the parent page's size over a resized page. No readiness percentage change. +- **2026-10-14** · On an object-based page the Page tool shows a selected object's liquid pins and resize locks as InDesign does: a click pins a side to the page edge or locks the width or height, with InDesign's tooltips. No readiness percentage change. +- **2026-10-14** · The Liquid Layout panel follows InDesign's: Liquid Page Rule with the page number, Content › Auto-Fit, and Object Constraints › Resize with Page and Pin for the selected object on an object-based page; Create Alternate Layout moved to the Layout menu. No readiness percentage change. +- **2026-10-14** · With the Page tool, the Control panel shows the page as InDesign's does: W/H from a reference point, page-size preset (US Business Card added), orientation, Liquid Page Rule (now with Controlled by Parent) and Show Parent Page Overlay, which draws the parent page's size over a resized page. No readiness percentage change. +- **2026-10-14** · With the Page tool, the Control panel shows the page as InDesign's does: W/H from a reference point, page-size preset (US Business Card added), orientation, Liquid Page Rule and Show Parent Page Overlay, which draws the parent page's size over a resized page. No readiness percentage change. +- **2026-10-14** · The Page tool works as InDesign's: a click selects a page and shows its handles and liquid page rule; dragging a handle previews the rule and springs back, Alt-drag resizes the page from the opposite edge, Shift keeps proportions, with a W/H readout. No readiness percentage change. +- **2026-10-14** · With the Page tool, a guide dragged from a ruler is a liquid guide (dashed), as in InDesign; guide-based pages stretch the objects it crosses. No readiness percentage change. +- **2026-10-14** · The Pages panel menu follows InDesign's order: Insert Pages…, Move Pages…, Duplicate Spread (and at End of Document), Delete Page/Spread, New Parent…, Apply Parent to Pages, Override All Parent Page Items, Parent Pages, Create Alternate Layout…, Numbering & Section Options…, Allow Selected Spread to Shuffle. No readiness percentage change. +- **2026-10-14** · Pages panel: Alt-drag copies a page, a page dropped on Create new page is duplicated at the end, Edit page size offers the presets and Custom Page Size…; duplicated pages keep their own size, rule and guides; thumbnails of pages of their own size stay in their rows. No readiness percentage change. - **2026-10-12 (PDFW)** · PDF, booklet and book composition share guarded writing, metadata and font preparation through craft-pdf. Layout, shaping, placed graphics, colour policy, tagging and PDF/X postprocessing remain in DesignCraft. Failed native drawing returns an export error; failed writer cleanup cannot serialize an incomplete document. No readiness percentage change. - **2026-10-12** · Text threads by hand: click a selected text frame's in or out port to load @@ -137,6 +153,8 @@ Newest first. `text.thread`, `text.unthread`, `text.removeFromThread` with undo; locked frames and layers refuse threads. The alpha gate now waits only on the Primary Text Frame page fixes (#165). No readiness percentage change. +- **2026-10-13** · Layout ▸ Table of Contents opens a real dialog: title and title style, Include Paragraph Styles and Other Styles lists with Add and Remove, and per style its entry style, page number position (after, before, none), separator and level. Replace Existing regenerates the current table of contents in place; otherwise OK makes a new frame on the page in view. IDML export writes the definition as a TOC style that import reads back, so Update works after a round trip. No readiness percentage change. +- **2026-10-13** · Control-channel clicks and drags validate positive counts and the complete pending-event budget before queuing input (#732). Oversized requests leave pending gestures intact, preventing unbounded click-count allocation. No readiness percentage change. - **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. @@ -155,6 +173,7 @@ Newest first. - **2026-10-11** · `story.replaceRange`, `story.setText`, notes, footnotes, endnotes, type on a path and table cells read `\r` and `\r\n` as paragraph returns, as `frame.create` and `text.insert` already did. No readiness percentage change. - **2026-10-11** · The View menu follows InDesign's: proofing first, then zoom, Rotate Spread, Screen Mode, Display Performance, rulers, Extras, Grids & Guides and Structure; the view items that sat at the end of the Type menu moved there. No readiness percentage change. - **2026-10-12** · Underlines and strikethroughs are drawn as one bar per stretch of text on screen, as in PDF export: a bar per letter left faint seams between the letters. No readiness percentage change. +- **2026-10-13** · Placed PDFs are drawn through pdfcraft's patched hayro crates: calculator functions whose `idiv` divides by zero or overflows, chains of stitching functions a hundred thousand deep, and mesh shadings with points far off the page, ten thousand overlapping triangles or forty thousand patches no longer panic, overflow the stack, run for over a minute or use more than 2 GiB of memory. A page with a `/UserUnit`, and its trim, bleed and art boxes, are sized in points. No readiness percentage change. - **2026-10-12** · Properties › Transform shows rotation, shear and X/Y scale, and Rotate 90° and Flip Horizontal/Vertical buttons, as InDesign's does. No readiness percentage change. - **2026-10-12** · Edit › Step and Repeat has its InDesign dialog: Repeat (Count, or Rows and Columns with Create as a grid), Offset (Vertical, Horizontal, in ruler units) and Preview, which shows the copies while the dialog is open; Cancel takes them away and OK makes them as one undo step. No readiness percentage change. - **2026-10-12** · Double-clicking the Fill or Stroke box in the Tools panel opens the Color Picker with the selection's colour, as in InDesign (it opened the Swatches panel). No readiness percentage change. diff --git a/crates/color/src/gradient.rs b/crates/color/src/gradient.rs index 4da4f2d8..6c19aabb 100644 --- a/crates/color/src/gradient.rs +++ b/crates/color/src/gradient.rs @@ -103,6 +103,26 @@ impl Gradient { } out } + /// Stops for a renderer that blends between them in RGB: the expanded stops, plus samples + /// through each span that isn't RGB at both ends, so process colours keep blending as inks. + pub fn display_stops(&self) -> Vec<(f32, Color, f32)> { + const STEPS: usize = 16; + let stops = self.expanded_stops(); + let mut out: Vec<(f32, Color, f32)> = Vec::with_capacity(stops.len() * STEPS); + for (i, s) in stops.iter().enumerate() { + out.push(*s); + let Some(n) = stops.get(i + 1) else { continue }; + if matches!((s.1, n.1), (Color::Rgb { .. }, Color::Rgb { .. })) || n.0 - s.0 <= 1e-4 { + continue; + } + for j in 1..STEPS { + let t = s.0 + (n.0 - s.0) * j as f32 / STEPS as f32; + let (c, a) = self.sample(t); + out.push((t, c, a)); + } + } + out + } pub fn reverse(&mut self) { // A midpoint belongs to the segment after its stop, so it moves with that segment and mirrors. let mids: Vec<_> = self.stops.iter().rev().skip(1).map(|s| 1.0 - s.midpoint).collect(); @@ -199,6 +219,25 @@ mod tests { assert!((mid - 0.5).abs() < 1e-5); } + #[test] + fn process_stops_blend_as_inks() { + // LYH Studio episode 6: magenta to cyan is C 50 M 50 halfway (a light lilac), not the RGB mix. + let mut g = Gradient::default(); + g.stops[0].color = Color::cmyk(0.0, 1.0, 0.0, 0.0); + g.stops[1].color = Color::cmyk(1.0, 0.0, 0.0, 0.0); + assert_eq!(g.sample(0.5).0, Color::cmyk(0.5, 0.5, 0.0, 0.0)); + let d = g.display_stops(); + assert_eq!(d.len(), 17, "sampled through the span"); + assert!(d.iter().any(|(o, c, _)| (*o - 0.5).abs() < 1e-6 && *c == Color::cmyk(0.5, 0.5, 0.0, 0.0))); + // A midpoint stays an ink too; RGB stops aren't sampled. + g.stops[0].midpoint = 0.25; + assert!(matches!(g.expanded_stops()[1].1, Color::Cmyk { .. })); + assert_eq!(Gradient::default().display_stops().len(), 2); + // Grey and CMYK meet as inks; RGB with anything else blends in RGB. + assert_eq!(Color::gray(1.0).lerp(&Color::cmyk(1.0, 0.0, 0.0, 0.0), 0.5), Color::cmyk(0.5, 0.0, 0.0, 0.5)); + assert!(matches!(Color::rgb(1.0, 0.0, 0.0).lerp(&Color::cmyk(1.0, 0.0, 0.0, 0.0), 0.5), Color::Rgb { .. })); + } + #[test] fn midpoint_shifts() { let mut g = Gradient::default(); diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index bd5e915d..24c2798a 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -169,8 +169,17 @@ impl Color { let [r, g, b] = self.to_rgb(); Color::rgb(1.0 - r, 1.0 - g, 1.0 - b) } - /// Linear interpolation in display RGB. + /// The colour `t` of the way to `other`. Process inks blend as inks (CMYK with CMYK or grey, + /// grey with grey), as InDesign blends a gradient's stops; anything else blends in RGB. pub fn lerp(&self, other: &Color, t: f32) -> Color { + let l = |a: f32, b: f32| a + (b - a) * t; + match (*self, *other) { + (Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2 }) => return Color::cmyk(l(c, c2), l(m, m2), l(y, y2), l(k, k2)), + (Color::Gray { k }, Color::Gray { k: k2 }) => return Color::gray(l(k, k2)), + (Color::Gray { k }, Color::Cmyk { .. }) => return Color::cmyk(0.0, 0.0, 0.0, k).lerp(other, t), + (Color::Cmyk { .. }, Color::Gray { k }) => return self.lerp(&Color::cmyk(0.0, 0.0, 0.0, k), t), + _ => {} + } let a = self.to_rgb(); let b = other.to_rgb(); Color::rgb(a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 4ed599b0..c6312e9f 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -591,8 +591,11 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C .and_then(|t| t.chars().next_back()) .and_then(break_start); if let Some(t) = story.para_table(pi) { + let start = after_break.unwrap_or(pp.start_paragraph); if cur.last_baseline.is_some() { - cur.start(after_break.unwrap_or(pp.start_paragraph), &cols, frames, doc); + cur.start(start, &cols, frames, doc); + } else { + cur.start_at_top(start, frames, doc); } let at_top = cur.last_baseline.is_none(); let start_at = (cur.fi, cur.col); @@ -847,17 +850,22 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C StartParagraph::NextColumn => next_column(cur), other => cur.start(other, &cols, frames, doc), }; - // Paragraph start options. - if let Some(start) = after_break - && cur.last_baseline.is_some() - { - begin_at(&mut cur, start); + // Paragraph start options. At the top of a frame there is nothing to break away from, but + // an odd or even page start still has to land on a page of that parity. + if let Some(start) = after_break { + if cur.last_baseline.is_some() { + begin_at(&mut cur, start); + } else { + cur.start_at_top(start, frames, doc); + } } if force_col[pi] { next_column(&mut cur); } if cur.last_baseline.is_some() { begin_at(&mut cur, pp.start_paragraph); + } else { + cur.start_at_top(pp.start_paragraph, frames, doc); } if spanning && cur.fi < frames.len() { // Close the band: the paragraph goes below the deepest of its columns, which are @@ -2070,6 +2078,20 @@ impl Cursor { StartParagraph::NextEvenPage => self.next_page(frames, doc, Some(false)), } } + /// A start option for a paragraph at the top of a frame: there is nothing to break away from, + /// but an odd or even page start still has to land on a page of that parity, so a paragraph + /// at the top of an even page that asks for the next odd page moves on (and vice versa). + fn start_at_top(&mut self, start: StartParagraph, frames: &[FrameSpec], doc: &Document) { + let want_odd = match start { + StartParagraph::NextOddPage => true, + StartParagraph::NextEvenPage => false, + _ => return, + }; + let Some(page) = frames.get(self.fi).and_then(|f| f.page) else { return }; + if (doc.page_number(page) % 2 == 1) != want_odd { + self.next_page(frames, doc, Some(want_odd)); + } + } /// Move to the next frame on a later page, one with an odd page number (`odd` true) or an /// even one (false) when asked. Frames without a page (parent pages) qualify; with no frame /// left, the rest of the story is overset. diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index ce8964bf..a20ec78b 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -834,6 +834,29 @@ fn break_characters_end_their_paragraph_and_keep_page_parity() { assert_eq!(cs.overset_at, Some(text.find("four").unwrap())); } +/// A paragraph that starts at the top of a frame isn't pushed on by Next Page, but Next Odd Page +/// (or Next Even Page) still moves it when that frame is on a page of the other parity. +#[test] +fn odd_and_even_page_starts_keep_parity_at_the_top_of_a_frame() { + use designcraft_doc::StartParagraph; + use designcraft_doc::story::PAGE_BREAK; + let pages_of = |start: StartParagraph| { + let mut d = Document::new(&NewDocument { pages: 4, facing_pages: false, primary_text_frame: true, ..Default::default() }); + let sid = d.settings.primary_story.unwrap(); + // "two" begins at the top of page 2, after the page break that ends "one". + d.story_mut(sid).unwrap().insert(0, &format!("one{PAGE_BREAK}\ntwo\nthree")); + d.story_mut(sid).unwrap().paras[1].para.start_paragraph = Some(start); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + // Frame i is on page i + 1. + cs.frames.iter().enumerate().flat_map(|(fi, f)| f.lines.iter().map(move |l| (l.para, fi + 1))).collect::>() + }; + // Already at the top of an even page: Next Page and Next Even Page leave "two" where it is. + assert_eq!(pages_of(StartParagraph::NextPage), vec![(0, 1), (1, 2), (2, 2)]); + assert_eq!(pages_of(StartParagraph::NextEvenPage), vec![(0, 1), (1, 2), (2, 2)]); + // Next Odd Page takes it to page 3, leaving page 2 empty. + assert_eq!(pages_of(StartParagraph::NextOddPage), vec![(0, 1), (1, 3), (2, 3)]); +} + #[test] fn line_before_a_break_character_is_a_last_line() { // As in InDesign: column, frame and page breaks end the paragraph's last line (set with the diff --git a/crates/compose/src/xref.rs b/crates/compose/src/xref.rs index 4bce48d2..b3371420 100644 --- a/crates/compose/src/xref.rs +++ b/crates/compose/src/xref.rs @@ -19,15 +19,17 @@ pub fn has_xrefs(st: &Story) -> bool { } impl XrefIndex { - /// `get` composes a story (normally through the cache). Stories that themselves hold - /// cross-references are composed without resolving them (their anchors' pages hardly move). + /// `get` composes a story (normally through the cache). Stories that hold cross-references or + /// running headers are composed without resolving them (their anchors' pages hardly move): + /// through the cache they would build this index again, directly or by way of the running-header index. pub fn build(doc: &Document, get: &dyn Fn(StoryId) -> Arc) -> XrefIndex { let mut pages = HashMap::new(); for (sid, st) in &doc.stories { if st.anchors.is_empty() { continue; } - let cs = if has_xrefs(st) { Arc::new(crate::compose_story(doc, *sid, &Default::default())) } else { get(*sid) }; + let indexed = has_xrefs(st) || crate::vars::has_running(doc, st); + let cs = if indexed { Arc::new(crate::compose_story(doc, *sid, &Default::default())) } else { get(*sid) }; for a in &st.anchors { let Some(pos) = st.anchor_pos(a.id) else { continue }; if let Some((page, ..)) = crate::vars::place(doc, &cs, pos) { diff --git a/crates/doc/src/assets.rs b/crates/doc/src/assets.rs index 24ba5c24..cff196fc 100644 --- a/crates/doc/src/assets.rs +++ b/crates/doc/src/assets.rs @@ -5,6 +5,7 @@ //! shown); each placement's frame data (transform, crop, fitting) lives on its [`Graphic`]. use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::hash::BuildHasher; use std::sync::Arc; use crate::story::Story; @@ -246,10 +247,19 @@ impl Document { if !self.asset_refs(&mut |_| {}) { return 0; } - let mut reps: HashMap<(usize, &str, u32), Vec<&Arc>> = HashMap::new(); + let fingerprint = std::collections::hash_map::RandomState::new(); + let mut hashes = HashMap::new(); + let mut reps: HashMap<_, Vec<&Arc>> = HashMap::new(); let mut map = HashMap::new(); for a in self.assets.values() { - let bucket = reps.entry((a.data.len(), a.mime.as_str(), a.page)).or_default(); + // Empty, unlinked assets never compare equal. Don't build a quadratic bucket + // of them, and hash a shared immutable buffer only once per compaction. + if a.data.is_empty() && a.link.is_none() { + continue; + } + let hash = *hashes.entry(Arc::as_ptr(&a.data)).or_insert_with(|| fingerprint.hash_one(a.data.as_slice())); + let bucket = reps.entry((hash, a.mime.as_str(), a.page, a.link.as_deref())).or_default(); + // A fingerprint only selects candidates; equality still checks the actual file. match bucket.iter().find(|r| r.same_file(a)) { Some(r) => { map.insert(a.id, r.id); @@ -280,6 +290,56 @@ mod tests { Asset { id: AssetId(id), name: "p.png".into(), mime: "image/png".into(), link: None, data: Arc::new(data.to_vec()), pixels: None, page: 0 } } + #[test] + fn equal_size_assets_keep_distinct_contents_and_merge_exact_copies() { + let mut d = Document::new(&Default::default()); + for id in 1..=256u64 { + let mut bytes = vec![42; 1024]; + bytes[1016..].copy_from_slice(&id.to_le_bytes()); + d.assets.insert(AssetId(id), Arc::new(asset(id, &bytes))); + d.assets.insert(AssetId(id + 256), Arc::new(asset(id + 256, &bytes))); + } + assert_eq!(d.merge_duplicate_assets(), 256); + assert_eq!(d.assets.len(), 256); + for id in 1..=256u64 { + assert_eq!(d.assets[&AssetId(id)].data[1016..], id.to_le_bytes()); + } + } + + #[test] + fn compaction_preserves_asset_identity_and_missing_data_rules() { + let mut d = Document::new(&Default::default()); + let mut cases = vec![asset(1, b"same"), asset(2, b"same"), asset(3, b"same"), asset(4, b"same")]; + cases[1].mime = "application/pdf".into(); + cases[2].page = 1; + cases[3].link = Some("/linked.png".into()); + cases.extend([asset(5, b""), asset(6, b""), asset(7, b""), asset(8, b"")]); + cases[6].link = Some("/missing.png".into()); + cases[7].link = Some("/missing.png".into()); + for a in cases { + d.assets.insert(a.id, Arc::new(a)); + } + assert_eq!(d.merge_duplicate_assets(), 1); + assert_eq!(d.assets.keys().copied().collect::>(), (1..=7).map(AssetId).collect::>()); + } + + #[test] + #[cfg(not(target_arch = "wasm32"))] + #[ignore = "manual bounded timing comparison; no timing assertion"] + fn equal_size_assets_timing() { + for count in [500, 1000, 2000] { + let mut d = Document::new(&Default::default()); + for id in 1..=count as u64 { + let mut bytes = vec![42; 8192]; + bytes[8184..].copy_from_slice(&id.to_le_bytes()); + d.assets.insert(AssetId(id), Arc::new(asset(id, &bytes))); + } + let start = std::time::Instant::now(); + assert_eq!(d.merge_duplicate_assets(), 0); + eprintln!("{count} distinct 8 KiB assets: {:?}", start.elapsed()); + } + } + fn graphic_item(id: u64, asset: u64, crop: f64) -> Item { let mut it = Item::new(ItemId(id), LayerId(1), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0))); it.content = Content::Graphic(Graphic { diff --git a/crates/doc/src/build.rs b/crates/doc/src/build.rs index c274f213..574049d7 100644 --- a/crates/doc/src/build.rs +++ b/crates/doc/src/build.rs @@ -226,6 +226,7 @@ impl Document { liquid: Default::default(), transition: None, view_rotation: 0, + show_parent_overlay: false, } } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index c1c55dcf..49a81830 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -578,6 +578,30 @@ pub struct TocEntry { /// 1-based nesting level. #[serde(default = "one")] pub level: u8, + /// Paragraph style of this style's entries; `None` is the generated "TOC Level n" style. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub entry_style: Option, + #[serde(default)] + pub page_number: TocPageNumber, + /// Text between the entry and its page number; `None` is a tab. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub separator: Option, +} + +impl TocEntry { + pub fn new(style: impl Into, level: u8) -> Self { + Self { style: style.into(), level, entry_style: None, page_number: TocPageNumber::After, separator: None } + } +} + +/// Where an entry's page number goes. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum TocPageNumber { + #[default] + After, + Before, + None, } fn one() -> u8 { @@ -592,6 +616,9 @@ pub struct Toc { pub entries: Vec, #[serde(default)] pub page_numbers: bool, + /// Paragraph style of the title; `None` is the generated "TOC Title" style. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title_style: Option, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/doc/src/page.rs b/crates/doc/src/page.rs index 1fa753b3..845d1a72 100644 --- a/crates/doc/src/page.rs +++ b/crates/doc/src/page.rs @@ -114,6 +114,9 @@ pub struct Page { /// Liquid page rule: how objects follow when the page changes size. #[serde(default, skip_serializing_if = "LiquidRule::is_off")] pub liquid: LiquidRule, + /// Page tool › Show Parent Page Overlay: the parent page's size drawn over this page. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub show_parent_overlay: bool, /// Window › Interactive › Page Transitions (the spread's first page holds the spread's). #[serde(default, skip_serializing_if = "Option::is_none")] pub transition: Option, @@ -208,6 +211,8 @@ pub enum LiquidRule { GuideBased, /// Each object's pins and resize settings decide (`Item::liquid`). ObjectBased, + /// The rule of the page's parent page (InDesign's "Controlled by Parent"; Off without one). + ControlledByParent, } impl LiquidRule { @@ -368,6 +373,7 @@ mod tests { liquid: Default::default(), transition: None, view_rotation: 0, + show_parent_overlay: false, } } diff --git a/crates/doc/src/selection.rs b/crates/doc/src/selection.rs index d9e9b7a3..2d28bf2c 100644 --- a/crates/doc/src/selection.rs +++ b/crates/doc/src/selection.rs @@ -80,6 +80,9 @@ pub struct Selection { /// Key object for Align. #[serde(default, skip_serializing_if = "Option::is_none")] pub key: Option, + /// The page selected with the Page tool (absolute index). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub page: Option, } impl Selection { diff --git a/crates/doc/src/table.rs b/crates/doc/src/table.rs index 9449be93..087412bc 100644 --- a/crates/doc/src/table.rs +++ b/crates/doc/src/table.rs @@ -444,6 +444,25 @@ impl Table { v } + /// Owning cells touched by the range, once each in row-major order. Bounds are clipped + /// to the grid; resolving covered positions does not expand the selection rectangle. + pub fn selected_owners(&self, range: CellRange) -> Vec<(usize, usize)> { + let (nr, nc) = (self.nrows(), self.ncols()); + if range.r0 >= nr || range.c0 >= nc || range.r0 > range.r1 || range.c0 > range.c1 { + return Vec::new(); + } + let owners = self.owners(); + let mut selected = std::collections::BTreeSet::new(); + for r in range.r0..=range.r1.min(nr - 1) { + for c in range.c0..=range.c1.min(nc - 1) { + if let Some(&owner) = owners.get(r * nc + c) { + selected.insert(owner); + } + } + } + selected.into_iter().collect() + } + /// The owning (top-left) cell address of grid position (r, c). pub fn owner(&self, r: usize, c: usize) -> (usize, usize) { self.owners().get(r * self.ncols() + c).copied().unwrap_or((r, c)) @@ -1136,6 +1155,35 @@ mod tests { Story::with_text(StoryId(1), t, ParaFormat::default()) } + #[test] + fn selected_owners_include_covered_cells_without_expanding_range() { + for column in [true, false] { + let mut t = Table::new(1, 2, 2, 0, 0, 120.0); + let range = if column { CellRange::new(0, 1, 1, 1) } else { CellRange::new(1, 0, 1, 1) }; + assert_eq!(t.selected_owners(range), if column { vec![(0, 1), (1, 1)] } else { vec![(1, 0), (1, 1)] }); + t.merge(if column { CellRange::new(0, 0, 0, 1) } else { CellRange::new(0, 0, 1, 0) }).unwrap(); + assert_eq!(t.selected_owners(range), vec![(0, 0), (1, 1)]); + } + } + + #[test] + fn selected_owners_are_bounded_sorted_and_unique() { + let mut t = Table::new(1, 3, 3, 0, 0, 180.0); + t.merge(CellRange::new(0, 0, 1, 1)).unwrap(); + assert_eq!(t.selected_owners(CellRange::new(1, 1, usize::MAX, usize::MAX)), vec![(0, 0), (1, 2), (2, 1), (2, 2)]); + assert_eq!(t.selected_owners(CellRange::new(0, 0, 1, 1)), vec![(0, 0)], "covered positions do not duplicate an owner"); + assert!(t.selected_owners(CellRange::new(3, 0, usize::MAX, usize::MAX)).is_empty()); + assert!(t.selected_owners(CellRange::new(0, 3, usize::MAX, usize::MAX)).is_empty()); + assert!(t.selected_owners(CellRange { r0: 1, c0: 0, r1: 0, c1: 1 }).is_empty()); + t.rows.clear(); + t.cells.clear(); + assert!(t.selected_owners(CellRange::cell(0, 0)).is_empty()); + + let mut right = Table::new(2, 2, 2, 0, 0, 120.0); + right.merge(CellRange::new(0, 1, 1, 1)).unwrap(); + assert_eq!(right.selected_owners(CellRange::new(1, 0, 1, 1)), vec![(0, 1), (1, 0)], "owner order, not encounter order"); + } + #[test] fn insert_table_gets_own_paragraph() { let mut s = story("before after"); diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 0885d155..e3c98540 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -67,7 +67,7 @@ fn paste_text(s: &mut Session, text: Option, formatted: bool) -> Result< } } let use_clip = formatted && clip.as_ref().is_some_and(|(_, p)| text.as_ref().is_none_or(|x| x.replace("\r\n", "\n") == *p)); - // Footnotes, cross-references and tables only paste into story text, not cells or footnotes. + // Footnotes, endnotes, editorial notes, cross-references and tables cannot be pasted into cells or footnotes. if use_clip && let Some((slice, _)) = clip { let slice = if t.cell.is_some() { strip_objects(&slice) } else { (*slice).clone() }; return s.edit(|d, sel| { @@ -152,7 +152,13 @@ fn paste_text_frame(s: &mut Session, p: &Value, text: &str, slice: Option<&desig fn strip_objects(st: &designcraft_doc::Story) -> designcraft_doc::Story { let mut out = st.clone(); - let marks = [designcraft_doc::FOOTNOTE_REF, designcraft_doc::XREF_MARK, designcraft_doc::TABLE_ANCHOR]; + let marks = [ + designcraft_doc::FOOTNOTE_REF, + designcraft_doc::ENDNOTE_REF, + designcraft_doc::NOTE_MARK, + designcraft_doc::XREF_MARK, + designcraft_doc::TABLE_ANCHOR, + ]; while let Some(i) = out.text.find(marks) { let n = out.text[i..].chars().next().map_or(1, char::len_utf8); out.delete(i..i + n); @@ -505,6 +511,124 @@ mod text_clipboard_tests { s.doc().unwrap().doc.check().unwrap(); } + fn note_clipboard_session() -> (Session, designcraft_doc::StoryId) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let source = frame(&mut s, "aéz"); + s.execute("text.select", &json!({"story": source.0, "anchor": 1, "focus": 1})).unwrap(); + s.execute("endnote.insert", &json!({"text": "Citation A"})).unwrap(); + s.execute("note.new", &json!({"text": "Memo A", "author": "Ed"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(source).unwrap().text, "a\u{E00F}\u{E010}éz"); + s.execute("text.select", &json!({"story": source.0, "anchor": 0, "focus": 10})).unwrap(); + s.execute("type.char", &json!({"attrs": {"fontStyle": "Bold"}})).unwrap(); + assert_eq!(s.execute("edit.copy", &json!({})).unwrap(), json!({"text": "aéz"})); + (s, source) + } + + fn assert_child_note_paste( + s: &mut Session, + source: designcraft_doc::StoryId, + parent: designcraft_doc::StoryId, + cell: designcraft_doc::CellAddr, + endpoints: (usize, usize), + ) { + let before = s.doc().unwrap().doc.clone(); + let selected = s.doc().unwrap().selection.text.unwrap(); + assert_eq!((selected.story, selected.cell, selected.anchor, selected.focus), (parent, Some(cell), endpoints.0, endpoints.1)); + assert_eq!(selected.frame, None); + let before_child = before.text_story(parent, Some(cell)).unwrap(); + assert_eq!(before_child.text, "L----R"); + let clipboard = s.text_clipboard.clone(); + let history = s.execute("document.history", &json!({})).unwrap(); + assert_eq!(history["redo"], json!([])); + + let result = s.execute("edit.paste", &json!({})).unwrap(); + let after = s.doc().unwrap().doc.clone(); + let child = after.text_story(parent, Some(cell)).unwrap(); + assert_eq!(child.text, "LaézR", "root-only note markers must not enter child text"); + assert_eq!(child.len(), 6); + assert_eq!(result, json!({"pos": 5, "formatted": true})); + assert_eq!(s.doc().unwrap().selection, Selection::text(designcraft_doc::TextSel { anchor: 5, focus: 5, ..selected })); + assert!(child.endnotes.is_empty() && child.editorial.is_empty()); + for pos in [1, 2, 4] { + let (_, format) = child.runs().find(|(range, _)| range.contains(&pos)).unwrap(); + assert_eq!(format.over.font_style.as_deref(), Some("Bold")); + } + // Inspect actual run coverage: char_format_at at a run boundary means the preceding run. + for pos in [0, 5] { + let (_, old_format) = before_child.runs().find(|(range, _)| range.contains(&pos)).unwrap(); + let (_, new_format) = child.runs().find(|(range, _)| range.contains(&pos)).unwrap(); + assert_eq!(new_format, old_format); + } + assert_eq!(after.story(parent).unwrap().text, before.story(parent).unwrap().text); + assert_eq!(after.story(parent).unwrap().chars, before.story(parent).unwrap().chars); + assert_eq!(after.story(source), before.story(source)); + assert_eq!(s.text_clipboard, clipboard); + + let mut pasted_history = history.clone(); + pasted_history["undo"].as_array_mut().unwrap().push(json!("Paste")); + assert_eq!(s.execute("document.history", &json!({})).unwrap(), pasted_history); + assert_eq!(s.execute("edit.undo", &json!({})).unwrap(), json!({"undone": "Paste"})); + assert_eq!(s.doc().unwrap().doc, before); + let mut undone_history = history; + undone_history["redo"] = json!(["Paste"]); + assert_eq!(s.execute("document.history", &json!({})).unwrap(), undone_history); + // This filter does not change which selection Undo restores. + assert_eq!(s.execute("edit.redo", &json!({})).unwrap(), json!({"redone": "Paste"})); + assert_eq!(s.doc().unwrap().doc, after); + assert_eq!(s.execute("document.history", &json!({})).unwrap(), pasted_history); + } + + #[test] + fn formatted_child_paste_filters_endnotes_and_editorial_in_cells() { + let (mut s, source) = note_clipboard_session(); + let parent = frame(&mut s, "PZ"); + s.execute("text.select", &json!({"story": parent.0, "anchor": 1, "focus": 1})).unwrap(); + let table_id = s.execute("table.insert", &json!({"rows": 1, "cols": 2})).unwrap()["table"].as_u64().unwrap(); + s.execute("text.insert", &json!({"text": "L----R"})).unwrap(); + s.execute("table.nextCell", &json!({})).unwrap(); + s.execute("text.insert", &json!({"text": "NEIGHBOR"})).unwrap(); + s.execute("table.prevCell", &json!({})).unwrap(); + s.execute("text.move", &json!({"dir": "storyStart"})).unwrap(); + s.execute("text.move", &json!({"dir": "right"})).unwrap(); + for _ in 0..4 { + s.execute("text.move", &json!({"dir": "right", "extend": true})).unwrap(); + } + let before_table = s.doc().unwrap().doc.story(parent).unwrap().tables[&table_id].clone(); + let cell = designcraft_doc::CellAddr { table: table_id, row: 0, col: 0 }; + assert_child_note_paste(&mut s, source, parent, cell, (1, 5)); + let table = &s.doc().unwrap().doc.story(parent).unwrap().tables[&table_id]; + assert_eq!(table.cell(0, 1).unwrap().text.text, "NEIGHBOR"); + let mut expected_table = (*before_table).clone(); + expected_table.cells[0].text = table.cells[0].text.clone(); + assert_eq!(table.as_ref(), &expected_table); + + // The same clipboard must still retain both markers when pasted into an ordinary root story. + let root = frame(&mut s, "L----R"); + s.execute("text.select", &json!({"story": root.0, "anchor": 1, "focus": 5})).unwrap(); + assert_eq!(s.execute("edit.paste", &json!({})).unwrap(), json!({"pos": 11, "formatted": true})); + assert_eq!(s.doc().unwrap().doc.story(root).unwrap().text, "La\u{E00F}\u{E010}ézR"); + } + + #[test] + fn formatted_child_paste_filters_endnotes_and_editorial_in_footnotes() { + let (mut s, source) = note_clipboard_session(); + let parent = frame(&mut s, "PZ"); + let neighbor = s.execute("footnote.insert", &json!({"text": "NEIGHBOR"})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.select", &json!({"story": parent.0, "anchor": 1, "focus": 1})).unwrap(); + let target = s.execute("footnote.insert", &json!({"text": "L----R"})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.move", &json!({"dir": "left"})).unwrap(); + for _ in 0..4 { + s.execute("text.move", &json!({"dir": "left", "extend": true})).unwrap(); + } + let before = s.doc().unwrap().doc.story(parent).unwrap().clone(); + assert_child_note_paste(&mut s, source, parent, designcraft_doc::CellAddr::footnote(target), (5, 1)); + let after = s.doc().unwrap().doc.story(parent).unwrap(); + assert_eq!(after.note(neighbor).unwrap().text.text, "NEIGHBOR"); + assert_eq!(after.note(neighbor), before.note(neighbor)); + assert_eq!(after.notes.iter().map(|note| note.id).collect::>(), before.notes.iter().map(|note| note.id).collect::>()); + } + #[test] fn change_case_keeps_runs() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/find.rs b/crates/engine/src/cmd/find.rs index 5dc0bc8a..f57845ba 100644 --- a/crates/engine/src/cmd/find.rs +++ b/crates/engine/src/cmd/find.rs @@ -1,17 +1,22 @@ //! Edit → Find/Change: plain text and GREP (regular expressions) with InDesign's metacharacters, //! optional formatting in the change, scoped to the document, a story or the selection. -use designcraft_doc::{StoryId, TextSel, story}; +use std::ops::Range; +use std::sync::{Arc, Weak}; + +use designcraft_doc::story::floor_char_boundary; +use designcraft_doc::{Document, Selection, StoryId, TextSel}; use serde_json::{Value, json}; use super::{CommandSpec, bad, bool_or, cmd, has_doc, str_param}; -use crate::{Result, Session}; +use crate::{DocState, Result, Session}; pub fn specs() -> Vec { vec![ cmd!(noundo "find.objects", "Find Object", [], None, "{fill?, stroke?: swatch, strokeWeight?, opacity? (0–1), kind?: text|graphic|shape|line|group, layer?, label?, select?: bool (default true)} → {ids} — Find/Change › Object, through the document", has_doc, |s, p| { + s.reset_find_scope(); let ids = find_objects(&s.doc()?.doc, p); if p.get("select").and_then(Value::as_bool).unwrap_or(true) && !ids.is_empty() { let st = s.doc_mut()?; @@ -28,6 +33,7 @@ pub fn specs() -> Vec { "{…find.objects criteria, change: {fill?, stroke?, strokeWeight?, opacity?}} → {changed}", has_doc, |s, p| { + s.reset_find_scope(); let ids = find_objects(&s.doc()?.doc, p); let ch = p.get("change").cloned().unwrap_or(Value::Null); if !ch.is_object() { @@ -65,7 +71,7 @@ pub fn specs() -> Vec { has_doc, change ), - cmd!(noundo "find.next", "Find Next", ["Edit"], Some("Cmd+Alt+F"), "{find, grep?, …} — selects the next match after the caret", has_doc, find_next), + cmd!(noundo "find.next", "Find Next", ["Edit"], Some("Cmd+Alt+F"), "{find, grep?, caseSensitive?, wholeWord?, scope?: document|story|selection, story?} — Selection starts at its original beginning; later calls advance within the scope", has_doc, find_next), ] } @@ -125,34 +131,182 @@ fn build(p: &Value) -> Result { .map_err(|e| bad("find", format!("bad GREP: {e}"))) } -fn scope(s: &Session, p: &Value) -> Result)>> { +#[derive(Clone, Debug, PartialEq, Eq)] +enum Domain { + Document, + Story, + Selection, + Explicit(StoryId), +} + +impl Domain { + fn from_params(p: &Value) -> Self { + if let Some(id) = p.get("story").and_then(Value::as_u64) { + return Self::Explicit(StoryId(id)); + } + match str_param(p, "scope").unwrap_or("document") { + "story" => Self::Story, + "selection" => Self::Selection, + _ => Self::Document, + } + } +} + +/// Whether two requests use the same search domain, including explicit-story precedence. +pub fn same_find_domain(a: &Value, b: &Value) -> bool { + Domain::from_params(a) == Domain::from_params(b) +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct Matcher { + find: String, + grep: bool, + case_sensitive: bool, + whole_word: bool, +} + +impl Matcher { + fn from_params(p: &Value) -> Self { + Self { + find: str_param(p, "find").unwrap_or("").to_string(), + grep: bool_or(p, "grep", false), + case_sensitive: bool_or(p, "caseSensitive", false), + whole_word: bool_or(p, "wholeWord", false), + } + } +} + +/// A search owns its original domain, not the selection used to paint the latest hit. +/// Weak identity avoids retaining an old document merely for this transient cache. +#[derive(Clone)] +pub(crate) struct FindContext { + document: Weak, + uid: u64, + revision: u64, + selection: Selection, + domain: Domain, + matcher: Matcher, + targets: Vec<(StoryId, Range)>, + order: Vec, + target: usize, + offset: usize, + last_match_end: Option, +} + +impl FindContext { + pub(crate) fn is_current(&self, st: &DocState) -> bool { + self.uid == st.uid && Weak::ptr_eq(&self.document, &Arc::downgrade(&st.doc)) && self.revision == st.revision && self.selection == st.selection + } + + fn acknowledge(&mut self, st: &DocState) { + self.revision = st.revision; + self.selection = st.selection.clone(); + } + + fn restart(&mut self, st: &DocState) -> Result<()> { + self.target = 0; + self.last_match_end = None; + self.order = (0..self.targets.len()).collect(); + let caret = st.selection.text.filter(|t| t.cell.is_none() && st.selection.cells.is_none()); + if self.domain == Domain::Document + && let Some(t) = st.selection.text + && let Some(i) = self.targets.iter().position(|(sid, _)| *sid == t.story) + { + self.order.rotate_left(i); + } + let first = self.order.first().and_then(|i| self.targets.get(*i)); + self.offset = match first { + Some((sid, r)) if self.domain != Domain::Selection => caret.filter(|t| t.story == *sid).map_or(r.start, |t| t.range().end.min(r.end)), + Some((_, r)) => r.start, + None => 0, + }; + if let Some((sid, r)) = first { + let text = target_text(&st.doc, *sid, r)?; + if text.get(self.offset.saturating_sub(r.start)..).is_none() { + return Err(bad("find", "invalid UTF-8 search cursor")); + } + } + Ok(()) + } + + fn advance_target(&mut self) { + self.target = self.target.saturating_add(1); + self.last_match_end = None; + self.offset = self.order.get(self.target).and_then(|i| self.targets.get(*i)).map_or(0, |(_, r)| r.start); + } + + fn advance_empty(&mut self, text: &str, relative_end: usize, end: usize) { + // Move one Unicode scalar, not one byte; an endpoint is returned at most once. + if let Some(ch) = text.get(relative_end..).and_then(|rest| rest.chars().next()) { + self.offset = end + ch.len_utf8(); + } else { + self.advance_target(); + } + } +} + +fn target_text<'a>(d: &'a Document, sid: StoryId, r: &Range) -> Result<&'a str> { + d.story(sid) + .ok_or_else(|| bad("find", "no such story"))? + .text + .get(r.clone()) + .ok_or_else(|| bad("find", "invalid selection bounds or UTF-8 boundary")) +} + +fn scope(s: &Session, p: &Value) -> Result { let st = s.doc()?; + let domain = Domain::from_params(p); + let matcher = Matcher::from_params(p); + if let Some(previous) = s.find_context.as_ref().filter(|c| c.is_current(st) && c.domain == domain) { + let mut context = previous.clone(); + if context.matcher != matcher { + context.matcher = matcher; + context.restart(st)?; + } + return Ok(context); + } let d = &st.doc; - if let Some(sid) = p.get("story").and_then(Value::as_u64) { - let sid = StoryId(sid); - let len = d.story(sid).map(|x| x.len()).ok_or_else(|| bad("find", "no such story"))?; - return Ok(vec![(sid, 0..len)]); - } - match str_param(p, "scope").unwrap_or("document") { - "story" => { - let t = st.selection.text.ok_or_else(|| bad("find", "no story selected"))?; - Ok(vec![(t.story, 0..d.story(t.story).map(|x| x.len()).unwrap_or(0))]) + let targets = match domain { + Domain::Explicit(sid) => { + let len = d.story(sid).ok_or_else(|| bad("find", "no such story"))?.len(); + vec![(sid, 0..len)] } - "selection" => { + Domain::Story | Domain::Selection => { + if st.selection.cells.is_some() || st.selection.text.is_some_and(|t| t.cell.is_some()) { + return Err(bad("find", "Story/Selection search in table cells or notes is not supported")); + } let t = st.selection.text.ok_or_else(|| bad("find", "no text selected"))?; - Ok(vec![(t.story, t.range())]) + let story = d.story(t.story).ok_or_else(|| bad("find", "no such story"))?; + let r = if domain == Domain::Selection { t.range() } else { 0..story.len() }; + target_text(d, t.story, &r)?; + vec![(t.story, r)] } - _ => Ok(d.stories.values().map(|x| (x.id, 0..x.len())).collect()), - } + Domain::Document => d.stories.values().map(|x| (x.id, 0..x.len())).collect(), + }; + let mut context = FindContext { + document: Arc::downgrade(d), + uid: st.uid, + revision: st.revision, + selection: st.selection.clone(), + domain, + matcher, + targets, + order: Vec::new(), + target: 0, + offset: 0, + last_match_end: None, + }; + context.restart(st)?; + Ok(context) } fn find(s: &mut Session, p: &Value) -> Result { let re = build(p)?; + let context = scope(s, p)?; let st = s.doc()?; let mut out = Vec::new(); - for (sid, r) in scope(s, p)? { - let Some(story) = st.doc.story(sid) else { continue }; - let text = &story.text[r.clone()]; + for (sid, r) in &context.targets { + let text = target_text(&st.doc, *sid, r)?; for m in re.find_iter(text) { out.push(json!({"story": sid.0, "start": r.start + m.start(), "end": r.start + m.end(), "text": m.as_str()})); if out.len() >= 10_000 { @@ -160,6 +314,7 @@ fn find(s: &mut Session, p: &Value) -> Result { } } } + s.find_context = Some(context); Ok(Value::Array(out)) } @@ -182,13 +337,13 @@ fn change(s: &mut Session, p: &Value) -> Result { } } super::text::validate_char_fill(&s.doc()?.doc, &over, "find.change")?; - let targets = scope(s, p)?; + let targets = scope(s, p)?.targets; s.edit(|d, sel| { let mut count = 0usize; for (sid, r) in targets { let Some(story) = d.story(sid) else { continue }; // Collect matches first (positions refer to the original text), apply back to front. - let text = story.text[r.clone()].to_string(); + let text = story.text.get(r.clone()).ok_or_else(|| bad("find", "invalid selection bounds or UTF-8 boundary"))?.to_string(); let mut edits: Vec<(std::ops::Range, String)> = Vec::new(); for caps in re.captures_iter(&text) { let Some(m) = caps.get(0) else { continue }; @@ -211,10 +366,21 @@ fn change(s: &mut Session, p: &Value) -> Result { } count += 1; } - if let Some(t) = sel.text.as_mut().filter(|t| t.story == sid) { - let len = st.len(); - t.anchor = t.anchor.min(len); - t.focus = t.focus.min(len); + if let Some(t) = sel.text.as_mut().filter(|t| t.story == sid && t.cell.is_none()) { + // Repair byte validity once against the final story, without remapping valid offsets. + let range = t.range(); + let start = floor_char_boundary(&st.text, range.start); + let mut end = range.end.min(st.len()); + if t.is_caret() { + end = start; + } else if !st.text.is_char_boundary(end) { + let floor = floor_char_boundary(&st.text, end); + end = st.text.get(floor..).and_then(|rest| rest.chars().next()).map_or(floor, |ch| floor + ch.len_utf8()); + } + (t.anchor, t.focus) = if t.anchor <= t.focus { (start, end) } else { (end, start) }; + } + if first_only && count > 0 { + break; } } Ok(json!({"count": count})) @@ -223,29 +389,44 @@ fn change(s: &mut Session, p: &Value) -> Result { fn find_next(s: &mut Session, p: &Value) -> Result { let re = build(p)?; + let mut context = scope(s, p)?; let st = s.doc()?; - let (start_story, start_pos) = st.selection.text.map(|t| (Some(t.story), t.range().end)).unwrap_or((None, 0)); - let mut order: Vec = st.doc.stories.keys().copied().collect(); - if let Some(ss) = start_story - && let Some(i) = order.iter().position(|x| *x == ss) - { - order.rotate_left(i); - } let mut hit = None; - for (k, sid) in order.iter().enumerate() { - let Some(story) = st.doc.story(*sid) else { continue }; - let from = if k == 0 && start_story == Some(*sid) { start_pos.min(story.len()) } else { 0 }; - if let Some(m) = re.find_at(&story.text, from) { - hit = Some((*sid, m.start(), m.end())); + while let Some((sid, r)) = context.order.get(context.target).and_then(|i| context.targets.get(*i)).cloned() { + let text = target_text(&st.doc, sid, &r)?; + let from = context.offset.checked_sub(r.start).ok_or_else(|| bad("find", "invalid search cursor"))?; + if text.get(from..).is_none() { + return Err(bad("find", "invalid UTF-8 search cursor")); + } + if let Some(m) = re.find_at(text, from) { + let (a, b) = (r.start + m.start(), r.start + m.end()); + // Match find_iter/captures_iter: suppress an empty match adjacent to the + // previous match's end, but keep adjacent nonempty matches. + if m.is_empty() && context.last_match_end == Some(b) { + context.advance_empty(text, m.end(), b); + continue; + } + hit = Some((sid, a, b)); + context.last_match_end = Some(b); + if m.is_empty() { + context.advance_empty(text, m.end(), b); + } else { + context.offset = b; + } break; } + context.advance_target(); } - let Some((sid, a, b)) = hit else { return Ok(Value::Null) }; + let Some((sid, a, b)) = hit else { + s.find_context = Some(context); + return Ok(Value::Null); + }; let frame = st.doc.story(sid).and_then(|x| x.frames.first().copied()); let stm = s.doc_mut()?; - stm.selection = designcraft_doc::Selection::text(TextSel { story: sid, anchor: a, focus: b, frame, cell: None }); + stm.selection = Selection::text(TextSel { story: sid, anchor: a, focus: b, frame, cell: None }); stm.revision += 1; - let _ = story::PAGE_NUMBER; + context.acknowledge(stm); + s.find_context = Some(context); Ok(json!({"story": sid.0, "start": a, "end": b})) } @@ -278,6 +459,664 @@ mod tests { s.execute("find.next", &json!({"find": "or"})).unwrap(); assert!(s.doc().unwrap().selection.text.is_some()); } + fn scope_session(text: &str) -> (Session, designcraft_doc::StoryId) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let frame = s.execute("frame.create", &json!({"rect": [36, 36, 100, 100], "content": "text", "text": text})).unwrap(); + (s, designcraft_doc::StoryId(frame["story"].as_u64().unwrap())) + } + + fn scope_select(s: &mut Session, sid: designcraft_doc::StoryId, a: usize, b: usize) { + s.execute("text.select", &json!({"story": sid.0, "anchor": a, "focus": b})).unwrap(); + } + + fn scope_hit(s: &mut Session, p: &serde_json::Value, sid: designcraft_doc::StoryId, a: usize, b: usize) { + assert_eq!(s.execute("find.next", p).unwrap(), json!({"story": sid.0, "start": a, "end": b})); + let selected = s.doc().unwrap().selection.text.unwrap(); + assert_eq!((selected.story, selected.range(), selected.cell), (sid, a..b, None)); + } + + #[test] + fn find_scope_first_change_stops_after_first_matching_story_and_repairs_selection() { + for (texts, first, explicit, grep, expected, count) in [ + (vec!["red red", "red"], Some(true), false, false, vec!["blue red", "red"], 1), + (vec!["keep", "red red", "red"], Some(true), false, false, vec!["keep", "blue red", "red"], 1), + (vec!["keep", "other"], Some(true), false, false, vec!["keep", "other"], 0), + (vec!["red red", "red"], Some(false), false, false, vec!["blue blue", "blue"], 3), + (vec!["red red", "red"], None, false, false, vec!["blue blue", "blue"], 3), + (vec!["red red", "red"], Some(true), true, false, vec!["blue red", "red"], 1), + (vec!["red red", "red"], Some(false), true, false, vec!["blue blue", "red"], 2), + (vec!["", "é🙂"], Some(true), false, true, vec!["X", "é🙂"], 1), + (vec!["é", "🙂"], Some(false), false, true, vec!["XéX", "X🙂X"], 4), + ] { + let (mut s, sid) = scope_session(texts[0]); + let mut sids = vec![sid]; + for text in texts.iter().skip(1) { + let frame = s.execute("frame.create", &json!({"rect": [120, 36, 200, 100], "content": "text", "text": text})).unwrap(); + sids.push(designcraft_doc::StoryId(frame["story"].as_u64().unwrap())); + } + assert_eq!(s.doc().unwrap().doc.stories.keys().copied().collect::>(), sids); + // Document Change keeps canonical target order even with a later story current. + scope_select(&mut s, *sids.last().unwrap(), 0, 0); + let before = s.doc().unwrap().doc.clone(); + let mut p = json!({ + "find": if grep { "(?:)" } else { "red" }, + "change": if grep { "X" } else { "blue" }, + "scope": "document", "grep": grep + }); + if let Some(first) = first { + p["first"] = json!(first); + } + if explicit { + p["story"] = json!(sid.0); + p["scope"] = json!("selection"); + } + if first == Some(true) && !explicit && !grep && texts.last() == Some(&"red") { + let last = *sids.last().unwrap(); + scope_hit(&mut s, &p, last, 0, 3); + let first_hit = sids[if texts.len() == 2 { 0 } else { 1 }]; + assert_eq!( + s.execute("find.find", &p).unwrap(), + json!([ + {"story": first_hit.0, "start": 0, "end": 3, "text": "red"}, + {"story": first_hit.0, "start": 4, "end": 7, "text": "red"}, + {"story": last.0, "start": 0, "end": 3, "text": "red"} + ]), + "Count remains canonical after Next paints the later story" + ); + } + let selection = s.doc().unwrap().selection.clone(); + assert_eq!(s.execute("find.change", &p).unwrap(), json!({"count": count})); + let st = s.doc().unwrap(); + assert_eq!(st.selection, selection); + for (i, sid) in sids.iter().enumerate() { + assert_eq!(st.doc.story(*sid).unwrap().text, expected[i]); + if texts[i] == expected[i] { + assert_eq!(st.doc.story(*sid), before.story(*sid), "untouched story {i} stays entirely unchanged"); + } + } + st.doc.check().unwrap(); + } + + // Identical replacement is still one counted match, not a reason to visit another story. + let (mut s, sid) = scope_session("red red"); + let other = s.execute("frame.create", &json!({"rect": [120, 36, 200, 100], "content": "text", "text": "red"})).unwrap(); + let other_sid = designcraft_doc::StoryId(other["story"].as_u64().unwrap()); + scope_select(&mut s, other_sid, 0, 3); + let before = s.doc().unwrap().doc.clone(); + let selection = s.doc().unwrap().selection.clone(); + assert_eq!( + s.execute("find.change", &json!({"find": "red", "change": "red", "scope": "document", "first": true})).unwrap(), + json!({"count": 1}) + ); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "red red"); + assert_eq!(s.doc().unwrap().doc.story(other_sid), before.story(other_sid)); + assert_eq!(s.doc().unwrap().selection, selection); + s.doc().unwrap().doc.check().unwrap(); + + for reverse in [false, true] { + let (mut s, sid) = scope_session("éX X"); + let other = s.execute("frame.create", &json!({"rect": [120, 36, 200, 100], "content": "text", "text": "X"})).unwrap(); + let other_sid = designcraft_doc::StoryId(other["story"].as_u64().unwrap()); + scope_select(&mut s, sid, if reverse { 3 } else { 2 }, if reverse { 2 } else { 3 }); + let before = s.doc().unwrap().doc.clone(); + let mut selection = s.doc().unwrap().selection.clone(); + let t = selection.text.as_mut().unwrap(); + (t.anchor, t.focus) = if reverse { (6, 2) } else { (2, 6) }; + let p = json!({"find": "X", "change": "🙂", "scope": "document", "first": true}); + assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 3); + assert_eq!(s.execute("find.change", &p).unwrap(), json!({"count": 1})); + let st = s.doc().unwrap(); + assert_eq!(st.doc.story(sid).unwrap().text, "é🙂 X"); + assert_eq!(st.doc.story(other_sid), before.story(other_sid)); + assert_eq!(st.selection, selection, "first-match exit must follow final UTF-8 repair"); + st.doc.check().unwrap(); + assert_eq!( + s.execute("find.find", &json!({"find": "🙂", "scope": "selection"})).unwrap(), + json!([{"story": sid.0, "start": 2, "end": 6, "text": "🙂"}]) + ); + } + } + + #[test] + fn find_scope_exact_baseline_count_next_change() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let frame = s + .execute( + "frame.create", + &json!({ + "rect": [36, 36, 300, 200], "content": "text", "text": "red red" + }), + ) + .unwrap(); + let sid = frame["story"].as_u64().unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 3})).unwrap(); + assert_eq!( + s.execute("find.find", &json!({"find": "red", "scope": "selection"})).unwrap(), + json!([{"story": sid, "start": 0, "end": 3, "text": "red"}]) + ); + assert_eq!(s.execute("find.next", &json!({"find": "red", "scope": "selection"})).unwrap(), json!({"story": sid, "start": 0, "end": 3})); + assert_eq!(s.execute("find.change", &json!({"find": "red", "change": "blue", "scope": "selection"})).unwrap(), json!({"count": 1})); + assert_eq!(s.execute("story.get", &json!({"story": sid})).unwrap()["text"], "blue red"); + } + + #[test] + fn find_scope_change_growth_preserves_direction_and_full_scalar_hits() { + for (text, find, change, changed, start, old_end, new_end) in [("a", "a", "é", "é", 0, 1, 2), ("éX", "X", "🙂", "é🙂", 2, 3, 6)] { + for reverse in [false, true] { + let (mut s, sid) = scope_session(text); + // Real caret movement keeps the frame identity, unlike text.select. + s.execute("text.move", &json!({"dir": "left", "extend": reverse})).unwrap(); + if !reverse { + s.execute("text.move", &json!({"dir": "right", "extend": true})).unwrap(); + } + let selected = s.doc().unwrap().selection.text.unwrap(); + assert_eq!((selected.anchor, selected.focus), if reverse { (old_end, start) } else { (start, old_end) }); + assert!(selected.frame.is_some()); + let mut expected = s.doc().unwrap().selection.clone(); + let t = expected.text.as_mut().unwrap(); + (t.anchor, t.focus) = if reverse { (new_end, start) } else { (start, new_end) }; + let p = json!({"find": find, "change": change, "scope": "selection"}); + assert_eq!(s.execute("find.find", &p).unwrap(), json!([{"story": sid.0, "start": start, "end": old_end, "text": find}])); + assert_eq!(s.execute("find.change", &p).unwrap(), json!({"count": 1})); + let st = s.doc().unwrap(); + assert_eq!(st.doc.story(sid).unwrap().text, changed); + assert_eq!(st.selection, expected); + let t = st.selection.text.unwrap(); + assert!(changed.is_char_boundary(t.anchor) && changed.is_char_boundary(t.focus)); + assert!(s.find_context.is_none()); + let p = json!({"find": change, "scope": "selection"}); + assert_eq!(s.execute("find.find", &p).unwrap(), json!([{"story": sid.0, "start": start, "end": new_end, "text": change}])); + scope_hit(&mut s, &p, sid, start, new_end); + assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null); + } + } + } + + #[test] + fn find_scope_change_repairs_final_boundaries_without_remapping_valid_offsets() { + for (text, (a, b), find, change, changed, (after_a, after_b), count) in [ + ("aX", (1, 1), "a", "é", "éX", (0, 0), 1), // A caret stays collapsed and floors. + ("aX", (2, 2), "a", "é", "éX", (2, 2), 1), // A valid caret stays at its byte offset. + ("abc", (1, 2), "abc", "🙂", "🙂", (0, 4), 1), // Both endpoints need repair. + ("abc", (2, 1), "abc", "🙂", "🙂", (4, 0), 1), + ("é🙂", (2, 6), "🙂", "X", "éX", (2, 3), 1), // Existing shortening behavior. + ("é🙂", (6, 2), "🙂", "X", "éX", (3, 2), 1), + ("red", (0, 3), "red", "blue", "blue", (0, 3), 1), // Do not extend a valid upper bound. + ("aX", (1, 2), "a", "zz", "zzX", (1, 2), 1), // Do not track the original X. + ("aaX", (1, 2), "a", "é", "ééX", (0, 2), 2), // Per-edit repair would wrongly yield 0..4. + ("aaX", (2, 3), "a", "é", "ééX", (2, 4), 2), + ("abc", (1, 3), "abc", "", "", (0, 0), 1), + ("abc", (2, 3), "abc", "é", "é", (2, 2), 1), // EOF clamp may collapse a nonempty range. + ] { + let (mut s, sid) = scope_session(text); + scope_select(&mut s, sid, a, b); + let mut expected = s.doc().unwrap().selection.clone(); + let t = expected.text.as_mut().unwrap(); + (t.anchor, t.focus) = (after_a, after_b); + assert_eq!(s.execute("find.change", &json!({"find": find, "change": change, "scope": "document"})).unwrap(), json!({"count": count})); + let st = s.doc().unwrap(); + assert_eq!(st.doc.story(sid).unwrap().text, changed, "{text} {a}->{b}"); + assert_eq!(st.selection, expected, "{text} {a}->{b}"); + assert!(changed.is_char_boundary(after_a) && changed.is_char_boundary(after_b)); + if after_a == after_b { + assert_eq!( + s.execute("find.find", &json!({"find": "(?:)", "grep": true, "scope": "selection"})).unwrap(), + json!([{"story": sid.0, "start": after_a, "end": after_a, "text": ""}]) + ); + } + } + } + + #[test] + fn find_scope_change_other_story_and_no_hits_preserve_selection() { + for explicit in [false, true] { + let (mut s, sid) = scope_session("éX"); + let frame = s.doc().unwrap().selection.text.unwrap().frame.unwrap(); + let other = s.execute("frame.create", &json!({"rect": [120, 36, 200, 100], "content": "text", "text": "a"})).unwrap(); + let other_sid = designcraft_doc::StoryId(other["story"].as_u64().unwrap()); + scope_select(&mut s, sid, 3, 2); + let before = s.doc().unwrap().selection.clone(); + let mut p = json!({"find": "a", "change": "🙂", "scope": "document"}); + if explicit { + p["story"] = json!(other_sid.0); + } + assert_eq!(s.execute("find.change", &p).unwrap(), json!({"count": 1})); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "éX"); + assert_eq!(s.doc().unwrap().doc.story(other_sid).unwrap().text, "🙂"); + assert_eq!(s.doc().unwrap().selection, before); + assert_eq!(s.execute("find.change", &p).unwrap(), json!({"count": 0})); + assert_eq!(s.doc().unwrap().selection, before); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "éX"); + assert_eq!(s.doc().unwrap().doc.story(other_sid).unwrap().text, "🙂"); + + s.execute("selection.set", &json!({"ids": [frame.0]})).unwrap(); + let before = s.doc().unwrap().selection.clone(); + assert!(before.text.is_none()); + p["find"] = json!("🙂"); + p["change"] = json!("é"); + assert_eq!(s.execute("find.change", &p).unwrap(), json!({"count": 1})); + assert_eq!(s.doc().unwrap().selection, before); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "éX"); + assert_eq!(s.doc().unwrap().doc.story(other_sid).unwrap().text, "é"); + } + } + + #[test] + fn find_scope_mixed_grep_matches_count_next_and_change() { + for (text, pattern, expected, changed) in [ + ("a", "a*", vec![(0, 1)], "X"), + ("aa", "a|(?:)", vec![(0, 1), (1, 2)], "XX"), + ("ab", "a*", vec![(0, 1), (2, 2)], "XbX"), + ("éa", "é|(?:)", vec![(0, 2), (3, 3)], "XaX"), + ] { + let (mut s, sid) = scope_session(text); + scope_select(&mut s, sid, 0, text.len()); + let p = json!({"find": pattern, "grep": true, "scope": "selection", "change": "X"}); + let counted = s.execute("find.find", &p).unwrap(); + let ranges: Vec<_> = + counted.as_array().unwrap().iter().map(|m| (m["start"].as_u64().unwrap() as usize, m["end"].as_u64().unwrap() as usize)).collect(); + assert_eq!(ranges, expected, "{pattern} on {text}"); + for (a, b) in &expected { + scope_hit(&mut s, &p, sid, *a, *b); + } + for _ in 0..2 { + assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null, "{pattern} on {text}"); + } + assert_eq!(s.execute("find.find", &p).unwrap(), counted); + assert_eq!(s.execute("find.change", &p).unwrap()["count"], expected.len()); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, changed); + } + } + + #[test] + fn find_scope_capture_replacement_and_first_keep_original_domain() { + let (mut s, sid) = scope_session("a1 a2 | a3"); + scope_select(&mut s, sid, 0, 5); + let p = json!({"find": r"a(\d)", "grep": true, "scope": "selection", "change": "b$1", "first": true}); + scope_hit(&mut s, &p, sid, 0, 2); + scope_hit(&mut s, &p, sid, 3, 5); + assert_eq!(s.execute("find.change", &p).unwrap()["count"], 1); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "b1 a2 | a3"); + } + + #[test] + fn find_scope_selection_retains_nonempty_hits_count_change_and_native_text() { + for (a, b) in [(0, 7), (7, 0)] { + let (mut s, sid) = scope_session("red red | red"); + scope_select(&mut s, sid, a, b); + let p = json!({"find": "red", "change": "blue", "scope": "selection", "attrs": {"fontStyle": "Italic"}}); + let before = s.doc().unwrap().doc.clone(); + let selection = s.doc().unwrap().selection.clone(); + let revision = s.doc().unwrap().revision; + let history = s.doc().unwrap().history.undo.len(); + let strong = std::sync::Arc::strong_count(&before); + assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 2); + assert_eq!(s.doc().unwrap().selection, selection); + assert_eq!(s.doc().unwrap().revision, revision); + assert_eq!(std::sync::Arc::strong_count(&before), strong, "search stores weak document identity"); + scope_hit(&mut s, &p, sid, 0, 3); + scope_hit(&mut s, &p, sid, 4, 7); + assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null); + assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null); + assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 2); + assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc)); + assert_eq!(s.doc().unwrap().history.undo.len(), history); + assert_eq!(s.execute("find.change", &p).unwrap()["count"], 2); + let changed = s.doc().unwrap().doc.story(sid).unwrap().clone(); + assert_eq!(changed.text, "blue blue | red"); + assert_eq!(changed.char_format_at(0).over.font_style.as_deref(), Some("Italic")); + assert_eq!(s.doc().unwrap().history.undo.len(), history + 1); + assert!(s.find_context.is_none()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "red red | red"); + assert!(s.find_context.is_none()); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap(), &changed); + let native = s.execute("file.serialize", &json!({})).unwrap(); + s.execute("file.openBytes", &json!({"name": "scope.designcraft", "base64": native["base64"]})).unwrap(); + assert_eq!(serde_json::to_value(s.doc().unwrap().doc.story(sid).unwrap()).unwrap(), serde_json::to_value(&changed).unwrap()); + assert!(s.find_context.is_none()); + } + } + + #[test] + fn find_scope_matcher_restart_replacement_continuation_and_external_reselection() { + let (mut s, sid) = scope_session("red blue red | blue red"); + scope_select(&mut s, sid, 0, 12); + let mut p = json!({"find": "red", "scope": "selection", "change": "one"}); + scope_hit(&mut s, &p, sid, 0, 3); + p["change"] = json!("two"); + p["attrs"] = json!({"fontStyle": "Italic"}); + scope_hit(&mut s, &p, sid, 9, 12); + p["find"] = json!("blue"); + scope_hit(&mut s, &p, sid, 4, 8); + p["find"] = json!("red"); + scope_hit(&mut s, &p, sid, 0, 3); + p["caseSensitive"] = json!(true); + scope_hit(&mut s, &p, sid, 0, 3); + p["grep"] = json!(true); + scope_hit(&mut s, &p, sid, 0, 3); // GREP alone changes matcher identity. + p["find"] = json!("r.d"); + scope_hit(&mut s, &p, sid, 0, 3); + p["wholeWord"] = json!(true); + scope_hit(&mut s, &p, sid, 0, 3); + let hit = s.doc().unwrap().selection.text.unwrap(); + scope_select(&mut s, sid, hit.anchor, hit.focus); + assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 1, "same-range external reselection starts a new domain"); + scope_hit(&mut s, &p, sid, 0, 3); + assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null); + scope_select(&mut s, sid, 20, 23); + p["find"] = json!("red"); + scope_hit(&mut s, &p, sid, 20, 23); + } + + #[test] + fn find_scope_word_reselection_at_same_hit_recaptures_bounds() { + let (mut s, sid) = scope_session("red red | red"); + scope_select(&mut s, sid, 0, 7); + let p = json!({"find": "red", "change": "blue", "scope": "selection"}); + assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 2); + scope_hit(&mut s, &p, sid, 0, 3); + let selected = s.doc().unwrap().selection.clone(); + let frame = selected.text.unwrap().frame.unwrap(); + let point = { + let st = s.doc().unwrap(); + let cs = s.cache.get(&st.doc, sid, None); + let (fi, x, baseline, ascent, _) = designcraft_compose::caret(&cs, 1).unwrap(); + let inner = designcraft_geom::Point::new(x, baseline - ascent * 0.5); + assert_eq!(cs.frames[fi].frame, frame); + assert_eq!(designcraft_compose::hit(&cs, fi, inner), Some(1), "real point lies inside the current hit"); + let loc = st.doc.find(frame).unwrap(); + st.doc.parent_xf(&loc) * st.doc.text_xf(st.doc.item(frame).unwrap()) * inner + }; + let before = s.doc().unwrap().doc.clone(); + let revision = s.doc().unwrap().revision; + let history = s.doc().unwrap().history.undo.len(); + s.execute("text.selectWord", &json!({"frame": frame.0, "point": [point.x, point.y]})).unwrap(); + assert!(!s.text_drag, "explicit word reselection does not start a text drag"); + assert_eq!(s.doc().unwrap().selection, selected, "same full selection, including frame"); + assert_eq!(s.doc().unwrap().revision, revision + 1); + assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc)); + assert_eq!(s.doc().unwrap().history.undo.len(), history); + assert!(s.find_context.is_none()); + assert_eq!(s.execute("find.find", &p).unwrap(), json!([{"story": sid.0, "start": 0, "end": 3, "text": "red"}])); + assert_eq!(s.execute("find.change", &p).unwrap(), json!({"count": 1})); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "blue red | red"); + assert_eq!(s.doc().unwrap().history.undo.len(), history + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "red red | red"); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "blue red | red"); + } + + #[test] + fn find_scope_story_explicit_story_document_order_and_scope_changes() { + let (mut s, a) = scope_session("black red"); + let other = s.execute("frame.create", &json!({"rect": [150, 36, 100, 100], "content": "text", "text": "red"})).unwrap(); + let b = designcraft_doc::StoryId(other["story"].as_u64().unwrap()); + scope_select(&mut s, a, 0, 9); + let story = json!({"find": "red", "scope": "story"}); + assert_eq!(s.execute("find.next", &story).unwrap(), serde_json::Value::Null, "Story does not enter B or wrap to its prefix"); + scope_select(&mut s, b, 0, 0); + let explicit = json!({"find": "black", "story": a.0, "scope": "selection"}); + scope_hit(&mut s, &explicit, a, 0, 5); + let selection = s.doc().unwrap().selection.clone(); + assert!(s.execute("find.next", &json!({"find": "red", "story": u64::MAX})).is_err()); + assert_eq!(s.doc().unwrap().selection, selection); + scope_select(&mut s, a, 0, 9); + scope_hit(&mut s, &json!({"find": "red"}), b, 0, 3); + let counted = s.execute("find.find", &json!({"find": "red"})).unwrap(); + assert_eq!(counted[0]["story"], a.0, "Count retains canonical story order"); + assert_eq!(counted[1]["story"], b.0); + scope_select(&mut s, a, 0, 9); + scope_hit(&mut s, &json!({"find": "red", "scope": "selection"}), a, 6, 9); + assert_eq!(s.execute("find.next", &story).unwrap(), serde_json::Value::Null, "scope change uses the current caret"); + s.execute("selection.set", &json!({"ids": []})).unwrap(); + for command in ["find.find", "find.next", "find.change"] { + assert!(s.execute(command, &json!({"find": "red", "change": "blue", "scope": "selection"})).is_err()); + } + } + + #[test] + fn find_scope_substring_context_utf8_zero_width_and_invalid_ranges() { + let (mut s, sid) = scope_session("scat é🙂 red"); + scope_select(&mut s, sid, 1, 4); + let p = json!({"find": "cat", "wholeWord": true, "scope": "selection"}); + scope_hit(&mut s, &p, sid, 1, 4); + assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 1); + scope_select(&mut s, sid, 1, 4); + let p = json!({"find": "^c|^a", "grep": true, "scope": "selection"}); + scope_hit(&mut s, &p, sid, 1, 2); + assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null, "cursor does not redefine ^"); + scope_select(&mut s, sid, 5, 11); + let empty = json!({"find": "(?:)", "grep": true, "scope": "selection"}); + for pos in [5, 7, 11] { + scope_hit(&mut s, &empty, sid, pos, pos); + } + for _ in 0..2 { + assert_eq!(s.execute("find.next", &empty).unwrap(), serde_json::Value::Null); + } + assert_eq!(s.execute("find.find", &empty).unwrap().as_array().unwrap().len(), 3); + scope_select(&mut s, sid, 7, 7); + scope_hit(&mut s, &empty, sid, 7, 7); + assert_eq!(s.execute("find.next", &empty).unwrap(), serde_json::Value::Null); + scope_select(&mut s, sid, 12, 15); + let nonempty = json!({"find": "red", "change": "blue", "scope": "selection"}); + scope_hit(&mut s, &nonempty, sid, 12, 15); + assert_eq!(s.execute("find.change", &nonempty).unwrap()["count"], 1); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "scat é🙂 blue"); + for range in [6..7, 0..usize::MAX] { + let st = s.doc_mut().unwrap(); + st.selection = designcraft_doc::Selection::text(designcraft_doc::TextSel { + story: sid, + anchor: range.start, + focus: range.end, + frame: None, + cell: None, + }); + st.revision += 1; + for command in ["find.find", "find.next", "find.change"] { + let before = s.doc().unwrap().doc.clone(); + let err = s.execute(command, &json!({"find": "x", "change": "y", "scope": "selection"})).unwrap_err(); + assert!(matches!(err, crate::EngineError::BadParams { .. }), "{err}"); + assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc)); + } + } + } + + #[test] + fn find_scope_invalid_query_keeps_continuation_and_cross_boundary_nonmatches() { + let (mut s, sid) = scope_session("red red | red"); + scope_select(&mut s, sid, 0, 7); + let p = json!({"find": "red", "scope": "selection"}); + scope_hit(&mut s, &p, sid, 0, 3); + let selected = s.doc().unwrap().selection.clone(); + assert!(s.execute("find.next", &json!({"find": "[", "grep": true, "scope": "selection"})).is_err()); + assert_eq!(s.doc().unwrap().selection, selected); + scope_hit(&mut s, &p, sid, 4, 7); + assert_eq!(s.execute("find.find", &json!({"find": "red |", "scope": "selection"})).unwrap(), json!([])); + } + + #[test] + fn find_scope_document_switch_edit_and_reset() { + let (mut s, sid) = scope_session("red red | red"); + scope_select(&mut s, sid, 0, 7); + let p = json!({"find": "red", "scope": "selection"}); + scope_hit(&mut s, &p, sid, 0, 3); + s.execute("file.new", &json!({})).unwrap(); + s.set_active(0); + assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 1); + scope_select(&mut s, sid, 0, 7); + scope_hit(&mut s, &p, sid, 0, 3); + s.execute("story.setText", &json!({"story": sid.0, "text": "red"})).unwrap(); + assert!(s.find_context.is_none()); + assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 1); + s.reset_find_scope(); + assert!(s.find_context.is_none()); + } + + #[test] + fn find_scope_real_preview_restores_before_resolving_bounds() { + let (mut s, sid) = scope_session(""); + s.execute( + "data.source.select", + &json!({ + "bytes": crate::cmd::base64_encode(b"x\nred red | red\n"), "name": "scope.csv" + }), + ) + .unwrap(); + s.execute("data.placeholder.add", &json!({"field": "x", "story": sid.0, "at": 0})).unwrap(); + let template = s.doc().unwrap().doc.clone(); + assert_eq!(template.story(sid).unwrap().text, "<>"); + s.execute("data.preview", &json!({"record": 1})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "red red | red"); + // Install a viewport selection in the real preview. text.select is journaled + // and would itself restore the template before selecting. + let st = s.doc_mut().unwrap(); + st.selection = designcraft_doc::Selection::text(designcraft_doc::TextSel { story: sid, anchor: 0, focus: 7, frame: None, cell: None }); + st.revision += 1; + let p = json!({"find": "red", "change": "blue", "scope": "selection"}); + assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 2); + let history = s.doc().unwrap().history.undo.len(); + let err = s.execute("find.change", &p).unwrap_err(); + assert!(matches!(err, crate::EngineError::BadParams { .. }), "{err}"); + assert!(std::sync::Arc::ptr_eq(&template, &s.doc().unwrap().doc)); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "<>"); + assert_eq!(s.doc().unwrap().history.undo.len(), history); + assert!(s.doc().unwrap().preview_stash.is_none()); + assert!(s.find_context.is_none()); + // Valid fresh bounds are allowed after restoration; no cached preview range + // survives. A document query can see the preview, but Next sees the template. + s.execute("data.preview", &json!({"record": 1})).unwrap(); + let document = json!({"find": "red"}); + assert_eq!(s.execute("find.find", &document).unwrap().as_array().unwrap().len(), 3); + assert_eq!(s.execute("find.next", &document).unwrap(), serde_json::Value::Null); + assert!(std::sync::Arc::ptr_eq(&template, &s.doc().unwrap().doc)); + assert_eq!(s.execute("find.find", &document).unwrap(), json!([])); + assert_eq!(s.doc().unwrap().history.undo.len(), history); + } + + fn child_scope_session(note: bool, child: &str) -> (Session, designcraft_doc::StoryId, designcraft_doc::TextSel) { + let (mut s, sid) = scope_session("red"); + scope_select(&mut s, sid, 3, 3); + if note { + s.execute("footnote.insert", &json!({"text": child})).unwrap(); + } else { + s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap(); + s.execute("text.insert", &json!({"text": child, "raw": true})).unwrap(); + } + let selected = s.doc().unwrap().selection.text.unwrap(); + (s, sid, selected) + } + + #[test] + fn find_scope_children_error_atomically_and_parent_requests_stay_top_level() { + for note in [false, true] { + for child in ["red", "red x", "é red red red red red"] { + let (mut s, sid, mut selected) = child_scope_session(note, child); + selected.anchor = 0; + selected.focus = child.len(); + s.doc_mut().unwrap().selection = designcraft_doc::Selection::text(selected); + let before = s.doc().unwrap().doc.clone(); + let selection = s.doc().unwrap().selection.clone(); + let history = s.doc().unwrap().history.undo.len(); + let revision = s.doc().unwrap().revision; + let journal = s.journal.clone(); + for scope in ["story", "selection"] { + for command in ["find.find", "find.next", "find.change"] { + let err = s.execute(command, &json!({"find": "red", "change": "blue", "scope": scope})).unwrap_err(); + assert!(matches!(err, crate::EngineError::BadParams { .. }), "{err}"); + assert!(err.to_string().contains("not supported")); + assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc)); + assert_eq!(s.doc().unwrap().selection, selection); + assert_eq!(s.doc().unwrap().history.undo.len(), history); + assert_eq!(s.doc().unwrap().revision, revision); + assert_eq!(s.journal, journal); + } + } + for explicit in [false, true] { + let mut p = json!({"find": "red", "change": "blue"}); + if explicit { + p["story"] = json!(sid.0); + p["scope"] = json!("selection"); + } + s.doc_mut().unwrap().selection = selection.clone(); + assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 1); + scope_hit(&mut s, &p, sid, 0, 3); + s.doc_mut().unwrap().selection = selection.clone(); + assert_eq!(s.execute("find.change", &p).unwrap()["count"], 1); + assert_eq!(s.doc().unwrap().doc.text_story(sid, selected.cell).unwrap().text, child); + assert_eq!(s.doc().unwrap().selection, selection, "parent replacement does not clamp child offsets"); + p["find"] = json!("blue"); + p["change"] = json!("red"); + s.execute("find.change", &p).unwrap(); + } + } + } + let (mut s, _, _) = child_scope_session(false, "red"); + s.execute("table.selectTable", &json!({})).unwrap(); + for scope in ["story", "selection"] { + for command in ["find.find", "find.next", "find.change"] { + assert!( + s.execute(command, &json!({"find": "red", "change": "blue", "scope": scope})).unwrap_err().to_string().contains("not supported") + ); + } + } + } + + #[test] + fn find_scope_threaded_overset_story_uses_logical_offsets() { + let text = format!("{}red", "word ".repeat(100)); + let (mut s, sid) = scope_session(&text); + let a = s.doc().unwrap().doc.story(sid).unwrap().frames[0]; + let next = s.execute("frame.create", &json!({"rect": [150, 36, 20, 1], "content": "text"})).unwrap(); + let b = designcraft_doc::ItemId(next["id"].as_u64().unwrap()); + s.edit(|d, _| Ok(d.thread(a, b)?)).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().frames.len(), 2); + scope_select(&mut s, sid, 0, 0); + scope_hit(&mut s, &json!({"find": "red", "scope": "story"}), sid, 500, 503); + assert_eq!(s.execute("find.next", &json!({"find": "red", "scope": "story"})).unwrap(), serde_json::Value::Null); + } + + #[test] + fn find_scope_zero_width_story_explicit_and_document_endpoints_terminate() { + for scope in ["story", "explicit", "document"] { + let (mut s, sid) = scope_session("é🙂"); + scope_select(&mut s, sid, 0, 0); + let mut p = json!({"find": "(?:)", "grep": true, "scope": scope}); + if scope == "explicit" { + p["story"] = json!(sid.0); + } + for pos in [0, 2, 6] { + scope_hit(&mut s, &p, sid, pos, pos); + } + assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null); + assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null); + } + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn find_scope_revert_releases_context_with_same_document_uid() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("scope.designcraft"); + let (mut s, sid) = scope_session("red red | red"); + s.execute("file.save", &json!({"path": path.to_string_lossy()})).unwrap(); + scope_select(&mut s, sid, 0, 7); + let p = json!({"find": "red", "scope": "selection"}); + scope_hit(&mut s, &p, sid, 0, 3); + let uid = s.doc().unwrap().uid; + s.execute("story.setText", &json!({"story": sid.0, "text": "red red"})).unwrap(); + scope_select(&mut s, sid, 0, 7); + scope_hit(&mut s, &p, sid, 0, 3); + s.execute("file.revert", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().uid, uid); + assert!(s.find_context.is_none()); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "red red | red"); + assert!(s.execute("find.find", &p).is_err(), "revert's empty selection cannot inherit a previous range"); + } } /// Objects matching Find/Change › Object criteria, in document order (nested ones included). diff --git a/crates/engine/src/cmd/guides.rs b/crates/engine/src/cmd/guides.rs index 1111cb3b..f4fb8e3d 100644 --- a/crates/engine/src/cmd/guides.rs +++ b/crates/engine/src/cmd/guides.rs @@ -21,7 +21,7 @@ pub fn specs() -> Vec { "New Guide", [], None, - "{orientation: horizontal|vertical, position (spread coordinate), spread?, page? (index in the spread; default: the page under `at` or the position), at? (the other coordinate), spreadGuide?: bool} → {page, index}", + "{orientation: horizontal|vertical, position (spread coordinate), spread?, page? (index in the spread; default: the page under `at` or the position), at? (the other coordinate), spreadGuide?: bool, liquid?: bool (a liquid guide, as the Page tool draws from the rulers)} → {page, index}", has_doc, add ), @@ -98,6 +98,7 @@ fn add(s: &mut Session, p: &Value) -> Result { let pos = finite_number(p, "position", "guide.add")?.ok_or_else(|| bad("guide.add", "missing position"))?; let r = super::spread_param(p, "guide.add")?; let spread_guide = p.get("spreadGuide").and_then(Value::as_bool).unwrap_or(false); + let liquid = p.get("liquid").and_then(Value::as_bool).unwrap_or(false); let d = &s.doc()?.doc; let pi = match optional_usize(p, "page", "guide.add")? { Some(pi) => pi, @@ -114,7 +115,14 @@ fn add(s: &mut Session, p: &Value) -> Result { s.edit(|d, _| { let sp = d.spread_mut(r).ok_or_else(|| bad("guide.add", "no such spread"))?; let pg = sp.pages.get_mut(pi).ok_or_else(|| bad("guide.add", "no such page"))?; - pg.guides.push(Guide { orientation: o, position: pos, spread: spread_guide, locked: false, layer: Some(layer), liquid: false }); + pg.guides.push(Guide { + orientation: o, + position: pos, + spread: spread_guide, + locked: false, + layer: Some(layer), + liquid: liquid && !spread_guide, + }); Ok(json!({"page": pi, "index": pg.guides.len() - 1})) }) } diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index bd532079..6d6e06ee 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -135,21 +135,63 @@ pub fn specs() -> Vec { ok() }) }), - cmd!("layout.pages.duplicateSpread", "Duplicate Spread", ["Layout", "Pages"], None, "{spread}", has_doc, |s, p| { - let si = p.get("spread").and_then(Value::as_u64).unwrap_or(0) as usize; - s.edit(|d, _| { - let sp = d.spreads.get(si).cloned().ok_or_else(|| bad("layout.pages.duplicateSpread", "no such spread"))?; - let after = d.first_page_of_spread(si) + sp.pages.len() - 1; - let parent = sp.pages.first().and_then(|p| p.parent); - d.insert_pages(Some(after), sp.pages.len(), parent)?; - // Copy items onto the new spread(s) at the same positions. - let src = d.clone(); - let ids: Vec<_> = sp.items.iter().map(|i| i.id).collect(); - let (nsi, _) = d.page_loc(after + 1).ok_or_else(|| bad("layout.pages.duplicateSpread", "insert failed"))?; - super::object::duplicate_from(d, &src, &ids, designcraft_doc::SpreadRef::Doc(nsi), designcraft_geom::Vec2::ZERO)?; - ok() - }) - }), + cmd!( + "layout.pages.duplicateSpread", + "Duplicate Spread", + ["Layout", "Pages"], + None, + "{spread, atEnd?: bool (Duplicate Spread at End of Document; the Pages panel's New Page button takes a dropped page there), to?: page index the copied pages move to, in the same undo step (the Pages panel's Alt-drag)} → {page: the copy's first page (0-based)} — the spread's pages (size, margins, columns, guides, liquid rule) and items", + has_doc, + |s, p| { + let si = p.get("spread").and_then(Value::as_u64).unwrap_or(0) as usize; + let at_end = p.get("atEnd").and_then(Value::as_bool).unwrap_or(false); + let to = p.get("to").and_then(Value::as_u64).map(|t| t as usize); + s.edit(|d, _| { + let sp = d.spreads.get(si).cloned().ok_or_else(|| bad("layout.pages.duplicateSpread", "no such spread"))?; + // The copies land at `to`..`to + n` of the document with them in it. + if to.is_some_and(|t| t > d.page_count()) { + return Err(bad("layout.pages.duplicateSpread", "no such page")); + } + let after = if at_end { d.page_count() - 1 } else { d.first_page_of_spread(si) + sp.pages.len() - 1 }; + let parent = sp.pages.first().and_then(|p| p.parent); + d.insert_pages(Some(after), sp.pages.len(), parent)?; + // The copies are the pages they copy: size, margins, columns, guides, liquid rule. + for (k, src) in sp.pages.iter().enumerate() { + let Some((nsi, npi)) = d.page_loc(after + 1 + k) else { continue }; + let nsp = std::sync::Arc::make_mut(&mut d.spreads[nsi]); + let pg = &mut nsp.pages[npi]; + let dx = pg.x - src.x; + (pg.width, pg.height, pg.margins, pg.columns, pg.liquid) = + (src.width, src.height, src.margins, src.columns.clone(), src.liquid); + pg.guides = src.guides.clone(); + for g in pg.guides.iter_mut().filter(|g| g.orientation == designcraft_doc::Orientation::Vertical) { + g.position += dx; + } + nsp.relayout(); + } + // Copy items onto the new spread(s) at the same positions. + let src = d.clone(); + let ids: Vec<_> = sp.items.iter().map(|i| i.id).collect(); + let (nsi, _) = d.page_loc(after + 1).ok_or_else(|| bad("layout.pages.duplicateSpread", "insert failed"))?; + super::object::duplicate_from(d, &src, &ids, designcraft_doc::SpreadRef::Doc(nsi), designcraft_geom::Vec2::ZERO)?; + let first = after + 1; + let Some(to) = to else { return Ok(json!({"page": first})) }; + // Moving a page shifts the ones between; order the moves so each copy keeps + // its place among the others. + let n = sp.pages.len(); + if first < to { + for k in (0..n).rev() { + d.move_page(first + k, to + k)?; + } + } else { + for k in 0..n { + d.move_page(first + k, to + k)?; + } + } + Ok(json!({"page": to})) + }) + } + ), cmd!( "layout.pages.applyParent", "Apply Parent to Pages…", @@ -376,10 +418,48 @@ pub fn specs() -> Vec { "Page Size", [], None, - "{pages: [1-based page numbers], width?, height?, preset?: \"A4\"|\"Letter\"|…} — pages of their own size (Page tool); objects on the pages keep their place on them", + "{pages: [1-based page numbers], width?, height?, preset?: \"A4\"|\"Letter\"|…, anchor?: 0–8 (reference point that stays put, row-major from the top-left; default 0)} — pages of their own size (Page tool); objects keep their place relative to the anchor, or follow the page's liquid rule", has_doc, page_size ), + cmd!( + "layout.pageOptions", + "Page Options", + [], + None, + "{pages: [1-based], showParentOverlay?: bool} — Page tool options (Show Parent Page Overlay: the parent page's size drawn over the page)", + has_doc, + page_options + ), + cmd!( + noundo "layout.selectPage", + "Select Page", + [], + None, + "{page: 1-based | null, ids?: [item ids] (objects selected with it: the Page tool on an object-based page)} — the page the Page tool selects (its handles, the Control panel)", + has_doc, + |s, p| { + let n = s.doc()?.doc.page_count(); + let page = match p.get("page") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_u64().filter(|v| *v >= 1 && *v as usize <= n).ok_or_else(|| bad("layout.selectPage", format!("`page`: 1–{n}")))? as usize - 1), + }; + let ids: Vec = { + let d = &s.doc()?.doc; + p.get("ids") + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(Value::as_u64).map(designcraft_doc::ItemId).filter(|id| d.item(*id).is_some()).collect()) + .unwrap_or_default() + }; + let st = s.doc_mut()?; + if page.is_some() { + st.selection = designcraft_doc::Selection::items(ids); + } + st.selection.page = page; + st.revision += 1; + Ok(json!({"page": page.map(|p| p + 1)})) + } + ), cmd!( "layout.section", "Numbering & Section Options…", @@ -640,8 +720,32 @@ fn page_size(s: &mut crate::Session, p: &Value) -> Result { w = Some(pr.width); h = Some(pr.height); } + let anchor = match p.get("anchor") { + None | Some(Value::Null) => 0, + Some(v) => v.as_u64().filter(|a| *a <= 8).ok_or_else(|| bad("layout.pageSize", "`anchor`: 0–8"))? as usize, + }; s.edit(|d, _| { - resize_pages(d, &pages, w, h); + resize_pages_from(d, &pages, w, h, anchor); + Ok(json!({"pages": pages.len()})) + }) +} + +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(); + let n = s.doc()?.doc.page_count(); + if pages.is_empty() || pages.iter().any(|x| *x == 0 || *x > n) { + return Err(bad("layout.pageOptions", "give `pages` (1-based)")); + } + let overlay = p.get("showParentOverlay").and_then(Value::as_bool); + s.edit(|d, _| { + for page in &pages { + let Some((si, pi)) = d.page_loc(page - 1) else { continue }; + let pg = &mut std::sync::Arc::make_mut(&mut d.spreads[si]).pages[pi]; + if let Some(v) = overlay { + pg.show_parent_overlay = v; + } + } Ok(json!({"pages": pages.len()})) }) } @@ -649,14 +753,35 @@ fn page_size(s: &mut crate::Session, p: &Value) -> Result { /// Give `pages` (1-based) their own size. Objects stay on their page; pages with a liquid rule /// re-flow their objects by it. pub(crate) fn resize_pages(d: &mut designcraft_doc::Document, pages: &[usize], w: Option, h: Option) { + resize_pages_from(d, pages, w, h, 0); +} + +/// [`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) { + 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 { pg.width = w.max(1.0); } @@ -693,8 +818,22 @@ pub(crate) fn resize_pages(d: &mut designcraft_doc::Document, pages: &[usize], w } } } - // Liquid rules: objects were carried along with the page's left edge. - let moved = old_bounds + designcraft_geom::Vec2::new(new[pi] - old[pi], 0.0); + // 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); } } diff --git a/crates/engine/src/cmd/liquid.rs b/crates/engine/src/cmd/liquid.rs index 879fdb36..8c8d4be2 100644 --- a/crates/engine/src/cmd/liquid.rs +++ b/crates/engine/src/cmd/liquid.rs @@ -35,7 +35,7 @@ fn axis(lo: f64, hi: f64, o: (f64, f64), n: (f64, f64), pin_lo: bool, pin_hi: bo fn target(rule: LiquidRule, b: Rect, old: Rect, new: Rect, liquid: Option, vguides: &[f64], hguides: &[f64]) -> Rect { let (ox, oy, nx, ny) = ((old.x0, old.x1), (old.y0, old.y1), (new.x0, new.x1), (new.y0, new.y1)); match rule { - LiquidRule::Off => b, + LiquidRule::Off | LiquidRule::ControlledByParent => b, LiquidRule::ReCenter => b + (new.center() - old.center()), LiquidRule::Scale => { let k = (new.width() / old.width()).min(new.height() / old.height()); @@ -63,7 +63,7 @@ fn target(rule: LiquidRule, b: Rect, old: Rect, new: Rect, liquid: Option LiquidRule { + let Some(pg) = d.spread(sr).and_then(|sp| sp.pages.get(pi)) else { return LiquidRule::Off }; + if pg.liquid != LiquidRule::ControlledByParent { + return pg.liquid; + } + let SpreadRef::Doc(si) = sr else { return LiquidRule::Off }; + let abs = d.first_page_of_spread(si) + pi; + match d.parent_page_for(abs).and_then(|(ppi, ppg)| d.parents.get(ppi)?.pages.get(ppg).map(|p| p.liquid)) { + Some(LiquidRule::ControlledByParent) | None => LiquidRule::Off, + Some(r) => r, + } +} + fn rule_param(p: &Value, id: &str) -> Result { - serde_json::from_value(p.get("rule").cloned().unwrap_or(json!("off"))).map_err(|_| bad(id, "`rule`: off|scale|reCenter|guideBased|objectBased")) + serde_json::from_value(p.get("rule").cloned().unwrap_or(json!("off"))) + .map_err(|_| bad(id, "`rule`: off|scale|reCenter|guideBased|objectBased|controlledByParent")) } pub fn specs() -> Vec { @@ -119,7 +135,7 @@ pub fn specs() -> Vec { "Liquid Page Rule", ["Layout", "Liquid Layout"], None, - "{rule: off|scale|reCenter|guideBased|objectBased, pages?: [1-based] (default: all)}", + "{rule: off|scale|reCenter|guideBased|objectBased|controlledByParent (the parent page's rule), pages?: [1-based] (default: all)}", has_doc, |s, p| { let rule = rule_param(p, "liquid.pageRule")?; diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index affe0712..b3bbb180 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -15,7 +15,8 @@ mod editorial; mod endnotes; mod export; mod file; -mod find; +pub(crate) mod find; +pub use find::same_find_domain; mod fonts; mod guides; mod index; diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 5e9ed391..82c58c50 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -179,12 +179,35 @@ pub fn specs() -> Vec { ) } ), - cmd!("object.fill", "Fill", [], None, "{swatch (a swatch's name, e.g. [Paper]), tint?: 0..1, ids?}", has_selection, |s, p| { - let sw = str_param(p, "swatch").ok_or_else(|| bad("object.fill", "missing swatch"))?.to_string(); - known_swatch(&s.doc()?.doc, &sw, "object.fill", "swatch")?; - let tint = f64_or(p, "tint", 1.0) as f32; - set_flag(s, p, move |i| i.fill = Fill { swatch: sw.clone(), tint, ..i.fill.clone() }, false) - }), + cmd!( + "object.fill", + "Fill", + [], + None, + "{swatch? (a swatch's name, e.g. [Paper]), tint?: 0..1, ids?} — at least one; a tint alone keeps each object's own swatch (the Swatches panel's Tint field)", + has_selection, + |s, p| { + let sw = str_param(p, "swatch").map(str::to_string); + let tint = match p.get("tint") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_f64().filter(|t| (0.0..=1.0).contains(t)).ok_or_else(|| bad("object.fill", "`tint` is 0..1"))? as f32), + }; + match &sw { + Some(sw) => known_swatch(&s.doc()?.doc, sw, "object.fill", "swatch")?, + None if tint.is_none() => return Err(bad("object.fill", "missing swatch")), + None => {} + } + set_flag( + s, + p, + move |i| match &sw { + Some(sw) => i.fill = Fill { swatch: sw.clone(), tint: tint.unwrap_or(1.0), ..i.fill.clone() }, + None => i.fill.tint = tint.unwrap_or(i.fill.tint), + }, + false, + ) + } + ), cmd!( "object.swapFillStroke", "Swap Fill and Stroke", @@ -217,10 +240,15 @@ pub fn specs() -> Vec { } } let p2 = p.clone(); + // A tint alone (the Swatches panel's Tint field) recolours strokes, it doesn't add one. + let tint_only = p.as_object().is_some_and(|o| o.contains_key("tint") && o.keys().all(|k| k == "tint" || k == "ids")); set_flag( s, p, move |i| { + if tint_only && i.stroke.is_none() { + return; + } let mut st = if i.stroke.is_none() && p2.get("swatch").is_none() { Stroke::default() } else { i.stroke.clone() }; if let Some(v) = p2.get("swatch").and_then(Value::as_str) { st.swatch = v.into(); @@ -3327,3 +3355,36 @@ mod spread_target_tests { assert!(s.doc().unwrap().doc.spreads[0].items.is_empty()); } } + +#[cfg(test)] +mod fill_tint_tests { + use super::*; + + #[test] + fn a_tint_alone_keeps_each_objects_own_swatch() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = s.execute("frame.create", &json!({"rect": [36, 36, 100, 100]})).unwrap()["id"].as_u64().unwrap(); + let b = s.execute("frame.create", &json!({"rect": [136, 36, 200, 100]})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.fill", &json!({"ids": [a], "swatch": "C=100 M=0 Y=0 K=0"})).unwrap(); + s.execute("object.fill", &json!({"ids": [b], "swatch": "C=0 M=100 Y=0 K=0"})).unwrap(); + s.execute("object.fill", &json!({"ids": [a, b], "tint": 0.4})).unwrap(); + let fill_of = |s: &Session, id: u64| s.doc().unwrap().doc.item(ItemId(id)).unwrap().fill.clone(); + assert_eq!((fill_of(&s, a).swatch.as_str(), fill_of(&s, a).tint), ("C=100 M=0 Y=0 K=0", 0.4)); + assert_eq!((fill_of(&s, b).swatch.as_str(), fill_of(&s, b).tint), ("C=0 M=100 Y=0 K=0", 0.4)); + // A swatch without a tint is at 100 %, as before. + s.execute("object.fill", &json!({"ids": [a], "swatch": "[Black]"})).unwrap(); + assert_eq!(fill_of(&s, a).tint, 1.0); + // Neither, or a tint out of range: refused. + assert!(s.execute("object.fill", &json!({"ids": [a]})).is_err()); + assert!(s.execute("object.fill", &json!({"ids": [a], "tint": 2})).is_err()); + assert!(s.execute("object.fill", &json!({"ids": [a], "tint": "x"})).is_err()); + // A stroke tint alone: stroked objects take it, an object without a stroke keeps none. + s.execute("object.stroke", &json!({"ids": [a], "swatch": "C=100 M=0 Y=0 K=0", "weight": 2})).unwrap(); + s.execute("object.stroke", &json!({"ids": [b], "swatch": "[None]"})).unwrap(); + s.execute("object.stroke", &json!({"ids": [a, b], "tint": 0.5})).unwrap(); + let stroke_of = |s: &Session, id: u64| s.doc().unwrap().doc.item(ItemId(id)).unwrap().stroke.clone(); + assert_eq!((stroke_of(&s, a).swatch.as_str(), stroke_of(&s, a).tint, stroke_of(&s, a).weight), ("C=100 M=0 Y=0 K=0", 0.5, 2.0)); + assert!(stroke_of(&s, b).is_none()); + } +} diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index ad306be8..4b2962b4 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -7,6 +7,7 @@ //! whole table). use designcraft_compose as compose; +use designcraft_doc::story::floor_char_boundary; use designcraft_doc::{ AltFills, CellAddr, CellRange, CellStroke, Document, RowHeightMode, Selection, StoryId, StrokeType, Table, TableSel, TextSel, VerticalJustification, @@ -140,11 +141,9 @@ fn table_specs() -> Vec { |s, p| { let g = target(s, p, "table.textCell")?; edit_table(s, &g, "table.textCell", |t| { - for r in g.range.r0..=g.range.r1 { - for c in g.range.c0..=g.range.c1 { - if let Some(cell) = t.cell_mut(r, c) { - cell.graphic = None; - } + for (r, c) in t.selected_owners(g.range) { + if let Some(cell) = t.cell_mut(r, c) { + cell.graphic = None; } } ok() @@ -351,13 +350,17 @@ fn clamp_selection(d: &Document, sel: &mut Selection) { match story.and_then(|x| x.tables.get(&c.table)) { Some(tb) => { let (r, col) = tb.owner(c.row.min(tb.nrows() - 1), c.col.min(tb.ncols() - 1)); - let len = tb.cell(r, col).map_or(0, |x| x.text.len()); - sel.text = - Some(TextSel { anchor: t.anchor.min(len), focus: t.focus.min(len), cell: Some(CellAddr { table: c.table, row: r, col }), ..t }); + let text = tb.cell(r, col).map_or("", |x| x.text.text.as_str()); + sel.text = Some(TextSel { + anchor: floor_char_boundary(text, t.anchor), + focus: floor_char_boundary(text, t.focus), + cell: Some(CellAddr { table: c.table, row: r, col }), + ..t + }); } None => { - let len = story.map_or(0, |x| x.len()); - sel.text = Some(TextSel { anchor: t.anchor.min(len), focus: t.anchor.min(len), cell: None, ..t }); + let pos = story.map_or(0, |x| floor_char_boundary(&x.text, t.anchor)); + sel.text = Some(TextSel { anchor: pos, focus: pos, cell: None, ..t }); } } } @@ -648,7 +651,6 @@ fn set_cell(s: &mut Session, p: &Value) -> Result { let text = str_param(p, "text").map(|_| super::text_param(p, "text")); let stroke = p.get("stroke").cloned(); edit_table(s, &g, "table.setCell", |t| { - let owners = t.owners(); let nc = t.ncols(); let nr = t.nrows(); let rtl = t.options.direction == designcraft_doc::TextDirection::RightToLeft; @@ -656,50 +658,45 @@ fn set_cell(s: &mut Session, p: &Value) -> Result { // Local edits outrank previously imported edges, including explicit zero/None. let priority = if stroke.is_some() { next_cell_stroke_priority(t) } else { 0 }; let rg = g.range; - for r in rg.r0..=rg.r1 { - for c in rg.c0..=rg.c1 { - if owners[r * nc + c] != (r, c) { - continue; - } - let Some(cell) = t.cell_mut(r, c) else { continue }; - if let Some(f) = &fill { - cell.fill = f.clone(); - } - if let Some(ti) = tint { - cell.fill_tint = ti; - } - if let Some(i) = insets { - cell.insets = i; - } - if let Some(v) = vj { - cell.vj = v; - } - if let Some(tx) = &text { - let len = cell.text.len(); - cell.text.replace(0..len, tx); - } - if let Some(sv) = &stroke { - let edges = str_param(sv, "edges").unwrap_or("all"); - let at_bottom = r.saturating_add(cell.row_span.max(1) as usize); - let at_right = c.saturating_add(cell.col_span.max(1) as usize); - let perimeter = [r == 0, if rtl { at_right == nc } else { c == 0 }, at_bottom == nr, if rtl { c == 0 } else { at_right == nc }]; - for (i, on) in [ - ("top", r == rg.r0), - ("left", if rtl { at_right.saturating_sub(1) == rg.c1 } else { c == rg.c0 }), - ("bottom", at_bottom.saturating_sub(1) == rg.r1), - ("right", if rtl { c == rg.c0 } else { at_right.saturating_sub(1) == rg.c1 }), - ] - .iter() - .enumerate() - .map(|(i, (name, outer))| (i, edges == "all" || edges == *name || (edges == "outer" && *outer) || (edges == "inner" && !*outer))) - { - if on { - let base = if perimeter[i] && !cell.border_overrides[i] { &borders[i] } else { &cell.strokes[i] }; - cell.strokes[i] = parse_stroke(sv, base.clone()); - cell.stroke_defined[i] = true; - cell.stroke_priorities[i] = priority; - cell.border_overrides[i] = true; - } + for (r, c) in t.selected_owners(rg) { + let Some(cell) = t.cell_mut(r, c) else { continue }; + if let Some(f) = &fill { + cell.fill = f.clone(); + } + if let Some(ti) = tint { + cell.fill_tint = ti; + } + if let Some(i) = insets { + cell.insets = i; + } + if let Some(v) = vj { + cell.vj = v; + } + if let Some(tx) = &text { + let len = cell.text.len(); + cell.text.replace(0..len, tx); + } + if let Some(sv) = &stroke { + let edges = str_param(sv, "edges").unwrap_or("all"); + let at_bottom = r.saturating_add(cell.row_span.max(1) as usize); + let at_right = c.saturating_add(cell.col_span.max(1) as usize); + let perimeter = [r == 0, if rtl { at_right == nc } else { c == 0 }, at_bottom == nr, if rtl { c == 0 } else { at_right == nc }]; + for (i, on) in [ + ("top", r == rg.r0), + ("left", if rtl { at_right.saturating_sub(1) == rg.c1 } else { c == rg.c0 }), + ("bottom", at_bottom.saturating_sub(1) == rg.r1), + ("right", if rtl { c == rg.c0 } else { at_right.saturating_sub(1) == rg.c1 }), + ] + .iter() + .enumerate() + .map(|(i, (name, outer))| (i, edges == "all" || edges == *name || (edges == "outer" && *outer) || (edges == "inner" && !*outer))) + { + if on { + let base = if perimeter[i] && !cell.border_overrides[i] { &borders[i] } else { &cell.strokes[i] }; + cell.strokes[i] = parse_stroke(sv, base.clone()); + cell.stroke_defined[i] = true; + cell.stroke_priorities[i] = priority; + cell.border_overrides[i] = true; } } } @@ -707,6 +704,7 @@ fn set_cell(s: &mut Session, p: &Value) -> Result { // Keep both copies of unmerged shared boundaries consistent. Left/right name // physical sides, also in RTL tables. if let Some(sv) = &stroke { + let owners = t.owners(); let edges = str_param(sv, "edges").unwrap_or("all"); let selected = |side: &str| edges == "all" || edges == "outer" || edges == side; if selected("top") @@ -931,8 +929,9 @@ fn options(s: &mut Session, p: &Value) -> Result { if let Some(v) = p.get("altCols") { o.alt_cols = parse_alt(v); } - let h = p.get("headerRows").and_then(Value::as_u64).map(|v| v as usize); - let f = p.get("footerRows").and_then(Value::as_u64).map(|v| v as usize); + // At most 100 of each, as in `table.insert`: the rows the table lacks are added below. + let h = p.get("headerRows").and_then(Value::as_u64).map(|v| v.min(100) as usize); + let f = p.get("footerRows").and_then(Value::as_u64).map(|v| v.min(100) as usize); if h.is_some() || f.is_some() { let (h, f) = (h.unwrap_or(t.header_rows()), f.unwrap_or(t.footer_rows())); // Add rows when there aren't enough for the requested header/footer plus one body row. @@ -1095,7 +1094,10 @@ fn cell_style_create(s: &mut Session, p: &Value) -> Result { let cell = d .story(g.story) .and_then(|st| st.tables.get(&g.table)) - .and_then(|t| t.cell(g.range.r0, g.range.c0)) + .and_then(|t| { + let (r, c) = t.owner(g.range.r0, g.range.c0); + t.cell(r, c) + }) .ok_or_else(|| bad("style.cell.create", "no cell"))?; cs.fill = Some(cell.fill.clone()); cs.fill_tint = Some(cell.fill_tint); @@ -1122,17 +1124,11 @@ fn cell_style_apply(s: &mut Session, p: &Value) -> Result { let cs = designcraft_doc::resolve_cell_style(styles, &name); let g = target(s, p, "style.cell.apply")?; edit_table(s, &g, "style.cell.apply", |t| { - let owners = t.owners(); - let nc = t.ncols(); let mut n = 0; - for r in g.range.r0..=g.range.r1 { - for c in g.range.c0..=g.range.c1 { - if owners[r * nc + c] == (r, c) - && let Some(cell) = t.cell_mut(r, c) - { - cs.apply_to(cell); - n += 1; - } + for (r, c) in t.selected_owners(g.range) { + if let Some(cell) = t.cell_mut(r, c) { + cs.apply_to(cell); + n += 1; } } Ok(json!({"cells": n})) @@ -1353,7 +1349,7 @@ fn drop_cells(s: &mut Session, p: &Value) -> Result { && ts.story == sid && ts.table == tid && tid2 == tid - && ts.range.contains(fr, fc) + && s.doc()?.doc.story(sid).and_then(|st| st.tables.get(&tid)).is_some_and(|t| t.selected_owners(ts.range).binary_search(&(fr, fc)).is_ok()) { let (nr, nc) = { let t = s.doc()?.doc.story(sid).and_then(|st| st.tables.get(&tid)).ok_or_else(|| bad(ID, "no table"))?; @@ -1680,6 +1676,19 @@ mod sort_tests { s.execute("table.moveColumn", &json!({"from": 1, "to": 0})).unwrap(); assert_eq!((cell(&s, 2, 0), cell(&s, 2, 1)), ("x".to_string(), "a".to_string())); } + + #[test] + fn table_options_limits_header_and_footer_rows() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 700], "content": "text", "text": ""})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); + s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap(); + // The limit `table.insert` has: the table used to grow by as many rows as were asked for. + s.execute("table.options", &json!({"headerRows": 5000, "footerRows": 5000})).unwrap(); + let t = table(&s); + assert_eq!((t.header_rows(), t.footer_rows(), t.nrows()), (100, 100, 201)); + } } #[cfg(test)] diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index e928ce47..add94aca 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -365,7 +365,8 @@ fn select_at(s: &mut Session, p: &Value, unit: &str) -> Result { s.typing_format = None; let st = s.doc_mut()?; st.selection = Selection::text(TextSel { story: sid, anchor: a, focus: e, frame: Some(frame), cell }); - st.revision += 1; + // Explicit reselection invalidates the Find scope even when the range is unchanged. + st.revision = st.revision.wrapping_add(1); Ok(json!({"story": sid.0, "anchor": a, "focus": e})) } @@ -943,14 +944,9 @@ pub(crate) fn format_targets(s: &Session) -> Vec { && let Some(t) = st.doc.story(ts.story).and_then(|x| x.tables.get(&ts.table)) { let mut v = Vec::new(); - let owners = t.owners(); - for r in ts.range.r0..=ts.range.r1.min(t.nrows().saturating_sub(1)) { - for c in ts.range.c0..=ts.range.c1.min(t.ncols().saturating_sub(1)) { - if owners[r * t.ncols() + c] == (r, c) { - let len = t.cell(r, c).map_or(0, |x| x.text.len()); - v.push(Target { story: ts.story, cell: Some(CellAddr { table: ts.table, row: r, col: c }), range: 0..len }); - } - } + for (r, c) in t.selected_owners(ts.range) { + let len = t.cell(r, c).map_or(0, |x| x.text.len()); + v.push(Target { story: ts.story, cell: Some(CellAddr { table: ts.table, row: r, col: c }), range: 0..len }); } return v; } diff --git a/crates/engine/src/cmd/toc.rs b/crates/engine/src/cmd/toc.rs index d6ed53d6..30ef0e87 100644 --- a/crates/engine/src/cmd/toc.rs +++ b/crates/engine/src/cmd/toc.rs @@ -1,9 +1,9 @@ //! Layout → Table of Contents: list the paragraphs of chosen styles with their page names, as a -//! story with "TOC Title" / "TOC Level n" styles (right tab with dot leader). Update Table of -//! Contents regenerates the same story after edits. +//! story with "TOC Title" / "TOC Level n" styles (right tab with dot leader) or the entry styles +//! chosen per style. Update Table of Contents regenerates the same story after edits. use designcraft_compose::{ComposeOptions, caret, compose_story}; -use designcraft_doc::{Align, Document, ParaAttrs, ParaFormat, ParagraphStyle, SpreadRef, StoryId, TabAlign, TabStop, Toc, TocEntry}; +use designcraft_doc::{Align, Document, ParaAttrs, ParaFormat, ParagraphStyle, SpreadRef, StoryId, TabAlign, TabStop, Toc, TocEntry, TocPageNumber}; use designcraft_geom::Rect; use serde_json::{Value, json}; @@ -17,7 +17,7 @@ pub fn specs() -> Vec { "Table of Contents…", ["Layout"], None, - "{entries: [{style, level?: 1}] | styles: [names], title?: \"Contents\", pageNumbers?: true, page?: 1-based page for a new frame (its margins), rect?: [x0,y0,x1,y1] (spread coords)} → {story, entries}", + "{entries: [{style, level?: 1 (1-9), entryStyle?: paragraph style of its entries (default \"TOC Level n\"), pageNumber?: \"after\"|\"before\"|\"none\", separator?: text between entry and number (default a tab)}] | styles: [names] (levels 1, 2, …), title?: \"Contents\", titleStyle?: paragraph style of the title (default \"TOC Title\"), pageNumbers?: true, replace?: true (regenerate the existing table of contents in place; false makes a new frame), page?: 1-based page for a new frame (its margins), rect?: [x0,y0,x1,y1] (spread coords)} → {story, entries}", has_doc, generate ), @@ -44,20 +44,66 @@ fn has_toc(s: &Session) -> std::result::Result<(), String> { } } +const MAX_ENTRIES: usize = 64; +const MAX_NAME: usize = 256; +const MAX_TITLE: usize = 1024; +const MAX_SEPARATOR: usize = 64; + fn entries_param(p: &Value) -> Result> { - if let Some(v) = p.get("entries") { - return serde_json::from_value(v.clone()).map_err(|e| bad("toc.generate", e.to_string())); + let mut entries: Vec = if let Some(v) = p.get("entries") { + if v.as_array().is_some_and(|a| a.len() > MAX_ENTRIES) { + return Err(bad("toc.generate", format!("at most {MAX_ENTRIES} entries"))); + } + serde_json::from_value(v.clone()).map_err(|e| bad("toc.generate", e.to_string()))? + } else if let Some(a) = p.get("styles").and_then(Value::as_array) { + if a.len() > MAX_ENTRIES { + return Err(bad("toc.generate", format!("at most {MAX_ENTRIES} styles"))); + } + a.iter().enumerate().filter_map(|(i, v)| v.as_str().map(|s| TocEntry::new(s, (i + 1).min(9) as u8))).collect() + } else { + return Err(bad("toc.generate", "give `entries` or `styles`")); + }; + for e in &mut entries { + if !(1..=9).contains(&e.level) { + return Err(bad("toc.generate", format!("level {} of `{}` is not 1–9", e.level, e.style))); + } + if e.style.chars().count() > MAX_NAME || e.entry_style.as_ref().is_some_and(|n| n.chars().count() > MAX_NAME) { + return Err(bad("toc.generate", format!("style names are at most {MAX_NAME} characters"))); + } + if let Some(sep) = &mut e.separator { + if sep.chars().count() > MAX_SEPARATOR { + return Err(bad("toc.generate", format!("the separator is at most {MAX_SEPARATOR} characters"))); + } + *sep = one_line(sep); + } + // The defaults are stored as `None`, so a definition reads the same however it was made. + if e.entry_style.as_deref() == Some("") { + e.entry_style = None; + } + if e.separator.as_deref() == Some("\t") { + e.separator = None; + } } - if let Some(a) = p.get("styles").and_then(Value::as_array) { - return Ok(a.iter().enumerate().filter_map(|(i, v)| v.as_str().map(|s| TocEntry { style: s.into(), level: (i + 1).min(9) as u8 })).collect()); + Ok(entries) +} + +/// Paragraph breaks in a title or separator would shift the paragraph styles after them. +fn one_line(s: &str) -> String { + s.chars().map(|c| if matches!(c, '\n' | '\r' | '\u{2029}') { ' ' } else { c }).collect() +} + +fn para_style_exists(d: &Document, name: &str) -> Result<()> { + match d.styles.para(name) { + Some(_) => Ok(()), + None => Err(bad("toc.generate", format!("no paragraph style `{name}`"))), } - Err(bad("toc.generate", "give `entries` or `styles`")) } pub struct Found { pub level: u8, pub text: String, pub page: String, + entry: usize, key: (usize, i64, i64), } @@ -70,7 +116,7 @@ pub fn collect(d: &Document, entries: &[TocEntry], skip: Option) -> Vec } let cs = compose_story(d, *sid, &ComposeOptions::default()); for (pi, r) in st.para_ranges().into_iter().enumerate() { - let Some(e) = entries.iter().find(|e| e.style == st.paras[pi].style) else { continue }; + let Some((ei, e)) = entries.iter().enumerate().find(|(_, e)| e.style == st.paras[pi].style) else { continue }; let text: String = st.text[r.clone()] .chars() .filter(|c| !('\u{E000}'..='\u{E1FF}').contains(c)) @@ -95,6 +141,7 @@ pub fn collect(d: &Document, entries: &[TocEntry], skip: Option) -> Vec out.push(Found { level: e.level, text, + entry: ei, page: d.page_name(abs), key: (abs, ((b.y0 + baseline) * 10.0) as i64, ((b.x0 + x) * 10.0) as i64), }); @@ -146,22 +193,35 @@ fn fill(d: &mut Document, toc: &Toc, width: Option) -> Result { d.story(toc.story).and_then(|st| st.frames.first()).and_then(|f| d.item(*f)).map(|i| i.text_area().width()).unwrap_or(300.0) }); ensure_styles(d, levels, width); + for name in toc.entries.iter().filter_map(|e| e.entry_style.as_deref()).chain(toc.title_style.as_deref()) { + para_style_exists(d, name)?; + } let mut text = String::new(); let mut styles = Vec::new(); if !toc.title.is_empty() { text.push_str(&toc.title); - styles.push("TOC Title".to_string()); + styles.push(toc.title_style.clone().unwrap_or_else(|| "TOC Title".into())); } for f in &found { + let Some(e) = toc.entries.get(f.entry) else { continue }; if !text.is_empty() { text.push('\n'); } - text.push_str(&f.text); - if toc.page_numbers { - text.push('\t'); - text.push_str(&f.page); + let sep = e.separator.as_deref().unwrap_or("\t"); + match e.page_number { + TocPageNumber::After if toc.page_numbers => { + text.push_str(&f.text); + text.push_str(sep); + text.push_str(&f.page); + } + TocPageNumber::Before if toc.page_numbers => { + text.push_str(&f.page); + text.push_str(sep); + text.push_str(&f.text); + } + _ => text.push_str(&f.text), } - styles.push(format!("TOC Level {}", f.level.max(1))); + styles.push(e.entry_style.clone().unwrap_or_else(|| format!("TOC Level {}", f.level.max(1)))); } let st = d.story_mut(toc.story).ok_or(designcraft_doc::DocError::NoStory(toc.story))?; let len = st.len(); @@ -179,13 +239,29 @@ fn generate(s: &mut Session, p: &Value) -> Result { if entries.is_empty() { return Err(bad("toc.generate", "no entries")); } - let title = str_param(p, "title").unwrap_or("Contents").to_string(); + let title = str_param(p, "title").unwrap_or("Contents"); + if title.chars().count() > MAX_TITLE { + return Err(bad("toc.generate", format!("the title is at most {MAX_TITLE} characters"))); + } + let title = one_line(title); + let title_style = str_param(p, "titleStyle").filter(|n| !n.is_empty()).map(str::to_string); + if title_style.as_ref().is_some_and(|n| n.chars().count() > MAX_NAME) { + return Err(bad("toc.generate", format!("style names are at most {MAX_NAME} characters"))); + } let page_numbers = p.get("pageNumbers").and_then(Value::as_bool).unwrap_or(true); + let replace = p.get("replace").and_then(Value::as_bool).unwrap_or(true); let rect = super::rect_param(p, "rect"); - let page = p.get("page").and_then(Value::as_u64).unwrap_or(1).max(1) as usize - 1; + if rect.is_some_and(|r| ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) || r.width() <= 0.0 || r.height() <= 0.0) { + return Err(bad("toc.generate", "`rect` must be finite with a positive size")); + } + let page = p.get("page").and_then(Value::as_u64).map_or(Some(0), |n| usize::try_from(n.max(1) - 1).ok()); + let page = page.ok_or_else(|| bad("toc.generate", "no such page"))?; s.edit(|d, sel| { - // Reuse the existing TOC story when there is one; otherwise make a frame. - let existing = d.toc.as_ref().map(|t| t.story).filter(|sid| d.story(*sid).is_some()); + for e in &entries { + para_style_exists(d, &e.style)?; + } + // Replace: regenerate the existing TOC story in place; otherwise make a frame. + let existing = d.toc.as_ref().map(|t| t.story).filter(|sid| replace && d.story(*sid).is_some()); let (story, width) = match existing { Some(sid) => (sid, None), None => { @@ -198,7 +274,7 @@ fn generate(s: &mut Session, p: &Value) -> Result { (sid, w) } }; - let toc = Toc { story, title: title.clone(), entries: entries.clone(), page_numbers }; + let toc = Toc { story, title: title.clone(), entries: entries.clone(), page_numbers, title_style: title_style.clone() }; let n = fill(d, &toc, width)?; d.toc = Some(toc); Ok(json!({"story": story.0, "entries": n})) @@ -237,4 +313,106 @@ mod tests { assert!(text.contains("Thoughts on the Grid"), "{text}"); s.doc().unwrap().doc.check().unwrap(); } + + fn sample_with_toc_page() -> (Session, usize) { + let mut s = Session::new(); + s.execute("file.newSample", &json!({})).unwrap(); + let pages = s.doc().unwrap().doc.page_count(); + s.execute("layout.pages.insert", &json!({"count": 1})).unwrap(); + (s, pages + 1) + } + + fn toc_lines(s: &Session) -> Vec<(String, String)> { + let d = &s.doc().unwrap().doc; + let st = d.story(d.toc.as_ref().unwrap().story).unwrap(); + st.para_ranges().into_iter().zip(&st.paras).map(|(r, f)| (st.text[r].trim_end_matches('\n').to_string(), f.style.clone())).collect() + } + + #[test] + fn toc_entry_style_page_number_position_and_separator_survive_update() { + let (mut s, page) = sample_with_toc_page(); + let basic = designcraft_doc::story::BASIC_PARAGRAPH; + s.execute( + "toc.generate", + &json!({"page": page, "title": "In This Issue", "titleStyle": basic, "entries": [ + {"style": "Cover Title", "level": 1, "pageNumber": "before", "separator": " — "}, + {"style": "Headline", "level": 2, "entryStyle": basic, "pageNumber": "none"}, + ]}), + ) + .unwrap(); + let check = |s: &Session, headline: &str| { + let lines = toc_lines(s); + assert_eq!(lines[0], ("In This Issue".to_string(), basic.to_string())); + let cover = lines.iter().find(|(t, _)| t.contains("The Quiet")).unwrap(); + assert!(cover.0.starts_with("1 — The Quiet"), "{cover:?}"); + assert_eq!(cover.1, "TOC Level 1"); + let head = lines.iter().find(|(t, _)| t.contains("on the Grid")).unwrap(); + assert!(head.0.starts_with(headline) && !head.0.contains('\t'), "{head:?}"); + assert_eq!(head.1, basic); + }; + check(&s, "Notes on the Grid"); + let d = &s.doc().unwrap().doc; + let hid = d.stories.iter().find(|(_, st)| st.text.starts_with("Notes on the Grid")).map(|(id, _)| *id).unwrap(); + s.execute("story.replaceRange", &json!({"story": hid.0, "start": 0, "end": 5, "text": "Thoughts"})).unwrap(); + s.execute("toc.update", &json!({})).unwrap(); + check(&s, "Thoughts on the Grid"); + } + + #[test] + fn replace_regenerates_the_toc_in_place_and_off_makes_a_new_frame() { + let (mut s, page) = sample_with_toc_page(); + let stories = |s: &Session| s.doc().unwrap().doc.stories.len(); + let first = s.execute("toc.generate", &json!({"styles": ["Headline"], "page": page})).unwrap()["story"].clone(); + let n = stories(&s); + let undo = s.doc().unwrap().history.undo.len(); + let r = s.execute("toc.generate", &json!({"styles": ["Headline"], "title": "Inside", "page": page, "replace": true})).unwrap(); + assert_eq!(r["story"], first); + assert_eq!(stories(&s), n, "one table of contents"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + assert_eq!(toc_lines(&s)[0].0, "Inside"); + let r = s.execute("toc.generate", &json!({"styles": ["Headline"], "page": page, "replace": false})).unwrap(); + assert_ne!(r["story"], first); + assert_eq!(stories(&s), n + 1); + assert_eq!(s.doc().unwrap().doc.toc.as_ref().unwrap().story.0, r["story"].as_u64().unwrap()); + } + + #[test] + fn hostile_toc_params_are_errors_that_leave_the_document_alone() { + let (mut s, page) = sample_with_toc_page(); + let long = "x".repeat(5000); + let many: Vec = (0..65).map(|_| json!({"style": "Headline"})).collect(); + for p in [ + json!({"entries": [{"style": "Headline", "level": 0}]}), + json!({"entries": [{"style": "Headline", "level": 10}]}), + json!({"entries": [{"style": "Headline", "level": 300}]}), + json!({"entries": [{"style": "Headline", "level": -1}]}), + json!({"entries": [{"style": "Headline", "pageNumber": "sideways"}]}), + json!({"entries": [{"style": "No Such Style"}]}), + json!({"entries": [{"style": "Headline", "entryStyle": "No Such Style"}]}), + json!({"entries": [{"style": "Headline", "separator": long}]}), + json!({"entries": [{"style": long}]}), + json!({"entries": many}), + json!({"entries": "Headline"}), + json!({"entries": []}), + json!({"styles": ["Headline"], "titleStyle": "No Such Style"}), + json!({"styles": ["Headline"], "title": long}), + json!({"styles": ["Headline"], "rect": [100, 100, 50, 50]}), + json!({"styles": ["Headline"], "page": u64::MAX}), + json!([1, 2]), + ] { + let rev = s.doc().unwrap().revision; + assert!(s.execute("toc.generate", &merge(&p, page)).is_err(), "{p}"); + assert_eq!(s.doc().unwrap().revision, rev, "{p}"); + assert!(s.doc().unwrap().doc.toc.is_none()); + } + } + + /// `p` with the TOC page added when it is an object and has none. + fn merge(p: &Value, page: usize) -> Value { + let mut p = p.clone(); + if let Some(o) = p.as_object_mut() { + o.entry("page").or_insert(json!(page)); + } + p + } } diff --git a/crates/engine/src/cmd/xref.rs b/crates/engine/src/cmd/xref.rs index b0b97ebd..54e5dd6b 100644 --- a/crates/engine/src/cmd/xref.rs +++ b/crates/engine/src/cmd/xref.rs @@ -214,4 +214,23 @@ mod tests { s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.execute("xref.list", &json!({})).unwrap()[0]["text"], "(p. 3)"); } + + #[test] + fn cross_reference_into_a_story_with_a_running_header_composes() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap(); + let head = s.execute("frame.create", &json!({"rect": [72, 72, 400, 120], "content": "text", "text": "Chapter"})).unwrap()["story"].clone(); + let body = s.execute("frame.create", &json!({"rect": [72, 200, 400, 300], "content": "text", "text": "See"})).unwrap()["story"].clone(); + s.execute("text.select", &json!({"story": head, "anchor": 0, "focus": 0})).unwrap(); + s.execute("variables.insert", &json!({"name": "Running Header"})).unwrap(); + s.execute("text.select", &json!({"story": body, "anchor": 3, "focus": 3})).unwrap(); + s.execute("xref.insert", &json!({"story": head, "para": 0})).unwrap(); + // The cross-reference index and the running-header index used to build each other without end. + assert_eq!(s.execute("xref.list", &json!({})).unwrap()[0]["text"], "\u{201C}Chapter\u{201D} on page 1"); + // The header shows the body's paragraph in front of its own text. + let d = s.doc().unwrap().doc.clone(); + let cs = s.cache.get(&d, StoryId(head.as_u64().unwrap()), None); + let glyphs: usize = cs.frames[0].lines.iter().map(|l| l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).count()).sum(); + assert_eq!(glyphs, "SeeChapter".len()); + } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 900bd886..ff8fb87b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -260,6 +260,7 @@ pub struct Session { pub cache: Arc, pub(crate) tool: Box, pub journal: Vec<(String, Value)>, + pub(crate) find_context: Option, pub clipboard: Option>, /// Crash-recovery folder (the app sets it; saving or closing a document removes its entry). pub recovery_dir: Option, @@ -302,6 +303,7 @@ impl Session { cache: Arc::new(Cache::new()), tool: designcraft_tools::create("selection"), journal: vec![], + find_context: None, clipboard: None, recovery_dir: None, text_clipboard: None, @@ -361,13 +363,26 @@ impl Session { pub fn doc_mut(&mut self) -> Result<&mut DocState> { self.active_mut().ok_or(EngineError::NoDocument) } + /// Release the transient Find/Change domain when its caller starts or closes a search. + pub fn reset_find_scope(&mut self) { + self.find_context = None; + } + + fn discard_stale_find_scope(&mut self) { + if self.find_context.as_ref().is_some_and(|c| self.active().is_none_or(|st| !c.is_current(st))) { + self.reset_find_scope(); + } + } + pub fn set_active(&mut self, i: usize) { if i < self.docs.len() && self.active != Some(i) { + self.reset_find_scope(); self.stop_data_preview(); self.active = Some(i); } } pub fn add_document(&mut self, d: DocState) -> usize { + self.reset_find_scope(); self.stop_data_preview(); self.docs.push(d); self.active = Some(self.docs.len() - 1); @@ -391,6 +406,7 @@ impl Session { recovery::discard_document(self, i, recovery_dir.as_deref()); } if i < self.docs.len() { + self.reset_find_scope(); self.docs.remove(i); self.active = if self.docs.is_empty() { None } else { Some(i.min(self.docs.len() - 1)) }; } @@ -421,6 +437,8 @@ impl Session { if id != "data.preview" && (spec.undoable || spec.journal) { self.stop_data_preview(); } + // Preview restoration can replace the document before a Find handler resolves bounds. + self.discard_stale_find_scope(); let before = self.active().map(|d| (d.uid, d.doc.clone())); let r = (spec.run)(self, params)?; // Undo, redo or deleting layers can take the active layer away: new objects would land on @@ -458,6 +476,7 @@ impl Session { if spec.journal { self.journal.push((id.to_string(), params.clone())); } + self.discard_stale_find_scope(); Ok(r) } @@ -545,6 +564,7 @@ impl Session { st.doc = Arc::new(doc); st.selection = sel; st.revision += 1; + self.reset_find_scope(); Ok(r) } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9d630898..712dc06b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -570,6 +570,186 @@ fn commands_take_non_object_params_without_panicking() { } } +#[test] +fn page_tool_selects_a_page_and_its_handles_preview_or_resize_it() { + use designcraft_tools::{CanvasLayout, Mods, PointerEvent, PointerKind}; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 612, "height": 792, "facingPages": false})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [400, 100, 500, 200]})).unwrap()["id"].as_u64().unwrap(); + let frame = |s: &Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds(); + let width = |s: &Session| s.doc().unwrap().doc.spreads[0].pages[0].width; + // The page on the canvas (a single page is centred on the spine). + let page = |s: &Session| { + let d = &s.doc().unwrap().doc; + CanvasLayout::new(d, false).page_rect(d, 0).unwrap() + }; + s.set_tool("page"); + let v = ViewInfo::at_zoom(1.0); + let ev = |kind, x: f64, y: f64, alt: bool| PointerEvent { mods: Mods { alt, ..Default::default() }, ..PointerEvent::new(kind, x, y) }; + let drag = |s: &mut Session, from: (f64, f64), by: f64, alt: bool, check: &dyn Fn(&Session)| { + s.pointer(&ev(PointerKind::Down, from.0, from.1, alt), v).unwrap(); + s.pointer(&ev(PointerKind::Drag, from.0 + by, from.1, alt), v).unwrap(); + check(s); + s.pointer(&ev(PointerKind::Up, from.0 + by, from.1, alt), v).unwrap(); + }; + // A click selects the page. + let c = page(&s).center(); + s.pointer(&ev(PointerKind::Down, c.x, c.y, false), v).unwrap(); + s.pointer(&ev(PointerKind::Up, c.x, c.y, false), v).unwrap(); + assert_eq!(s.doc().unwrap().selection.page, Some(0)); + // A plain drag of the right handle previews the page's liquid rule and springs back. + s.execute("liquid.pageRule", &json!({"rule": "scale"})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + let r = page(&s); + drag(&mut s, (r.x1, r.center().y), 612.0, false, &|s| { + assert_eq!(width(s), 1224.0, "previewed"); + // Scale keeps proportions: only wider, so the frame is centred on the wider page. + assert_eq!(frame(s).x0, 706.0, "the Scale rule in the preview: {:?}", frame(s)); + }); + assert_eq!((width(&s), frame(&s)), (612.0, designcraft_geom::Rect::new(400.0, 100.0, 500.0, 200.0)), "sprung back"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "nothing to undo"); + // Alt-drag resizes; with the rule off the frame stays where it is. + s.execute("liquid.pageRule", &json!({"rule": "off"})).unwrap(); + let r = page(&s); + drag(&mut s, (r.x1, r.center().y), 100.0, true, &|_| {}); + assert_eq!(width(&s), 712.0); + assert_eq!(frame(&s).x0, 400.0); + // The left handle grows the page to the left: the frame keeps its place by the right edge. + let r = page(&s); + drag(&mut s, (r.x0, r.center().y), -100.0, true, &|_| {}); + let pg = s.doc().unwrap().doc.spreads[0].pages[0].clone(); + assert_eq!(pg.width, 812.0); + assert_eq!(frame(&s).x0 - pg.x, 500.0, "100 pt further from the page's left edge"); + // A click on the pasteboard deselects the page. + let r = page(&s); + s.pointer(&ev(PointerKind::Down, r.x0 - 200.0, r.center().y, false), v).unwrap(); + assert_eq!(s.doc().unwrap().selection.page, None); +} + +#[test] +fn page_size_keeps_objects_by_its_anchor() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400, "facingPages": false})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].as_u64().unwrap(); + let frame = |s: &Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds(); + // Bottom-right stays put: 100 wider and 50 taller moves the frame 100 right and 50 down. + s.execute("layout.pageSize", &json!({"pages": [1], "width": 500, "height": 450, "anchor": 8})).unwrap(); + assert_eq!(frame(&s), designcraft_geom::Rect::new(200.0, 150.0, 300.0, 250.0)); + // The centre: half of it. + s.execute("layout.pageSize", &json!({"pages": [1], "width": 700, "height": 450, "anchor": 4})).unwrap(); + assert_eq!(frame(&s).x0, 300.0); + assert!(s.execute("layout.pageSize", &json!({"pages": [1], "width": 700, "anchor": 9})).is_err()); + assert!(s.execute("layout.selectPage", &json!({"page": 2})).is_err()); + assert_eq!(s.execute("layout.selectPage", &json!({"page": 1})).unwrap()["page"], 1); +} + +#[test] +fn controlled_by_parent_takes_the_parent_pages_rule() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400, "facingPages": false})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].as_u64().unwrap(); + let frame = |s: &Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds(); + // The page follows its parent; the parent page re-centres. + s.execute("liquid.pageRule", &json!({"rule": "controlledByParent", "pages": [1]})).unwrap(); + s.edit(|d, _| { + std::sync::Arc::make_mut(&mut d.parents[0]).pages[0].liquid = designcraft_doc::LiquidRule::ReCenter; + Ok(()) + }) + .unwrap(); + s.execute("layout.pageSize", &json!({"pages": [1], "width": 600})).unwrap(); + assert_eq!(frame(&s).x0, 200.0, "re-centred: {:?}", frame(&s)); + // Without a parent (or a parent that's also controlled by its parent) nothing moves. + s.execute("layout.pages.applyParent", &json!({"pages": [0], "parent": null})).unwrap(); + s.execute("layout.pageSize", &json!({"pages": [1], "width": 800})).unwrap(); + assert_eq!(frame(&s).x0, 200.0); + // Show Parent Page Overlay is a page option. + s.execute("layout.pageOptions", &json!({"pages": [1], "showParentOverlay": true})).unwrap(); + assert!(s.doc().unwrap().doc.spreads[0].pages[0].show_parent_overlay); + assert!(s.execute("layout.pageOptions", &json!({"pages": [2], "showParentOverlay": true})).is_err()); +} + +#[test] +fn page_tool_pins_and_locks_objects_on_object_based_pages() { + use designcraft_tools::{CanvasLayout, Mods, PointerEvent, PointerKind}; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 612, "height": 792, "facingPages": false})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [256, 72, 356, 132]})).unwrap()["id"].as_u64().unwrap(); + s.execute("liquid.pageRule", &json!({"rule": "objectBased"})).unwrap(); + s.execute("edit.deselectAll", &json!({})).unwrap(); + s.set_tool("page"); + let v = ViewInfo::at_zoom(1.0); + let ev = + |kind, p: designcraft_geom::Point, alt: bool| PointerEvent { mods: Mods { alt, ..Default::default() }, ..PointerEvent::new(kind, p.x, p.y) }; + let canvas = |s: &Session| { + let d = &s.doc().unwrap().doc; + let l = CanvasLayout::new(d, false); + ( + l.page_rect(d, 0).unwrap(), + l.xf(designcraft_doc::SpreadRef::Doc(0)).transform_rect_bbox(d.item(designcraft_doc::ItemId(id)).unwrap().bounds()), + ) + }; + let click = |s: &mut Session, p: designcraft_geom::Point| { + s.pointer(&ev(PointerKind::Down, p, false), v).unwrap(); + s.pointer(&ev(PointerKind::Up, p, false), v).unwrap(); + }; + let liquid = |s: &Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().liquid; + // A click on the object selects it with its page; its pins and locks show. + let (_, o) = canvas(&s); + click(&mut s, o.center()); + let sel = s.doc().unwrap().selection.clone(); + assert_eq!((sel.page, sel.items.clone()), (Some(0), vec![designcraft_doc::ItemId(id)])); + assert!( + s.overlays(v) + .iter() + .any(|o| matches!(o, designcraft_tools::Overlay::LiquidPins { pins: [false, false, false, false], resize: [true, true], .. })) + ); + // The right pin (14 px outside the right edge) and the width lock (10 px right of the centre). + click(&mut s, designcraft_geom::Point::new(o.x1 + 14.0, o.center().y)); + click(&mut s, designcraft_geom::Point::new(o.center().x + 10.0, o.center().y)); + let l = liquid(&s).unwrap(); + assert!(l.pin_right && !l.resize_width && l.resize_height && !l.pin_left, "{l:?}"); + // Alt-drag the page 100 pt wider: the pinned, width-locked frame keeps its size by the right edge. + let (p, _) = canvas(&s); + let right = designcraft_geom::Point::new(p.x1, p.center().y); + s.pointer(&ev(PointerKind::Down, right, true), v).unwrap(); + s.pointer(&ev(PointerKind::Drag, right + designcraft_geom::Vec2::new(100.0, 0.0), true), v).unwrap(); + s.pointer(&ev(PointerKind::Up, right + designcraft_geom::Vec2::new(100.0, 0.0), true), v).unwrap(); + let d = &s.doc().unwrap().doc; + let (pg, b) = (&d.spreads[0].pages[0], d.item(designcraft_doc::ItemId(id)).unwrap().bounds()); + assert_eq!(pg.width, 712.0); + assert!((b.width() - 100.0).abs() < 1e-6 && (pg.x + pg.width - b.x1 - 256.0).abs() < 1e-6, "{b:?}"); + // Hovering a pin shows its tooltip. + let (_, o) = canvas(&s); + s.pointer(&ev(PointerKind::Move, designcraft_geom::Point::new(o.x1 + 14.0, o.center().y), false), v).unwrap(); + assert!(s.overlays(v).iter().any(|o| matches!(o, designcraft_tools::Overlay::Tip { text, .. } if text.starts_with("Object is pinned")))); +} + +#[test] +fn duplicating_a_page_keeps_its_own_size_rule_guides_and_objects() { + // Chris Cicchino's Page Tool video (2:06): the letterhead page is duplicated, then resized. + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 252, "height": 144, "facingPages": false})).unwrap(); + s.execute("layout.pages.insert", &json!({"count": 1})).unwrap(); + s.execute("layout.pageSize", &json!({"pages": [2], "width": 612, "height": 792})).unwrap(); + s.execute("liquid.pageRule", &json!({"rule": "scale", "pages": [2]})).unwrap(); + s.execute("guide.add", &json!({"spread": 1, "page": 0, "orientation": "vertical", "position": 300})).unwrap(); + s.execute("frame.create", &json!({"spread": 1, "rect": [100, 100, 200, 200]})).unwrap(); + let r = s.execute("layout.pages.duplicateSpread", &json!({"spread": 1})).unwrap(); + assert_eq!(r["page"], 2); + let d = s.doc().unwrap().doc.clone(); + let (si, pi) = d.page_loc(2).unwrap(); + let pg = &d.spreads[si].pages[pi]; + assert_eq!((pg.width, pg.height, pg.liquid), (612.0, 792.0, designcraft_doc::LiquidRule::Scale)); + assert_eq!(pg.guides.len(), 1); + assert_eq!(d.spreads[si].items.len(), 1); + // At the end of the document (the New Page button): the business card, not the last page's size. + let r = s.execute("layout.pages.duplicateSpread", &json!({"spread": 0, "atEnd": true})).unwrap(); + assert_eq!(r["page"], 3); + let d = s.doc().unwrap().doc.clone(); + let (si, pi) = d.page_loc(3).unwrap(); + assert_eq!((d.spreads[si].pages[pi].width, d.spreads[si].pages[pi].height), (252.0, 144.0)); +} + /// Load the text from a frame's out port with the Selection tool and click on the pasteboard: /// a new frame is threaded there and the overset text flows into it. #[test] @@ -669,3 +849,31 @@ fn type_tool_frames_snap_to_the_margins() { assert!((r.x0 - margin.x0).abs() < 1e-6 && (r.y0 - margin.y0).abs() < 1e-6, "press point {r:?} vs {margin:?}"); assert!((r.x1 - margin.x1).abs() < 1e-6, "right edge {} vs {}", r.x1, margin.x1); } + +#[test] +fn duplicating_a_page_to_an_index_is_one_undo_step() { + let mut s = Session::new(); + s.execute("file.new", &json!({"facingPages": false})).unwrap(); + s.execute("layout.pages.insert", &json!({"count": 2})).unwrap(); + for (page, width) in [(1, 300), (2, 400), (3, 500)] { + s.execute("layout.pageSize", &json!({"pages": [page], "width": width, "height": 600})).unwrap(); + } + let widths = |s: &Session| { + let d = &s.doc().unwrap().doc; + (0..d.page_count()).map(|i| d.page_loc(i).map(|(si, pi)| d.spreads[si].pages[pi].width)).collect::>>().unwrap() + }; + // Backwards: the last page copied to the front. + let r = s.execute("layout.pages.duplicateSpread", &json!({"spread": 2, "to": 0})).unwrap(); + assert_eq!(r["page"], 0); + assert_eq!(widths(&s), [500.0, 300.0, 400.0, 500.0]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(widths(&s), [300.0, 400.0, 500.0]); + // Forwards: the first page copied to the end. + s.execute("layout.pages.duplicateSpread", &json!({"spread": 0, "to": 3})).unwrap(); + assert_eq!(widths(&s), [300.0, 400.0, 500.0, 300.0]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(widths(&s), [300.0, 400.0, 500.0]); + // Past the end: refused, nothing changes. + assert!(s.execute("layout.pages.duplicateSpread", &json!({"spread": 0, "to": 4})).is_err()); + assert_eq!(widths(&s), [300.0, 400.0, 500.0]); +} diff --git a/crates/engine/src/tests_idml.rs b/crates/engine/src/tests_idml.rs index f16d3efc..5e84532c 100644 --- a/crates/engine/src/tests_idml.rs +++ b/crates/engine/src/tests_idml.rs @@ -363,3 +363,33 @@ fn type_para_sets_a_drop_cap_that_survives_idml() { assert_eq!(p.drop_cap_style.as_deref(), Some("Initial")); assert_eq!(p.nested_styles, Some(vec![designcraft_doc::NestedStyle::drop_cap("Initial")])); } + +#[test] +fn table_of_contents_definition_round_trips_through_idml_and_updates() { + let mut s = Session::new(); + s.execute("file.newSample", &json!({})).unwrap(); + let page = s.doc().unwrap().doc.page_count() + 1; + s.execute("layout.pages.insert", &json!({"count": 1})).unwrap(); + let basic = designcraft_doc::story::BASIC_PARAGRAPH; + s.execute( + "toc.generate", + &json!({"page": page, "title": "Inside", "titleStyle": basic, "entries": [ + {"style": "Cover Title", "level": 1, "pageNumber": "before", "separator": " ^ "}, + {"style": "Headline", "level": 2, "entryStyle": basic, "pageNumber": "none"}, + ]}), + ) + .unwrap(); + let toc_text = |s: &Session| { + let d = &s.doc().unwrap().doc; + d.story(d.toc.as_ref().unwrap().story).unwrap().text.clone() + }; + let before = s.doc().unwrap().doc.toc.clone().unwrap(); + let text = toc_text(&s); + let b64 = s.execute("file.exportIdml", &json!({})).unwrap()["base64"].as_str().unwrap().to_string(); + s.execute("file.openIdml", &json!({"base64": b64, "name": "toc.idml"})).unwrap(); + let after = s.doc().unwrap().doc.toc.clone().expect("the TOC definition is imported"); + assert_eq!((&after.title, &after.title_style, &after.entries), (&before.title, &before.title_style, &before.entries)); + assert_eq!(toc_text(&s), text); + s.execute("toc.update", &json!({})).unwrap(); + assert_eq!(toc_text(&s), text); +} diff --git a/crates/engine/src/tests_table.rs b/crates/engine/src/tests_table.rs index 3934aabe..c3a575d0 100644 --- a/crates/engine/src/tests_table.rs +++ b/crates/engine/src/tests_table.rs @@ -16,6 +16,321 @@ fn check(s: &Session) { s.doc().unwrap().doc.check().unwrap(); } +#[test] +fn table_text_replacement_floors_utf8_selection_and_allows_quote_typing() { + for (original, (a, b), replacement, (after_a, after_b), quoted) in [ + ("a", (1, 1), "é", (0, 0), Some("“é")), // Exact caret/quote baseline. + ("abc", (3, 3), "🙂", (0, 0), Some("“🙂")), + ("ab", (1, 2), "éX", (0, 2), None), + ("ab", (2, 1), "éX", (2, 0), None), + ("abcdef", (3, 5), "🙂éX", (0, 4), None), + ("abcdef", (5, 3), "🙂éX", (4, 0), None), + ("abc", (1, 2), "🙂", (0, 0), None), // Both endpoints floor to the same boundary. + ("abcdef", (2, 6), "é🙂", (2, 6), None), // Valid Unicode endpoints stay fixed. + ("abcdef", (1, 4), "ABCDEF", (1, 4), None), + ("abcdef", (4, 1), "ABCDEF", (4, 1), None), + ("abcdef", (6, 6), "é", (2, 2), None), // Shortening clamps to EOF. + ("abc", (1, 3), "", (0, 0), None), + ] { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let frame = s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text", "text": ""})).unwrap(); + let sid = frame["story"].as_u64().unwrap(); + let fid = frame["id"].as_u64().unwrap(); + let tid = s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap()["table"].as_u64().unwrap(); + s.execute("text.insert", &json!({"text": original, "raw": true})).unwrap(); + // Real commands position the child selection; text.select addresses parent stories. + for _ in a..original.len() { + s.execute("text.move", &json!({"dir": "left"})).unwrap(); + } + let (dir, steps) = if b >= a { ("right", b - a) } else { ("left", a - b) }; + for _ in 0..steps { + s.execute("text.move", &json!({"dir": dir, "extend": true})).unwrap(); + } + let mut expected = s.doc().unwrap().selection.clone(); + let selected = expected.text.as_mut().unwrap(); + assert_eq!((selected.anchor, selected.focus), (a, b)); + assert_eq!(selected.frame, Some(doc::ItemId(fid))); + assert_eq!(selected.cell, Some(doc::CellAddr { table: tid, row: 0, col: 0 })); + (selected.anchor, selected.focus) = (after_a, after_b); + let parent = s.doc().unwrap().doc.story(doc::StoryId(sid)).unwrap().text.clone(); + s.execute("table.setCell", &json!({"text": replacement})).unwrap(); + assert_eq!(table_for(&s, sid, tid).cell(0, 0).unwrap().text.text, replacement); + assert_eq!(s.doc().unwrap().selection, expected, "{original}: {a}->{b}, replacement {replacement}"); + assert!(replacement.is_char_boundary(after_a) && replacement.is_char_boundary(after_b)); + assert_eq!(s.doc().unwrap().doc.story(doc::StoryId(sid)).unwrap().text, parent); + if let Some(quoted) = quoted { + assert!(s.prefs.typographers_quotes); + s.execute("text.insert", &json!({"text": "\""})).unwrap(); + assert_eq!(table_for(&s, sid, tid).cell(0, 0).unwrap().text.text, quoted); + let selected = expected.text.as_mut().unwrap(); + (selected.anchor, selected.focus) = (3, 3); // Opening curly quote is three bytes. + assert_eq!(s.doc().unwrap().selection, expected); + } + check(&s); + } +} + +#[test] +fn table_edit_after_anchor_removal_floors_parent_caret() { + for (original, (a, b), replacement, pos, quoted, typed_pos) in [("ab", (1, 2), "é", 0, "“é\n", 3), ("abcde", (5, 1), "é🙂", 2, "é”🙂\n", 5)] + { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let other = s.execute("frame.create", &json!({"rect": [320, 36, 580, 200], "content": "text"})).unwrap(); + let other_sid = other["story"].as_u64().unwrap(); + let other_tid = s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap()["table"].as_u64().unwrap(); + s.execute("text.insert", &json!({"text": "keep", "raw": true})).unwrap(); + let mut expected_other = table_for(&s, other_sid, other_tid).clone(); + expected_other.cell_mut(0, 0).unwrap().insets = [7.0; 4]; + + let frame = s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text"})).unwrap(); + let sid = frame["story"].as_u64().unwrap(); + let tid = s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap()["table"].as_u64().unwrap(); + s.execute("text.insert", &json!({"text": original, "raw": true})).unwrap(); + for _ in a..original.len() { + s.execute("text.move", &json!({"dir": "left"})).unwrap(); + } + let (dir, steps) = if b >= a { ("right", b - a) } else { ("left", a - b) }; + for _ in 0..steps { + s.execute("text.move", &json!({"dir": dir, "extend": true})).unwrap(); + } + let mut expected = s.doc().unwrap().selection.clone(); + let selected = expected.text.unwrap(); + assert_eq!((selected.anchor, selected.focus), (a, b)); + assert_eq!(selected.frame, Some(doc::ItemId(frame["id"].as_u64().unwrap()))); + assert_eq!(selected.cell, Some(doc::CellAddr { table: tid, row: 0, col: 0 })); + // Explicit parent replacement removes its anchor, leaving the child address stale. + assert_eq!( + s.execute("find.change", &json!({"story": sid, "find": doc::TABLE_ANCHOR.to_string(), "change": replacement})).unwrap()["count"], + 1 + ); + let parent = s.doc().unwrap().doc.story(doc::StoryId(sid)).unwrap(); + assert_eq!(parent.text, format!("{replacement}\n")); + assert!(!parent.tables.contains_key(&tid)); + assert_eq!(s.doc().unwrap().selection, expected); + assert!(!parent.text.is_char_boundary(selected.anchor)); + // An explicit edit to the surviving table reaches the disappeared-table fallback. + s.execute("table.setCell", &json!({"story": other_sid, "table": other_tid, "insets": 7})).unwrap(); + let selected = expected.text.as_mut().unwrap(); + (selected.anchor, selected.focus, selected.cell) = (pos, pos, None); // Both floor the OLD anchor. + assert_eq!(s.doc().unwrap().selection, expected); + assert_eq!(s.doc().unwrap().doc.story(doc::StoryId(sid)).unwrap().text, format!("{replacement}\n")); + assert_eq!(table_for(&s, other_sid, other_tid), &expected_other); + s.execute("text.insert", &json!({"text": "\""})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(doc::StoryId(sid)).unwrap().text, quoted); + let selected = expected.text.as_mut().unwrap(); + (selected.anchor, selected.focus) = (typed_pos, typed_pos); + assert_eq!(s.doc().unwrap().selection, expected); + assert_eq!(table_for(&s, other_sid, other_tid), &expected_other); + check(&s); + } +} + +fn selected_merged_table(column: bool) -> (Session, u64, u64, u64) { + let (mut s, sid, fid) = session_with_frame(); + let tid = s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap()["table"].as_u64().unwrap(); + for (i, text) in ["A", "B", "C", "D"].iter().enumerate() { + s.execute("text.insert", &json!({"text": text})).unwrap(); + if i < 3 { + s.execute("table.nextCell", &json!({})).unwrap(); + } + } + let merge = if column { json!({"rows": [0, 0], "cols": [0, 1]}) } else { json!({"rows": [0, 1], "cols": [0, 0]}) }; + s.execute("table.select", &merge).unwrap(); + s.execute("table.merge", &json!({})).unwrap(); + s.execute("table.select", &json!({"rows": [1, 1], "cols": [1, 1]})).unwrap(); + s.execute(if column { "table.selectColumn" } else { "table.selectRow" }, &json!({})).unwrap(); + (s, sid, tid, fid) +} + +#[test] +fn selected_merged_owners_receive_text_formatting_and_character_styles() { + for column in [true, false] { + let (mut s, sid, tid, _) = selected_merged_table(column); + let selected = s.doc().unwrap().selection.clone(); + assert_eq!(selected.cells.unwrap().range, if column { doc::CellRange::new(0, 1, 1, 1) } else { doc::CellRange::new(1, 0, 1, 1) }); + let before = table_for(&s, sid, tid).clone(); + let neighbor = if column { (1, 0) } else { (0, 1) }; + s.execute("type.char", &json!({"attrs": {"size": 23}})).unwrap(); + for (r, c) in [(0, 0), (1, 1)] { + let cell = table_for(&s, sid, tid).cell(r, c).unwrap(); + assert!(cell.text.runs().all(|(_, f)| f.over.size == Some(23.0)), "all runs of owner {r},{c}"); + assert_eq!(cell.text.text, before.cell(r, c).unwrap().text.text); + } + assert_eq!(table_for(&s, sid, tid).cell(neighbor.0, neighbor.1), before.cell(neighbor.0, neighbor.1)); + assert_eq!(s.doc().unwrap().selection, selected); + s.execute("style.character.create", &json!({"name": "Selected owners", "chars": {"tracking": 37}})).unwrap(); + s.execute("style.character.apply", &json!({"name": "Selected owners"})).unwrap(); + for (r, c) in [(0, 0), (1, 1)] { + assert!(table_for(&s, sid, tid).cell(r, c).unwrap().text.runs().all(|(_, f)| f.style == "Selected owners")); + } + let after = table_for(&s, sid, tid); + assert_eq!(after.cell(neighbor.0, neighbor.1), before.cell(neighbor.0, neighbor.1)); + assert_eq!(after.rows, before.rows); + assert_eq!(after.columns, before.columns); + assert_eq!(s.doc().unwrap().selection, selected, "formatting preserves the original grid rectangle"); + check(&s); + } +} + +#[test] +fn selected_merged_owners_receive_cell_options_and_unique_style_counts() { + for column in [true, false] { + let (mut s, sid, tid, _) = selected_merged_table(column); + let range = s.doc().unwrap().selection.cells.unwrap().range; + let before = table_for(&s, sid, tid).clone(); + let neighbor = if column { (1, 0) } else { (0, 1) }; + let covered = if column { (0, 1) } else { (1, 0) }; + s.execute("table.setCell", &json!({"fill": "[Black]", "insets": 7, "text": "REPLACED"})).unwrap(); + for (r, c) in [(0, 0), (1, 1)] { + let cell = table_for(&s, sid, tid).cell(r, c).unwrap(); + assert_eq!(serde_json::to_value(&cell.fill).unwrap(), json!("[Black]")); + assert_eq!(cell.insets, [7.0; 4]); + assert_eq!(cell.text.text, "REPLACED"); + } + s.execute("style.cell.create", &json!({"name": "Selected cell owners", "insets": 9})).unwrap(); + assert_eq!(s.execute("style.cell.apply", &json!({"name": "Selected cell owners"})).unwrap()["cells"], 2); + let after = table_for(&s, sid, tid); + for (r, c) in [(0, 0), (1, 1)] { + assert_eq!(after.cell(r, c).unwrap().style, "Selected cell owners"); + assert_eq!(after.cell(r, c).unwrap().insets, [9.0; 4]); + } + assert_eq!(after.cell(neighbor.0, neighbor.1), before.cell(neighbor.0, neighbor.1)); + assert_eq!(after.cell(covered.0, covered.1), before.cell(covered.0, covered.1)); + assert_eq!(after.rows, before.rows); + assert_eq!(after.columns, before.columns); + assert_eq!(s.doc().unwrap().selection.cells.unwrap().range, range); + let merged = if column { json!({"rows": [0, 0], "cols": [0, 1]}) } else { json!({"rows": [0, 1], "cols": [0, 0]}) }; + s.execute("table.select", &merged).unwrap(); + assert_eq!(s.execute("style.cell.apply", &json!({"name": "Selected cell owners"})).unwrap()["cells"], 1); + check(&s); + } +} + +#[test] +fn covered_owner_strokes_keep_literal_edge_masks_and_neighbors() { + for rtl in [false, true] { + for outer in [false, true] { + let (mut s, sid, tid, _) = selected_merged_table(true); + if rtl { + s.execute("table.options", &json!({"direction": "rightToLeft"})).unwrap(); + } + s.execute("table.select", &json!({"rows": [0, 0], "cols": [1, 1]})).unwrap(); + let before = table_for(&s, sid, tid).clone(); + s.execute("table.setCell", &json!({"stroke": {"weight": 3, "edges": if outer { "outer" } else { "inner" }}})).unwrap(); + let after = table_for(&s, sid, tid); + let mask = if rtl { [true, true, true, false] } else { [true, false, true, true] }; + let expected = mask.map(|edge| if outer { edge } else { !edge }); + let owner = after.cell(0, 0).unwrap(); + assert_eq!(owner.border_overrides, expected); + assert_eq!(owner.stroke_defined, expected); + for (i, on) in expected.into_iter().enumerate() { + if on { + assert_eq!(owner.strokes[i].weight, 3.0); + assert_eq!(owner.stroke_priorities[i], 1); + } else { + assert_eq!(owner.strokes[i], before.cell(0, 0).unwrap().strokes[i]); + } + } + for (r, c) in [(0, 1), (1, 0), (1, 1)] { + assert_eq!(after.cell(r, c), before.cell(r, c), "strict merged/covered mirror guard preserves {r},{c}"); + } + assert_eq!(s.doc().unwrap().selection.cells.unwrap().range, doc::CellRange::cell(0, 1)); + check(&s); + } + } +} + +#[test] +fn cell_style_sampling_resolves_covered_point_but_retains_upper_left_policy() { + let (mut s, sid, tid, _) = selected_merged_table(true); + s.execute("table.setCell", &json!({"row": 0, "col": 0, "insets": 7})).unwrap(); + s.execute("table.select", &json!({"rows": [0, 0], "cols": [1, 1]})).unwrap(); + let selection = s.doc().unwrap().selection.clone(); + let before = table_for(&s, sid, tid).clone(); + s.execute("style.cell.create", &json!({"name": "Covered", "fromSelection": true})).unwrap(); + assert_eq!(s.doc().unwrap().doc.styles.cell.iter().find(|c| c.name == "Covered").unwrap().insets, Some([7.0; 4])); + assert_eq!(table_for(&s, sid, tid), &before); + assert_eq!(s.doc().unwrap().selection, selection); + + let (mut s, sid, _) = session_with_frame(); + let tid = s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap()["table"].as_u64().unwrap(); + s.execute("table.select", &json!({"rows": [0, 1], "cols": [1, 1]})).unwrap(); + s.execute("table.merge", &json!({})).unwrap(); + for (row, col, insets, size) in [(0, 1, 7, 17), (1, 0, 11, 29)] { + s.execute("table.setCell", &json!({"row": row, "col": col, "text": "Text", "insets": insets})).unwrap(); + s.execute("table.select", &json!({"rows": [row, row], "cols": [col, col]})).unwrap(); + s.execute("type.char", &json!({"attrs": {"size": size}})).unwrap(); + } + s.execute("table.selectRow", &json!({})).unwrap(); + let before = table_for(&s, sid, tid).clone(); + let selection = s.doc().unwrap().selection.clone(); + // Row-major owners are [(0,1),(1,0)], while the rectangle starts at (1,0). + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["size"], 17.0); + s.execute("style.cell.create", &json!({"name": "Upper left", "fromSelection": true})).unwrap(); + assert_eq!(s.doc().unwrap().doc.styles.cell.iter().find(|c| c.name == "Upper left").unwrap().insets, Some([11.0; 4])); + assert_eq!(table_for(&s, sid, tid), &before); + assert_eq!(s.doc().unwrap().selection, selection); +} + +#[test] +fn selected_merged_owners_convert_graphics_back_to_text() { + let png = designcraft_render::Rendered { width: 1, height: 1, pixels: vec![255, 0, 0, 255] }.to_png(); + let image = crate::cmd::base64_encode(&png); + for column in [true, false] { + let (mut s, sid, tid, _) = selected_merged_table(column); + let neighbor = if column { (1, 0) } else { (0, 1) }; + let covered = if column { (0, 1) } else { (1, 0) }; + for (r, c) in [covered, (1, 1), neighbor] { + let placed = s.execute("table.placeGraphic", &json!({"row": r, "col": c, "base64": image, "name": "cell.png"})).unwrap(); + if (r, c) == covered { + assert_eq!((placed["row"].as_u64(), placed["col"].as_u64()), (Some(0), Some(0))); + } + } + let before = table_for(&s, sid, tid).clone(); + let selected = s.doc().unwrap().selection.cells.unwrap(); + s.execute("table.textCell", &json!({})).unwrap(); + let after = table_for(&s, sid, tid); + for (r, c) in [(0, 0), (1, 1)] { + assert!(before.cell(r, c).unwrap().graphic.is_some()); + assert!(after.cell(r, c).unwrap().graphic.is_none()); + assert_eq!(after.cell(r, c).unwrap().text, before.cell(r, c).unwrap().text); + } + assert_eq!(after.cell(neighbor.0, neighbor.1), before.cell(neighbor.0, neighbor.1)); + assert_eq!(after.cell(covered.0, covered.1), before.cell(covered.0, covered.1)); + assert_eq!(s.doc().unwrap().selection.cells, Some(selected)); + check(&s); + } +} + +#[test] +fn covered_selected_owner_drag_keeps_merged_move_restrictions_atomic() { + for column in [true, false] { + let (mut s, sid, _, fid) = selected_merged_table(column); + let before = s.doc().unwrap().doc.clone(); + let selection = s.doc().unwrap().selection.clone(); + let revision = s.doc().unwrap().revision; + let undo = s.doc().unwrap().history.undo.len(); + let redo = s.doc().unwrap().history.redo.len(); + let cs = s.cache.get(&before, doc::StoryId(sid), None); + let cells = &cs.frames[0].tables[0].cells; + let point = |row, col| { + let cell = cells.iter().find(|c| c.row == row && c.col == col).unwrap(); + before.text_xf(before.item(doc::ItemId(fid)).unwrap()) * cell.rect.center() + }; + let from = point(0, 0); + let to = if column { point(1, 0) } else { point(0, 1) }; + let err = s.execute("table.dropCells", &json!({"frame": fid, "from": [from.x, from.y], "to": [to.x, to.y]})).unwrap_err(); + assert!(err.to_string().contains(if column { "unmerge cells that span columns" } else { "unmerge cells that span rows" }), "{err}"); + let st = s.doc().unwrap(); + assert_eq!(st.doc, before); + assert_eq!(st.selection, selection); + assert_eq!(st.revision, revision); + assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo, redo)); + } +} + #[test] fn insert_type_in_cells_and_tab() { let (mut s, sid, _) = session_with_frame(); diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 35edb48b..1c891074 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -12,6 +12,7 @@ #![forbid(unsafe_code)] use std::collections::{HashMap, HashSet}; +use std::hash::BuildHasher; use std::io::{Cursor, Read, Write}; use std::sync::Arc; @@ -108,12 +109,13 @@ fn load_zip(bytes: &[u8]) -> Result { let names: Vec = z.file_names().map(str::to_string).collect(); // Files saved with one copy per placement: identical copies share one buffer as they are // read, so memory holds each distinct file once. - let mut loaded: HashMap>>> = HashMap::new(); + let fingerprint = std::collections::hash_map::RandomState::new(); + let mut loaded: HashMap>>> = HashMap::new(); for name in names { let Some(stem) = name.strip_prefix("assets/") else { continue }; let Some(id) = stem.split('.').next().and_then(|s| s.parse::().ok()) else { continue }; if let (Some(data), Some(a)) = (read(&mut z, &name), doc.assets.get_mut(&AssetId(id))) { - let same = loaded.entry(data.len()).or_default(); + let same = loaded.entry(fingerprint.hash_one(data.as_slice())).or_default(); let data = match same.iter().find(|d| ***d == data) { Some(d) => d.clone(), None => { @@ -504,6 +506,33 @@ mod tests { } } + #[test] + fn equal_size_archive_assets_preserve_content_and_share_only_identical_buffers() { + let mut d = Document::new(&NewDocument::default()); + let mut pairs = Vec::new(); + for n in 0..128u64 { + let mut bytes = vec![42; 1024]; + bytes[1016..].copy_from_slice(&n.to_le_bytes()); + let a = png_asset(&mut d, &bytes); + let b = png_asset(&mut d, &bytes); + // Distinct links must remain independently relinkable even when their bytes match. + Arc::make_mut(d.assets.get_mut(&a).unwrap()).link = Some(format!("/a/{n}.png")); + Arc::make_mut(d.assets.get_mut(&b).unwrap()).link = Some(format!("/b/{n}.png")); + place(&mut d, a, 1.0); + place(&mut d, b, 2.0); + pairs.push((a, b)); + } + let back = load(&save_uncompacted(&d)).unwrap(); + assert_eq!(back.assets.len(), 256); + for (n, (a, b)) in pairs.into_iter().enumerate() { + assert_eq!(back.assets[&a].data[1016..], (n as u64).to_le_bytes()); + assert!(Arc::ptr_eq(&back.assets[&a].data, &back.assets[&b].data)); + assert_eq!(back.assets[&a].link, d.assets[&a].link); + assert_eq!(back.assets[&b].link, d.assets[&b].link); + } + assert_eq!(back.spreads, d.spreads); + } + #[test] fn older_files_with_one_asset_per_placement_open_with_one() { let mut d = Document::new(&NewDocument::default()); diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index b7e5ca3c..0b9435c7 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -16,6 +16,8 @@ use crate::{DOM_VERSION, MIMETYPE, base64_encode}; const PKG_NS: &str = "http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging"; const NO_PARA_ID: &str = "ParagraphStyle/$ID/[No paragraph style]"; +const TOC_STYLE_ID: &str = "TOCStyle/Table of Contents"; +const TOC_STYLE_NAME: &str = "Table of Contents"; const NO_CHAR_ID: &str = "CharacterStyle/$ID/[No character style]"; /// Export options. @@ -1050,9 +1052,46 @@ impl<'a> Ex<'a> { let mut ro = El::new("RootObjectStyleGroup").attr("Self", self.fresh()); og.emit(&mut ro, "ObjectStyleGroup", ""); root.push(ro); + if let Some(toc) = d.toc.as_ref().filter(|t| d.story(t.story).is_some()) { + root.push(self.toc_style_el(toc)); + } document(&root) } + /// The document's table of contents as a TOC style; its story names it in `AppliedTOCStyle`. + fn toc_style_el(&mut self, toc: &designcraft_doc::Toc) -> El { + let para = |n: &str| names::style_self("ParagraphStyle", PARA_BUILTINS, n); + let mut el = El::new("TOCStyle") + .attr("Self", TOC_STYLE_ID) + .attr("Name", TOC_STYLE_NAME) + .attr("Title", &toc.title) + .attr("TitleStyle", para(toc.title_style.as_deref().unwrap_or("TOC Title"))) + .attr("RunIn", "false") + .attr("IncludeHidden", "false") + .attr("IncludeBookDocuments", "false") + .attr("CreatePdfBookmarks", "false"); + for e in &toc.entries { + let format = e.entry_style.clone().unwrap_or_else(|| format!("TOC Level {}", e.level)); + let position = match e.page_number { + _ if !toc.page_numbers => "NoPageNumber", + designcraft_doc::TocPageNumber::After => "AfterEntry", + designcraft_doc::TocPageNumber::Before => "BeforeEntry", + designcraft_doc::TocPageNumber::None => "NoPageNumber", + }; + el.push( + El::new("TOCStyleEntry") + .attr("Self", self.fresh()) + .attr("Name", names::style_name_out(PARA_BUILTINS, &e.style)) + .attr("FormatStyle", para(&format)) + .attr("Level", e.level) + .attr("PageNumberPosition", position) + .attr("Separator", names::toc_separator_out(e.separator.as_deref().unwrap_or("\t"))) + .attr("Sort", "false"), + ); + } + el + } + fn char_attrs(&mut self, el: &mut El, props: &mut Vec, a: &CharAttrs) { if let Some(v) = a.leading_aki { el.set("LeadingAki", num(v.unwrap_or(-1.0))); @@ -1959,7 +1998,12 @@ impl<'a> Ex<'a> { // ---------- stories ---------- fn story_el(&mut self, s: &Story) -> El { - let mut el = El::new("Story").attr("Self", uid(s.id.0)).attr("AppliedTOCStyle", "n").attr("TrackChanges", "false").attr("StoryTitle", "$ID/"); + let toc = self.d.toc.as_ref().is_some_and(|t| t.story == s.id); + let mut el = El::new("Story") + .attr("Self", uid(s.id.0)) + .attr("AppliedTOCStyle", if toc { TOC_STYLE_ID } else { "n" }) + .attr("TrackChanges", "false") + .attr("StoryTitle", "$ID/"); el.push( El::new("StoryPreference") .attr("OpticalMarginAlignment", "false") diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 1a4fa65e..51dc66d8 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -209,6 +209,7 @@ struct Importer<'r> { /// Index topic Self → topic path and per-level sort keys; topic cross-references. index_topics: HashMap, index_xrefs: Vec, + toc: Option, } /// IDML stores a sort key on each topic, rather than on its page references. @@ -315,6 +316,7 @@ impl<'r> Importer<'r> { xref_formats: designcraft_doc::xref::default_formats(), index_topics: HashMap::new(), index_xrefs: Vec::new(), + toc: None, } } @@ -488,6 +490,7 @@ impl<'r> Importer<'r> { let story = self.story(id, e); self.stories.insert(id, story); } + self.toc = self.toc_of(top); // Topic cross-references (See / See also) become markers at the start of the first story // with text. if !self.index_xrefs.is_empty() @@ -1143,6 +1146,44 @@ impl<'r> Importer<'r> { self.footnote_options = o; } + /// The table of contents: the TOC style a story is generated from (`AppliedTOCStyle`), with + /// its entries. Styles the document doesn't have fall back to the generated TOC styles. + fn toc_of(&self, top: &[El]) -> Option { + use designcraft_doc::{TocEntry, TocPageNumber}; + let (ts, story) = top.iter().filter(|e| e.local() == "TOCStyle").find_map(|ts| { + let id = ts.get("Self")?; + let story = top.iter().find(|e| e.local() == "Story" && e.get("AppliedTOCStyle") == Some(id))?; + Some((ts, *self.story_ids.get(story.get("Self")?)?)) + })?; + let known = |r: Option<&str>| r.map(|r| self.para_style_ref(r)).filter(|n| self.styles.para(n).is_some()); + let entries: Vec = ts + .elements() + .filter(|e| e.local() == "TOCStyleEntry") + .filter_map(|e| { + let style = names::style_name_in(PARA_BUILTINS, e.get("Name")?); + if style.is_empty() || style.chars().count() > 256 { + return None; + } + let level = e.get("Level").and_then(|l| l.trim().parse::().ok()).unwrap_or(1).clamp(1, 9); + let page_number = match e.get("PageNumberPosition") { + Some("BeforeEntry") => TocPageNumber::Before, + Some("NoPageNumber") => TocPageNumber::None, + _ => TocPageNumber::After, + }; + let separator = e.get("Separator").map(crate::names::toc_separator_in).filter(|s| s != "\t" && s.chars().count() <= 64); + let entry_style = known(e.get("FormatStyle")).filter(|n| *n != format!("TOC Level {level}")); + Some(TocEntry { style, level, entry_style, page_number, separator }) + }) + .take(64) + .collect(); + if entries.is_empty() { + return None; + } + let title: String = ts.get("Title").unwrap_or("").chars().filter(|c| !matches!(c, '\n' | '\r' | '\u{2029}')).take(1024).collect(); + let title_style = known(ts.get("TitleStyle")).filter(|n| n != "TOC Title"); + Some(designcraft_doc::Toc { story, title, entries, page_numbers: true, title_style }) + } + fn para_style_ref(&self, r: &str) -> String { self.para_names.get(r).cloned().unwrap_or_else(|| names::style_name_in(PARA_BUILTINS, &unescape_id(r.trim_start_matches("ParagraphStyle/")))) } @@ -1977,6 +2018,7 @@ impl<'r> Importer<'r> { liquid: Default::default(), transition: None, view_rotation: 0, + show_parent_overlay: false, }); } let overrides: Vec> = @@ -2487,7 +2529,7 @@ impl<'r> Importer<'r> { bookmarks: vec![], user_words: vec![], hyphenation_exceptions: vec![], - toc: None, + toc: self.toc.take(), text_variables: designcraft_doc::vars::defaults(), footnote_options: std::mem::take(&mut self.footnote_options), xref_formats: std::mem::take(&mut self.xref_formats), diff --git a/crates/idml/src/names.rs b/crates/idml/src/names.rs index 5a47f194..ed0daaf4 100644 --- a/crates/idml/src/names.rs +++ b/crates/idml/src/names.rs @@ -693,3 +693,29 @@ pub fn digits_in(s: &str) -> Option { use designcraft_doc::Digits; [Digits::Default, Digits::Arabic, Digits::Hindi, Digits::Farsi, Digits::Native].into_iter().find(|d| digits_out(*d) == s) } + +/// A TOC separator in IDML's special-character notation: `^t` is a tab, `^^` a caret. +pub fn toc_separator_out(s: &str) -> String { + s.replace('^', "^^").replace('\t', "^t") +} +/// IDML TOC separator → text (`^t` and `^y`, the right indent tab, are tabs; other codes stay). +pub fn toc_separator_in(s: &str) -> String { + let mut out = String::new(); + let mut it = s.chars(); + while let Some(c) = it.next() { + if c != '^' { + out.push(c); + continue; + } + match it.next() { + Some('t' | 'y') => out.push('\t'), + Some('^') => out.push('^'), + Some(o) => { + out.push('^'); + out.push(o); + } + None => out.push('^'), + } + } + out +} diff --git a/crates/images/src/eps.rs b/crates/images/src/eps.rs index 01d95929..f86c4eae 100644 --- a/crates/images/src/eps.rs +++ b/crates/images/src/eps.rs @@ -1,6 +1,7 @@ -//! EPS: there's no PostScript interpreter here, so a placed EPS shows (and prints) through a -//! proxy, as InDesign does on screen: the file's TIFF preview when it has one, else a placeholder -//! the size of its bounding box. +//! EPS: there's no general PostScript interpreter here, so a placed EPS shows (and prints) +//! through a proxy, as InDesign does on screen: the file's TIFF preview when it has one, else the +//! rectangles of a simple one drawn here (InDesign's QR codes), else a placeholder the size of its +//! bounding box. /// An EPS file (plain, or DOS EPS with a binary header)? pub fn is_eps(bytes: &[u8]) -> bool { @@ -45,6 +46,10 @@ pub fn eps_proxy(bytes: &[u8]) -> Option<(Vec, (f64, f64))> { { return Some((tiff.to_vec(), (w, h))); } + // Simple PostScript drawn here: InDesign's QR codes are coloured rectangles only. + if let Some(png) = render_rectangles(sections(bytes).0, bb) { + return Some((png, (w, h))); + } let (pw, ph) = ((w * 2.0).round().clamp(2.0, 4000.0) as u32, (h * 2.0).round().clamp(2.0, 4000.0) as u32); let mut img = image::RgbaImage::from_pixel(pw, ph, image::Rgba([228, 228, 228, 255])); // Diagonals and a border mark it as a stand-in. @@ -68,10 +73,146 @@ pub fn eps_proxy(bytes: &[u8]) -> Option<(Vec, (f64, f64))> { Some((out, (w, h))) } +/// Pixels per point for rendered EPS (a QR code's modules stay crisp when scaled up on screen). +const RENDER_SCALE: f64 = 8.0; +/// The most rectangles and operators read from an EPS before giving up on drawing it. +const MAX_RECTS: usize = 200_000; +const MAX_TOKENS: usize = 2_000_000; +/// The most pixels painted, in multiples of the image's area (overlapping rectangles repaint). +const MAX_PAINT_AREAS: u64 = 64; + +/// PostScript that only fills rectangles in solid colours (`setcmykcolor`, `setrgbcolor`, +/// `setgray`, `rectfill`, and procedures that stand for one of them, such as InDesign's +/// `/rf { rectfill } bind def`), drawn as a PNG of the bounding box `bb`. None for anything else +/// (paths, text, images, transforms), which keeps its placeholder, or when nothing is drawn. +fn render_rectangles(ps: &[u8], bb: [f64; 4]) -> Option> { + let text = std::str::from_utf8(ps).ok()?; + let (w, h) = (bb[2] - bb[0], bb[3] - bb[1]); + if !(w > 0.0 && h > 0.0) { + return None; + } + let scale = RENDER_SCALE.min(4000.0 / w.max(h)); + let (pw, ph) = ((w * scale).round().max(1.0) as u32, (h * scale).round().max(1.0) as u32); + let mut img = image::RgbaImage::from_pixel(pw, ph, image::Rgba([0, 0, 0, 0])); + let mut aliases: std::collections::HashMap = std::collections::HashMap::new(); + let mut stack: Vec = Vec::new(); + let mut color = [0u8, 0, 0, 255]; + let mut drawn = 0usize; + let mut painted = 0u64; + let paint_budget = u64::from(pw) * u64::from(ph) * MAX_PAINT_AREAS; + // Tokens without comments; `{ … }` procedures kept whole. + let mut tokens: Vec = Vec::new(); + for line in text.lines() { + let line = line.split('%').next().unwrap_or(""); + let spaced = line.replace('{', " { ").replace('}', " } "); + tokens.extend(spaced.split_whitespace().map(str::to_string)); + if tokens.len() > MAX_TOKENS { + return None; + } + } + let unit = |v: f64| v.clamp(0.0, 1.0); + let mut i = 0; + while let Some(tok) = tokens.get(i) { + i += 1; + if let Ok(v) = tok.parse::() { + if !v.is_finite() { + return None; + } + stack.push(v); + continue; + } + // `/name { op } bind def`: name stands for op. + if let Some(name) = tok.strip_prefix('/') { + // At most `{ op } bind`: anything longer isn't an alias this reader knows. + let body: Vec<&String> = tokens.iter().skip(i).take(5).take_while(|t| *t != "def").collect(); + let op = match body.as_slice() { + [open, op, close] | [open, op, close, _] if *open == "{" && *close == "}" => (*op).clone(), + _ => return None, + }; + aliases.insert(name.to_string(), op); + i += body.len() + 1; + continue; + } + let op = aliases.get(tok).map_or(tok.as_str(), String::as_str); + let mut pop = |n: usize| -> Option> { (stack.len() >= n).then(|| stack.split_off(stack.len() - n)) }; + match op { + "setcmykcolor" => { + let v = pop(4)?; + let k = unit(v[3]); + let ch = |c: f64| ((1.0 - unit(c)) * (1.0 - k) * 255.0).round() as u8; + color = [ch(v[0]), ch(v[1]), ch(v[2]), 255]; + } + "setrgbcolor" => { + let v = pop(3)?; + let ch = |c: f64| (unit(c) * 255.0).round() as u8; + color = [ch(v[0]), ch(v[1]), ch(v[2]), 255]; + } + "setgray" => { + let v = pop(1)?; + let g = (unit(v[0]) * 255.0).round() as u8; + color = [g, g, g, 255]; + } + "rectfill" => { + let v = pop(4)?; + drawn += 1; + if drawn > MAX_RECTS { + return None; + } + // PostScript y runs up; the image's down. Edges round to whole pixels, so + // touching rectangles (QR code modules) meet without gaps. + let (x0, x1) = ((v[0] - bb[0]) * scale, (v[0] + v[2] - bb[0]) * scale); + let (y0, y1) = ((bb[3] - (v[1] + v[3])) * scale, (bb[3] - v[1]) * scale); + let px = |a: f64, b: f64, max: u32| { + let (lo, hi) = (a.min(b).round().max(0.0), a.max(b).round().min(max as f64)); + (lo as u32, hi.max(lo) as u32) + }; + let ((xa, xb), (ya, yb)) = (px(x0, x1, pw), px(y0, y1, ph)); + painted = painted.saturating_add(u64::from(xb - xa) * u64::from(yb - ya)); + if painted > paint_budget { + return None; + } + for y in ya..yb { + for x in xa..xb { + img.put_pixel(x, y, image::Rgba(color)); + } + } + } + "bind" | "gsave" | "grestore" | "newpath" | "showpage" | "save" | "restore" | "pop" | "end" | "begin" => {} + _ => return None, + } + } + if drawn == 0 { + return None; + } + let mut out = Vec::new(); + image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).ok()?; + Some(out) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn simple_rectangle_eps_is_drawn_like_a_qr_code() { + // As InDesign writes QR codes: a procedure for rectfill, a colour, then modules. + let eps = b"%!PS-Adobe-3.1 EPSF-3.0\n%%BoundingBox: 0 0 4 4\n%%EndComments\n/rf { rectfill } bind def\n0 0 0 1 setcmykcolor\n0 2 2 2 rf\n2 0 2 2 rf\n%%EOF\n"; + let (png, size) = eps_proxy(eps).unwrap(); + assert_eq!(size, (4.0, 4.0)); + let img = image::load_from_memory(&png).unwrap().to_rgba8(); + assert_eq!(img.dimensions(), (32, 32), "8 pixels per point"); + // PostScript y runs up: (0, 2)–(2, 4) is the image's top left. + assert_eq!(img.get_pixel(4, 4).0, [0, 0, 0, 255]); + assert_eq!(img.get_pixel(28, 28).0, [0, 0, 0, 255]); + assert_eq!(img.get_pixel(28, 4).0[3], 0, "undrawn stays transparent"); + assert_eq!(img.get_pixel(4, 28).0[3], 0); + // Anything else (here a path) keeps the placeholder. + let curve = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 4 4\n0 0 moveto 4 4 lineto stroke\n%%EOF\n"; + let (png, _) = eps_proxy(curve).unwrap(); + let img = image::load_from_memory(&png).unwrap().to_rgba8(); + assert_eq!(img.get_pixel(2, 4).0, [228, 228, 228, 255]); + } + #[test] fn eps_bounding_box_and_proxies() { let eps = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 50\n%%HiResBoundingBox: 0 0 100.5 50.25\nnewpath\n%%EOF\n"; diff --git a/crates/images/src/lib.rs b/crates/images/src/lib.rs index 4751263a..7ce48c0a 100644 --- a/crates/images/src/lib.rs +++ b/crates/images/src/lib.rs @@ -140,6 +140,10 @@ pub fn natural_size(bytes: &[u8]) -> Option<(f64, f64)> { let s = svg_tree(bytes)?.size(); return Some((s.width() as f64 * PT_PER_PX, s.height() as f64 * PT_PER_PX)); } + if is_eps(bytes) { + let bb = eps::bounding_box(bytes)?; + return Some((bb[2] - bb[0], bb[3] - bb[1])).filter(|(w, h)| w.is_finite() && h.is_finite() && *w > 0.0 && *h > 0.0); + } let (w, h) = pixel_size(bytes)?; Some((w as f64, h as f64)) } @@ -159,6 +163,11 @@ pub fn decode_rgba(bytes: &[u8]) -> Option { if is_psd(bytes) { return psd::decode(bytes); } + // An EPS is drawn as its proxy (preview, simple PostScript or placeholder); the document + // keeps the EPS itself. + if is_eps(bytes) { + return decode_rgba(&eps_proxy(bytes)?.0); + } Some(decode_to_rgba8(&image::load_from_memory(bytes).ok()?)) } @@ -386,6 +395,19 @@ mod tests { b } + /// A placed EPS is measured by its bounding box in points and drawn through its proxy; the + /// bytes stay the EPS. + #[test] + fn eps_draws_through_its_proxy_at_its_bounding_box_size() { + let eps = + b"%!PS-Adobe-3.1 EPSF-3.0\n%%BoundingBox: 0 0 4 2\n%%EndComments\n/rf { rectfill } bind def\n0 0 0 1 setcmykcolor\n0 0 2 2 rf\n%%EOF\n"; + assert_eq!(natural_size(eps), Some((4.0, 2.0))); + let img = decode_rgba(eps).expect("drawn"); + assert_eq!(img.dimensions(), (32, 16)); + assert_eq!(img.get_pixel(1, 1).0, [0, 0, 0, 255], "the rectangle"); + assert_eq!(img.get_pixel(30, 1).0[3], 0, "outside it, transparent"); + } + /// CMYK TIFFs read back as their ink values; RGB ones aren't CMYK. #[test] fn cmyk_tiff_keeps_inks() { diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index f9d09b61..f332b92c 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -989,7 +989,15 @@ impl Exporter<'_> { let (swatch, tint, angle) = (fill.swatch.as_str(), fill.tint, fill.gradient_angle); if let Some(g) = designcraft_color::swatch::resolve_gradient(&self.doc.swatches, swatch) { let kind = g.kind; - let expanded = g.expanded_stops(); + let mut expanded = g.expanded_stops(); + // Grey and CMYK stops blend as inks, so a grey stop beside a CMYK one is the K ink. + if !self.rgb_only && expanded.iter().any(|(_, c, _)| matches!(c, Color::Cmyk { .. })) { + for (_, c, _) in &mut expanded { + if let Color::Gray { k } = *c { + *c = Color::cmyk(0.0, 0.0, 0.0, k); + } + } + } // A PDF shading needs one colour space. Midpoint expansion can introduce RGB even // when every authored stop is CMYK/Gray; PDF/A can also change the output space. let components = |c: &Color| match c { diff --git a/crates/pdf/tests/gradient_color_spaces.rs b/crates/pdf/tests/gradient_color_spaces.rs index c523a418..05806a12 100644 --- a/crates/pdf/tests/gradient_color_spaces.rs +++ b/crates/pdf/tests/gradient_color_spaces.rs @@ -53,7 +53,7 @@ fn mixed_gradient_export_survives_in_subprocess() { let rgb = Color::rgb(1.0, 0.0, 0.0); let cmyk = Color::cmyk(1.0, 0.0, 0.0, 0.0); let gray = Color::gray(0.4); - for colors in [vec![cmyk, rgb], vec![rgb, cmyk], vec![rgb, gray], vec![gray, cmyk], vec![gray, rgb, cmyk]] { + for colors in [vec![cmyk, rgb], vec![rgb, cmyk], vec![rgb, gray], vec![gray, rgb, cmyk]] { for kind in [GradientKind::Linear, GradientKind::Radial] { for standard in [Standard::None, Standard::PdfX4, Standard::PdfA2b] { let d = document(&colors, kind, 0.5, 0.7); @@ -78,15 +78,14 @@ fn mixed_gradient_export_survives_in_subprocess() { } } } - // Expansion inserts an RGB midpoint even when the authoring stops share CMYK or Gray. + // A midpoint's expanded stop blends in the stops' own space: CMYK or Gray stays homogeneous. for colors in [[cmyk, Color::cmyk(0.0, 1.0, 0.0, 0.0)], [gray, Color::gray(0.8)]] { for kind in [GradientKind::Linear, GradientKind::Radial] { for standard in [Standard::None, Standard::PdfX4, Standard::PdfA2b] { let report = export_pdf_with_report(&document(&colors, kind, 0.25, 1.0), &Cache::new(), &PdfOptions { standard, ..Default::default() }) .unwrap(); - let archival_cmyk = standard == Standard::PdfA2b && matches!(colors[0], Color::Cmyk { .. }); - assert_eq!(conversion_warnings(&report.warnings), usize::from(!archival_cmyk), "{standard:?}: {:?}", report.warnings); + assert_eq!(conversion_warnings(&report.warnings), 0, "{standard:?}: {:?}", report.warnings); assert_eq!(Pdf::new(report.bytes).unwrap().pages().len(), 1); } } @@ -108,6 +107,8 @@ fn homogeneous_gradients_keep_device_spaces_and_components() { ([Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0)], "DeviceRGB", vec![1.0, 0.0, 0.0], vec![0.0, 0.0, 1.0]), ([Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::cmyk(0.0, 1.0, 0.0, 0.0)], "DeviceCMYK", vec![1.0, 0.0, 0.0, 0.0], vec![0.0, 1.0, 0.0, 0.0]), ([Color::gray(0.2), Color::gray(0.8)], "DeviceGray", vec![0.8], vec![0.2]), + // Grey beside CMYK is the K ink, so the shading stays CMYK. + ([Color::gray(0.4), Color::cmyk(1.0, 0.0, 0.0, 0.0)], "DeviceCMYK", vec![0.0, 0.0, 0.0, 0.4], vec![1.0, 0.0, 0.0, 0.0]), ] { for standard in [Standard::None, Standard::PdfX4] { let report = export_pdf_with_report( diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index dd2225b7..46788271 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -880,7 +880,9 @@ pub fn color_of(c: &designcraft_color::Color, alpha: f32) -> peniko::Color { fn set_fill_paint(ctx: &mut RenderContext, doc: &Document, fill: &designcraft_doc::Fill, bounds: Rect) -> bool { let (swatch, tint, angle) = (fill.swatch.as_str(), fill.tint, fill.gradient_angle); if let Some(g) = designcraft_color::swatch::resolve_gradient(&doc.swatches, swatch) { - let stops: Vec = g.expanded_stops().iter().map(|(o, c, a)| peniko::ColorStop::from((*o, color_of(c, *a)))).collect(); + // Sampled so process colours blend as inks, as InDesign shows them (and as the PDF's CMYK + // shading prints). + let stops: Vec = g.display_stops().iter().map(|(o, c, a)| peniko::ColorStop::from((*o, color_of(c, *a)))).collect(); if stops.is_empty() { return false; } @@ -1081,18 +1083,20 @@ pub fn pdf_page_box(bytes: &[u8], page: usize, kind: &str) -> Option<(f64, f64, let pdf = hayro::hayro_syntax::Pdf::new(std::sync::Arc::new(bytes.to_vec())).ok()?; let p = pdf.pages().get(page)?; let visible = p.intersected_crop_box(); + // The boxes are in user space units; `/UserUnit` is the size of one in points. + let unit = p.user_unit() as f64; let key: &[u8] = match kind { "trim" => keys::TRIM_BOX, "bleed" => keys::BLEED_BOX, "art" => keys::ART_BOX, - "media" => return Some(rel(visible, p.media_box())), - _ => return Some(rel(visible, visible)), + "media" => return Some(rel(visible, p.media_box(), unit)), + _ => return Some(rel(visible, visible, unit)), }; let b = p.raw().get::(key).map(|b| b.intersect(p.media_box())).unwrap_or(visible); - fn rel(v: PRect, b: PRect) -> (f64, f64, f64, f64) { - (b.x0 - v.x0, v.y1 - b.y1, b.width(), b.height()) + fn rel(v: PRect, b: PRect, unit: f64) -> (f64, f64, f64, f64) { + ((b.x0 - v.x0) * unit, (v.y1 - b.y1) * unit, b.width() * unit, b.height() * unit) } - Some(rel(visible, b)) + Some(rel(visible, b, unit)) } /// Separations Preview on a rendered image: `plate` 0–3 shows that process plate (C, M, Y, K) as diff --git a/crates/render/tests/gradient_inks.rs b/crates/render/tests/gradient_inks.rs new file mode 100644 index 00000000..8bb407a6 --- /dev/null +++ b/crates/render/tests/gradient_inks.rs @@ -0,0 +1,36 @@ +//! A gradient between process colours shows the inks' blend, as InDesign shows it. +use designcraft_color::swatch::Swatch; +use designcraft_color::{Color, Gradient, GradientKind, GradientStop, SwatchValue}; +use designcraft_compose::Cache; +use designcraft_doc::build::NewDocument; +use designcraft_doc::{Document, Fill, Item, ItemId, Shape, SpreadRef, Stroke}; +use designcraft_geom::{Affine, Rect, shapes}; +use designcraft_render::{Placed, RenderOptions, Renderer}; + +#[test] +fn a_cmyk_gradient_is_c50_m50_halfway() { + let mut d = Document::new(&NewDocument { width: 100.0, height: 20.0, facing_pages: false, ..Default::default() }); + let stop = |offset: f32, color: Color| GradientStop { offset, color, opacity: 1.0, midpoint: 0.5 }; + let gradient = + Gradient { kind: GradientKind::Linear, stops: vec![stop(0.0, Color::cmyk(0.0, 1.0, 0.0, 0.0)), stop(1.0, Color::cmyk(1.0, 0.0, 0.0, 0.0))] }; + d.swatches.push(Swatch { name: "Magenta to Cyan".into(), value: SwatchValue::Gradient { gradient }, locked: false, named: true, hidden: false }); + let id = ItemId(d.alloc()); + let mut item = Item::new(id, d.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 20.0))); + item.fill = Fill::swatch("Magenta to Cyan"); + item.stroke = Stroke::default(); + d.insert_item(SpreadRef::Doc(0), item, None).unwrap(); + let img = Renderer::new().render( + &d, + &Cache::new(), + &[Placed { spread: SpreadRef::Doc(0), xf: Affine::IDENTITY }], + 100, + 20, + Affine::IDENTITY, + &RenderOptions { paper: false, background: Some([255; 4]), ..Default::default() }, + ); + let want = Color::cmyk(0.5, 0.5, 0.0, 0.0).to_rgba8(1.0); + let got = img.pixel(50, 10); + for ch in 0..3 { + assert!(got[ch].abs_diff(want[ch]) <= 4, "halfway {got:?}, the inks' blend is {want:?}"); + } +} diff --git a/crates/render/tests/placed_pdf.rs b/crates/render/tests/placed_pdf.rs new file mode 100644 index 00000000..d7487086 --- /dev/null +++ b/crates/render/tests/placed_pdf.rs @@ -0,0 +1,249 @@ +//! Placed PDFs are drawn through pdfcraft's patched hayro crates. Calculator functions whose `idiv` +//! divides by zero or overflows, stitching functions nested a hundred thousand deep, and mesh +//! shadings with points far off the page, ten thousand overlapping triangles or forty thousand +//! patches do not panic, overflow the stack or take more than a minute, and the rest of the page is +//! drawn. A page with a `/UserUnit` is sized in points, and so are its boxes. The function and mesh +//! cases follow pdfcraft's tests in `crates/render/src/raster.rs`. + +use std::sync::Arc; +use std::sync::mpsc::{self, RecvTimeoutError}; +use std::time::Duration; + +use designcraft_compose::Cache; +use designcraft_doc::build::NewDocument; +use designcraft_doc::{Asset, AssetId, Content, Document, Graphic, Item, ItemId, Shape, SpreadRef}; +use designcraft_geom::{Affine, Rect, shapes}; +use designcraft_render::{Rendered, Renderer, pdf_page_box, pdf_page_size}; +use hayro::hayro_interpret::Function; +use hayro::hayro_syntax::Pdf; +use hayro::hayro_syntax::object::{Object, ObjectIdentifier}; + +const WHITE: [u8; 4] = [255, 255, 255, 255]; +const RED: [u8; 4] = [255, 0, 0, 255]; +const BLUE: [u8; 4] = [0, 0, 255, 255]; + +/// An exponential function of one input with one output. +const LEAF: &str = "<>"; + +/// Mesh entries common to the shadings below: RGB colors, 8-bit coordinates and components. +const MESH: &str = "/ColorSpace /DeviceRGB /BitsPerCoordinate 8 /BitsPerComponent 8"; + +/// One triangle of a free-form triangle mesh: three vertices of a flag, x, y and a color. +const TRIANGLE: [u8; 18] = [0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 255, 0, 0, 0, 255, 0, 0, 255]; + +/// A stream object with the `dict` entries, its `/Length` and `data`. +fn stream(dict: &str, data: &[u8]) -> Vec { + let mut s = format!("<<{dict} /Length {}>>\nstream\n", data.len()).into_bytes(); + s.extend_from_slice(data); + s.extend_from_slice(b"\nendstream"); + s +} + +/// A one-page PDF whose page dictionary holds the `page` entries (boxes, resources) and whose page +/// draws `content`. The `objects` are numbered from 5. +fn pdf(page: &str, content: &str, objects: &[Vec]) -> Vec { + let mut bodies = vec![ + b"<>".to_vec(), + b"<>".to_vec(), + format!("<>").into_bytes(), + stream("", content.as_bytes()), + ]; + bodies.extend_from_slice(objects); + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, body) in bodies.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(body); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f\r\n", bodies.len() + 1).as_bytes()); + for offset in offsets { + out.extend_from_slice(format!("{offset:010} 00000 n\r\n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<>\nstartxref\n{xref}\n%%EOF\n", bodies.len() + 1).as_bytes()); + out +} + +/// The page of a document `size` points square with `pdf` placed to fill it, rendered by +/// `Renderer::render_page` at one pixel per point. Fails when the render panics or takes more +/// than a minute. +fn placed(size: f64, pdf: Vec) -> Rendered { + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let mut d = Document::new(&NewDocument { width: size, height: size, facing_pages: false, ..Default::default() }); + let id = AssetId(d.alloc()); + let asset = + d.add_asset(Asset { id, name: "art.pdf".into(), mime: "application/pdf".into(), link: None, data: Arc::new(pdf), pixels: None, page: 0 }); + let id = ItemId(d.alloc()); + let mut item = Item::new(id, d.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, size, size))); + item.content = + Content::Graphic(Graphic { asset, size: (size, size), xf: Affine::IDENTITY, auto_fit: Default::default(), fit_align: 4, crop: [0.0; 4] }); + d.insert_item(SpreadRef::Doc(0), item, None).unwrap(); + let mut renderer = Renderer::new(); + renderer.threads = 0; + let _ = tx.send(renderer.render_page(&d, &Cache::new(), 0, 1.0, false, &Default::default())); + }); + match rx.recv_timeout(Duration::from_secs(60)) { + Ok(page) => page.expect("the page renders"), + Err(RecvTimeoutError::Timeout) => panic!("the placed PDF did not render within a minute"), + Err(RecvTimeoutError::Disconnected) => panic!("the render of the placed PDF panicked"), + } +} + +/// A 40-point page that paints an axial shading through the first of `functions` (objects 6 +/// onwards), then a blue square over the whole page. +fn shaded(functions: &[Vec]) -> Vec { + let mut objects = vec![b"<>".to_vec()]; + objects.extend_from_slice(functions); + pdf("/MediaBox[0 0 40 40]/Resources<>>>", "/S sh 0 0 1 rg 0 0 40 40 re f", &objects) +} + +/// A calculator (Type 4) function of one input that runs `program`. +fn calculator(program: &str) -> Vec { + stream("/FunctionType 4/Domain[0 1]/Range[0 1 0 1 0 1]", program.as_bytes()) +} + +/// The function that is object 5 of a PDF holding `objects`. +fn function(objects: &[&str]) -> Option { + let objects: Vec> = objects.iter().map(|o| o.as_bytes().to_vec()).collect(); + let parsed = Pdf::new(pdf("/MediaBox[0 0 20 20]", "", &objects)).unwrap(); + let object = parsed.xref().get::>(ObjectIdentifier::new(5, 0)).unwrap(); + Function::new(&object) +} + +#[test] +fn placed_pdf_function_dividing_by_zero_leaves_the_page_drawn() { + assert_eq!(placed(40.0, shaded(&[calculator("{ pop 1 0 idiv }")])).pixel(20, 20), BLUE); +} + +#[test] +fn placed_pdf_function_dividing_the_smallest_integer_by_minus_one_leaves_the_page_drawn() { + assert_eq!(placed(40.0, shaded(&[calculator("{ pop -2147483648 -1 idiv }")])).pixel(20, 20), BLUE); +} + +/// A chain of 100,000 functions: each stitching function names the next by reference, and the last +/// is an exponential function. +#[test] +fn placed_pdf_chain_of_stitching_functions_leaves_the_page_drawn() { + let mut functions: Vec> = + (7..100_006).map(|next| format!("<>").into_bytes()).collect(); + functions.push(b"<>".to_vec()); + assert_eq!(placed(40.0, shaded(&functions)).pixel(20, 20), BLUE); +} + +/// Stitching functions that each name the next through `N 0 R`: 64 evaluate, 65 are refused. +#[test] +fn function_limits_depth_guard_refuses_a_chain_of_references() { + for functions in [64, 65] { + let objects: Vec = (5..5 + functions) + .map(|number| { + if number < 4 + functions { + format!("<>", number + 1) + } else { + LEAF.to_owned() + } + }) + .collect(); + let objects: Vec<&str> = objects.iter().map(String::as_str).collect(); + let chain = function(&objects); + if functions == 64 { + assert_eq!(chain.unwrap().eval([0.5].into_iter().collect()).unwrap().as_slice(), &[0.5]); + } else { + assert!(chain.is_none(), "65 functions are one past the function-depth guard"); + } + } +} + +/// Stitching functions with a cycle, no children or a missing child are refused; one child named +/// twice still evaluates. +#[test] +fn function_limits_stitching_cycles_are_refused_and_shared_children_work() { + for objects in [ + &["<>"][..], + &[ + "<>", + "<>", + ], + &["<>"], + &["<>"], + &["<>", LEAF], + ] { + assert!(function(objects).is_none(), "accepted invalid children: {objects:?}"); + } + let shared = function(&["<>", LEAF]).unwrap(); + let output = shared.eval([0.75].into_iter().collect()).unwrap(); + assert!((output[0] - 0.5).abs() < 0.001); +} + +/// Ten thousand triangles that each cover half of the placed page, which is drawn 1000 pixels +/// square. +#[test] +fn many_overlapping_mesh_triangles_finish() { + let shading = stream(&format!("/ShadingType 4 {MESH} /BitsPerFlag 8 /Decode [0 125 0 125 0 1 0 1 0 1]"), &TRIANGLE.repeat(10_000)); + let page = placed(125.0, pdf("/MediaBox[0 0 125 125]/Resources<>>>", "/Sh0 sh", &[shading])); + let p = page.pixel(12, 112); + assert!(p != WHITE && p[3] == 255, "the triangles are drawn: {p:?}"); +} + +/// One Coons patch: its flag, twelve points around a square of 8-bit coordinates, four colors. +fn coons() -> Vec { + let mut patch = vec![0u8]; + for p in [(0, 0), (0, 85), (0, 170), (0, 255), (85, 255), (170, 255), (255, 255), (255, 170), (255, 85), (255, 0), (170, 0), (85, 0)] { + patch.extend_from_slice(&[p.0, p.1]); + } + patch.extend_from_slice(&[255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 0]); + patch +} + +/// A 40-point page that paints `shading` (object 5), then a red square at its bottom left. +fn meshed(shading: Vec) -> Vec { + pdf("/MediaBox[0 0 40 40]/Resources<>>>", "/Sh0 sh 1 0 0 rg 0 0 4 4 re f", &[shading]) +} + +/// Meshes whose points lie far off the page are sampled only where the page shows them. +#[test] +fn mesh_shadings_far_off_the_page_render() { + let far = "/Decode [-40000 40000 -40000 40000 0 1 0 1 0 1]"; + for (what, shading) in [ + ("a Coons patch", stream(&format!("/ShadingType 6 {MESH} /BitsPerFlag 8 {far}"), &coons())), + ("a triangle mesh", stream(&format!("/ShadingType 4 {MESH} /BitsPerFlag 8 {far}"), &TRIANGLE)), + ] { + assert_eq!(placed(40.0, meshed(shading)).pixel(1, 38), RED, "{what} far off the page: the rest of the page draws"); + } + for (what, decode) in [("on the page", "[0 40 0 40 0 1 0 1 0 1]"), ("covering the page", "[-40000 40000 -40000 40000 0 1 0 1 0 1]")] { + let page = placed(40.0, meshed(stream(&format!("/ShadingType 6 {MESH} /BitsPerFlag 8 /Decode {decode}"), &coons()))); + let center = page.pixel(20, 20); + assert!(center != WHITE && center[3] == 255, "a patch {what} is shaded: {center:?}"); + } + // A patch filling a 4 × 4 pt rectangle as a pattern is still shaded. + let shading = stream(&format!("/ShadingType 6 {MESH} /BitsPerFlag 8 /Decode [10 14 10 14 0 1 0 1 0 1]"), &coons()); + let page = placed( + 40.0, + pdf("/MediaBox[0 0 40 40]/Resources<>>>>>", "/Pattern cs /P0 scn 10 10 4 4 re f", &[shading]), + ); + let middle = page.pixel(12, 28); + assert!(middle != WHITE && middle[3] == 255, "a small patch is shaded: {middle:?}"); +} + +/// Forty thousand Coons patches beside the page, and a lattice with no vertices per row. +#[test] +fn mesh_shadings_with_many_patches_or_no_rows_finish() { + for shading in [ + stream(&format!("/ShadingType 6 {MESH} /BitsPerFlag 8 /Decode [100 140 100 140 0 1 0 1 0 1]"), &coons().repeat(40_000)), + stream(&format!("/ShadingType 5 {MESH} /VerticesPerRow 0 /Decode [0 40 0 40 0 1 0 1 0 1]"), &[0; 64]), + ] { + assert_eq!(placed(40.0, meshed(shading)).pixel(1, 38), RED, "the rest of the page draws"); + } +} + +/// A page with `/UserUnit 2` is twice its media box in points, and Image Import Options › Crop +/// to measures the trim box in points as well. +#[test] +fn a_page_with_a_user_unit_and_its_boxes_are_sized_in_points() { + let page = pdf("/MediaBox[0 0 100 50]/TrimBox[10 10 90 40]/UserUnit 2", "", &[]); + assert_eq!(pdf_page_size(&page, 0), Some((200.0, 100.0))); + assert_eq!(pdf_page_box(&page, 0, "trim"), Some((20.0, 20.0, 160.0, 60.0))); + assert_eq!(pdf_page_box(&page, 0, "media"), Some((0.0, 0.0, 200.0, 100.0))); +} diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 8dbc262b..2542767a 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -15,6 +15,7 @@ mod frame; mod gradient; pub mod layout; mod nav; +pub use nav::{LOCK_OFFSET_PX, PIN_OFFSET_PX}; mod pen; mod pencil; pub mod select; @@ -322,6 +323,28 @@ pub enum Overlay { b: Point, label: String, }, + /// A page selected with the Page tool (canvas rect): its outline and eight handles. + PageHandles(Rect), + /// A small tag whose bottom-left is at `p` (the Page tool's liquid page rule; English, the UI + /// translates it). + Tag { + p: Point, + text: String, + }, + /// A tooltip near the pointer (English; the UI translates it). + Tip { + p: Point, + text: String, + }, + /// An object's liquid pins and resize locks on an object-based page (Page tool), canvas + /// coordinates: pinned sides (top, right, bottom, left) and whether width and height follow the + /// page. + LiquidPins { + object: Rect, + page: Rect, + pins: [bool; 4], + resize: [bool; 2], + }, } /// Correction from a snap, in spread coordinates for `delta`. @@ -412,7 +435,7 @@ pub fn create(id: &str) -> Box { "hand" => Box::new(nav::HandTool::default()), "placeGun" => Box::new(nav::PlaceGun::default()), "zoom" => Box::new(nav::ZoomTool::default()), - "page" => Box::new(nav::PageTool), + "page" => Box::new(nav::PageTool::default()), "typeOnPath" => Box::new(nav::PathTypeTool), "gap" => Box::new(nav::GapTool::default()), "contentCollector" => Box::new(nav::ConveyorTool::new(false)), diff --git a/crates/tools/src/nav.rs b/crates/tools/src/nav.rs index d892b090..bd53e4ca 100644 --- a/crates/tools/src/nav.rs +++ b/crates/tools/src/nav.rs @@ -3,7 +3,7 @@ use designcraft_geom::Point; use serde_json::json; -use crate::{Action, Cursor, Mods, PointerEvent, PointerKind, Tool, ToolContext}; +use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext}; /// Hand tool: drag to pan. Alt-press starts Power Zoom: the view zooms out to the spread, the /// pointer aims a rectangle of the previous view, and releasing zooms back in there. @@ -162,27 +162,293 @@ impl Tool for PlaceGun { } } -/// Page tool (Shift+P): click a page to set its own size. +/// Page tool (Shift+P): click a page to select it; drag one of its handles to preview its liquid +/// page rule (the page springs back on release), Alt-drag to resize it, Shift to keep its +/// proportions; double-click a page for exact sizes. #[derive(Default)] -pub struct PageTool; +pub struct PageTool { + drag: Option, + /// The pin or lock under the pointer (for its tooltip). + hover: Option<(LiquidControl, Point)>, +} + +/// An object's liquid control on an object-based page: a side's pin (top, right, bottom, left) or +/// the width/height lock. +#[derive(Clone, Copy, Debug, PartialEq)] +enum LiquidControl { + Pin(usize), + Width, + Height, +} + +impl LiquidControl { + /// The `liquid.object` parameter it toggles. + fn key(self) -> &'static str { + match self { + LiquidControl::Pin(0) => "pinTop", + LiquidControl::Pin(1) => "pinRight", + LiquidControl::Pin(2) => "pinBottom", + LiquidControl::Pin(_) => "pinLeft", + LiquidControl::Width => "resizeWidth", + LiquidControl::Height => "resizeHeight", + } + } + + /// InDesign's tooltip for the control in its current state. + fn tip(self, l: &designcraft_doc::ObjectLiquid) -> &'static str { + match self { + LiquidControl::Pin(i) if [l.pin_top, l.pin_right, l.pin_bottom, l.pin_left][i.min(3)] => { + "Object is pinned relative to the page edge. Click to release." + } + LiquidControl::Pin(_) => "Click to pin the object relative to the page edge.", + LiquidControl::Width if l.resize_width => "The object changes width as the page changes width. Click to lock the object width.", + LiquidControl::Width => "The object's width is locked. Click to let the object change width as the page changes width.", + LiquidControl::Height if l.resize_height => "The object changes height as the page changes height. Click to lock the object height.", + LiquidControl::Height => "The object's height is locked. Click to let the object change height as the page changes height.", + } + } + + fn value(self, l: &designcraft_doc::ObjectLiquid) -> bool { + match self { + LiquidControl::Pin(0) => l.pin_top, + LiquidControl::Pin(1) => l.pin_right, + LiquidControl::Pin(2) => l.pin_bottom, + LiquidControl::Pin(_) => l.pin_left, + LiquidControl::Width => l.resize_width, + LiquidControl::Height => l.resize_height, + } + } +} + +/// Screen pixels between an object's edge and its pin (the canvas draws them there too). +pub const PIN_OFFSET_PX: f64 = 14.0; +/// Screen pixels between an object's centre and its width/height locks. +pub const LOCK_OFFSET_PX: f64 = 10.0; + +/// Where an object's liquid controls sit (canvas): pins outside each edge's middle, the width lock +/// right of the centre, the height lock above it. +fn liquid_control_points(cx: &ToolContext, r: designcraft_geom::Rect) -> [(LiquidControl, Point); 6] { + let (c, o, k) = (r.center(), cx.tol(PIN_OFFSET_PX), cx.tol(LOCK_OFFSET_PX)); + [ + (LiquidControl::Pin(0), Point::new(c.x, r.y0 - o)), + (LiquidControl::Pin(1), Point::new(r.x1 + o, c.y)), + (LiquidControl::Pin(2), Point::new(c.x, r.y1 + o)), + (LiquidControl::Pin(3), Point::new(r.x0 - o, c.y)), + (LiquidControl::Width, Point::new(c.x + k, c.y)), + (LiquidControl::Height, Point::new(c.x, c.y - k)), + ] +} + +/// A page handle being dragged. +#[derive(Clone, Copy, Debug)] +struct PageDrag { + /// Absolute page index. + page: usize, + /// Reference-point index of the handle (0–8, row-major; 4 is the centre and has none). + handle: usize, + start: Point, + cur: Point, + /// The page's size when the drag began. + size: (f64, f64), + alt: bool, +} + +/// The eight handles of a page rect: (reference-point index, canvas point). +fn page_handles(r: designcraft_geom::Rect) -> impl Iterator { + (0..9).filter(|i| *i != 4).map(move |i| (i, Point::new(r.x0 + r.width() * (i % 3) as f64 / 2.0, r.y0 + r.height() * (i / 3) as f64 / 2.0))) +} + +/// The liquid page rule as the Page tool tags a page. +pub fn rule_label(rule: designcraft_doc::LiquidRule) -> &'static str { + use designcraft_doc::LiquidRule as R; + match rule { + R::Off => "Off", + R::Scale => "Scale", + R::ReCenter => "Re-center", + R::ObjectBased => "Object-Based", + R::GuideBased => "Guide-Based", + R::ControlledByParent => "Controlled by Parent", + } +} + +/// Whether page `abs` follows the object-based rule (its own, or its parent page's). +fn object_based(doc: &designcraft_doc::Document, abs: usize) -> bool { + use designcraft_doc::LiquidRule as R; + let Some((si, pi)) = doc.page_loc(abs) else { return false }; + match doc.spreads[si].pages[pi].liquid { + R::ObjectBased => true, + R::ControlledByParent => { + doc.parent_page_for(abs).and_then(|(ppi, ppg)| doc.parents.get(ppi)?.pages.get(ppg).map(|p| p.liquid == R::ObjectBased)).unwrap_or(false) + } + _ => false, + } +} + +impl PageTool { + /// The selected object's liquid settings, canvas bounds and page bounds when the Page tool + /// shows its pins: one object selected on the selected page, whose rule is object-based. + fn liquid_target( + cx: &ToolContext, + ) -> Option<(designcraft_doc::ItemId, designcraft_doc::ObjectLiquid, designcraft_geom::Rect, designcraft_geom::Rect)> { + let page = cx.selection.page?; + let [id] = cx.selection.items.as_slice() else { return None }; + let (si, _) = cx.doc.page_loc(page)?; + if !object_based(cx.doc, page) || cx.doc.page_of_item(*id) != Some(page) { + return None; + } + let it = cx.doc.item(*id)?; + let l = it.liquid.unwrap_or(designcraft_doc::ObjectLiquid { resize_width: true, resize_height: true, ..Default::default() }); + let r = cx.layout.xf(designcraft_doc::SpreadRef::Doc(si)).transform_rect_bbox(it.bounds()); + Some((*id, l, r, cx.layout.page_rect(cx.doc, page)?)) + } + + /// The selected object's pin or lock under `p`. + fn control_at(cx: &ToolContext, p: Point) -> Option<(designcraft_doc::ItemId, designcraft_doc::ObjectLiquid, LiquidControl)> { + let (id, l, r, _) = Self::liquid_target(cx)?; + liquid_control_points(cx, r).into_iter().find(|(_, q)| (q.x - p.x).hypot(q.y - p.y) <= cx.tol(6.0)).map(|(c, _)| (id, l, c)) + } + + /// 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)) + } + + /// 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 (col, row) = (d.handle % 3, d.handle / 3); + let dx = match col { + 0 => -v.x, + 2 => v.x, + _ => 0.0, + }; + let dy = match row { + 0 => -v.y, + 2 => v.y, + _ => 0.0, + }; + let (w0, h0) = d.size; + let (mut w, mut h) = ((w0 + dx).max(1.0), (h0 + dy).max(1.0)); + if shift { + // The axis being dragged (the larger change at a corner) sets the scale. + let k = match (col == 1, row == 1) { + (true, _) => h / h0, + (_, true) => w / w0, + _ if (w / w0 - 1.0).abs() >= (h / h0 - 1.0).abs() => w / w0, + _ => h / h0, + }; + (w, h) = ((w0 * k).max(1.0), (h0 * k).max(1.0)); + } + (w, h) + } +} impl Tool for PageTool { fn id(&self) -> &'static str { "page" } fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { - if ev.kind != PointerKind::Up { - return 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 }); + return vec![Action::Begin("Resize Page".into())]; + } + // An object-based page's object: a click on a pin or lock toggles it. + if let Some((id, l, c)) = Self::control_at(cx, ev.pos) { + return vec![Action::Exec("liquid.object".into(), json!({"ids": [id.0], c.key(): !c.value(&l)}))]; + } + // 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 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))); + let ids: Vec = object.map(|id| id.0).into_iter().collect(); + if page == cx.selection.page && cx.selection.items.iter().map(|i| i.0).eq(ids.iter().copied()) { + return vec![]; + } + vec![Action::Exec("layout.selectPage".into(), json!({"page": page.map(|p| p + 1), "ids": ids}))] + } + PointerKind::Drag => { + let Some(d) = self.drag.as_mut() else { return vec![] }; + d.cur = ev.pos; + d.alt |= ev.mods.alt; + let d = *d; + 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}))] + } + PointerKind::Up => match self.drag.take() { + // Alt-drag resizes; a plain drag previews the liquid rule and springs back. + Some(d) if d.alt || ev.mods.alt => vec![Action::Commit], + Some(_) => vec![Action::Cancel], + None => vec![], + }, + 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 { + 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}))] + } + PointerKind::Move => { + self.hover = Self::control_at(cx, ev.pos).map(|(_, _, c)| (c, ev.pos)); + vec![] + } } - let Some((designcraft_doc::SpreadRef::Doc(si), sp)) = cx.layout.spread_at(ev.pos) else { return vec![] }; - let Some(spread) = cx.doc.spreads.get(si) else { return vec![] }; - let Some(pi) = spread.pages.iter().position(|p| p.bounds().contains(sp)) else { return vec![] }; - let pg = &spread.pages[pi]; - let n = cx.doc.first_page_of_spread(si) + pi + 1; - vec![Action::Dialog("cmd:layout.pageSize".into(), json!({"pages": [n], "width": pg.width, "height": pg.height}))] } - fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { - Cursor::Arrow + 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 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 { + object, + page, + pins: [l.pin_top, l.pin_right, l.pin_bottom, l.pin_left], + resize: [l.resize_width, l.resize_height], + }); + if let Some((c, p)) = self.hover { + v.push(Overlay::Tip { p, text: c.tip(&l).into() }); + } + } + if let Some(d) = self.drag { + v.push(Overlay::Measure { + p: d.cur, + text: format!( + "W: {}\nH: {}", + designcraft_geom::format_measure(pg.width, cx.unit), + designcraft_geom::format_measure(pg.height, cx.unit) + ), + }); + } + v + } + fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor { + 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)); + match handle { + Some(1 | 7) => Cursor::ResizeV, + Some(3 | 5) => Cursor::ResizeH, + Some(0 | 8) => Cursor::ResizeNwSe, + Some(_) => Cursor::ResizeNeSw, + None => Cursor::Arrow, + } + } + fn busy(&self) -> bool { + self.drag.is_some() } } diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index 6df86485..6f8493dc 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -227,14 +227,22 @@ fn hand_tool_alt_press_power_zooms() { } #[test] -fn page_tool_opens_page_size() { +fn page_tool_selects_a_page_and_opens_page_size_on_double_click() { let d = Document::new(&NewDocument::default()); let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false)); let cx = ctx(&d, &s, &c, &l); let off = l.xf(SpreadRef::Doc(0)).translation(); + let (x, y) = (200.0 + off.x + d.spreads[0].pages[0].x, 200.0 + off.y); let mut t = create("page"); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 200.0 + off.x + d.spreads[0].pages[0].x, 200.0 + off.y)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, x, y)); + assert_eq!(a, vec![Action::Exec("layout.selectPage".into(), serde_json::json!({"page": 1, "ids": []}))]); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::DoubleClick, x, y)); assert_eq!(a, vec![Action::Dialog("cmd:layout.pageSize".into(), serde_json::json!({"pages": [1], "width": 612.0, "height": 792.0}))]); + // The selected page shows its handles and its liquid page rule. + let s = Selection { page: Some(0), ..Default::default() }; + let cx = ctx(&d, &s, &c, &l); + let o = t.overlays(&cx); + assert!(matches!(o.as_slice(), [Overlay::PageHandles(_), Overlay::Tag { text, .. }] if text == "Off"), "{o:?}"); } #[test] diff --git a/crates/ui-egui/examples/ui_shot.rs b/crates/ui-egui/examples/ui_shot.rs index c0bf3894..c241df13 100644 --- a/crates/ui-egui/examples/ui_shot.rs +++ b/crates/ui-egui/examples/ui_shot.rs @@ -31,6 +31,7 @@ fn main() { } let mut harness = egui_kittest::Harness::builder() .with_size(egui::vec2(1440.0, 900.0)) + .with_step_dt(1.0 / 60.0) .with_pixels_per_point(2.0) .with_max_steps(1_000_000) .wgpu() diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index b2ce3172..9c39f0c3 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -312,7 +312,12 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { designcraft_doc::Orientation::Horizontal => (pos2(rect.min.x, p.y), pos2(rect.max.x, p.y)), designcraft_doc::Orientation::Vertical => (pos2(p.x, rect.min.y), pos2(p.x, rect.max.y)), }; - painter.line_segment([a, b], Stroke::new(1.0, col)); + // A new guide from the rulers with the Page tool is a liquid guide: dashed. + if g.from.is_none() && app.session.tool_id() == "page" { + dashed(&painter, a, b, Stroke::new(1.0, col), 6.0, 3.0); + } else { + painter.line_segment([a, b], Stroke::new(1.0, col)); + } } draw_frames(app, &painter, &xf, &doc, &layout); if app.ui.hidden_characters { @@ -415,12 +420,14 @@ fn render_texture(app: &mut DesignApp, ctx: &egui::Context, rect: Rect, xf: &Xf, let Some(st) = app.session.active() else { return }; let ppp = ctx.pixels_per_point() as f64; let doc_key = (st.uid, std::sync::Arc::as_ptr(&st.doc) as u64, st.revision, preview); + let editing_parents = st.editing_parents; // Overscan: 25% of the viewport on every side, within the GPU's maximum texture size. let max_side = ctx.input(|i| i.max_texture_side) as f32 / ppp as f32; let (ow, oh) = ((rect.width() * 0.25).min((max_side - rect.width()) / 2.0).max(0.0), (rect.height() * 0.25).min((max_side - rect.height()) / 2.0).max(0.0)); let want = |origin: Point| crate::Shown { doc: doc_key, + editing_parents, origin: Point::new(origin.x - ow as f64 / xf.zoom, origin.y - oh as f64 / xf.zoom), zoom: xf.zoom, size: (rect.width() + 2.0 * ow, rect.height() + 2.0 * oh), @@ -428,7 +435,7 @@ fn render_texture(app: &mut DesignApp, ctx: &egui::Context, rect: Rect, xf: &Xf, let target = want(xf.origin); // Does the current texture cover the viewport at this zoom and document state? let covers = |s: &crate::Shown| { - s.doc == doc_key && (s.zoom - xf.zoom).abs() < 1e-9 && { + s.doc == doc_key && s.editing_parents == editing_parents && (s.zoom - xf.zoom).abs() < 1e-9 && { let x0 = (xf.origin.x - s.origin.x) * s.zoom; let y0 = (xf.origin.y - s.origin.y) * s.zoom; x0 >= -0.5 && y0 >= -0.5 && x0 + rect.width() as f64 <= s.size.0 as f64 + 0.5 && y0 + rect.height() as f64 <= s.size.1 as f64 + 0.5 @@ -450,6 +457,7 @@ fn render_texture(app: &mut DesignApp, ctx: &egui::Context, rect: Rect, xf: &Xf, return; } let doc_changed = app.canvas.shown.is_none_or(|s| s.doc != doc_key); + let mode_changed = app.canvas.shown.is_some_and(|s| s.editing_parents != editing_parents); let zoom_same = app.canvas.shown.is_some_and(|s| (s.zoom - xf.zoom).abs() < 1e-9); let job = |app: &DesignApp| { let st = app.session.active()?; @@ -492,7 +500,9 @@ fn render_texture(app: &mut DesignApp, ctx: &egui::Context, rect: Rect, xf: &Xf, if doc_changed && zoom_same && patch_texture(app, layout, ppp, doc_key, preview) { return; } - if !has_worker || app.canvas.shown.is_none() || (doc_changed && zoom_same) { + // A mode switch changes every spread's meaning, even if the document has no damage. + // Show the new canvas immediately, including when fitting it changes the zoom. + if !has_worker || app.canvas.shown.is_none() || mode_changed || (doc_changed && zoom_same) { let t0 = crate::now_ms(); let Some((doc, placed, w, h, view, opts)) = job(app) else { return }; let img = app.canvas.renderer.render(&doc, &app.session.cache, &placed, w, h, view, &opts); @@ -500,6 +510,7 @@ fn render_texture(app: &mut DesignApp, ctx: &egui::Context, rect: Rect, xf: &Xf, app.canvas.shown = Some(target); app.canvas.shown_doc = Some(doc); app.canvas.pending = None; + app.canvas.pending_doc = None; app.perf.render_ms = crate::now_ms() - t0; return; } @@ -507,7 +518,7 @@ fn render_texture(app: &mut DesignApp, ctx: &egui::Context, rect: Rect, xf: &Xf, { // Already rendering this view? if app.canvas.pending.is_some_and(|(_, p)| { - p.doc == doc_key && (p.zoom - xf.zoom).abs() < 1e-9 && { + p.doc == doc_key && p.editing_parents == editing_parents && (p.zoom - xf.zoom).abs() < 1e-9 && { let x0 = (xf.origin.x - p.origin.x) * p.zoom; let y0 = (xf.origin.y - p.origin.y) * p.zoom; x0 >= 0.0 && y0 >= 0.0 && x0 + rect.width() as f64 <= p.size.0 as f64 && y0 + rect.height() as f64 <= p.size.1 as f64 @@ -530,7 +541,7 @@ fn render_texture(app: &mut DesignApp, ctx: &egui::Context, rect: Rect, xf: &Xf, /// Returns false when a full render is needed (view moved, too much changed, or damage unknown). fn patch_texture(app: &mut DesignApp, layout: &CanvasLayout, ppp: f64, doc_key: (u64, u64, u64, bool), preview: bool) -> bool { let (Some(sh), Some(old), Some(st)) = (app.canvas.shown, app.canvas.shown_doc.clone(), app.session.active()) else { return false }; - if sh.doc.0 != doc_key.0 || sh.doc.3 != doc_key.3 || app.canvas.texture.is_none() { + if sh.doc.0 != doc_key.0 || sh.doc.3 != doc_key.3 || sh.editing_parents != st.editing_parents || app.canvas.texture.is_none() { return false; } let new = st.doc.clone(); @@ -627,6 +638,17 @@ fn dashed(painter: &egui::Painter, a: Pos2, b: Pos2, stroke: Stroke, dash: f32, painter.extend(egui::Shape::dashed_line(&[a, b], stroke, dash, gap)); } +/// The most baseline grid lines drawn on a page: one per point of the tallest page (216 in). +const MAX_BASELINES: usize = 216 * 72; + +/// Baseline grid lines inside a page, measured from its top: `start + n * increment` for n >= 0 (the bottom +/// edge is not a line). Lines above the page are skipped without walking them, however far up the start is. +fn baseline_grid_lines(start: f64, increment: f64, height: f64) -> impl Iterator { + let step = increment.max(1.0); + let first = if start < 0.0 { start.rem_euclid(step) } else { start }; + (0..MAX_BASELINES).map(move |n| first + n as f64 * step).take_while(move |y| *y < height) +} + fn draw_guides(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document, layout: &CanvasLayout, t: &Tokens) { let s = &doc.settings; for slot in &layout.slots { @@ -647,21 +669,29 @@ fn draw_guides(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document painter.rect_stroke(rect(br), 0.0, Stroke::new(hair(painter), c32(s.bleed_color)), StrokeKind::Middle); } let pasteboard = m.inverse().transform_rect_bbox(layout.pasteboard); - for p in &sp.pages { + for (pi, p) in sp.pages.iter().enumerate() { let pr = p.bounds(); + // Page tool › Show Parent Page Overlay: the parent page's size over the page. + if p.show_parent_overlay + && let SpreadRef::Doc(si) = slot.spread + && let Some((ppi, ppg)) = doc.parent_page_for(doc.first_page_of_spread(si) + pi) + && let Some(pp) = doc.parents.get(ppi).and_then(|ps| ps.pages.get(ppg)) + { + let r = rect(DRect::new(pr.x0, pr.y0, pr.x0 + pp.width, pr.y0 + pp.height)); + painter.rect_filled(r, 0.0, Color32::from_rgba_unmultiplied(110, 110, 230, 28)); + painter.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::from_rgb(110, 110, 230)), StrokeKind::Middle); + } if !app.ui.guides { continue; } // Baseline grid. if app.ui.baseline_grid && xf.zoom >= s.baseline_grid.view_threshold { let g = &s.baseline_grid; - let mut y = g.start; - while y < p.height { + for y in baseline_grid_lines(g.start, g.increment, p.height) { painter.line_segment( [pt(Point::new(pr.x0, pr.y0 + y)), pt(Point::new(pr.x1, pr.y0 + y))], Stroke::new(hair(painter), c32(g.color).gamma_multiply(0.8)), ); - y += g.increment.max(1.0); } } // Margins (magenta) and columns (violet). @@ -985,11 +1015,12 @@ fn draw_text_selection( let range = ts.range(); // Selected table cells: tint every selected cell. if let Some(sel) = cells.filter(|c| c.story == ts.story) { + let selected = doc.story(sel.story).and_then(|s| s.tables.get(&sel.table)).map(|t| t.selected_owners(sel.range)).unwrap_or_default(); for ft in &cs.frames { let (Some((a, _)), Some(it)) = (item_canvas_xf(doc, layout, ft.frame), doc.item(ft.frame)) else { continue }; let m = a * doc.text_xf(it); for t in ft.tables.iter().filter(|t| t.table == sel.table) { - for c in t.cells.iter().filter(|c| sel.range.contains(c.row, c.col)) { + for c in t.cells.iter().filter(|c| selected.binary_search(&(c.row, c.col)).is_ok()) { let q = [ xf.to_screen(m * Point::new(c.rect.x0, c.rect.y0)), xf.to_screen(m * Point::new(c.rect.x1, c.rect.y0)), @@ -1285,11 +1316,84 @@ fn draw_tool_overlays(app: &mut DesignApp, painter: &egui::Painter, xf: &Xf) { painter.rect_filled(r, 3.0, tok.measure_bg); painter.galley(r.min + vec2(5.0, 3.0), g, tok.measure_text); } + Overlay::PageHandles(r) => { + // The Page tool's selected page: outline and eight handles, as InDesign draws them. + let r = xf.rect(r); + let col = Color32::from_rgb(79, 153, 255); + painter.rect_stroke(r, 0.0, Stroke::new(1.0, col), StrokeKind::Middle); + for i in (0..9).filter(|i| *i != 4) { + let c = pos2(r.min.x + r.width() * (i % 3) as f32 / 2.0, r.min.y + r.height() * (i / 3) as f32 / 2.0); + let h = Rect::from_center_size(c, vec2(7.0, 7.0)); + painter.rect_filled(h, 0.0, Color32::WHITE); + painter.rect_stroke(h, 0.0, Stroke::new(1.0, col), StrokeKind::Inside); + } + } + Overlay::Tag { p, text } => { + let text = crate::i18n::tr(&app.ui.language, &text).to_owned(); + let s = xf.to_screen(p); + let g = painter.layout_no_wrap(text, egui::FontId::proportional(10.0), tok.measure_text); + let r = Rect::from_min_size(s - vec2(0.0, g.size().y + 4.0), g.size() + vec2(8.0, 4.0)); + painter.rect_filled(r, 2.0, tok.measure_bg); + painter.rect_stroke(r, 2.0, Stroke::new(1.0, Color32::from_rgb(79, 153, 255)), StrokeKind::Inside); + painter.galley(r.min + vec2(4.0, 2.0), g, tok.measure_text); + } + Overlay::Tip { p, text } => { + let text = crate::i18n::tr(&app.ui.language, &text).to_owned(); + let s = xf.to_screen(p) + vec2(14.0, 14.0); + let g = painter.layout(text, egui::FontId::proportional(11.0), tok.measure_text, 260.0); + let r = Rect::from_min_size(s, g.size() + vec2(10.0, 6.0)); + painter.rect_filled(r, 3.0, tok.measure_bg); + painter.galley(r.min + vec2(5.0, 3.0), g, tok.measure_text); + } + Overlay::LiquidPins { object, page, pins, resize } => draw_liquid_pins(painter, xf.rect(object), xf.rect(page), pins, resize), Overlay::Path { .. } => {} } } } +/// An object's liquid controls on an object-based page, as InDesign draws them in orange: a +/// dashed line across the object for width and height with a padlock (closed: locked), and a pin +/// outside each edge (pinned: a line to the page edge). +fn draw_liquid_pins(painter: &egui::Painter, o: Rect, page: Rect, pins: [bool; 4], resize: [bool; 2]) { + let col = Color32::from_rgb(178, 88, 18); + let st = Stroke::new(1.5, col); + let c = o.center(); + let mids = [pos2(c.x, o.min.y), pos2(o.max.x, c.y), pos2(c.x, o.max.y), pos2(o.min.x, c.y)]; + // The width and height lines between the edges' middles. + for (a, b) in [(mids[3], mids[1]), (mids[0], mids[2])] { + dashed(painter, a, b, Stroke::new(1.0, col), 3.0, 3.0); + } + for m in mids { + painter.circle_filled(m, 3.5, col); + } + let (po, lo) = (designcraft_tools::PIN_OFFSET_PX as f32, designcraft_tools::LOCK_OFFSET_PX as f32); + let outward = [vec2(0.0, -1.0), vec2(1.0, 0.0), vec2(0.0, 1.0), vec2(-1.0, 0.0)]; + let edge = [pos2(c.x, page.min.y), pos2(page.max.x, c.y), pos2(c.x, page.max.y), pos2(page.min.x, c.y)]; + for i in 0..4 { + if pins[i] { + painter.line_segment([mids[i], edge[i]], st); + painter.circle_filled(edge[i], 4.0, col); + } else { + painter.circle_stroke(mids[i] + outward[i] * po, 4.0, st); + } + } + // Padlocks: the width lock right of the centre, the height lock above it. + for (at, locked) in [(c + vec2(lo, 0.0), !resize[0]), (c + vec2(0.0, -lo), !resize[1])] { + let body = Rect::from_center_size(at + vec2(0.0, 2.0), vec2(8.0, 6.0)); + painter.rect_filled(body.expand(1.0), 1.0, Color32::WHITE); + painter.rect_filled(body, 1.0, col); + // The shackle: over the body when locked, swung open when not. + let base = if locked { body.center().x } else { body.center().x + 3.0 }; + let pts: Vec<_> = (0..=8) + .map(|k| { + let a = std::f32::consts::PI * k as f32 / 8.0; + pos2(base - 2.6 * a.cos(), body.min.y - 3.0 * a.sin()) + }) + .collect(); + painter.add(egui::Shape::line(pts, Stroke::new(1.3, col))); + } +} + #[allow(clippy::too_many_arguments)] fn draw_rulers(app: &DesignApp, ui: &egui::Ui, full: Rect, rect: Rect, xf: &Xf, doc: &Document, layout: &CanvasLayout, t: &Tokens) { let painter = ui.painter_at(full); @@ -1812,7 +1916,10 @@ fn guide_drag(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, rec let _ = app.run("guide.move", json!({"spread": r, "page": pi, "index": gi, "position": pos_of(sp)})); } (None, true, Some((r, sp, on_page))) => { - let mut params = json!({"spread": r, "orientation": orient, "position": pos_of(sp), "at": sp.x, "spreadGuide": on_page.is_none()}); + // The Page tool draws liquid guides from the rulers. + let liquid = app.session.tool_id() == "page" && on_page.is_some(); + let mut params = + json!({"spread": r, "orientation": orient, "position": pos_of(sp), "at": sp.x, "spreadGuide": on_page.is_none(), "liquid": liquid}); if let Some(pi) = on_page { params["page"] = json!(pi); } @@ -1848,6 +1955,10 @@ fn release_clicks(press: Clicks, pos: Pos2, now: f64, dist: f32) -> Clicks { Clicks { at: now, pos, n } } +#[cfg(test)] +#[path = "parent_canvas_tests.rs"] +mod parent_canvas_tests; + #[cfg(test)] mod tests { use designcraft_geom::Unit; @@ -1885,6 +1996,42 @@ mod tests { h } + /// A primary-button drag from `a` to `b` over a few frames. + fn drag(h: &mut Harness<'_, DesignApp>, a: Pos2, b: Pos2) { + h.hover_at(a); + h.step(); + h.event(egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + for k in 1..=4 { + h.hover_at(a + (b - a) * (k as f32 / 4.0)); + h.step(); + } + h.event(egui::Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(3); + } + + #[test] + fn the_page_tool_draws_liquid_guides_from_the_rulers() { + // Chris Cicchino's Page Tool and Liquid Layout video (11:00): with the Page tool, a guide + // dragged from the ruler is a liquid guide (dashed). + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.session.set_tool("page"); + let mut h = harness(app); + let r = h.state().canvas_rect.unwrap(); + let guides = |h: &Harness<'_, DesignApp>| h.state().session.doc().unwrap().doc.spreads[0].pages[0].guides.clone(); + drag(&mut h, pos2(r.center().x, r.min.y - 8.0), r.center()); + let g = guides(&h); + assert_eq!(g.len(), 1, "{g:?}"); + assert!(g[0].liquid && g[0].orientation == designcraft_doc::Orientation::Horizontal); + // With the Selection tool it's a ruler guide. + h.state_mut().session.set_tool("selection"); + drag(&mut h, pos2(r.center().x, r.min.y - 8.0), r.center() + vec2(0.0, 40.0)); + let g = guides(&h); + assert_eq!(g.len(), 2); + assert!(!g[1].liquid); + } + fn right_click(h: &mut Harness<'_, DesignApp>, p: Pos2) { h.hover_at(p); h.step(); @@ -1899,6 +2046,79 @@ mod tests { (s.horizontal_units, s.vertical_units) } + #[test] + fn selected_merged_owners_paint_the_same_row_and_column_targets() { + for column in [true, false] { + for merged in [false, true] { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"pages": 1})).unwrap(); + app.run("frame.create", json!({"rect": [36, 36, 300, 300], "content": "text"})).unwrap(); + app.run("table.insert", json!({"rows": 2, "cols": 2})).unwrap(); + if merged { + let range = if column { json!({"rows": [0, 0], "cols": [0, 1]}) } else { json!({"rows": [0, 1], "cols": [0, 0]}) }; + app.run("table.select", range).unwrap(); + app.run("table.merge", json!({})).unwrap(); + } + app.run("table.select", json!({"rows": [1, 1], "cols": [1, 1]})).unwrap(); + app.run(if column { "table.selectColumn" } else { "table.selectRow" }, json!({})).unwrap(); + let st = app.session.doc().unwrap(); + let selection = st.selection.clone(); + let ts = selection.text.unwrap(); + let selected = selection.cells.unwrap(); + assert_eq!( + selected.range, + if column { designcraft_doc::CellRange::new(0, 1, 1, 1) } else { designcraft_doc::CellRange::new(1, 0, 1, 1) } + ); + let layout = CanvasLayout::new(&st.doc, st.editing_parents); + let cs = app.session.cache.get(&st.doc, ts.story, None); + let ft = &cs.frames[0]; + let table = ft.tables.iter().find(|t| t.table == selected.table).unwrap(); + let (a, _) = item_canvas_xf(&st.doc, &layout, ft.frame).unwrap(); + let m = a * st.doc.text_xf(st.doc.item(ft.frame).unwrap()); + let xf = Xf { min: pos2(0.0, 0.0), origin: Point::new(0.0, 0.0), zoom: 1.0, rot: 0, pivot: pos2(0.0, 0.0) }; + let owners = if merged { + [(0, 0), (1, 1)] + } else if column { + [(0, 1), (1, 1)] + } else { + [(1, 0), (1, 1)] + }; + let expected: Vec<_> = owners + .into_iter() + .map(|(r, c)| { + let rect = table.cells.iter().find(|cell| cell.row == r && cell.col == c).unwrap().rect; + vec![ + xf.to_screen(m * Point::new(rect.x0, rect.y0)), + xf.to_screen(m * Point::new(rect.x1, rect.y0)), + xf.to_screen(m * Point::new(rect.x1, rect.y1)), + xf.to_screen(m * Point::new(rect.x0, rect.y1)), + ] + }) + .collect(); + let ctx = egui::Context::default(); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + draw_text_selection(&app, ui.painter(), &xf, &st.doc, &layout, ts, Some(selected)); + }); + output.textures_delta.clear(); + let tint = Color32::from_rgba_unmultiplied(80, 140, 255, 90); + let painted: Vec<_> = output + .shapes + .into_iter() + .filter_map(|s| match s.shape { + egui::Shape::Path(path) if path.fill == tint => { + assert!(path.closed); + assert_eq!(path.stroke, egui::epaint::PathStroke::NONE); + Some(path.points) + } + _ => None, + }) + .collect(); + assert_eq!(painted, expected, "only the two touched owners are tinted; column={column}, merged={merged}"); + assert_eq!(app.session.doc().unwrap().selection, selection); + } + } + } + #[test] fn right_clicking_a_ruler_sets_that_rulers_units() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); @@ -2012,4 +2232,18 @@ mod tests { right_click(&mut h, pos2(rect.center().x, rect.min.y - RULER / 2.0)); assert!(h.query_by_label(" Points").is_none()); } + + #[test] + fn baseline_grid_lines_are_the_ones_on_the_page() { + // A start far above the page used to be walked line by line, without end. + for start in [-1e9, -1e17] { + let mut far = baseline_grid_lines(start, 12.0, 792.0); + assert_eq!((far.next(), far.count()), (Some(8.0), 65)); + } + let lines = |start, increment, height| baseline_grid_lines(start, increment, height).collect::>(); + assert_eq!(lines(36.0, 12.0, 72.0), [36.0, 48.0, 60.0], "the bottom edge is not a line"); + assert_eq!(lines(-30.0, 12.0, 30.0), [6.0, 18.0], "a start above the page keeps its rhythm"); + assert_eq!(lines(f64::NAN, 12.0, 792.0), [0.0; 0]); + assert_eq!(baseline_grid_lines(0.0, 12.0, f64::INFINITY).count(), MAX_BASELINES); + } } diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index c2f1cb48..44a970af 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -182,7 +182,9 @@ pub fn control_bar(app: &mut DesignApp, ui: &mut egui::Ui) { ui.horizontal_top(|ui| { let text_mode = matches!(app.session.tool_id(), "type" | "verticalType") || app.session.active().is_some_and(|d| d.selection.text.is_some()); - if text_mode { + if app.session.tool_id() == "page" { + control_page(app, ui); + } else if text_mode { control_text(app, ui); } else { control_object(app, ui); @@ -192,6 +194,93 @@ pub fn control_bar(app: &mut DesignApp, ui: &mut egui::Ui) { }); } +/// The Control panel with the Page tool, as InDesign's: reference point, X/Y, W/H, page-size +/// preset, orientation, Liquid Page Rule and Show Parent Page Overlay, for +/// the page the Page tool selected (else the page in view). W/H keep the reference point in place. +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(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; + // 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) { + app.ui.ref_point = p; + } + 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); + 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})); + } + 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})); + } + ui.end_row(); + }); + vsep(ui); + // Page-size preset and orientation. + let landscape = pg.width > pg.height; + ui.vertical(|ui| { + let cur = panels::properties::preset_name(pg.width, pg.height); + egui::ComboBox::from_id_salt("ctl_page_preset").selected_text(crate::rtl::widget(ui, crate::i18n::tr(&lang, cur))).width(130.0).show_ui( + ui, + |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})); + } + } + }, + ); + 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})); + } + } + }); + }); + vsep(ui); + ui.vertical(|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 { + let _ = app.run("liquid.pageRule", json!({"rule": r, "pages": [n]})); + } + } + }, + ); + }); + vsep(ui); + ui.vertical(|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() { + let _ = app.run("layout.pageOptions", json!({"pages": [n], "showParentOverlay": overlay})); + } + }); +} + /// A field label drawn as a tool icon (hover for its name). fn icon_caption(ui: &mut egui::Ui, icon: &str, tip: &str) { let (r, resp) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover()); @@ -830,6 +919,25 @@ 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 the_page_tool_has_its_own_control_panel() { + // Chris Cicchino's Page Tool and Liquid Layout video (1:02): with the Page tool, the + // Control panel shows the page's size, preset, orientation and liquid page rule. + let mut app = app(); + app.run("file.new", serde_json::json!({"width": 252, "height": 144, "facingPages": false})).unwrap(); + app.session.set_tool("page"); + let mut h = test_window::open(app, vec2(1800.0, 600.0)); + let _ = h.get_by_label("Liquid Page Rule:"); + // Pages don't move within their spread, so there is no "Objects Move with Page" option, and + // no "Controlled by Parent" rule until parent pages have rules of their own. + assert!(h.query_by_label("Objects Move with Page").is_none()); + assert!(!crate::panels::interactive::LIQUID_RULES.iter().any(|(r, _)| *r == designcraft_doc::LiquidRule::ControlledByParent)); + assert_eq!(crate::panels::properties::preset_name(252.0, 144.0), "US Business Card"); + h.get_by_label("Show Parent Page Overlay").click(); + h.run_steps(4); + assert!(h.state().app.session.doc().unwrap().doc.spreads[0].pages[0].show_parent_overlay); + } + #[test] fn a_narrow_window_scrolls_the_control_bar() { let w = 700.0; diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 15da79df..8ee45652 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -100,6 +100,30 @@ fn key_from(name: &str) -> Option { }) } +// Retain room for the existing maximum drag (10,000 moves plus move/down/up), but +// bound the whole gesture and already queued input before generating any events. +const MAX_PENDING_POINTER_EVENTS: usize = 10_003; + +fn pointer_counts(p: &Value, drag: bool, pending: usize) -> Result<(u64, u64), String> { + let positive = |name: &str, default: u64| match p.get(name) { + None => Ok(default), + Some(v) => v.as_u64().filter(|n| *n > 0).ok_or_else(|| format!("`{name}` must be a positive integer")), + }; + let count = positive("count", 1)?; + if count > 255 { + return Err("`count` must be an integer from 1 through 255".into()); + } + let steps = if drag { positive("steps", 8)? } else { 0 }; + // One initial move, two button events per click, and each drag step. Check the + // arithmetic too: hostile counts must never overflow into a small budget. + let events = count.checked_mul(2).and_then(|n| n.checked_add(steps)).and_then(|n| n.checked_add(1)); + let available = MAX_PENDING_POINTER_EVENTS.saturating_sub(pending) as u64; + if events.is_none_or(|n| n > available) { + return Err(format!("pointer input would exceed the {MAX_PENDING_POINTER_EVENTS} pending-event limit")); + } + Ok((count, steps)) +} + /// Handle one control request. Commands it runs leave the user's system clipboard alone. pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { app.install_clipboard_guard(ctx); @@ -196,14 +220,11 @@ fn handle_request(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest ok(Value::Null) } "ui.click" | "ui.drag" => { - let count = match p.get("count") { - None => 1, - Some(v) => match v.as_u64().filter(|n| (1..=255).contains(n)) { - Some(n) => n, - None => return err("`count` must be an integer from 1 through 255"), - }, - }; // Screen-space (egui points) pointer input through egui itself: reaches every widget. + let (count, steps) = match pointer_counts(p, req.method == "ui.drag", app.control_synthetic.len().saturating_add(app.synthetic.len())) { + Ok(counts) => counts, + Err(e) => return err(e), + }; let f = |k: &str| p.get(k).and_then(Value::as_f64).unwrap_or(0.0) as f32; let button = match s("button") { Some("right") | Some("secondary") => egui::PointerButton::Secondary, @@ -223,8 +244,6 @@ fn handle_request(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest app.control_synthetic.push(egui::Event::PointerMoved(a)); app.control_synthetic.push(egui::Event::PointerButton { pos: a, button, pressed: true, modifiers }); if req.method == "ui.drag" { - // From the caller: a huge count would queue events until memory runs out. - let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(8).clamp(1, 10_000); for i in 1..=steps { let t = i as f32 / steps as f32; app.control_synthetic.push(egui::Event::PointerMoved(a + (end - a) * t)); @@ -310,13 +329,19 @@ fn handle_request(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest "ui.dialog.set" => match app.ui.dialog.as_mut() { Some(d) => { let Some(f) = s("field") else { return err("missing `field`") }; - d.fields.insert(f.to_string(), p.get("value").cloned().unwrap_or(Value::Null)); + let value = p.get("value").cloned().unwrap_or(Value::Null); + if d.set_field(f, value) { + app.session.reset_find_scope(); + } ok(serde_json::to_value(&*d).unwrap_or_default()) } None => err("no dialog open"), }, "ui.dialog.open" => { let Some(id) = s("id") else { return err("missing `id`") }; + if id == "findChange" || app.ui.dialog.as_ref().is_some_and(|d| d.id == "findChange") { + app.session.reset_find_scope(); + } let d = crate::dialogs::Dialog::new(id, p.get("fields").cloned().unwrap_or(Value::Null)); let v = serde_json::to_value(&d).unwrap_or_default(); app.ui.dialog = Some(d); @@ -442,6 +467,127 @@ mod automation_contract_tests { output.textures_delta.clear(); } #[test] + fn oversized_pointer_requests_leave_pending_events_unchanged() { + let mut app = app(); + let ctx = egui::Context::default(); + let pending = vec![egui::Event::PointerMoved(egui::pos2(12.0, 34.0))]; + for (method, params) in [ + ("ui.click", json!({"count": 5002})), + ("ui.drag", json!({"steps": 10000, "count": 2})), + ("ui.drag", json!({"steps": 10001})), + ("ui.click", json!({"count": u64::MAX})), + ("ui.drag", json!({"steps": u64::MAX})), + ("ui.drag", json!({"count": u64::MAX, "steps": u64::MAX})), + ] { + app.control_synthetic = pending.clone(); + let reply = call(&mut app, &ctx, method, params); + assert_eq!(reply["ok"], false, "{method}: {reply}"); + assert_eq!(app.control_synthetic, pending, "a rejected gesture must not leave a press queued"); + } + } + #[test] + fn pointer_counts_require_positive_json_integers() { + let mut app = app(); + let ctx = egui::Context::default(); + let pending = vec![egui::Event::PointerMoved(egui::pos2(12.0, 34.0))]; + app.control_synthetic = pending.clone(); + for value in [json!(0), json!(-1), json!(1.5), Value::Null, json!("2"), json!(true), json!(18446744073709551616_f64)] { + for (method, field) in [("ui.click", "count"), ("ui.drag", "count"), ("ui.drag", "steps")] { + let mut params = json!({}); + params[field] = value.clone(); + let reply = call(&mut app, &ctx, method, params); + assert_eq!(reply["ok"], false, "{method}, {field}={value}: {reply}"); + assert!(reply["error"].as_str().unwrap().contains(field)); + assert_eq!(app.control_synthetic, pending); + } + } + } + #[test] + fn pointer_gestures_accept_the_exact_event_budget() { + let mut app = app(); + let ctx = egui::Context::default(); + for (method, params) in + [("ui.drag", json!({"steps": 9492, "count": 255})), ("ui.drag", json!({"steps": 10000})), ("ui.drag", json!({"steps": 9998, "count": 2}))] + { + app.control_synthetic.clear(); + assert_eq!(call(&mut app, &ctx, method, params)["ok"], true, "{method}"); + assert_eq!(app.control_synthetic.len(), 10003); + assert!(matches!(app.control_synthetic.last(), Some(egui::Event::PointerButton { pressed: false, .. }))); + let queued = app.control_synthetic.clone(); + assert_eq!(call(&mut app, &ctx, "ui.click", json!({}))["ok"], false); + assert_eq!(app.control_synthetic, queued, "a full queue retains its release events"); + } + } + #[test] + fn pointer_budget_counts_pending_input_and_recovers_when_it_drains() { + let mut app = app(); + let ctx = egui::Context::default(); + assert_eq!(call(&mut app, &ctx, "ui.drag", json!({"steps": 9998}))["ok"], true); + assert_eq!(app.control_synthetic.len(), 10001); + let queued = app.control_synthetic.clone(); + assert_eq!(call(&mut app, &ctx, "ui.click", json!({}))["ok"], false); + assert_eq!(app.control_synthetic, queued); + // The ordinary input hook consumes one pointer event per frame, freeing + // enough room for a complete click without dropping earlier input. + app.raw_input_hook(&mut egui::RawInput::default()); + assert_eq!(app.control_synthetic.len(), 10000); + assert_eq!(call(&mut app, &ctx, "ui.click", json!({}))["ok"], true); + assert_eq!(app.control_synthetic.len(), 10003); + + app.control_synthetic.clear(); + assert_eq!(call(&mut app, &ctx, "ui.move", json!({"x": 12, "y": 34}))["ok"], true); + let queued = app.control_synthetic.clone(); + assert_eq!(call(&mut app, &ctx, "ui.drag", json!({"steps": 10000}))["ok"], false); + assert_eq!(app.control_synthetic, queued); + assert_eq!(call(&mut app, &ctx, "ui.drag", json!({"steps": 9999}))["ok"], true); + assert_eq!(app.control_synthetic.len(), 10003); + } + #[test] + fn ordinary_pointer_gestures_keep_their_defaults_and_event_order() { + let mut app = app(); + let ctx = egui::Context::default(); + let a = egui::pos2(10.0, 20.0); + let end = egui::pos2(30.0, 40.0); + assert_eq!(call(&mut app, &ctx, "ui.click", json!({"x": 10, "y": 20}))["ok"], true); + assert_eq!( + app.control_synthetic, + vec![ + egui::Event::PointerMoved(a), + egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }, + egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }, + ] + ); + app.control_synthetic.clear(); + assert_eq!(call(&mut app, &ctx, "ui.drag", json!({"x": 10, "y": 20, "toX": 30, "toY": 40}))["ok"], true); + assert_eq!(app.control_synthetic.len(), 11, "default drag has eight steps"); + assert_eq!(app.control_synthetic.get(9), Some(&egui::Event::PointerMoved(end))); + + app.control_synthetic.clear(); + assert_eq!( + call( + &mut app, + &ctx, + "ui.drag", + json!({"x": 10, "y": 20, "toX": 30, "toY": 40, "steps": 2, "count": 2, "button": "right", "shift": true}) + )["ok"], + true + ); + let modifiers = egui::Modifiers { shift: true, ..Default::default() }; + let button = egui::PointerButton::Secondary; + assert_eq!( + app.control_synthetic, + vec![ + egui::Event::PointerMoved(a), + egui::Event::PointerButton { pos: a, button, pressed: true, modifiers }, + egui::Event::PointerMoved(egui::pos2(20.0, 30.0)), + egui::Event::PointerMoved(end), + egui::Event::PointerButton { pos: end, button, pressed: false, modifiers }, + egui::Event::PointerButton { pos: end, button, pressed: true, modifiers }, + egui::Event::PointerButton { pos: end, button, pressed: false, modifiers }, + ] + ); + } + #[test] fn control_typing_uses_explicit_caret_but_retains_widget_events() { let mut app = app(); let ctx = egui::Context::default(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 17a68e7a..3aef89f5 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2,7 +2,7 @@ //! (`ui.dialog.set {field, value}`) and confirm them (`ui.dialog.confirm`) like a user would. use designcraft_geom::{Unit, format_measure, units::parse_measure}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use serde_json::{Map, Value, json}; use crate::DesignApp; @@ -77,6 +77,16 @@ impl Dialog { } Dialog { id: id.into(), fields } } + /// Set a field and report a genuine Find domain/mode transition to the caller. + pub(crate) fn set_field(&mut self, key: &str, value: Value) -> bool { + let before = + (self.id == "findChange" && matches!(key, "scope" | "story" | "objectMode")).then(|| (self.b("objectMode"), find_change_params(self))); + self.fields.insert(key.into(), value); + before.is_some_and(|(object_mode, params)| { + object_mode != self.b("objectMode") || !designcraft_engine::cmd::same_find_domain(¶ms, &find_change_params(self)) + }) + } + fn s(&self, k: &str) -> String { match self.fields.get(k) { Some(Value::String(s)) => s.clone(), @@ -1181,536 +1191,641 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { // forms remain reachable at large UI scales, without shrinking their text. let footer_height = ui.spacing().interact_size.y.max(ui.text_style_height(&egui::TextStyle::Button) + 2.0 * ui.spacing().button_padding.y); let body_height = (available.y - ui.min_size().y - footer_height - 12.0 - 2.0 * ui.spacing().item_spacing.y).max(1.0); - egui::ScrollArea::both().id_salt(("dialog_body", &d.id)).min_scrolled_width(1.0).min_scrolled_height(1.0).max_width(width).max_height(body_height).show(ui, |ui| { - match d.id.as_str() { - "newDocument" => { - ui.horizontal(|ui| { - ui.with_layout(if crate::i18n::is_rtl(&app.ui.language) { egui::Layout::right_to_left(egui::Align::Center) } else { egui::Layout::left_to_right(egui::Align::Center) }, |ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Preset")); - let cur = d.s("preset"); - egui::ComboBox::from_id_salt("preset").selected_text(&cur).width(180.0).show_ui(ui, |ui| { - for p in designcraft_doc::build::PRESETS { - if ui.selectable_label(cur == p.name, p.name).clicked() { - d.fields.insert("preset".into(), json!(p.name)); - d.fields.insert("width".into(), json!(format_measure(p.width, p.units))); - d.fields.insert("height".into(), json!(format_measure(p.height, p.units))); - d.fields.insert("facingPages".into(), json!(p.intent == designcraft_doc::Intent::Print)); - } - } + egui::ScrollArea::both() + .id_salt(("dialog_body", &d.id)) + .min_scrolled_width(1.0) + .min_scrolled_height(1.0) + .max_width(width) + .max_height(body_height) + .show(ui, |ui| match d.id.as_str() { + "newDocument" => { + ui.horizontal(|ui| { + ui.with_layout( + if crate::i18n::is_rtl(&app.ui.language) { + egui::Layout::right_to_left(egui::Align::Center) + } else { + egui::Layout::left_to_right(egui::Align::Center) + }, + |ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Preset")); + let cur = d.s("preset"); + egui::ComboBox::from_id_salt("preset").selected_text(&cur).width(180.0).show_ui(ui, |ui| { + for p in designcraft_doc::build::PRESETS { + if ui.selectable_label(cur == p.name, p.name).clicked() { + d.fields.insert("preset".into(), json!(p.name)); + d.fields.insert("width".into(), json!(format_measure(p.width, p.units))); + d.fields.insert("height".into(), json!(format_measure(p.height, p.units))); + d.fields.insert("facingPages".into(), json!(p.intent == designcraft_doc::Intent::Print)); + } + } + }); + }, + ); }); - }); - }); - if crate::i18n::is_rtl(&app.ui.language) { - // egui Grid currently supports LTR only. Use explicit RTL rows; - // the numeric editors retain LTR alignment and logical values. - for (first, key, second, other, toggle) in [ - ("Width", "width", "Height", "height", false), - ("Pages", "pages", "Facing Pages", "facingPages", true), - ("Columns", "columns", "Gutter", "gutter", false), - ("Top", "marginTop", "Bottom", "marginBottom", false), - ("Inside", "marginInside", "Outside", "marginOutside", false), - ("Bleed", "bleed", "Primary Text Frame", "primaryTextFrame", true), - ] { - ui.horizontal(|ui| { - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, first)); - text_field(ui, &mut d, key, 80.0); - if toggle { - check(ui, &mut d, other, crate::i18n::tr(&app.ui.language, second)); - } else { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, second)); - text_field(ui, &mut d, other, 80.0); - } + if crate::i18n::is_rtl(&app.ui.language) { + // egui Grid currently supports LTR only. Use explicit RTL rows; + // the numeric editors retain LTR alignment and logical values. + for (first, key, second, other, toggle) in [ + ("Width", "width", "Height", "height", false), + ("Pages", "pages", "Facing Pages", "facingPages", true), + ("Columns", "columns", "Gutter", "gutter", false), + ("Top", "marginTop", "Bottom", "marginBottom", false), + ("Inside", "marginInside", "Outside", "marginOutside", false), + ("Bleed", "bleed", "Primary Text Frame", "primaryTextFrame", true), + ] { + ui.horizontal(|ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, first)); + text_field(ui, &mut d, key, 80.0); + if toggle { + check(ui, &mut d, other, crate::i18n::tr(&app.ui.language, second)); + } else { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, second)); + text_field(ui, &mut d, other, 80.0); + } + }); }); + } + } else { + egui::Grid::new("nd").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Width")); + text_field(ui, &mut d, "width", 80.0); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Height")); + text_field(ui, &mut d, "height", 80.0); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Pages")); + text_field(ui, &mut d, "pages", 80.0); + ui.label(""); + check(ui, &mut d, "facingPages", crate::i18n::tr(&app.ui.language, "Facing Pages")); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Columns")); + text_field(ui, &mut d, "columns", 80.0); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Gutter")); + text_field(ui, &mut d, "gutter", 80.0); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Top")); + text_field(ui, &mut d, "marginTop", 80.0); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Bottom")); + text_field(ui, &mut d, "marginBottom", 80.0); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Inside")); + text_field(ui, &mut d, "marginInside", 80.0); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Outside")); + text_field(ui, &mut d, "marginOutside", 80.0); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Bleed")); + text_field(ui, &mut d, "bleed", 80.0); + ui.label(""); + check(ui, &mut d, "primaryTextFrame", crate::i18n::tr(&app.ui.language, "Primary Text Frame")); + ui.end_row(); }); } - } else { - egui::Grid::new("nd").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Width")); - text_field(ui, &mut d, "width", 80.0); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Height")); - text_field(ui, &mut d, "height", 80.0); - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Pages")); - text_field(ui, &mut d, "pages", 80.0); - ui.label(""); - check(ui, &mut d, "facingPages", crate::i18n::tr(&app.ui.language, "Facing Pages")); - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Columns")); - text_field(ui, &mut d, "columns", 80.0); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Gutter")); - text_field(ui, &mut d, "gutter", 80.0); - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Top")); - text_field(ui, &mut d, "marginTop", 80.0); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Bottom")); - text_field(ui, &mut d, "marginBottom", 80.0); - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Inside")); - text_field(ui, &mut d, "marginInside", 80.0); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Outside")); - text_field(ui, &mut d, "marginOutside", 80.0); - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Bleed")); - text_field(ui, &mut d, "bleed", 80.0); - ui.label(""); - check(ui, &mut d, "primaryTextFrame", crate::i18n::tr(&app.ui.language, "Primary Text Frame")); - ui.end_row(); - }); } - } - "frameSize" => { - egui::Grid::new("fs").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Width")); - text_field(ui, &mut d, "width", 90.0); - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Height")); - text_field(ui, &mut d, "height", 90.0); - ui.end_row(); - }); - } - "findChange" => { - ui.horizontal(|ui| { - for (label, grep) in [("Text", false), ("GREP", true)] { - if ui.selectable_label(!d.b("objectMode") && d.b("grep") == grep, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() { - d.fields.insert("grep".into(), json!(grep)); - d.fields.insert("objectMode".into(), json!(false)); + "frameSize" => { + egui::Grid::new("fs").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Width")); + text_field(ui, &mut d, "width", 90.0); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Height")); + text_field(ui, &mut d, "height", 90.0); + ui.end_row(); + }); + } + "findChange" => { + ui.horizontal(|ui| { + for (label, grep) in [("Text", false), ("GREP", true)] { + if ui + .selectable_label( + !d.b("objectMode") && d.b("grep") == grep, + crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label)), + ) + .clicked() + { + if d.set_field("objectMode", json!(false)) { + app.session.reset_find_scope(); + } + d.fields.insert("grep".into(), json!(grep)); + } + } + if ui.selectable_label(d.b("objectMode"), crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Object"))).clicked() + && d.set_field("objectMode", json!(true)) + { + app.session.reset_find_scope(); + } + }); + if d.b("objectMode") { + // Find/Change › Object: objects by fill and kind; change their fill or opacity. + let swatches: Vec = app + .session + .active() + .map(|st| st.doc.swatches.iter().filter(|w| !w.hidden).map(|w| w.name.clone()).collect()) + .unwrap_or_default(); + egui::Grid::new("fco").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + for (key, label, any) in [("objFill", "Find fill:", "(any)"), ("objChangeFill", "Change fill to:", "(unchanged)")] { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, label)); + let cur = d.s(key); + egui::ComboBox::from_id_salt(key) + .selected_text(if cur.is_empty() { any } else { cur.as_str() }) + .width(180.0) + .show_ui(ui, |ui| { + if ui + .selectable_label(cur.is_empty(), crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, any))) + .clicked() + { + d.fields.insert(key.into(), json!("")); + } + for w in &swatches { + if ui.selectable_label(*w == cur, w).clicked() { + d.fields.insert(key.into(), json!(w)); + } + } + }); + ui.end_row(); + } + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Kind:")); + let cur = d.s("objKind"); + egui::ComboBox::from_id_salt("objKind").selected_text(if cur.is_empty() { "(any)" } else { cur.as_str() }).show_ui( + ui, + |ui| { + for v in ["", "text", "graphic", "shape", "line", "group"] { + if ui + .selectable_label( + cur == v, + crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, if v.is_empty() { "(any)" } else { v })), + ) + .clicked() + { + d.fields.insert("objKind".into(), json!(v)); + } + } + }, + ); + ui.end_row(); + }); + let mut crit = json!({}); + if !d.s("objFill").is_empty() { + crit["fill"] = json!(d.s("objFill")); + } + if !d.s("objKind").is_empty() { + crit["kind"] = json!(d.s("objKind")); + } + ui.horizontal(|ui| { + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Find All"))).clicked() { + let n = app.run("find.objects", crit.clone()).ok().and_then(|r| r["ids"].as_array().map(Vec::len)).unwrap_or(0); + d.fields.insert("status".into(), json!(format!("{n} found"))); + } + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Change All"))).clicked() + && !d.s("objChangeFill").is_empty() + { + let mut p = crit.clone(); + p["change"] = json!({"fill": d.s("objChangeFill")}); + let n = app.run("find.changeObjects", p).ok().and_then(|r| r["changed"].as_u64()).unwrap_or(0); + d.fields.insert("status".into(), json!(format!("{n} changed"))); + } + }); + ui.label(d.s("status")); + } else { + egui::Grid::new("fc").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Find what:")); + text_field(ui, &mut d, "find", 260.0); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Change to:")); + text_field(ui, &mut d, "change", 260.0); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Search:")); + let cur = d.s("scope"); + egui::ComboBox::from_id_salt("fcscope") + .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, &cur))) + .show_ui(ui, |ui| { + for v in ["document", "story", "selection"] { + if ui.selectable_label(cur == v, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, v))).clicked() + && d.set_field("scope", json!(v)) + { + app.session.reset_find_scope(); + } + } + }); + ui.end_row(); + }); + ui.horizontal(|ui| { + check(ui, &mut d, "caseSensitive", crate::i18n::tr(&app.ui.language, "Case sensitive")); + check(ui, &mut d, "wholeWord", crate::i18n::tr(&app.ui.language, "Whole word")); + }); + ui.horizontal(|ui| { + let params0 = find_change_params(&d); + let params = || params0.clone(); + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Find Next"))).clicked() { + let r = app.run("find.next", params()); + let msg = match r { + Ok(Value::Null) => "No matches".to_string(), + Ok(_) => "Found".to_string(), + Err(e) => e, + }; + d.fields.insert("status".into(), json!(msg)); + } + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Change All"))).clicked() { + let msg = match app.run("find.change", params()) { + Ok(v) => format!("{} replacement(s) made", v["count"]), + Err(e) => e, + }; + d.fields.insert("status".into(), json!(msg)); + } + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Count"))).clicked() { + let msg = match app.run("find.find", params()) { + Ok(v) => format!("{} match(es)", v.as_array().map(|a| a.len()).unwrap_or(0)), + Err(e) => e, + }; + d.fields.insert("status".into(), json!(msg)); + } + }); + if let Some(st) = d.fields.get("status").and_then(Value::as_str) { + ui.label(egui::RichText::new(st).color(crate::theme::Tokens::get(ui.ctx()).text_dim)); } } - if ui.selectable_label(d.b("objectMode"),crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Object"))).clicked() { - d.fields.insert("objectMode".into(), json!(true)); + } + "paragraphStyleOptions" => paragraph_style_options(app, ui, &mut d), + "characterStyleOptions" => character_style_options(app, ui, &mut d), + "footnoteOptions" => footnote_options(app, ui, &mut d), + "paragraphRules" => { + let current = d.fields.get("current").cloned().unwrap_or_default(); + paragraph_rules(app, ui, &mut d, "", ¤t); + } + "insertXref" => insert_xref(app, ui, &mut d), + "findFont" => find_font(app, ui, &mut d), + "colorPicker" => color_picker(ui, &mut d), + "preferences" => preferences(app, ui, &mut d, body_height), + "print" => print_dialog(app, ui, &mut d), + "pdfImport" => { + ui.label(egui::RichText::new(if d.b("async") { d.s("name") } else { d.s("path") }).size(11.0)); + ui.horizontal(|ui| { + crate::rtl::label( + ui, + crate::i18n::tr(&app.ui.language, "Page (1–{count}):").replace("{count}", &d.n("pages").unwrap_or(1.0).to_string()), + ); + text_field(ui, &mut d, "page", 60.0); + }); + ui.horizontal(|ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Crop to:")); + let cur = d.s("crop"); + let label = |v: &str| match v { + "trim" => crate::i18n::tr(&app.ui.language, "Trim"), + "bleed" => crate::i18n::tr(&app.ui.language, "Bleed"), + "art" => crate::i18n::tr(&app.ui.language, "Art"), + "media" => crate::i18n::tr(&app.ui.language, "Media"), + _ => crate::i18n::tr(&app.ui.language, "Crop"), + }; + egui::ComboBox::from_id_salt("pdf_crop").selected_text(crate::rtl::widget(ui, label(&cur))).show_ui(ui, |ui| { + for v in ["crop", "trim", "bleed", "art", "media"] { + if ui.selectable_label(label(&cur) == label(v), label(v)).clicked() { + d.fields.insert("crop".into(), json!(v)); + } + } + }); + }); + } + "layerOptions" => { + let mut layers = d.fields.get("layers").and_then(Value::as_array).cloned().unwrap_or_default(); + if layers.is_empty() { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "This PDF has no layers.")); } - }); - if d.b("objectMode") { - // Find/Change › Object: objects by fill and kind; change their fill or opacity. - let swatches: Vec = app.session.active().map(|st| st.doc.swatches.iter().filter(|w| !w.hidden).map(|w| w.name.clone()).collect()).unwrap_or_default(); - egui::Grid::new("fco").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { - for (key, label, any) in [("objFill", "Find fill:", "(any)"), ("objChangeFill", "Change fill to:", "(unchanged)")] { + for l in &mut layers { + let mut on = l["visible"].as_bool().unwrap_or(true); + if ui.checkbox(&mut on, l["name"].as_str().unwrap_or("")).changed() { + l["visible"] = json!(on); + } + } + d.fields.insert("layers".into(), json!(layers)); + } + "colorSettings" => { + let profiles = d.fields.get("profiles").and_then(Value::as_array).cloned().unwrap_or_default(); + egui::Grid::new("color_settings").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + for (key, label, kind) in [("rgb", "RGB:", "rgb"), ("cmyk", "CMYK:", "cmyk")] { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, label)); let cur = d.s(key); - egui::ComboBox::from_id_salt(key).selected_text(if cur.is_empty() { any } else { cur.as_str() }).width(180.0).show_ui(ui, |ui| { - if ui.selectable_label(cur.is_empty(), crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, any))).clicked() { - d.fields.insert(key.into(), json!("")); - } - for w in &swatches { - if ui.selectable_label(*w == cur, w).clicked() { - d.fields.insert(key.into(), json!(w)); + egui::ComboBox::from_id_salt(("ws", key)).selected_text(&cur).width(260.0).show_ui(ui, |ui| { + for pr in profiles.iter().filter(|p| p["kind"] == kind) { + let name = pr["name"].as_str().unwrap_or(""); + if ui.selectable_label(name == cur, name).clicked() { + d.fields.insert(key.into(), json!(name)); } } }); ui.end_row(); } - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Kind:")); - let cur = d.s("objKind"); - egui::ComboBox::from_id_salt("objKind").selected_text(if cur.is_empty() { "(any)" } else { cur.as_str() }).show_ui(ui, |ui| { - for v in ["", "text", "graphic", "shape", "line", "group"] { - if ui.selectable_label(cur == v, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, if v.is_empty() { "(any)" } else { v }))).clicked() { - d.fields.insert("objKind".into(), json!(v)); - } - } - }); - ui.end_row(); - }); - let mut crit = json!({}); - if !d.s("objFill").is_empty() { - crit["fill"] = json!(d.s("objFill")); - } - if !d.s("objKind").is_empty() { - crit["kind"] = json!(d.s("objKind")); - } - ui.horizontal(|ui| { - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Find All"))).clicked() { - let n = app.run("find.objects", crit.clone()).ok().and_then(|r| r["ids"].as_array().map(Vec::len)).unwrap_or(0); - d.fields.insert("status".into(), json!(format!("{n} found"))); - } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Change All"))).clicked() && !d.s("objChangeFill").is_empty() { - let mut p = crit.clone(); - p["change"] = json!({"fill": d.s("objChangeFill")}); - let n = app.run("find.changeObjects", p).ok().and_then(|r| r["changed"].as_u64()).unwrap_or(0); - d.fields.insert("status".into(), json!(format!("{n} changed"))); - } - }); - ui.label(d.s("status")); - } else { - egui::Grid::new("fc").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Find what:")); - text_field(ui, &mut d, "find", 260.0); - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Change to:")); - text_field(ui, &mut d, "change", 260.0); - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Search:")); - let cur = d.s("scope"); - egui::ComboBox::from_id_salt("fcscope").selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, &cur))).show_ui(ui, |ui| { - for v in ["document", "story", "selection"] { - if ui.selectable_label(cur == v, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, v))).clicked() { - d.fields.insert("scope".into(), json!(v)); - } - } - }); - ui.end_row(); - }); - ui.horizontal(|ui| { - check(ui, &mut d, "caseSensitive", crate::i18n::tr(&app.ui.language, "Case sensitive")); - check(ui, &mut d, "wholeWord", crate::i18n::tr(&app.ui.language, "Whole word")); - }); - ui.horizontal(|ui| { - let params0 = json!({"find": d.s("find"), "change": d.s("change"), "grep": d.b("grep"), "caseSensitive": d.b("caseSensitive"), "wholeWord": d.b("wholeWord"), "scope": d.s("scope")}); - let params = || params0.clone(); - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Find Next"))).clicked() { - let r = app.run("find.next", params()); - let msg = match r { - Ok(Value::Null) => "No matches".to_string(), - Ok(_) => "Found".to_string(), - Err(e) => e, - }; - d.fields.insert("status".into(), json!(msg)); - } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Change All"))).clicked() { - let msg = match app.run("find.change", params()) { - Ok(v) => format!("{} replacement(s) made", v["count"]), - Err(e) => e, - }; - d.fields.insert("status".into(), json!(msg)); - } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Count"))).clicked() { - let msg = match app.run("find.find", params()) { - Ok(v) => format!("{} match(es)", v.as_array().map(|a| a.len()).unwrap_or(0)), - Err(e) => e, - }; - d.fields.insert("status".into(), json!(msg)); - } - }); - if let Some(st) = d.fields.get("status").and_then(Value::as_str) { - ui.label(egui::RichText::new(st).color(crate::theme::Tokens::get(ui.ctx()).text_dim)); - } - } - } - "paragraphStyleOptions" => paragraph_style_options(app, ui, &mut d), - "characterStyleOptions" => character_style_options(app, ui, &mut d), - "footnoteOptions" => footnote_options(app, ui, &mut d), - "paragraphRules" => { - let current = d.fields.get("current").cloned().unwrap_or_default(); - paragraph_rules(app, ui, &mut d, "", ¤t); - } - "insertXref" => insert_xref(app, ui, &mut d), - "findFont" => find_font(app, ui, &mut d), - "colorPicker" => color_picker(ui, &mut d), - "preferences" => preferences(app, ui, &mut d, body_height), - "print" => print_dialog(app, ui, &mut d), - "pdfImport" => { - ui.label(egui::RichText::new(if d.b("async") { d.s("name") } else { d.s("path") }).size(11.0)); - ui.horizontal(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Page (1–{count}):").replace("{count}", &d.n("pages").unwrap_or(1.0).to_string())); - text_field(ui, &mut d, "page", 60.0); - }); - ui.horizontal(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Crop to:")); - let cur = d.s("crop"); - let label = |v: &str| match v { - "trim" => crate::i18n::tr(&app.ui.language, "Trim"), - "bleed" => crate::i18n::tr(&app.ui.language, "Bleed"), - "art" => crate::i18n::tr(&app.ui.language, "Art"), - "media" => crate::i18n::tr(&app.ui.language, "Media"), - _ => crate::i18n::tr(&app.ui.language, "Crop"), - }; - egui::ComboBox::from_id_salt("pdf_crop").selected_text(crate::rtl::widget(ui, label(&cur))).show_ui(ui, |ui| { - for v in ["crop", "trim", "bleed", "art", "media"] { - if ui.selectable_label(label(&cur) == label(v), label(v)).clicked() { - d.fields.insert("crop".into(), json!(v)); - } - } - }); - }); - } - "layerOptions" => { - let mut layers = d.fields.get("layers").and_then(Value::as_array).cloned().unwrap_or_default(); - if layers.is_empty() { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "This PDF has no layers.")); - } - for l in &mut layers { - let mut on = l["visible"].as_bool().unwrap_or(true); - if ui.checkbox(&mut on, l["name"].as_str().unwrap_or("")).changed() { - l["visible"] = json!(on); - } - } - d.fields.insert("layers".into(), json!(layers)); - } - "colorSettings" => { - let profiles = d.fields.get("profiles").and_then(Value::as_array).cloned().unwrap_or_default(); - egui::Grid::new("color_settings").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { - for (key, label, kind) in [("rgb", "RGB:", "rgb"), ("cmyk", "CMYK:", "cmyk")] { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, label)); - let cur = d.s(key); - egui::ComboBox::from_id_salt(("ws", key)).selected_text(&cur).width(260.0).show_ui(ui, |ui| { - for pr in profiles.iter().filter(|p| p["kind"] == kind) { - let name = pr["name"].as_str().unwrap_or(""); - if ui.selectable_label(name == cur, name).clicked() { - d.fields.insert(key.into(), json!(name)); - } - } - }); - ui.end_row(); - } - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Intent:")); - let cur = d.s("intent"); - egui::ComboBox::from_id_salt("ws_intent") - .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, designcraft_color::cms::Intent::parse(&cur).map_or("", |i| i.label())))) - .show_ui(ui, |ui| { - for i in designcraft_color::cms::Intent::ALL { - if ui.selectable_label(i.id() == cur, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, i.label()))).clicked() { - d.fields.insert("intent".into(), json!(i.id())); - } - } - }); - ui.end_row(); - }); - check(ui, &mut d, "bpc", crate::i18n::tr(&app.ui.language, "Use Black Point Compensation")); - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Load Profile…"))).clicked() - && let Some(path) = app.services.pick_open.as_mut().and_then(|f| f("icc")) - { - match app.session.execute("color.loadProfile", &json!({"path": path})) { - Ok(_) => { - let f = app.session.execute("color.settings", &json!({})).unwrap_or_default(); - d.fields.insert("profiles".into(), f["profiles"].clone()); - } - Err(e) => app.status(format!("Color Settings: {e}")), - } - } - } - "keyboardShortcuts" => keyboard_shortcuts(app, ui, &mut d), - "userDictionary" => user_dictionary(app, ui, &mut d), - "menus" => { - text_field(ui, &mut d, "query", 260.0); - let q = d.s("query").to_lowercase(); - egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { - for (menu, label) in crate::menus::all_menu_items().into_iter().filter(|(m, l)| q.is_empty() || m.to_lowercase().contains(&q) || l.to_lowercase().contains(&q)) { - let key = crate::menus::menu_key(&menu, &label); - let mut visible = !app.ui.hidden_menu_items.contains(&key); - if ui.checkbox(&mut visible, crate::rtl::widget(ui, format!("{} › {}", crate::i18n::tr(&app.ui.language, &menu), crate::i18n::tr(&app.ui.language, &label)))).changed() { - let _ = app.run("window.hideMenuItem", json!({"item": key, "hidden": !visible})); - } - } - }); - } - "importOptions" => { - ui.label(egui::RichText::new(d.s("path")).size(10.5)); - check(ui, &mut d, "removeStyles", crate::i18n::tr(&app.ui.language, "Remove Styles and Formatting from Text and Tables")); - if !d.b("removeStyles") { - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Style Name Conflicts")).font(semibold(12.0))); - let conflicts: Vec = d.fields.get("conflicts").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default(); - ui.label(if conflicts.is_empty() { crate::i18n::tr(&app.ui.language, "No conflicts").to_string() } else { format!("{} conflict(s): {}", conflicts.len(), conflicts.join(", ")) }); - let cur = d.s("styleConflicts"); - ui.horizontal(|ui| { - for (v, l) in [("useExisting", "Use Document Style Definition"), ("redefine", "Redefine Document Style"), ("autoRename", "Auto Rename")] { - if ui.radio(cur == v, l).clicked() { - d.fields.insert("styleConflicts".into(), json!(v)); - } - } - }); - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Style Mapping")).font(semibold(12.0))); - let names: Vec = app.session.active().map(|s| s.doc.styles.paragraph.iter().map(|p| p.name.clone()).collect()).unwrap_or_default(); - let imported: Vec = d.fields.get("styles").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default(); - egui::ScrollArea::vertical().max_height(180.0).show(ui, |ui| { - egui::Grid::new("imp_map").num_columns(2).spacing([8.0, 4.0]).show(ui, |ui| { - for w in &imported { - ui.label(w); - let mapped = d.fields.get("map").and_then(|m| m.get(w)).and_then(Value::as_str).unwrap_or("").to_string(); - let shown = if mapped.is_empty() { "(import)".to_string() } else { mapped.clone() }; - egui::ComboBox::from_id_salt(("imp_map", w)).selected_text(crate::rtl::widget(ui, &shown)).width(170.0).show_ui(ui, |ui| { - if ui.selectable_label(mapped.is_empty(),crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "(import)"))).clicked() - && let Some(m) = d.fields.get_mut("map").and_then(Value::as_object_mut) + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Intent:")); + let cur = d.s("intent"); + egui::ComboBox::from_id_salt("ws_intent") + .selected_text(crate::rtl::widget( + ui, + crate::i18n::tr(&app.ui.language, designcraft_color::cms::Intent::parse(&cur).map_or("", |i| i.label())), + )) + .show_ui(ui, |ui| { + for i in designcraft_color::cms::Intent::ALL { + if ui + .selectable_label(i.id() == cur, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, i.label()))) + .clicked() { - m.remove(w); + d.fields.insert("intent".into(), json!(i.id())); } - for n in &names { - if ui.selectable_label(*n == mapped, n).clicked() - && let Some(m) = d.fields.get_mut("map").and_then(Value::as_object_mut) - { - m.insert(w.clone(), json!(n)); - } + } + }); + ui.end_row(); + }); + check(ui, &mut d, "bpc", crate::i18n::tr(&app.ui.language, "Use Black Point Compensation")); + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Load Profile…"))).clicked() + && let Some(path) = app.services.pick_open.as_mut().and_then(|f| f("icc")) + { + match app.session.execute("color.loadProfile", &json!({"path": path})) { + Ok(_) => { + let f = app.session.execute("color.settings", &json!({})).unwrap_or_default(); + d.fields.insert("profiles".into(), f["profiles"].clone()); + } + Err(e) => app.status(format!("Color Settings: {e}")), + } + } + } + "keyboardShortcuts" => keyboard_shortcuts(app, ui, &mut d), + "userDictionary" => user_dictionary(app, ui, &mut d), + "menus" => { + text_field(ui, &mut d, "query", 260.0); + let q = d.s("query").to_lowercase(); + egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { + for (menu, label) in crate::menus::all_menu_items() + .into_iter() + .filter(|(m, l)| q.is_empty() || m.to_lowercase().contains(&q) || l.to_lowercase().contains(&q)) + { + let key = crate::menus::menu_key(&menu, &label); + let mut visible = !app.ui.hidden_menu_items.contains(&key); + if ui + .checkbox( + &mut visible, + crate::rtl::widget( + ui, + format!("{} › {}", crate::i18n::tr(&app.ui.language, &menu), crate::i18n::tr(&app.ui.language, &label)), + ), + ) + .changed() + { + let _ = app.run("window.hideMenuItem", json!({"item": key, "hidden": !visible})); + } + } + }); + } + "importOptions" => { + ui.label(egui::RichText::new(d.s("path")).size(10.5)); + check(ui, &mut d, "removeStyles", crate::i18n::tr(&app.ui.language, "Remove Styles and Formatting from Text and Tables")); + if !d.b("removeStyles") { + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Style Name Conflicts")).font(semibold(12.0))); + let conflicts: Vec = d + .fields + .get("conflicts") + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()) + .unwrap_or_default(); + ui.label(if conflicts.is_empty() { + crate::i18n::tr(&app.ui.language, "No conflicts").to_string() + } else { + format!("{} conflict(s): {}", conflicts.len(), conflicts.join(", ")) + }); + let cur = d.s("styleConflicts"); + ui.horizontal(|ui| { + for (v, l) in [ + ("useExisting", "Use Document Style Definition"), + ("redefine", "Redefine Document Style"), + ("autoRename", "Auto Rename"), + ] { + if ui.radio(cur == v, l).clicked() { + d.fields.insert("styleConflicts".into(), json!(v)); + } + } + }); + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Style Mapping")).font(semibold(12.0))); + let names: Vec = + app.session.active().map(|s| s.doc.styles.paragraph.iter().map(|p| p.name.clone()).collect()).unwrap_or_default(); + let imported: Vec = d + .fields + .get("styles") + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()) + .unwrap_or_default(); + egui::ScrollArea::vertical().max_height(180.0).show(ui, |ui| { + egui::Grid::new("imp_map").num_columns(2).spacing([8.0, 4.0]).show(ui, |ui| { + for w in &imported { + ui.label(w); + let mapped = d.fields.get("map").and_then(|m| m.get(w)).and_then(Value::as_str).unwrap_or("").to_string(); + let shown = if mapped.is_empty() { "(import)".to_string() } else { mapped.clone() }; + egui::ComboBox::from_id_salt(("imp_map", w)).selected_text(crate::rtl::widget(ui, &shown)).width(170.0).show_ui( + ui, + |ui| { + if ui + .selectable_label( + mapped.is_empty(), + crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "(import)")), + ) + .clicked() + && let Some(m) = d.fields.get_mut("map").and_then(Value::as_object_mut) + { + m.remove(w); + } + for n in &names { + if ui.selectable_label(*n == mapped, n).clicked() + && let Some(m) = d.fields.get_mut("map").and_then(Value::as_object_mut) + { + m.insert(w.clone(), json!(n)); + } + } + }, + ); + ui.end_row(); + } + }); + }); + } + } + "closeDocument" => { + let text = crate::i18n::tr(&app.ui.language, "“{}” has changes that are not saved. Save them before closing?") + .replace("{}", &d.s("title")); + crate::rtl::label(ui, text); + } + "newWorkspace" => { + ui.horizontal(|ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Name:")); + text_field(ui, &mut d, "name", 220.0); + }); + crate::rtl::label( + ui, + egui::RichText::new(crate::i18n::tr(&app.ui.language, "Saves which bars show and where the panels are.")).size(10.5), + ); + } + "fittingOptions" => { + check(ui, &mut d, "autoFit", crate::i18n::tr(&app.ui.language, "Auto-Fit")); + ui.add_space(4.0); + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Content Fitting")).font(semibold(12.0))); + let cur = d.s("fitting"); + let label = |v: &str| match v { + "fillProportionally" => crate::i18n::tr(&app.ui.language, "Fill Frame Proportionally"), + "fitProportionally" => crate::i18n::tr(&app.ui.language, "Fit Content Proportionally"), + "fitContentToFrame" => crate::i18n::tr(&app.ui.language, "Fit Content to Frame"), + "centerContent" => crate::i18n::tr(&app.ui.language, "Center Content"), + _ => crate::i18n::tr(&app.ui.language, "None"), + }; + ui.horizontal(|ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Fitting:")); + egui::ComboBox::from_id_salt("ff_fit").selected_text(crate::rtl::widget(ui, label(&cur))).width(200.0).show_ui(ui, |ui| { + for v in ["none", "fillProportionally", "fitProportionally", "fitContentToFrame", "centerContent"] { + if ui.selectable_label(cur == v, crate::rtl::widget(ui, label(v))).clicked() { + d.fields.insert("fitting".into(), json!(v)); + } + } + }); + }); + ui.horizontal(|ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Align From:")); + let a = d.n("align").unwrap_or(4.0) as u8; + egui::Grid::new("ff_align").spacing([2.0, 2.0]).show(ui, |ui| { + for row in 0..3u8 { + for col in 0..3u8 { + let i = row * 3 + col; + let (r, resp) = ui.allocate_exact_size(egui::vec2(14.0, 14.0), egui::Sense::click()); + let c = ui.visuals().text_color(); + if a == i { + ui.painter().rect_filled(r.shrink(2.0), 1.0, c); + } else { + ui.painter().rect_stroke(r.shrink(3.0), 1.0, egui::Stroke::new(1.0, c), egui::StrokeKind::Inside); } - }); + if resp.clicked() { + d.fields.insert("align".into(), json!(i)); + } + } ui.end_row(); } }); }); - } - } - "closeDocument" => { - let text = crate::i18n::tr(&app.ui.language, "“{}” has changes that are not saved. Save them before closing?").replace("{}", &d.s("title")); - crate::rtl::label(ui, text); - } - "newWorkspace" => { - ui.horizontal(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Name:")); - text_field(ui, &mut d, "name", 220.0); - }); - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Saves which bars show and where the panels are.")).size(10.5)); - } - "fittingOptions" => { - check(ui, &mut d, "autoFit", crate::i18n::tr(&app.ui.language, "Auto-Fit")); - ui.add_space(4.0); - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Content Fitting")).font(semibold(12.0))); - let cur = d.s("fitting"); - let label = |v: &str| match v { - "fillProportionally" => crate::i18n::tr(&app.ui.language, "Fill Frame Proportionally"), - "fitProportionally" => crate::i18n::tr(&app.ui.language, "Fit Content Proportionally"), - "fitContentToFrame" => crate::i18n::tr(&app.ui.language, "Fit Content to Frame"), - "centerContent" => crate::i18n::tr(&app.ui.language, "Center Content"), - _ => crate::i18n::tr(&app.ui.language, "None"), - }; - ui.horizontal(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Fitting:")); - egui::ComboBox::from_id_salt("ff_fit").selected_text(crate::rtl::widget(ui, label(&cur))).width(200.0).show_ui(ui, |ui| { - for v in ["none", "fillProportionally", "fitProportionally", "fitContentToFrame", "centerContent"] { - if ui.selectable_label(cur == v, crate::rtl::widget(ui, label(v))).clicked() { - d.fields.insert("fitting".into(), json!(v)); - } + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Crop Amount")).font(semibold(12.0))); + egui::Grid::new("ff_crop").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { + for (k, l) in [("cropTop", "Top:"), ("cropLeft", "Left:")] { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, l)); + text_field(ui, &mut d, k, 70.0); } + ui.end_row(); + for (k, l) in [("cropBottom", "Bottom:"), ("cropRight", "Right:")] { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, l)); + text_field(ui, &mut d, k, 70.0); + } + ui.end_row(); }); - }); - ui.horizontal(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Align From:")); - let a = d.n("align").unwrap_or(4.0) as u8; - egui::Grid::new("ff_align").spacing([2.0, 2.0]).show(ui, |ui| { - for row in 0..3u8 { - for col in 0..3u8 { - let i = row * 3 + col; - let (r, resp) = ui.allocate_exact_size(egui::vec2(14.0, 14.0), egui::Sense::click()); - let c = ui.visuals().text_color(); - if a == i { - ui.painter().rect_filled(r.shrink(2.0), 1.0, c); - } else { - ui.painter().rect_stroke(r.shrink(3.0), 1.0, egui::Stroke::new(1.0, c), egui::StrokeKind::Inside); - } - if resp.clicked() { - d.fields.insert("align".into(), json!(i)); + } + "qrCode" => { + let cur = d.s("type"); + ui.horizontal(|ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Type:")); + let label = |v: &str| match v { + "text" => crate::i18n::tr(&app.ui.language, "Plain Text"), + "sms" => crate::i18n::tr(&app.ui.language, "Text Message"), + "email" => crate::i18n::tr(&app.ui.language, "Email"), + "vcard" => crate::i18n::tr(&app.ui.language, "Business Card"), + _ => crate::i18n::tr(&app.ui.language, "Web Hyperlink"), + }; + egui::ComboBox::from_id_salt("qr_type").selected_text(crate::rtl::widget(ui, label(&cur))).width(160.0).show_ui(ui, |ui| { + for v in ["url", "text", "sms", "email", "vcard"] { + if ui.selectable_label(cur == v, crate::rtl::widget(ui, label(v))).clicked() { + d.fields.insert("type".into(), json!(v)); } } + }); + }); + let fields: &[(&str, &str)] = match cur.as_str() { + "sms" => &[("number", "Cell Number:"), ("message", "Message:")], + "email" => &[("to", "Email Address:"), ("subject", "Subject:"), ("body", "Message:")], + "vcard" => &[("name", "Name:"), ("org", "Organization:"), ("phone", "Phone:"), ("email", "Email:"), ("url", "URL:")], + "text" => &[("content", "Text:")], + _ => &[("content", "URL:")], + }; + egui::Grid::new("qr_fields").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + for (k, l) in fields { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, l)); + text_field(ui, &mut d, k, 260.0); ui.end_row(); } - }); - }); - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Crop Amount")).font(semibold(12.0))); - egui::Grid::new("ff_crop").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { - for (k, l) in [("cropTop", "Top:"), ("cropLeft", "Left:")] { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, l)); - text_field(ui, &mut d, k, 70.0); - } - ui.end_row(); - for (k, l) in [("cropBottom", "Bottom:"), ("cropRight", "Right:")] { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, l)); - text_field(ui, &mut d, k, 70.0); - } - ui.end_row(); - }); - } - "qrCode" => { - let cur = d.s("type"); - ui.horizontal(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Type:")); - let label = |v: &str| match v { - "text" => crate::i18n::tr(&app.ui.language, "Plain Text"), - "sms" => crate::i18n::tr(&app.ui.language, "Text Message"), - "email" => crate::i18n::tr(&app.ui.language, "Email"), - "vcard" => crate::i18n::tr(&app.ui.language, "Business Card"), - _ => crate::i18n::tr(&app.ui.language, "Web Hyperlink"), - }; - egui::ComboBox::from_id_salt("qr_type").selected_text(crate::rtl::widget(ui, label(&cur))).width(160.0).show_ui(ui, |ui| { - for v in ["url", "text", "sms", "email", "vcard"] { - if ui.selectable_label(cur == v, crate::rtl::widget(ui, label(v))).clicked() { - d.fields.insert("type".into(), json!(v)); - } - } - }); - }); - let fields: &[(&str, &str)] = match cur.as_str() { - "sms" => &[("number", "Cell Number:"), ("message", "Message:")], - "email" => &[("to", "Email Address:"), ("subject", "Subject:"), ("body", "Message:")], - "vcard" => &[("name", "Name:"), ("org", "Organization:"), ("phone", "Phone:"), ("email", "Email:"), ("url", "URL:")], - "text" => &[("content", "Text:")], - _ => &[("content", "URL:")], - }; - egui::Grid::new("qr_fields").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { - for (k, l) in fields { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, l)); - text_field(ui, &mut d, k, 260.0); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Color:")); + text_field(ui, &mut d, "color", 160.0); ui.end_row(); - } - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Color:")); - text_field(ui, &mut d, "color", 160.0); - ui.end_row(); - }); - } - "polygonSettings" => { - egui::Grid::new("poly").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Number of Sides:")); - text_field(ui, &mut d, "sides", 60.0); - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Star Inset:")); - ui.horizontal(|ui| { - text_field(ui, &mut d, "starInset", 60.0); - ui.label("%"); }); - ui.end_row(); - }); - } - STEP_AND_REPEAT => step_and_repeat_dialog(app, ui, &mut d), - id if id.starts_with("cmd:") => command_form(app, ui, &mut d), - "goToPage" => { - ui.horizontal(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Page")); - text_field(ui, &mut d, "page", 80.0); - }); - } - "ruby" => { - ui.horizontal(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Ruby:")); - text_field(ui, &mut d, "text", 200.0); - }); - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Set over the selected text; empty removes it.")).size(11.0)); - } - "insertTable" => { - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Table Dimensions")).font(semibold(12.0))); - egui::Grid::new("ins_table").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Body Rows")); - text_field(ui, &mut d, "bodyRows", 60.0); - crate::rtl::label(ui, crate::i18n::tr_context(&app.ui.language, "Columns", "table")); - text_field(ui, &mut d, "columns", 60.0); - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Header Rows")); - text_field(ui, &mut d, "headerRows", 60.0); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Footer Rows")); - text_field(ui, &mut d, "footerRows", 60.0); - ui.end_row(); - }); - } - "textFrameOptions" => { - seed_text_frame_options(app, &mut d); - text_frame_options_dialog(app, ui, &mut d); - } - "documentSetup" => document_setup(app, ui, &mut d), - "pdfExport" => pdf_export(app, ui, &mut d), - "alert" => { - let file = d.s("file"); - if !file.is_empty() { - crate::rtl::label(ui, egui::RichText::new(crate::rtl::isolate(&file)).font(semibold(13.0))); - ui.add_space(4.0); } - crate::rtl::label(ui, d.s("message")); - } - _ => {} - } - }); + "polygonSettings" => { + egui::Grid::new("poly").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Number of Sides:")); + text_field(ui, &mut d, "sides", 60.0); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Star Inset:")); + ui.horizontal(|ui| { + text_field(ui, &mut d, "starInset", 60.0); + ui.label("%"); + }); + ui.end_row(); + }); + } + STEP_AND_REPEAT => step_and_repeat_dialog(app, ui, &mut d), + TOC => toc_dialog(app, ui, &mut d), + id if id.starts_with("cmd:") => command_form(app, ui, &mut d), + "goToPage" => { + ui.horizontal(|ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Page")); + text_field(ui, &mut d, "page", 80.0); + }); + } + "ruby" => { + ui.horizontal(|ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Ruby:")); + text_field(ui, &mut d, "text", 200.0); + }); + crate::rtl::label( + ui, + egui::RichText::new(crate::i18n::tr(&app.ui.language, "Set over the selected text; empty removes it.")).size(11.0), + ); + } + "insertTable" => { + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Table Dimensions")).font(semibold(12.0))); + egui::Grid::new("ins_table").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Body Rows")); + text_field(ui, &mut d, "bodyRows", 60.0); + crate::rtl::label(ui, crate::i18n::tr_context(&app.ui.language, "Columns", "table")); + text_field(ui, &mut d, "columns", 60.0); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Header Rows")); + text_field(ui, &mut d, "headerRows", 60.0); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Footer Rows")); + text_field(ui, &mut d, "footerRows", 60.0); + ui.end_row(); + }); + } + "textFrameOptions" => { + seed_text_frame_options(app, &mut d); + text_frame_options_dialog(app, ui, &mut d); + } + "documentSetup" => document_setup(app, ui, &mut d), + "pdfExport" => pdf_export(app, ui, &mut d), + "alert" => { + let file = d.s("file"); + if !file.is_empty() { + crate::rtl::label(ui, egui::RichText::new(crate::rtl::isolate(&file)).font(semibold(13.0))); + ui.add_space(4.0); + } + crate::rtl::label(ui, d.s("message")); + } + _ => {} + }); ui.add_space(12.0); let ok_label = if d.id == "closeDocument" { " Save " } else { " OK " }; ui.horizontal(|ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui .add( - egui::Button::new(crate::rtl::widget(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, ok_label)).color(egui::Color32::WHITE))) - .fill(crate::theme::Tokens::get(ui.ctx()).accent_strong), + egui::Button::new(crate::rtl::widget( + ui, + egui::RichText::new(crate::i18n::tr(&app.ui.language, ok_label)).color(egui::Color32::WHITE), + )) + .fill(crate::theme::Tokens::get(ui.ctx()).accent_strong), ) .clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) @@ -1742,12 +1857,26 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { /// Close the open dialog without applying it (Cancel, Esc, the control channel): a previewing /// dialog puts the document back as it was. pub fn cancel(app: &mut DesignApp) { + if app.ui.dialog.as_ref().is_some_and(|d| d.id == "findChange") { + app.session.reset_find_scope(); + } if app.ui.dialog.take().is_some_and(|d| d.fields.contains_key(PREVIEWED)) { app.session.cancel_interaction(); } app.cancel_pdf_import(); } +fn find_change_params(d: &Dialog) -> Value { + let mut p = json!({ + "find": d.s("find"), "change": d.s("change"), "grep": d.b("grep"), + "caseSensitive": d.b("caseSensitive"), "wholeWord": d.b("wholeWord"), "scope": d.s("scope"), + }); + if let Some(story) = d.fields.get("story") { + p["story"] = story.clone(); + } + p +} + /// Apply the open dialog. pub fn confirm(app: &mut DesignApp) -> Result { let Some(d) = app.ui.dialog.take() else { return Err("no dialog open".into()) }; @@ -2009,7 +2138,10 @@ pub fn confirm(app: &mut DesignApp) -> Result { let words: Vec = d.s("userWords").lines().map(str::to_string).collect(); app.run("spelling.setWords", json!({"words": words}))?; } - app.ui.story_editor_size = d.n("storyEditorSize").unwrap_or(14.0) as f32; + // The slider's range. A size that isn't finite is saved as `null`, and ui.json then fails to load. + if let Some(size) = d.n("storyEditorSize").filter(|v| v.is_finite()) { + app.ui.story_editor_size = size.clamp(8.0, 36.0) as f32; + } // Highlight options change the screen view only. app.canvas.shown = None; let view = match d.s("displayQuality").as_str() { @@ -2172,8 +2304,12 @@ pub fn confirm(app: &mut DesignApp) -> Result { app.run("footnote.options", p) } "alert" => Ok(Value::Null), - "findChange" if d.b("objectMode") => Ok(Value::Null), - "findChange" => app.run("find.change", json!({"find": d.s("find"), "change": d.s("change"), "grep": d.b("grep"), "caseSensitive": d.b("caseSensitive"), "wholeWord": d.b("wholeWord"), "scope": d.s("scope")})), + "findChange" => { + let result = if d.b("objectMode") { Ok(Value::Null) } else { app.run("find.change", find_change_params(&d)) }; + // Confirmation consumes retained bounds before closing, even on an error. + app.session.reset_find_scope(); + result + } STEP_AND_REPEAT => { // The preview goes; the copies are made once, as one undo step. if d.fields.contains_key(PREVIEWED) { @@ -2188,6 +2324,15 @@ pub fn confirm(app: &mut DesignApp) -> Result { } r } + TOC => { + let r = app.run("toc.generate", toc_params(app, &d)); + if let Err(e) = &r { + let mut d = d.clone(); + d.fields.insert("status".into(), json!(e)); + app.ui.dialog = Some(d); + } + r + } id if id.starts_with("cmd:") => { let cid = &id[4..]; let doc = designcraft_engine::find_command(cid).map_or("", |c| c.params); @@ -3632,6 +3777,223 @@ fn command_form(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { } const STEP_AND_REPEAT: &str = "cmd:edit.stepAndRepeat"; +const TOC: &str = "cmd:toc.generate"; + +/// One included style of the Table of Contents dialog, as `toc.generate` takes it, with the +/// separator in the dialog's notation (`^t` a tab). +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", default)] +struct TocRow { + style: String, + level: u8, + entry_style: String, + page_number: String, + separator: String, +} + +fn toc_separator_shown(s: &str) -> String { + s.replace('\t', "^t").replace('\u{2003}', "^m").replace('\u{2002}', "^>") +} + +fn toc_separator_typed(s: &str) -> String { + s.replace("^t", "\t").replace("^m", "\u{2003}").replace("^>", "\u{2002}") +} + +fn toc_rows(d: &Dialog) -> Vec { + d.fields.get("included").cloned().and_then(|v| serde_json::from_value(v).ok()).unwrap_or_default() +} + +/// Layout › Table of Contents…: prefilled from the document's table of contents when it has one. +pub fn open_toc(app: &mut DesignApp) { + let Some(st) = app.session.active() else { return }; + let d = &st.doc; + let styles: Vec<&str> = d.styles.paragraph.iter().map(|p| p.name.as_str()).filter(|n| *n != designcraft_doc::NO_PARA_STYLE).collect(); + let toc = d.toc.as_ref().filter(|t| d.story(t.story).is_some()); + let rows: Vec = toc + .map(|t| { + t.entries + .iter() + .map(|e| TocRow { + style: e.style.clone(), + level: e.level, + entry_style: e.entry_style.clone().unwrap_or_default(), + page_number: match (t.page_numbers, e.page_number) { + (false, _) | (_, designcraft_doc::TocPageNumber::None) => "none", + (_, designcraft_doc::TocPageNumber::Before) => "before", + _ => "after", + } + .into(), + separator: toc_separator_shown(e.separator.as_deref().unwrap_or("\t")), + }) + .collect() + }) + .unwrap_or_default(); + let fields = json!({ + "title": toc.map_or("Contents", |t| t.title.as_str()), + "titleStyle": toc.and_then(|t| t.title_style.clone()).unwrap_or_default(), + "included": rows, + "selected": 0, + "styles": styles, + "hasToc": toc.is_some(), + "replace": toc.is_some(), + }); + app.ui.dialog = Some(Dialog::new(TOC, fields)); +} + +/// The dialog's fields as `toc.generate` params. A new frame goes on the page in view. +fn toc_params(app: &DesignApp, d: &Dialog) -> Value { + let entries: Vec = toc_rows(d) + .iter() + .map(|r| { + let mut e = json!({"style": r.style, "level": r.level, "separator": toc_separator_typed(&r.separator)}); + if !r.entry_style.is_empty() { + e["entryStyle"] = json!(r.entry_style); + } + if !r.page_number.is_empty() { + e["pageNumber"] = json!(r.page_number); + } + e + }) + .collect(); + let mut p = json!({"title": d.s("title"), "entries": entries, "replace": d.b("replace")}); + let title_style = d.s("titleStyle"); + if !title_style.is_empty() { + p["titleStyle"] = json!(title_style); + } + if let Some(page) = crate::canvas::current_page(app) { + p["page"] = json!(page + 1); + } + p +} + +/// A paragraph style menu whose first choice, stored as "", is the generated style `auto`. +fn toc_style_combo(ui: &mut egui::Ui, salt: &str, styles: &[String], auto: &str, cur: &mut String) -> bool { + let shown = if cur.is_empty() { auto.to_string() } else { cur.clone() }; + let mut changed = false; + egui::ComboBox::from_id_salt(("toc_style", salt)).selected_text(shown).width(200.0).show_ui(ui, |ui| { + if ui.selectable_label(cur.is_empty(), auto).clicked() { + cur.clear(); + changed = true; + } + for n in styles.iter().filter(|n| *n != auto) { + if ui.selectable_label(cur == n, n.as_str()).clicked() { + *cur = n.clone(); + changed = true; + } + } + }); + changed +} + +/// Layout › Table of Contents: the title and its style, the paragraph styles to include (Add / +/// Remove between the two lists) and, for the selected one, its entry style, page number +/// position, separator and level. +fn toc_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { + let lang = app.ui.language.clone(); + let tr = |s: &str| crate::i18n::tr(&lang, s).to_string(); + let styles: Vec = d.fields.get("styles").cloned().and_then(|v| serde_json::from_value(v).ok()).unwrap_or_default(); + let mut rows = toc_rows(d); + let mut sel = d.n("selected").map_or(0, |n| n.max(0.0) as usize); + let mut other = d.s("other"); + let mut title_style = d.s("titleStyle"); + egui::Grid::new("toc_title").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { + crate::rtl::label(ui, tr("Title:")); + text_field(ui, d, "title", 200.0); + crate::rtl::label(ui, tr("Style:")); + toc_style_combo(ui, "title", &styles, "TOC Title", &mut title_style); + ui.end_row(); + }); + ui.add_space(8.0); + crate::rtl::label(ui, egui::RichText::new(tr("Styles in Table of Contents")).font(semibold(12.0))); + let others: Vec<&String> = styles.iter().filter(|n| !rows.iter().any(|r| &r.style == *n)).collect(); + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + crate::rtl::label(ui, tr("Include Paragraph Styles:")); + egui::Frame::group(ui.style()).show(ui, |ui| { + ui.set_width(200.0); + egui::ScrollArea::vertical().id_salt("toc_included").max_height(150.0).min_scrolled_height(150.0).show(ui, |ui| { + for (i, r) in rows.iter().enumerate() { + let indent = " ".repeat(usize::from(r.level.saturating_sub(1))); + if ui.selectable_label(i == sel, format!("{indent}{}", r.style)).clicked() { + sel = i; + } + } + }); + }); + }); + ui.vertical(|ui| { + ui.add_space(40.0); + if ui.button(tr("« Add")).clicked() && others.iter().any(|n| **n == other) { + let level = u8::try_from(rows.len() + 1).unwrap_or(9).min(9); + rows.push(TocRow { style: other.clone(), level, page_number: "after".into(), separator: "^t".into(), ..Default::default() }); + sel = rows.len() - 1; + other.clear(); + } + if ui.button(tr("Remove »")).clicked() && sel < rows.len() { + other = rows.remove(sel).style; + sel = sel.min(rows.len().saturating_sub(1)); + } + }); + ui.vertical(|ui| { + crate::rtl::label(ui, tr("Other Styles:")); + egui::Frame::group(ui.style()).show(ui, |ui| { + ui.set_width(200.0); + egui::ScrollArea::vertical().id_salt("toc_other").max_height(150.0).min_scrolled_height(150.0).show(ui, |ui| { + for n in styles.iter().filter(|n| !rows.iter().any(|r| &r.style == *n)) { + if ui.selectable_label(*n == other, n.as_str()).clicked() { + other = n.clone(); + } + } + }); + }); + }); + }); + if let Some(r) = rows.get_mut(sel) { + ui.add_space(8.0); + crate::rtl::label(ui, egui::RichText::new(format!("{} {}", tr("Style:"), r.style)).font(semibold(12.0))); + egui::Grid::new("toc_entry").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + crate::rtl::label(ui, tr("Entry Style:")); + let auto = format!("TOC Level {}", r.level.clamp(1, 9)); + toc_style_combo(ui, "entry", &styles, &auto, &mut r.entry_style); + ui.end_row(); + crate::rtl::label(ui, tr("Page Number:")); + let choices = [("after", "After Entry"), ("before", "Before Entry"), ("none", "No Page Number")]; + let cur = choices.iter().find(|(v, _)| *v == r.page_number).map_or("After Entry", |(_, l)| l); + egui::ComboBox::from_id_salt("toc_page_number").selected_text(tr(cur)).width(200.0).show_ui(ui, |ui| { + for (v, l) in choices { + if ui.selectable_label(r.page_number == v, tr(l)).clicked() { + r.page_number = v.into(); + } + } + }); + ui.end_row(); + crate::rtl::label(ui, tr("Between Entry and Number:")); + ui.add(egui::TextEdit::singleline(&mut r.separator).desired_width(80.0).horizontal_align(egui::Align::Min)); + ui.end_row(); + crate::rtl::label(ui, tr("Level:")); + egui::ComboBox::from_id_salt("toc_level").selected_text(r.level.to_string()).width(60.0).show_ui(ui, |ui| { + for l in 1..=9u8 { + if ui.selectable_label(r.level == l, l.to_string()).clicked() { + r.level = l; + } + } + }); + ui.end_row(); + }); + } + ui.add_space(8.0); + crate::rtl::label(ui, egui::RichText::new(tr("Options")).font(semibold(12.0))); + let has_toc = d.b("hasToc"); + ui.add_enabled_ui(has_toc, |ui| check(ui, d, "replace", &tr("Replace Existing Table of Contents"))); + if let Some(st) = d.fields.get("status").and_then(Value::as_str) { + ui.add_space(6.0); + crate::rtl::label(ui, egui::RichText::new(st).color(crate::theme::Tokens::get(ui.ctx()).text_dim)); + } + d.fields.insert("included".into(), json!(rows)); + d.fields.insert("selected".into(), json!(sel)); + d.fields.insert("other".into(), json!(other)); + d.fields.insert("titleStyle".into(), json!(title_style)); +} /// The params a previewing dialog shows on the page (its interaction is open while it is set). const PREVIEWED: &str = "_previewed"; @@ -4006,6 +4368,44 @@ mod tests { assert_eq!(d.s("status"), "Invalid value: up"); } + #[test] + fn toc_dialog_adds_a_style_and_ok_makes_the_table_of_contents() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.newSample", json!({})).unwrap(); + crate::menus::activate(&mut app, "toc.generate", &Value::Null); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some(TOC)); + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0)); + dialog_frame(&mut app, &ctx, screen, vec![]); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + for expected in + ["Table of Contents", "Title:", "Include Paragraph Styles:", "Other Styles:", "« Add", "Remove »", "Replace Existing Table of Contents"] + { + visible_label(&labels, screen, expected); + } + click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "Headline").center()); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "« Add").center()); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + for expected in ["Entry Style:", "TOC Level 1", "After Entry", "Between Entry and Number:", "Level:"] { + visible_label(&labels, screen, expected); + } + let undo = app.session.doc().unwrap().history.undo.len(); + click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "OK").center()); + assert!(app.ui.dialog.is_none()); + let d = &app.session.doc().unwrap().doc; + let toc = d.toc.clone().unwrap(); + assert_eq!(toc.entries, vec![designcraft_doc::TocEntry::new("Headline", 1)]); + let text = &d.story(toc.story).unwrap().text; + assert!(text.starts_with("Contents\n") && text.contains("\nNotes on the Grid\t"), "{text}"); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1); + // Reopened, the dialog shows the table of contents and replaces it. + crate::menus::activate(&mut app, "toc.generate", &Value::Null); + let d = app.ui.dialog.as_ref().unwrap(); + assert!(d.b("replace")); + assert_eq!(toc_rows(d).iter().map(|r| r.style.as_str()).collect::>(), ["Headline"]); + } + fn dialog_context(language: &str) -> egui::Context { let ctx = egui::Context::default(); crate::theme::install_fonts(&ctx, language); @@ -4074,6 +4474,722 @@ mod tests { } } + fn scope_control(app: &mut DesignApp, ctx: &egui::Context, method: &str, params: Value) -> Value { + let (request, _reply) = crate::control::ControlRequest::new(method, params); + let crate::control::Outcome::Done(value) = crate::control::handle(app, ctx, &request) else { panic!("ordinary control response") }; + assert_eq!(value["ok"], true, "{value}"); + value["result"].clone() + } + + fn scope_click(app: &mut DesignApp, ctx: &egui::Context, screen: egui::Rect, label: &str) { + let labels = dialog_frame(app, ctx, screen, vec![]); + let pos = visible_label(&labels, screen, label).center(); + click_dialog(app, ctx, screen, pos); + } + + fn scope_dialog() -> (DesignApp, egui::Context, egui::Rect, designcraft_engine::doc::StoryId) { + scope_dialog_with_selection("red red | red", 7) + } + + fn scope_dialog_with_selection(text: &str, end: usize) -> (DesignApp, egui::Context, egui::Rect, designcraft_engine::doc::StoryId) { + scope_dialog_with_tool(text, end, None) + } + + fn scope_dialog_with_tool( + text: &str, + end: usize, + tool: Option<&str>, + ) -> (DesignApp, egui::Context, egui::Rect, designcraft_engine::doc::StoryId) { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.ui.language = "en".into(); + app.run("file.new", json!({})).unwrap(); + let frame = app.run("frame.create", json!({"rect": [36, 36, 100, 100], "content": "text", "text": text})).unwrap(); + let sid = designcraft_engine::doc::StoryId(frame["story"].as_u64().unwrap()); + let ctx = dialog_context("en"); + if let Some(tool) = tool { + scope_control(&mut app, &ctx, "ui.tool.select", json!({"tool": tool})); + } + app.run("text.select", json!({"story": sid.0, "anchor": 0, "focus": end})).unwrap(); + app.run("app.findChange", json!({})).unwrap(); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1178.0, 814.0)); + for (field, value) in [("find", "red"), ("change", "unused")] { + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": field, "value": value})); + } + for _ in 0..3 { + dialog_frame(&mut app, &ctx, screen, vec![]); + } + scope_click(&mut app, &ctx, screen, "document"); + scope_click(&mut app, &ctx, screen, "selection"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("scope"), "selection"); + (app, ctx, screen, sid) + } + + fn scope_selected(app: &DesignApp, sid: designcraft_engine::doc::StoryId, a: usize, b: usize) { + let t = app.session.doc().unwrap().selection.text.unwrap(); + assert_eq!((t.story, t.range(), t.cell), (sid, a..b, None)); + } + + fn scope_state_unchanged(app: &DesignApp, before: &designcraft_engine::DocState) { + let after = app.session.doc().unwrap(); + assert!(std::sync::Arc::ptr_eq(&before.doc, &after.doc)); + assert_eq!(after.uid, before.uid); + assert_eq!(after.revision, before.revision); + assert_eq!(after.selection, before.selection); + assert_eq!(after.history.limit, before.history.limit); + for (actual, expected) in [(&after.history.undo, &before.history.undo), (&after.history.redo, &before.history.redo)] { + assert_eq!(actual.len(), expected.len()); + for (a, b) in actual.iter().zip(expected) { + assert_eq!(a.label, b.label); + assert_eq!(a.selection, b.selection); + assert!(std::sync::Arc::ptr_eq(&a.doc, &b.doc)); + } + } + } + + #[test] + fn find_scope_dialog_control_effective_noops_retain_bounds() { + for (field, value) in [("objectMode", json!(false)), ("story", Value::Null)] { + let (mut app, ctx, screen, sid) = scope_dialog(); + assert!(!app.ui.dialog.as_ref().unwrap().fields.contains_key(field)); + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "change", "value": "blue"})); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 3); + let before = app.session.doc().unwrap().clone(); + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": field, "value": value})); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields[field], value); + scope_state_unchanged(&app, &before); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 4, 7); + scope_click(&mut app, &ctx, screen, "Change All"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 replacement(s) made"); + assert_eq!(app.session.doc().unwrap().doc.story(sid).unwrap().text, "blue blue | red"); + assert_eq!(app.session.doc().unwrap().history.undo.len(), before.history.undo.len() + 1); + } + } + + #[test] + fn find_scope_dialog_explicit_story_ignored_scope_preserves_grep_cursor() { + for physical in [false, true] { + for (text, pattern, hits, changed) in [ + ("é", "é|(?:)", vec![(0, 2)], "X"), + ("é🙂", "é|(?:)", vec![(0, 2), (6, 6)], "X🙂X"), + ("é🙂", "(?:)", vec![(0, 0), (2, 2), (6, 6)], "XéX🙂X"), + ] { + let (mut app, ctx, screen, sid) = scope_dialog_with_selection(text, 0); + for (field, value) in [("find", json!(pattern)), ("change", json!("X")), ("story", json!(sid.0))] { + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": field, "value": value})); + } + scope_click(&mut app, &ctx, screen, "GREP"); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), format!("{} match(es)", hits.len())); + for &(a, b) in &hits { + scope_click(&mut app, &ctx, screen, "Find Next"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "Found"); + scope_selected(&app, sid, a, b); + let before = app.session.doc().unwrap().clone(); + let cur = app.ui.dialog.as_ref().unwrap().s("scope"); + let next = if cur == "selection" { "document" } else { "selection" }; + if physical { + scope_click(&mut app, &ctx, screen, &cur); + scope_click(&mut app, &ctx, screen, next); + } else { + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "scope", "value": next})); + } + assert_eq!(app.ui.dialog.as_ref().unwrap().s("scope"), next); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["story"], json!(sid.0)); + scope_state_unchanged(&app, &before); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), format!("{} match(es)", hits.len())); + } + scope_click(&mut app, &ctx, screen, "Find Next"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "No matches"); + // An ignored scope change must not revive an exhausted endpoint either. + let before = app.session.doc().unwrap().clone(); + let cur = app.ui.dialog.as_ref().unwrap().s("scope"); + let next = if cur == "selection" { "document" } else { "selection" }; + if physical { + scope_click(&mut app, &ctx, screen, &cur); + scope_click(&mut app, &ctx, screen, next); + } else { + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "scope", "value": next})); + } + scope_click(&mut app, &ctx, screen, "Find Next"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "No matches"); + scope_state_unchanged(&app, &before); + scope_click(&mut app, &ctx, screen, "Change All"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), format!("{} replacement(s) made", hits.len())); + assert_eq!(app.session.doc().unwrap().doc.story(sid).unwrap().text, changed); + } + } + } + + #[test] + fn find_scope_dialog_explicit_story_transitions_reset_cursor() { + for remove_override in [false, true] { + let (mut app, ctx, screen, sid) = scope_dialog_with_selection("é🙂", 0); + let other = app.run("frame.create", json!({"rect": [180, 36, 100, 100], "content": "text", "text": "other"})).unwrap(); + app.run("text.select", json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); + for (field, value) in [("find", json!("(?:)")), ("story", json!(sid.0))] { + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": field, "value": value})); + } + scope_click(&mut app, &ctx, screen, "GREP"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 0); + let before = app.session.doc().unwrap().clone(); + let value = if remove_override { Value::Null } else { other["story"].clone() }; + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "story", "value": value})); + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "story", "value": sid.0})); + scope_state_unchanged(&app, &before); + // Away and back still releases continuation even if no Find command intervenes. + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 0); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 2, 2); + assert_eq!(app.session.doc().unwrap().doc.story(sid).unwrap().text, "é🙂"); + let other_sid = designcraft_engine::doc::StoryId(other["story"].as_u64().unwrap()); + assert_eq!(app.session.doc().unwrap().doc.story(other_sid).unwrap().text, "other"); + } + } + + #[test] + fn find_scope_dialog_control_mode_changes_release_bounds() { + for object_mode in [false, true] { + let (mut app, ctx, _, sid) = scope_dialog(); + if object_mode { + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "objectMode", "value": true})); + } + // Direct Session consumers share the retained domain even while Object is shown. + let p = json!({"find": "red", "change": "blue", "scope": "selection"}); + let count = scope_control(&mut app, &ctx, "engine.execute", json!({"command": "find.find", "params": p})); + assert_eq!(count.as_array().unwrap().len(), 2); + scope_control(&mut app, &ctx, "engine.execute", json!({"command": "find.next", "params": p})); + scope_selected(&app, sid, 0, 3); + let before = app.session.doc().unwrap().clone(); + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "objectMode", "value": !object_mode})); + assert_eq!(app.ui.dialog.as_ref().unwrap().b("objectMode"), !object_mode); + scope_state_unchanged(&app, &before); + let count = scope_control(&mut app, &ctx, "engine.execute", json!({"command": "find.find", "params": p})); + assert_eq!(count, json!([{"story": sid.0, "start": 0, "end": 3, "text": "red"}])); + let changed = scope_control(&mut app, &ctx, "engine.execute", json!({"command": "find.change", "params": p})); + assert_eq!(changed, json!({"count": 1})); + assert_eq!(app.session.doc().unwrap().doc.story(sid).unwrap().text, "blue red | red"); + } + } + + #[test] + fn find_scope_dialog_tool_replacement_releases_bounds() { + for (tool, replacement) in [("page", "cmd:layout.pageSize"), ("rectangleFrame", "frameSize")] { + let (mut app, ctx, screen, sid) = scope_dialog_with_tool("red red | red", 7, Some(tool)); + assert_eq!(app.session.tool_id(), tool); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 3); + let before = app.session.doc().unwrap().clone(); + let active = (app.session.active_index(), app.session.documents().len()); + let off = app.session.layout().xf(designcraft_engine::doc::SpreadRef::Doc(0)).translation(); + let x = off.x + before.doc.spreads[0].pages[0].x + 200.0; + let y = off.y + 200.0; + // The Page tool opens Page Size on a double-click (a click selects the page). + let events = if tool == "page" { + json!([{"kind": "doubleclick", "x": x, "y": y}]) + } else { + json!([{"kind": "down", "x": x, "y": y}, {"kind": "up", "x": x, "y": y}]) + }; + // Real tool -> Action::Dialog -> after_engine; no injected UI request. + scope_control(&mut app, &ctx, "ui.pointer", json!({"events": events})); + assert_eq!(app.ui.dialog.as_ref().unwrap().id, replacement); + assert!(app.session.ui_requests.is_empty()); + assert_eq!((app.session.active_index(), app.session.documents().len()), active); + scope_state_unchanged(&app, &before); + scope_click(&mut app, &ctx, screen, "Cancel"); + assert!(app.ui.dialog.is_none()); + assert_eq!((app.session.active_index(), app.session.documents().len()), active); + scope_state_unchanged(&app, &before); + let p = json!({"find": "red", "change": "blue", "scope": "selection"}); + let count = scope_control(&mut app, &ctx, "engine.execute", json!({"command": "find.find", "params": p})); + assert_eq!(count, json!([{"story": sid.0, "start": 0, "end": 3, "text": "red"}])); + scope_state_unchanged(&app, &before); + let changed = scope_control(&mut app, &ctx, "engine.execute", json!({"command": "find.change", "params": p})); + assert_eq!(changed, json!({"count": 1})); + assert_eq!(app.session.doc().unwrap().doc.story(sid).unwrap().text, "blue red | red"); + assert_eq!(app.session.doc().unwrap().history.undo.len(), before.history.undo.len() + 1); + } + } + + #[test] + fn find_scope_dialog_tool_without_replacement_retains_bounds() { + for tool in ["page", "rectangleFrame"] { + let (mut app, ctx, screen, sid) = scope_dialog_with_tool("red red | red", 7, Some(tool)); + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "change", "value": "blue"})); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 3); + let before = app.session.doc().unwrap().clone(); + let fields = app.ui.dialog.as_ref().unwrap().fields.clone(); + scope_control(&mut app, &ctx, "ui.pointer", json!({"events": [{"kind": "move", "x": 200, "y": 200}]})); + assert_eq!(app.ui.dialog.as_ref().unwrap().id, "findChange"); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields, fields); + scope_state_unchanged(&app, &before); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 4, 7); + scope_click(&mut app, &ctx, screen, "Change All"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 replacement(s) made"); + assert_eq!(app.session.doc().unwrap().doc.story(sid).unwrap().text, "blue blue | red"); + } + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn find_scope_dialog_native_pdf_replacement_releases_bounds() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("three.pdf"); + std::fs::write(&path, crate::browser_import_tests::pdf()).unwrap(); + for dismissal in ["Cancel", "Escape", "control"] { + let (mut app, ctx, screen, sid) = scope_dialog(); + let picked = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let read = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let seen_pick = picked.clone(); + let seen_read = read.clone(); + let pick_path = path.to_string_lossy().to_string(); + let read_path = pick_path.clone(); + app.services.pick_open = Some(Box::new(move |purpose| { + assert_eq!(purpose, "place"); + seen_pick.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Some(pick_path.clone()) + })); + app.services.read = Some(Box::new(move |path| { + assert_eq!(path, read_path); + seen_read.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + std::fs::read(path).map_err(|e| e.to_string()) + })); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 3); + let before = app.session.doc().unwrap().clone(); + let active = (app.session.active_index(), app.session.documents().len()); + // Exercise engine.execute -> UiRequest::Pick -> native host PDF replacement. + scope_control(&mut app, &ctx, "engine.execute", json!({"command": "file.place", "params": {}})); + assert_eq!(picked.load(std::sync::atomic::Ordering::SeqCst), 1); + assert_eq!(read.load(std::sync::atomic::Ordering::SeqCst), 1); + let dialog = app.ui.dialog.as_ref().unwrap(); + assert_eq!(dialog.id, "pdfImport"); + assert_eq!(dialog.s("path"), path.to_string_lossy()); + assert_eq!(dialog.fields["pages"], 3); + assert_eq!(dialog.s("page"), "1"); + assert!(!dialog.b("async")); + assert_eq!(dialog.fields["target"], json!(before.uid)); + assert!(app.session.ui_requests.is_empty()); + assert!(app.pending_pdf.is_none()); + assert_eq!((app.session.active_index(), app.session.documents().len()), active); + scope_state_unchanged(&app, &before); + match dismissal { + "Cancel" => scope_click(&mut app, &ctx, screen, "Cancel"), + "Escape" => { + dialog_frame( + &mut app, + &ctx, + screen, + vec![egui::Event::Key { + key: egui::Key::Escape, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::NONE, + }], + ); + } + "control" => { + scope_control(&mut app, &ctx, "ui.dialog.cancel", json!({})); + } + _ => panic!("known dismissal"), + } + assert!(app.ui.dialog.is_none()); + assert!(app.pending_pdf.is_none()); + assert_eq!(picked.load(std::sync::atomic::Ordering::SeqCst), 1); + assert_eq!(read.load(std::sync::atomic::Ordering::SeqCst), 1); + assert_eq!((app.session.active_index(), app.session.documents().len()), active); + scope_state_unchanged(&app, &before); + let p = json!({"find": "red", "change": "blue", "scope": "selection"}); + let count = scope_control(&mut app, &ctx, "engine.execute", json!({"command": "find.find", "params": p})); + assert_eq!(count, json!([{"story": sid.0, "start": 0, "end": 3, "text": "red"}])); + scope_state_unchanged(&app, &before); + let changed = scope_control(&mut app, &ctx, "engine.execute", json!({"command": "find.change", "params": p})); + assert_eq!(changed, json!({"count": 1})); + assert_eq!(app.session.doc().unwrap().doc.story(sid).unwrap().text, "blue red | red"); + assert_eq!(app.session.doc().unwrap().history.undo.len(), before.history.undo.len() + 1); + } + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn find_scope_dialog_native_picker_cancel_retains_bounds() { + let (mut app, ctx, screen, sid) = scope_dialog(); + let picked = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let seen = picked.clone(); + app.services.pick_open = Some(Box::new(move |purpose| { + assert_eq!(purpose, "place"); + seen.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + None + })); + let read = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let seen_read = read.clone(); + app.services.read = Some(Box::new(move |_| { + seen_read.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Err("canceled picker must not read".into()) + })); + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "change", "value": "blue"})); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 3); + let before = app.session.doc().unwrap().clone(); + let active = (app.session.active_index(), app.session.documents().len()); + let fields = app.ui.dialog.as_ref().unwrap().fields.clone(); + scope_control(&mut app, &ctx, "engine.execute", json!({"command": "file.place", "params": {}})); + assert_eq!(picked.load(std::sync::atomic::Ordering::SeqCst), 1); + assert_eq!(read.load(std::sync::atomic::Ordering::SeqCst), 0); + assert_eq!(app.ui.dialog.as_ref().unwrap().id, "findChange"); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields, fields); + assert_eq!((app.session.active_index(), app.session.documents().len()), active); + scope_state_unchanged(&app, &before); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 4, 7); + scope_click(&mut app, &ctx, screen, "Change All"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 replacement(s) made"); + assert_eq!(app.session.doc().unwrap().doc.story(sid).unwrap().text, "blue blue | red"); + assert_eq!(app.session.doc().unwrap().history.undo.len(), before.history.undo.len() + 1); + } + + #[test] + fn find_scope_dialog_exact_baseline_buttons() { + let (mut app, ctx, screen, sid) = scope_dialog_with_selection("red red", 3); + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "change", "value": "blue"})); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "1 match(es)"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 3); + scope_click(&mut app, &ctx, screen, "Change All"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "1 replacement(s) made"); + assert_eq!(app.session.doc().unwrap().doc.story(sid).unwrap().text, "blue red"); + } + + #[test] + fn find_scope_dialog_grep_tab_alone_restarts_original_selection() { + let (mut app, ctx, screen, sid) = scope_dialog(); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 3); + scope_click(&mut app, &ctx, screen, "GREP"); + assert!(app.ui.dialog.as_ref().unwrap().b("grep")); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("find"), "red"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 3); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 4, 7); + scope_click(&mut app, &ctx, screen, "Text"); + assert!(!app.ui.dialog.as_ref().unwrap().b("grep")); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 3); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + } + + #[test] + fn find_scope_dialog_buttons_retain_nonempty_hits_and_change_all() { + let (mut app, ctx, screen, sid) = scope_dialog(); + let history = app.session.doc().unwrap().history.undo.len(); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 3); + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "change", "value": "blue"})); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 4, 7); + scope_click(&mut app, &ctx, screen, "Find Next"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "No matches"); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + assert_eq!(app.session.doc().unwrap().history.undo.len(), history); + scope_click(&mut app, &ctx, screen, "Change All"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 replacement(s) made"); + assert_eq!(app.session.doc().unwrap().doc.story(sid).unwrap().text, "blue blue | red"); + assert_eq!(app.session.doc().unwrap().history.undo.len(), history + 1); + scope_click(&mut app, &ctx, screen, "Cancel"); + assert!(app.ui.dialog.is_none()); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc.story(sid).unwrap().text, "red red | red"); + app.run("edit.redo", json!({})).unwrap(); + let native = app.run("file.serialize", json!({})).unwrap(); + app.run("file.openBytes", json!({"name": "scope.designcraft", "base64": native["base64"]})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc.story(sid).unwrap().text, "blue blue | red"); + } + + fn scope_confirm_case(action: &str) { + let (mut app, ctx, screen, sid) = scope_dialog(); + scope_click(&mut app, &ctx, screen, "Count"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 3); + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "change", "value": "blue"})); + match action { + "OK" => scope_click(&mut app, &ctx, screen, "OK"), + "Enter" => { + dialog_frame( + &mut app, + &ctx, + screen, + vec![egui::Event::Key { + key: egui::Key::Enter, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::NONE, + }], + ); + } + "control" => { + assert_eq!(scope_control(&mut app, &ctx, "ui.dialog.confirm", json!({}))["count"], 2); + } + _ => panic!("known confirmation route"), + } + assert!(app.ui.dialog.is_none()); + assert_eq!(app.session.doc().unwrap().doc.story(sid).unwrap().text, "blue blue | red"); + // The closed dialog cannot leave its old seven-byte range for a later direct query. + let matches = app.run("find.find", json!({"find": "blue", "scope": "selection"})).unwrap(); + assert_eq!(matches.as_array().unwrap().len(), 0); + } + + #[test] + fn find_scope_dialog_ok_consumes_retained_bounds() { + scope_confirm_case("OK"); + } + + #[test] + fn find_scope_dialog_enter_consumes_retained_bounds() { + scope_confirm_case("Enter"); + } + + #[test] + fn find_scope_dialog_control_confirm_consumes_retained_bounds() { + scope_confirm_case("control"); + } + + #[test] + fn find_scope_dialog_query_reselection_cancel_and_reopen() { + let (mut app, ctx, screen, sid) = scope_dialog(); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 3); + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "grep", "value": true})); + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "find", "value": "r.d"})); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 3); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + scope_control(&mut app, &ctx, "ui.dialog.cancel", json!({})); + assert_eq!(app.run("find.find", json!({"find": "red", "scope": "selection"})).unwrap().as_array().unwrap().len(), 1); + app.run("text.select", json!({"story": sid.0, "anchor": 0, "focus": 7})).unwrap(); + app.run("find.next", json!({"find": "red", "scope": "selection"})).unwrap(); + scope_control(&mut app, &ctx, "ui.dialog.open", json!({"id": "findChange", "fields": {"find": "red", "scope": "selection"}})); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "1 match(es)"); + app.run("text.select", json!({"story": sid.0, "anchor": 4, "focus": 7})).unwrap(); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 4, 7); + app.run("app.findChange", json!({})).unwrap(); + for (field, value) in [("find", "red"), ("scope", "selection")] { + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": field, "value": value})); + } + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "1 match(es)"); + } + + #[test] + fn find_scope_dialog_word_reselection_limits_count_and_change_buttons() { + let (mut app, ctx, screen, sid) = scope_dialog(); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 3); + let selected = app.session.doc().unwrap().selection.clone(); + let frame = selected.text.unwrap().frame.unwrap(); + let point = { + let st = app.session.doc().unwrap(); + let cs = app.session.cache.get(&st.doc, sid, None); + let (fi, x, baseline, ascent, _) = designcraft_compose::caret(&cs, 1).unwrap(); + let inner = designcraft_geom::Point::new(x, baseline - ascent * 0.5); + assert_eq!(cs.frames[fi].frame, frame); + assert_eq!(designcraft_compose::hit(&cs, fi, inner), Some(1)); + let loc = st.doc.find(frame).unwrap(); + st.doc.parent_xf(&loc) * st.doc.text_xf(st.doc.item(frame).unwrap()) * inner + }; + let before = app.session.doc().unwrap().doc.clone(); + let revision = app.session.doc().unwrap().revision; + app.run("text.selectWord", json!({"frame": frame.0, "point": [point.x, point.y]})).unwrap(); + assert_eq!(app.session.doc().unwrap().selection, selected); + assert_eq!(app.session.doc().unwrap().revision, revision + 1); + assert!(std::sync::Arc::ptr_eq(&before, &app.session.doc().unwrap().doc)); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "1 match(es)"); + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "change", "value": "blue"})); + scope_click(&mut app, &ctx, screen, "Change All"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "1 replacement(s) made"); + assert_eq!(app.session.doc().unwrap().doc.story(sid).unwrap().text, "blue red | red"); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn find_scope_dialog_failed_native_drop_alert_releases_bounds() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("broken.designcraft"); + std::fs::write(&path, b"not a native document").unwrap(); + for dismissal in ["OK", "Escape", "control"] { + let (mut app, ctx, screen, sid) = scope_dialog(); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 3); + let before = app.session.doc().unwrap().doc.clone(); + let selected = app.session.doc().unwrap().selection.clone(); + let revision = app.session.doc().unwrap().revision; + let history = app.session.doc().unwrap().history.undo.len(); + let uid = app.session.doc().unwrap().uid; + let error = app.open_dropped(&path.to_string_lossy()).unwrap_err(); + let alert = app.ui.dialog.as_ref().unwrap(); + assert_eq!(alert.id, "alert"); + assert_eq!(alert.s("title"), "Can't Open the File"); + assert_eq!(alert.s("message"), error); + assert!(std::sync::Arc::ptr_eq(&before, &app.session.doc().unwrap().doc)); + assert_eq!(app.session.doc().unwrap().selection, selected); + assert_eq!(app.session.doc().unwrap().revision, revision); + assert_eq!(app.session.doc().unwrap().uid, uid); + assert_eq!(app.session.doc().unwrap().history.undo.len(), history); + match dismissal { + "OK" => scope_click(&mut app, &ctx, screen, "OK"), + "Escape" => { + dialog_frame( + &mut app, + &ctx, + screen, + vec![egui::Event::Key { + key: egui::Key::Escape, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::NONE, + }], + ); + } + "control" => { + scope_control(&mut app, &ctx, "ui.dialog.cancel", json!({})); + } + _ => panic!("known dismissal"), + } + assert!(app.ui.dialog.is_none()); + let p = json!({"find": "red", "change": "blue", "scope": "selection"}); + assert_eq!(app.run("find.find", p.clone()).unwrap(), json!([{"story": sid.0, "start": 0, "end": 3, "text": "red"}])); + assert_eq!(app.run("find.change", p).unwrap(), json!({"count": 1})); + assert_eq!(app.session.doc().unwrap().doc.story(sid).unwrap().text, "blue red | red"); + assert_eq!(app.session.doc().unwrap().history.undo.len(), history + 1); + } + } + + #[test] + fn find_scope_dialog_child_errors_and_explicit_parent() { + let (mut app, ctx, screen, sid) = scope_dialog(); + app.run("text.select", json!({"story": sid.0, "anchor": 13, "focus": 13})).unwrap(); + app.run("table.insert", json!({"rows": 1, "cols": 1})).unwrap(); + app.run("text.insert", json!({"text": "red child", "raw": true})).unwrap(); + let child = app.session.doc().unwrap().selection.text.unwrap(); + let before = app.session.doc().unwrap().doc.clone(); + let selection = app.session.doc().unwrap().selection.clone(); + let history = app.session.doc().unwrap().history.undo.len(); + for label in ["Count", "Find Next", "Change All"] { + scope_click(&mut app, &ctx, screen, label); + assert!(app.ui.dialog.as_ref().unwrap().s("status").contains("not supported")); + assert!(std::sync::Arc::ptr_eq(&before, &app.session.doc().unwrap().doc)); + assert_eq!(app.session.doc().unwrap().selection, selection); + assert_eq!(app.session.doc().unwrap().history.undo.len(), history); + } + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "change", "value": "blue"})); + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "story", "value": sid.0})); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "3 match(es)"); + scope_click(&mut app, &ctx, screen, "Change All"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "3 replacement(s) made"); + assert_eq!(app.session.doc().unwrap().doc.text_story(sid, child.cell).unwrap().text, "red child"); + assert!(app.session.doc().unwrap().doc.story(sid).unwrap().text.starts_with("blue blue | blue")); + } + + #[test] + fn find_scope_dialog_error_confirmation_and_escape_release_context() { + for escape in [false, true] { + let (mut app, ctx, screen, sid) = scope_dialog(); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 3); + if escape { + dialog_frame( + &mut app, + &ctx, + screen, + vec![egui::Event::Key { + key: egui::Key::Escape, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::NONE, + }], + ); + } else { + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "find", "value": "["})); + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "grep", "value": true})); + scope_click(&mut app, &ctx, screen, "OK"); + } + assert!(app.ui.dialog.is_none()); + assert_eq!(app.session.doc().unwrap().doc.story(sid).unwrap().text, "red red | red"); + assert_eq!(app.run("find.find", json!({"find": "red", "scope": "selection"})).unwrap().as_array().unwrap().len(), 1); + } + } + + #[test] + fn find_scope_dialog_domain_and_object_changes_release_original_range() { + for route in ["scope", "object", "menu", "command"] { + let (mut app, ctx, screen, _) = scope_dialog(); + scope_click(&mut app, &ctx, screen, "Find Next"); + match route { + "scope" => { + for scope in ["story", "selection"] { + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "scope", "value": scope})); + } + } + "object" => { + scope_click(&mut app, &ctx, screen, "Object"); + scope_click(&mut app, &ctx, screen, "Text"); + } + "menu" => crate::menus::activate(&mut app, "app.newDocumentDialog", &Value::Null), + "command" => { + app.run("app.newDocumentDialog", json!({})).unwrap(); + } + _ => panic!("known reset route"), + } + assert_eq!(app.run("find.find", json!({"find": "red", "scope": "selection"})).unwrap().as_array().unwrap().len(), 1); + } + } + #[test] fn dialog_title_and_actions_fit_scaled_viewports() { for language in ["en", "ar"] { @@ -4147,6 +5263,22 @@ mod tests { assert!(app.ui.dialog.as_ref().unwrap().b("absolutePageNumbers")); } + #[test] + fn preferences_ok_saves_a_story_editor_size_that_loads_back() { + // `ui.dialog.set` takes any value; a size that isn't finite is saved as `null`. + let sizes = [(json!("nan"), 20.0), (json!("inf"), 20.0), (json!(null), 20.0), (json!(1e39), 36.0), (json!(3), 8.0), (json!(11), 11.0)]; + for (size, want) in sizes { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.ui.story_editor_size = 20.0; + app.run("app.preferences", json!({})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("storyEditorSize".into(), size); + confirm(&mut app).unwrap(); + let saved = serde_json::to_vec(&app.ui).unwrap(); + assert_eq!(crate::UiState::from_preferences_json(&saved).unwrap().story_editor_size, want); + } + } + #[test] fn new_document_vertical_scroll_reaches_last_field() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 6d6503a0..04ee153c 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -296,6 +296,28 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Document Setup", ["Document Setup", "Document Setup", "Document Setup", "Document Setup", "文档设置"]), ("Drop Shadow", ["Drop Shadow", "Drop Shadow", "Drop Shadow", "Drop Shadow", "投影"]), ("Duplicate Spread", ["Duplicate Spread", "Duplicate Spread", "Duplicate Spread", "Duplicate Spread", "复制跨页"]), + ( + "Duplicate Spread at End of Document", + [ + "Druckbogen am Dokumentende duplizieren", + "Dupliquer la planche à la fin du document", + "Duplicar pliego al final del documento", + "ドキュメントの最後にスプレッドを複製", + "在文档末尾复制跨页", + ], + ), + ("Delete Spread", ["Druckbogen löschen", "Supprimer la planche", "Eliminar pliego", "スプレッドを削除", "删除跨页"]), + ("Parent Pages", ["Übergeordnete Seiten", "Pages parents", "Páginas principales", "親ページ", "父页面"]), + ( + "Apply Parent to Pages", + [ + "Übergeordnete Seite auf Seiten anwenden", + "Appliquer le parent aux pages", + "Aplicar principal a páginas", + "親ページを適用", + "将父页面应用于页面", + ], + ), ("Email", ["Email", "Email", "Email", "Email", "电子邮件"]), ("Embed", ["Embed", "Embed", "Embed", "Embed", "嵌入"]), ("Export PDF", ["PDF exportieren", "Exporter en PDF", "Exportar PDF", "PDF を書き出し", "导出PDF"]), @@ -612,6 +634,22 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Edit Page", ["Edit Page", "Edit Page", "Edit Page", "Edit Page", "编辑页面"]), ("Import File", ["Import File", "Import File", "Import File", "Import File", "导入文件"]), ("Insert Page", ["Insert Page", "Insert Page", "Insert Page", "Insert Page", "插入页面"]), + ( + "Delete selected pages", + ["Ausgewählte Seiten löschen", "Supprimer les pages sélectionnées", "Eliminar páginas seleccionadas", "選択したページを削除", "删除选定页面"], + ), + ("Create new page", ["Neue Seite erstellen", "Créer une page", "Crear página nueva", "新規ページを作成", "新建页面"]), + ("Edit page size", ["Seitenformat bearbeiten", "Modifier le format de page", "Editar tamaño de página", "ページサイズを編集", "编辑页面大小"]), + ( + "Custom Page Size…", + [ + "Benutzerdefiniertes Seitenformat …", + "Format de page personnalisé…", + "Tamaño de página personalizado…", + "カスタムページサイズ…", + "自定页面大小…", + ], + ), ("Hide Bar", ["Hide Bar", "Hide Bar", "Hide Bar", "Hide Bar", "隐藏栏"]), ("Properties Panel", ["Properties Panel", "Properties Panel", "Properties Panel", "Properties Panel", "属性面板"]), ("New Workspace…", ["New Workspace…", "New Workspace…", "New Workspace…", "New Workspace…", "新建工作区…"]), @@ -2516,6 +2554,96 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Added", ["Added", "Added", "Added", "Added", "已添加"]), ("Deleted", ["Deleted", "Deleted", "Deleted", "Deleted", "已删除"]), ("Guide-based", ["Guide-based", "Guide-based", "Guide-based", "Guide-based", "基于参考线"]), + ("Page:", ["Seite:", "Page :", "Página:", "ページ:", "页面:"]), + ("Content:", ["Inhalt:", "Contenu :", "Contenido:", "コンテンツ:", "内容:"]), + ("Object Constraints:", ["Objektbeschränkungen:", "Contraintes d’objet :", "Restricciones de objeto:", "オブジェクトの制約:", "对象约束:"]), + ( + "Resize with Page:", + ["Mit Seite skalieren:", "Redimensionner avec la page :", "Cambiar tamaño con la página:", "ページと共にサイズ変更:", "随页面调整大小:"], + ), + ("Pin:", ["Fixieren:", "Épingler :", "Fijar:", "固定:", "固定:"]), + ("Object-Based", ["Object-Based", "Object-Based", "Object-Based", "Object-Based", "基于对象"]), + ("Guide-Based", ["Guide-Based", "Guide-Based", "Guide-Based", "Guide-Based", "基于参考线"]), + ("Resize Page", ["Seitengröße ändern", "Redimensionner la page", "Cambiar tamaño de página", "ページのサイズを変更", "调整页面大小"]), + ( + "Liquid Page Rule:", + ["Regel für flüssige Seiten:", "Règle de page liquide :", "Regla de página líquida:", "リキッドページルール:", "自适应页面规则:"], + ), + ( + "Show Parent Page Overlay", + [ + "Überlagerung der übergeordneten Seite anzeigen", + "Afficher la superposition de la page parent", + "Mostrar superposición de página principal", + "親ページオーバーレイを表示", + "显示父页面叠加", + ], + ), + ( + "Controlled by Parent", + ["Durch übergeordnete Seite gesteuert", "Contrôlé par le parent", "Controlado por principal", "親ページで制御", "由父页面控制"], + ), + ("US Business Card", ["US-Visitenkarte", "Carte de visite US", "Tarjeta de visita EE. UU.", "US 名刺", "美国名片"]), + ( + "Object is pinned relative to the page edge. Click to release.", + [ + "Das Objekt ist relativ zum Seitenrand fixiert. Zum Lösen klicken.", + "L’objet est épinglé par rapport au bord de la page. Cliquez pour le libérer.", + "El objeto está fijado respecto al borde de la página. Haga clic para soltarlo.", + "オブジェクトはページの端を基準に固定されています。クリックすると解除します。", + "对象已相对于页面边缘固定。单击可释放。", + ], + ), + ( + "Click to pin the object relative to the page edge.", + [ + "Klicken, um das Objekt relativ zum Seitenrand zu fixieren.", + "Cliquez pour épingler l’objet par rapport au bord de la page.", + "Haga clic para fijar el objeto respecto al borde de la página.", + "クリックすると、オブジェクトをページの端を基準に固定します。", + "单击可将对象相对于页面边缘固定。", + ], + ), + ( + "The object changes width as the page changes width. Click to lock the object width.", + [ + "Die Objektbreite ändert sich mit der Seitenbreite. Zum Sperren der Breite klicken.", + "La largeur de l’objet suit celle de la page. Cliquez pour verrouiller la largeur.", + "El ancho del objeto cambia con el de la página. Haga clic para bloquear el ancho.", + "オブジェクトの幅はページの幅に合わせて変わります。クリックすると幅をロックします。", + "对象宽度随页面宽度变化。单击可锁定对象宽度。", + ], + ), + ( + "The object's width is locked. Click to let the object change width as the page changes width.", + [ + "Die Objektbreite ist gesperrt. Klicken, damit sie sich mit der Seitenbreite ändert.", + "La largeur de l’objet est verrouillée. Cliquez pour qu’elle suive celle de la page.", + "El ancho del objeto está bloqueado. Haga clic para que cambie con el de la página.", + "オブジェクトの幅はロックされています。クリックすると、ページの幅に合わせて変わります。", + "对象宽度已锁定。单击可让其随页面宽度变化。", + ], + ), + ( + "The object changes height as the page changes height. Click to lock the object height.", + [ + "Die Objekthöhe ändert sich mit der Seitenhöhe. Zum Sperren der Höhe klicken.", + "La hauteur de l’objet suit celle de la page. Cliquez pour verrouiller la hauteur.", + "El alto del objeto cambia con el de la página. Haga clic para bloquear el alto.", + "オブジェクトの高さはページの高さに合わせて変わります。クリックすると高さをロックします。", + "对象高度随页面高度变化。单击可锁定对象高度。", + ], + ), + ( + "The object's height is locked. Click to let the object change height as the page changes height.", + [ + "Die Objekthöhe ist gesperrt. Klicken, damit sie sich mit der Seitenhöhe ändert.", + "La hauteur de l’objet est verrouillée. Cliquez pour qu’elle suive celle de la page.", + "El alto del objeto está bloqueado. Haga clic para que cambie con el de la página.", + "オブジェクトの高さはロックされています。クリックすると、ページの高さに合わせて変わります。", + "对象高度已锁定。单击可让其随页面高度变化。", + ], + ), ("Loop", ["Loop", "Loop", "Loop", "Loop", "循环播放"]), ("Object-based", ["Object-based", "Object-based", "Object-based", "Object-based", "基于对象"]), ("Off", ["Off", "Off", "Off", "Off", "关闭"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 76951196..f68e7587 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -270,6 +270,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Quick Actions", "الإجراءات السريعة"), ("Import File", "استيراد ملف"), ("Insert Page", "إدراج صفحة"), + ("Delete selected pages", "حذف الصفحات المحددة"), + ("Create new page", "إنشاء صفحة جديدة"), + ("Edit page size", "تحرير حجم الصفحة"), + ("Custom Page Size…", "حجم صفحة مخصص…"), ("Edit Page", "تحرير الصفحة"), ("Adjust Layout", "ضبط التخطيط"), ("Smart Guides", "أدلة ذكية"), @@ -718,6 +722,36 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Added", "مضاف"), ("Deleted", "محذوف"), ("Guide-based", "استنادًا إلى الأدلة"), + ("Page:", "الصفحة:"), + ("Content:", "المحتوى:"), + ("Object Constraints:", "قيود الكائن:"), + ("Resize with Page:", "تغيير الحجم مع الصفحة:"), + ("Pin:", "تثبيت:"), + ("Object-Based", "استنادًا إلى الكائنات"), + ("Guide-Based", "استنادًا إلى الأدلة"), + ("Resize Page", "تغيير حجم الصفحة"), + ("Liquid Page Rule:", "قاعدة الصفحة المرنة:"), + ("Show Parent Page Overlay", "إظهار تراكب الصفحة الأصل"), + ("Controlled by Parent", "تتحكم فيه الصفحة الأصل"), + ("US Business Card", "بطاقة عمل أمريكية"), + ("Object is pinned relative to the page edge. Click to release.", "الكائن مثبت بالنسبة إلى حافة الصفحة. انقر لتحريره."), + ("Click to pin the object relative to the page edge.", "انقر لتثبيت الكائن بالنسبة إلى حافة الصفحة."), + ( + "The object changes width as the page changes width. Click to lock the object width.", + "يتغير عرض الكائن مع تغير عرض الصفحة. انقر لقفل عرض الكائن.", + ), + ( + "The object's width is locked. Click to let the object change width as the page changes width.", + "عرض الكائن مقفل. انقر للسماح بتغيره مع عرض الصفحة.", + ), + ( + "The object changes height as the page changes height. Click to lock the object height.", + "يتغير ارتفاع الكائن مع تغير ارتفاع الصفحة. انقر لقفل ارتفاع الكائن.", + ), + ( + "The object's height is locked. Click to let the object change height as the page changes height.", + "ارتفاع الكائن مقفل. انقر للسماح بتغيره مع ارتفاع الصفحة.", + ), ("Loop", "تكرار"), ("Object-based", "استنادًا إلى الكائنات"), ("Play on Page Load", "تشغيل عند تحميل الصفحة"), @@ -869,6 +903,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Document Grid", "شبكة المستند"), ("Document Recovery Data", "بيانات استرداد المستند"), ("Duplicate Spread", "تكرار الصفحات المتقابلة"), + ("Duplicate Spread at End of Document", "تكرار الانتشار في نهاية المستند"), + ("Delete Spread", "حذف الانتشار"), + ("Parent Pages", "الصفحات الأصل"), + ("Apply Parent to Pages", "تطبيق الأصل على الصفحات"), ("Email", "بريد إلكتروني"), ("Embed", "تضمين"), ("Fast", "سريع"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 747511ae..e91307f1 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -177,6 +177,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Document Setup", "Impostazione documento"), ("Drop Shadow", "Ombra esterna"), ("Duplicate Spread", "Duplica doppia pagina"), + ("Duplicate Spread at End of Document", "Duplica pagine affiancate alla fine del documento"), + ("Delete Spread", "Elimina pagine affiancate"), + ("Parent Pages", "Pagine principali"), + ("Apply Parent to Pages", "Applica pagina principale alle pagine"), ("Email", "E-mail"), ("Embed", "Incorpora"), ("Export XML…", "Esporta XML…"), @@ -363,6 +367,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Edit Page", "Modifica pagina"), ("Import File", "Importa file"), ("Insert Page", "Inserisci pagina"), + ("Delete selected pages", "Elimina pagine selezionate"), + ("Create new page", "Crea nuova pagina"), + ("Edit page size", "Modifica dimensioni pagina"), + ("Custom Page Size…", "Dimensioni pagina personalizzate…"), ("Hide Bar", "Nascondi barra"), ("Properties Panel", "Pannello Proprietà"), ("New Workspace…", "Nuova area di lavoro…"), @@ -1296,6 +1304,36 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Added", "Aggiunto"), ("Deleted", "Eliminato"), ("Guide-based", "Basato su guide"), + ("Page:", "Pagina:"), + ("Content:", "Contenuto:"), + ("Object Constraints:", "Vincoli oggetto:"), + ("Resize with Page:", "Ridimensiona con la pagina:"), + ("Pin:", "Fissa:"), + ("Object-Based", "Basato su oggetti"), + ("Guide-Based", "Basato su guide"), + ("Resize Page", "Ridimensiona pagina"), + ("Liquid Page Rule:", "Regola pagina liquida:"), + ("Show Parent Page Overlay", "Mostra sovrapposizione pagina principale"), + ("Controlled by Parent", "Controllato dalla pagina principale"), + ("US Business Card", "Biglietto da visita USA"), + ("Object is pinned relative to the page edge. Click to release.", "L’oggetto è fissato rispetto al bordo della pagina. Fai clic per sbloccarlo."), + ("Click to pin the object relative to the page edge.", "Fai clic per fissare l’oggetto rispetto al bordo della pagina."), + ( + "The object changes width as the page changes width. Click to lock the object width.", + "La larghezza dell’oggetto cambia con quella della pagina. Fai clic per bloccarla.", + ), + ( + "The object's width is locked. Click to let the object change width as the page changes width.", + "La larghezza dell’oggetto è bloccata. Fai clic perché cambi con quella della pagina.", + ), + ( + "The object changes height as the page changes height. Click to lock the object height.", + "L’altezza dell’oggetto cambia con quella della pagina. Fai clic per bloccarla.", + ), + ( + "The object's height is locked. Click to let the object change height as the page changes height.", + "L’altezza dell’oggetto è bloccata. Fai clic perché cambi con quella della pagina.", + ), ("Loop", "Ciclo continuo"), ("Object-based", "Basato su oggetti"), ("Off", "Disattivato"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 263d4226..f45ee094 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -179,6 +179,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Document Setup", "ドキュメント設定"), ("Drop Shadow", "ドロップシャドウ"), ("Duplicate Spread", "スプレッドを複製"), + ("Duplicate Spread at End of Document", "ドキュメントの最後にスプレッドを複製"), + ("Delete Spread", "スプレッドを削除"), + ("Parent Pages", "親ページ"), ("Email", "メール"), ("Embed", "埋め込み"), ("Export XML…", "XML を書き出し…"), @@ -365,6 +368,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Edit Page", "ページを編集"), ("Import File", "ファイルを読み込み"), ("Insert Page", "ページを挿入"), + ("Delete selected pages", "選択したページを削除"), + ("Create new page", "新規ページを作成"), + ("Edit page size", "ページサイズを編集"), + ("Custom Page Size…", "カスタムページサイズ…"), ("Hide Bar", "バーを隠す"), ("Properties Panel", "プロパティパネル"), ("New Workspace…", "新規ワークスペース…"), @@ -1292,6 +1299,36 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Added", "追加済み"), ("Deleted", "削除済み"), ("Guide-based", "ガイド基準"), + ("Page:", "ページ:"), + ("Content:", "コンテンツ:"), + ("Object Constraints:", "オブジェクトの制約:"), + ("Resize with Page:", "ページと共にサイズ変更:"), + ("Pin:", "固定:"), + ("Object-Based", "オブジェクト基準"), + ("Guide-Based", "ガイド基準"), + ("Resize Page", "ページのサイズを変更"), + ("Liquid Page Rule:", "リキッドページルール:"), + ("Show Parent Page Overlay", "親ページオーバーレイを表示"), + ("Controlled by Parent", "親ページで制御"), + ("US Business Card", "US 名刺"), + ("Object is pinned relative to the page edge. Click to release.", "オブジェクトはページの端を基準に固定されています。クリックすると解除します。"), + ("Click to pin the object relative to the page edge.", "クリックすると、オブジェクトをページの端を基準に固定します。"), + ( + "The object changes width as the page changes width. Click to lock the object width.", + "オブジェクトの幅はページの幅に合わせて変わります。クリックすると幅をロックします。", + ), + ( + "The object's width is locked. Click to let the object change width as the page changes width.", + "オブジェクトの幅はロックされています。クリックすると、ページの幅に合わせて変わります。", + ), + ( + "The object changes height as the page changes height. Click to lock the object height.", + "オブジェクトの高さはページの高さに合わせて変わります。クリックすると高さをロックします。", + ), + ( + "The object's height is locked. Click to let the object change height as the page changes height.", + "オブジェクトの高さはロックされています。クリックすると、ページの高さに合わせて変わります。", + ), ("Loop", "繰り返し再生"), ("Object-based", "オブジェクト基準"), ("Off", "オフ"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 3f955f68..09e639e5 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -271,6 +271,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Quick Actions", "Ações rápidas"), ("Import File", "Importar arquivo"), ("Insert Page", "Inserir página"), + ("Delete selected pages", "Excluir páginas selecionadas"), + ("Create new page", "Criar nova página"), + ("Edit page size", "Editar tamanho da página"), + ("Custom Page Size…", "Tamanho de página personalizado…"), ("Edit Page", "Editar página"), ("Adjust Layout", "Ajustar layout"), ("Smart Guides", "Guias inteligentes"), @@ -709,6 +713,36 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Added", "Adicionado"), ("Deleted", "Excluído"), ("Guide-based", "Baseado em guias"), + ("Page:", "Página:"), + ("Content:", "Conteúdo:"), + ("Object Constraints:", "Restrições de objeto:"), + ("Resize with Page:", "Redimensionar com a página:"), + ("Pin:", "Fixar:"), + ("Object-Based", "Baseado em objetos"), + ("Guide-Based", "Baseado em guias"), + ("Resize Page", "Redimensionar página"), + ("Liquid Page Rule:", "Regra de página líquida:"), + ("Show Parent Page Overlay", "Mostrar sobreposição da página principal"), + ("Controlled by Parent", "Controlado pela página principal"), + ("US Business Card", "Cartão de visita EUA"), + ("Object is pinned relative to the page edge. Click to release.", "O objeto está fixado em relação à borda da página. Clique para liberar."), + ("Click to pin the object relative to the page edge.", "Clique para fixar o objeto em relação à borda da página."), + ( + "The object changes width as the page changes width. Click to lock the object width.", + "A largura do objeto muda com a da página. Clique para bloquear a largura.", + ), + ( + "The object's width is locked. Click to let the object change width as the page changes width.", + "A largura do objeto está bloqueada. Clique para que mude com a da página.", + ), + ( + "The object changes height as the page changes height. Click to lock the object height.", + "A altura do objeto muda com a da página. Clique para bloquear a altura.", + ), + ( + "The object's height is locked. Click to let the object change height as the page changes height.", + "A altura do objeto está bloqueada. Clique para que mude com a da página.", + ), ("Loop", "Repetir"), ("Object-based", "Baseado em objetos"), ("Play on Page Load", "Reproduzir ao carregar a página"), @@ -863,6 +897,18 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Document Grid", "Grade do documento"), ("Document Recovery Data", "Dados de recuperação do documento"), ("Duplicate Spread", "Duplicar página dupla"), + ("Insert Pages…", "Inserir páginas…"), + ("Move Pages…", "Mover páginas…"), + ("New Parent…", "Nova página principal…"), + ("Override All Parent Page Items", "Substituir todos os itens da página principal"), + ("Remove All Local Overrides", "Remover todas as substituições locais"), + ("Detach All Objects from Parent", "Desanexar todos os objetos da página principal"), + ("Create Alternate Layout…", "Criar layout alternativo…"), + ("Allow Selected Spread to Shuffle", "Permitir que a página espelhada selecionada seja reorganizada"), + ("Duplicate Spread at End of Document", "Duplicar página espelhada no final do documento"), + ("Delete Spread", "Excluir página espelhada"), + ("Parent Pages", "Páginas principais"), + ("Apply Parent to Pages", "Aplicar página principal às páginas"), ("Email", "E-mail"), ("Embed", "Embutir"), ("Fast", "Rápido"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index efccfc16..eab4159d 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -180,6 +180,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Document Setup", "Параметри документа"), ("Drop Shadow", "Тінь"), ("Duplicate Spread", "Дублювати розворот"), + ("Duplicate Spread at End of Document", "Дублювати розворот у кінці документа"), + ("Delete Spread", "Видалити розворот"), + ("Parent Pages", "Батьківські сторінки"), + ("Apply Parent to Pages", "Застосувати батьківську до сторінок"), ("Email", "Електронна пошта"), ("Embed", "Вбудувати"), ("Export XML…", "Експортувати XML…"), @@ -366,6 +370,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Edit Page", "Редагувати сторінку"), ("Import File", "Імпортувати файл"), ("Insert Page", "Вставити сторінку"), + ("Delete selected pages", "Видалити вибрані сторінки"), + ("Create new page", "Створити нову сторінку"), + ("Edit page size", "Змінити розмір сторінки"), + ("Custom Page Size…", "Власний розмір сторінки…"), ("Hide Bar", "Сховати панель"), ("Properties Panel", "Панель властивостей"), ("New Workspace…", "Нове робоче середовище…"), @@ -1293,6 +1301,36 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Added", "Додано"), ("Deleted", "Видалено"), ("Guide-based", "На основі напрямних"), + ("Page:", "Сторінка:"), + ("Content:", "Вміст:"), + ("Object Constraints:", "Обмеження об’єкта:"), + ("Resize with Page:", "Змінювати розмір зі сторінкою:"), + ("Pin:", "Закріпити:"), + ("Object-Based", "На основі об’єктів"), + ("Guide-Based", "На основі напрямних"), + ("Resize Page", "Змінити розмір сторінки"), + ("Liquid Page Rule:", "Правило гнучкої сторінки:"), + ("Show Parent Page Overlay", "Показати накладення батьківської сторінки"), + ("Controlled by Parent", "Керується батьківською сторінкою"), + ("US Business Card", "Візитна картка (США)"), + ("Object is pinned relative to the page edge. Click to release.", "Об’єкт закріплено відносно краю сторінки. Клацніть, щоб звільнити."), + ("Click to pin the object relative to the page edge.", "Клацніть, щоб закріпити об’єкт відносно краю сторінки."), + ( + "The object changes width as the page changes width. Click to lock the object width.", + "Ширина об’єкта змінюється разом зі сторінкою. Клацніть, щоб заблокувати ширину.", + ), + ( + "The object's width is locked. Click to let the object change width as the page changes width.", + "Ширину об’єкта заблоковано. Клацніть, щоб вона змінювалася разом зі сторінкою.", + ), + ( + "The object changes height as the page changes height. Click to lock the object height.", + "Висота об’єкта змінюється разом зі сторінкою. Клацніть, щоб заблокувати висоту.", + ), + ( + "The object's height is locked. Click to let the object change height as the page changes height.", + "Висоту об’єкта заблоковано. Клацніть, щоб вона змінювалася разом зі сторінкою.", + ), ("Loop", "Зациклити"), ("Object-based", "На основі об’єктів"), ("Off", "Вимкнено"), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1db2f591..4b5c63f9 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -418,6 +418,8 @@ impl Default for View { pub struct Shown { /// (doc uid, doc pointer, revision, preview). pub doc: (u64, u64, u64, bool), + /// Parent and document spreads have different canvas placement and content. + pub editing_parents: bool, /// Canvas point at the texture's top-left, zoom, and texture size in screen points. pub origin: Point, pub zoom: f64, @@ -737,6 +739,7 @@ impl DesignApp { pub fn after_engine(&mut self) { let reqs = std::mem::take(&mut self.session.ui_requests); for r in reqs { + let was_find = self.ui.dialog.as_ref().is_some_and(|d| d.id == "findChange"); match r { UiRequest::Dialog { id, params } => self.ui.dialog = Some(dialogs::Dialog::new(&id, params)), UiRequest::View { params } => canvas::apply_view_request(self, ¶ms), @@ -745,6 +748,10 @@ impl DesignApp { let _ = self.pick_and_open(&purpose); } } + // Release only when the request actually replaces Find, including a native PDF picker. + if was_find && !self.ui.dialog.as_ref().is_some_and(|d| d.id == "findChange") { + self.session.reset_find_scope(); + } } // New documents get a fitted view. if let Some(st) = self.session.active() { @@ -1104,6 +1111,9 @@ impl DesignApp { let r = self.run(cmd, params); if let Err(e) = &r { let title = if cmd == "file.place" { "Can't Place the File" } else { "Can't Open the File" }; + if self.ui.dialog.as_ref().is_some_and(|d| d.id == "findChange") { + self.session.reset_find_scope(); + } self.ui.dialog = Some(dialogs::Dialog::new("alert", json!({"title": title, "file": file, "message": e}))); } else if let Ok(result) = &r { let warnings: Vec<&str> = result @@ -2246,7 +2256,7 @@ mod browser_import_tests { result.push_str(&format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1)); result.into_bytes() } - fn pdf() -> Vec { + pub(super) fn pdf() -> Vec { pdf_pages(3) } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index dc8b88bf..9a581d4c 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -169,6 +169,12 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("window.panel.close", "Close Panel", None, "{panel} — hide a panel"), ("window.dockPanel", "Dock Panel", None, "{panel} — back into the dock's icon column"), ("window.controlBar", "Control", Some("Cmd+Alt+6"), "{}"), + // Object › Fitting with InDesign's shortcuts (one `object.fit` mode each). + ("fit.fillFrameProportionally", "Fill Frame Proportionally", Some("Cmd+Alt+Shift+C"), "{} — object.fit {mode: fillProportionally}"), + ("fit.contentProportionally", "Fit Content Proportionally", Some("Cmd+Alt+Shift+E"), "{} — object.fit {mode: fitProportionally}"), + ("fit.frameToContent", "Fit Frame to Content", Some("Cmd+Alt+C"), "{} — object.fit {mode: fitFrameToContent}"), + ("fit.contentToFrame", "Fit Content to Frame", Some("Cmd+Alt+E"), "{} — object.fit {mode: fitContentToFrame}"), + ("fit.centerContent", "Center Content", Some("Cmd+Shift+E"), "{} — object.fit {mode: centerContent}"), ("window.split", "Split Window", None, "{on?: bool} — two views of the document side by side, each with its own zoom and scroll"), ("window.newWindow", "New Window", None, "{on?: bool} — another view of the active document in its own window"), ("window.taskBar", "Contextual Task Bar", None, "{}"), @@ -314,6 +320,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "<", "cmd:layout.marginsAndColumns", "cmd:layout.createGuides", + "cmd:layout.createAlternate", "-", "ui:view.goToPage", "-", @@ -558,11 +565,11 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:anchored.release", "<", ">Fitting", - "cmd:object.fit|Fill Frame Proportionally|{\"mode\":\"fillProportionally\"}", - "cmd:object.fit|Fit Content Proportionally|{\"mode\":\"fitProportionally\"}", - "cmd:object.fit|Fit Frame to Content|{\"mode\":\"fitFrameToContent\"}", - "cmd:object.fit|Fit Content to Frame|{\"mode\":\"fitContentToFrame\"}", - "cmd:object.fit|Center Content|{\"mode\":\"centerContent\"}", + "ui:fit.fillFrameProportionally", + "ui:fit.contentProportionally", + "ui:fit.frameToContent", + "ui:fit.contentToFrame", + "ui:fit.centerContent", "-", "ui:app.fittingOptions", "<", @@ -758,6 +765,15 @@ pub fn ui_label(id: &str) -> Option<(&'static str, Option<&'static str>)> { /// Run a UI command; `None` if `id` isn't one. pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option> { + let was_find = app.ui.dialog.as_ref().is_some_and(|d| d.id == "findChange"); + let result = run_ui_inner(app, id, p); + if was_find && app.ui.dialog.as_ref().is_none_or(|d| d.id != "findChange") { + app.session.reset_find_scope(); + } + result +} + +fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option> { if let Some(result) = crate::panel_docking::command(app, id, p) { return Some(result); } @@ -963,6 +979,7 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option { + app.session.reset_find_scope(); app.ui.dialog = Some(crate::dialogs::Dialog::new("findChange", json!({}))); Ok(Value::Null) } @@ -1275,6 +1292,16 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option { + let mode = match id { + "fit.fillFrameProportionally" => "fillProportionally", + "fit.contentProportionally" => "fitProportionally", + "fit.frameToContent" => "fitFrameToContent", + "fit.contentToFrame" => "fitContentToFrame", + _ => "centerContent", + }; + app.run("object.fit", json!({"mode": mode})) + } "app.keyboardShortcuts" => { app.ui.dialog = Some(crate::dialogs::Dialog::new("keyboardShortcuts", json!({"query": "", "recording": ""}))); Ok(Value::Null) @@ -1866,6 +1893,9 @@ pub fn checked(app: &DesignApp, id: &str, params: &Value) -> Option { /// Enablement for menu display (UI commands need a document unless they're app/window-level). pub fn menu_enabled(app: &DesignApp, id: &str) -> bool { + if id.starts_with("fit.") { + return enabled(app, "object.fit"); + } if ui_label(id).is_some() { return app.session.active().is_some() || id.starts_with("app.") || id.starts_with("window."); } @@ -1890,6 +1920,14 @@ pub fn close_document(app: &mut DesignApp, index: Option) { /// A menu item was chosen. An engine command whose label ends in "…" and that takes parameters /// opens a dialog built from its parameter documentation (see [`crate::dialogs::command_fields`]). pub fn activate(app: &mut DesignApp, id: &str, params: &Value) { + let was_find = app.ui.dialog.as_ref().is_some_and(|d| d.id == "findChange"); + activate_inner(app, id, params); + if was_find && app.ui.dialog.as_ref().is_none_or(|d| d.id != "findChange") { + app.session.reset_find_scope(); + } +} + +fn activate_inner(app: &mut DesignApp, id: &str, params: &Value) { if params.is_null() && id == "file.close" { close_document(app, None); return; @@ -1898,6 +1936,10 @@ pub fn activate(app: &mut DesignApp, id: &str, params: &Value) { crate::dialogs::open_document_setup(app); return; } + if params.is_null() && id == "toc.generate" { + crate::dialogs::open_toc(app); + return; + } if params.is_null() && id == "file.print" { crate::dialogs::open_print(app); return; @@ -2911,6 +2953,47 @@ mod tests { assert!(run_ui(&mut app, "view.proofSetup", &json!({"target": "nope"})).unwrap().is_err()); } + #[test] + fn fitting_has_indesigns_shortcuts() { + // LYH Studio episode 6 (7:00): "Fit Content to Frame (Ctrl + Shift + Alt + C)" fills the + // photo frames proportionally. + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let png = designcraft_render::Rendered { width: 40, height: 20, pixels: vec![200; 40 * 20 * 4] }.to_png(); + let f = app.run("frame.create", json!({"rect": [100, 100, 300, 300]})).unwrap()["id"].as_u64().unwrap(); + app.run("file.place", json!({"base64": designcraft_engine::cmd::base64_encode(&png), "name": "a.png", "frame": f})).unwrap(); + app.run("selection.set", json!({"ids": [f]})).unwrap(); + let width = |app: &crate::DesignApp| { + let it = app.session.doc().unwrap().doc.item(designcraft_doc::ItemId(f)).unwrap().clone(); + let designcraft_doc::Content::Graphic(g) = &it.content else { panic!("graphic") }; + it.xf.transform_rect_bbox(g.xf.transform_rect_bbox(designcraft_geom::Rect::new(0.0, 0.0, g.size.0, g.size.1))).width() + }; + for (id, keys) in [ + ("fit.fillFrameProportionally", "Cmd+Alt+Shift+C"), + ("fit.contentProportionally", "Cmd+Alt+Shift+E"), + ("fit.frameToContent", "Cmd+Alt+C"), + ("fit.contentToFrame", "Cmd+Alt+E"), + ("fit.centerContent", "Cmd+Shift+E"), + ] { + assert_eq!(shortcut_of(&app, id).as_deref(), Some(keys)); + } + let ctx = egui::Context::default(); + let press = |app: &mut crate::DesignApp, key: egui::Key| { + let modifiers = + egui::Modifiers { alt: true, ctrl: !cfg!(target_os = "macos"), shift: true, mac_cmd: cfg!(target_os = "macos"), command: true }; + let input = egui::RawInput { + events: vec![egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }], + ..Default::default() + }; + let mut out = ctx.run_ui(input, |ui| shortcuts(app, &ui.ctx().clone())); + out.textures_delta.clear(); + }; + press(&mut app, egui::Key::C); + assert!((width(&app) - 400.0).abs() < 1e-6, "filled: {}", width(&app)); + press(&mut app, egui::Key::E); + assert!((width(&app) - 200.0).abs() < 1e-6, "fitted: {}", width(&app)); + } + #[test] fn tab_hides_panels_and_documents_cycle() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); @@ -3508,7 +3591,7 @@ mod tests { }); assert!(special.is_some_and(|c| c.iter().any(|i| matches!(i, Item::Sub(n, _) if n == "Symbols")))); // Fixed-parameter variants keep their own labels. - assert!(all.iter().any(|(l, id, p)| l == "Fill Frame Proportionally" && id == "object.fit" && p["mode"] == "fillProportionally")); + assert!(all.iter().any(|(l, id, _)| l == "Fill Frame Proportionally" && id == "fit.fillFrameProportionally")); } #[test] diff --git a/crates/ui-egui/src/panel_docking_tests.rs b/crates/ui-egui/src/panel_docking_tests.rs index de08e3cf..56b90621 100644 --- a/crates/ui-egui/src/panel_docking_tests.rs +++ b/crates/ui-egui/src/panel_docking_tests.rs @@ -617,7 +617,7 @@ fn legacy_dock_menu_button_opens_the_active_panel_menu() { } h.run_steps(2); assert!(egui::Popup::is_any_open(&h.ctx), "the three-line button must open a menu"); - h.get_by_label("Insert Page After").click(); + h.get_by_label("Duplicate Spread").click(); h.run_steps(3); assert_eq!(h.state().session.active().unwrap().doc.page_count(), pages + 1); assert!(!egui::Popup::is_any_open(&h.ctx)); diff --git a/crates/ui-egui/src/panels/interactive.rs b/crates/ui-egui/src/panels/interactive.rs index 5b770aec..6608cd46 100644 --- a/crates/ui-egui/src/panels/interactive.rs +++ b/crates/ui-egui/src/panels/interactive.rs @@ -486,64 +486,118 @@ pub fn buttons(app: &mut DesignApp, ui: &mut egui::Ui) { } } -/// Liquid Layout panel: the current page's rule, the selection's pins (object-based), the page's -/// liquid guides, and Create Alternate Layout. +/// Liquid page rules in InDesign's order, with their menu labels. +pub const LIQUID_RULES: [(designcraft_doc::LiquidRule, &str); 5] = [ + (designcraft_doc::LiquidRule::Off, "Off"), + (designcraft_doc::LiquidRule::Scale, "Scale"), + (designcraft_doc::LiquidRule::ReCenter, "Re-center"), + (designcraft_doc::LiquidRule::ObjectBased, "Object-based"), + (designcraft_doc::LiquidRule::GuideBased, "Guide-based"), +]; + +/// A liquid page rule's menu label. +pub fn liquid_rule_label(rule: designcraft_doc::LiquidRule) -> &'static str { + LIQUID_RULES.iter().find(|(r, _)| *r == rule).map_or("Off", |(_, l)| l) +} + +/// Whether page `abs` follows the object-based rule (its own, or its parent page's). +fn object_based(d: &designcraft_doc::Document, abs: usize) -> bool { + use designcraft_doc::LiquidRule as L; + let Some((si, pi)) = d.page_loc(abs) else { return false }; + match d.spreads[si].pages[pi].liquid { + L::ObjectBased => true, + L::ControlledByParent => { + d.parent_page_for(abs).and_then(|(ppi, ppg)| d.parents.get(ppi)?.pages.get(ppg).map(|p| p.liquid == L::ObjectBased)).unwrap_or(false) + } + _ => false, + } +} + +/// Liquid Layout panel, laid out as InDesign's: the page's Liquid Page Rule (with "Page: n"), +/// Content › Auto-Fit, and Object Constraints › Resize with Page (Height, Width) and Pin (Top, +/// Left, Bottom, Right) for the selected object on an object-based page. The page's liquid +/// guides follow (Create Alternate Layout is in the Layout menu). pub fn liquid(app: &mut DesignApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); - let Some(page) = crate::canvas::current_page(app) else { - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "No document.")).size(11.0).color(t.text_dim)); + let lang = app.ui.language.clone(); + // The Page tool's page, the page in view, or (before the canvas lays out) the first. + let Some(page) = app.session.active().map(|s| s.selection.page.or_else(|| crate::canvas::current_page(app)).unwrap_or(0)) else { + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&lang, "No document.")).size(11.0).color(t.text_dim)); return; }; let Some(d) = app.session.active().map(|d| d.doc.clone()) else { return }; let Some((si, pi)) = d.page_loc(page) else { return }; let pg = d.spreads[si].pages[pi].clone(); - use designcraft_doc::LiquidRule as L; - crate::rtl::label(ui, egui::RichText::new(format!("{} {}", crate::i18n::tr(&app.ui.language, "Page"), d.page_name(page))).strong()); - let mut rule = pg.liquid; - egui::ComboBox::from_id_salt("liquid_rule") - .selected_text(crate::rtl::widget( - ui, - crate::i18n::tr( - &app.ui.language, - match rule { - L::Off => "Off", - L::Scale => "Scale", - L::ReCenter => "Re-center", - L::GuideBased => "Guide-based", - L::ObjectBased => "Object-based", - }, - ), - )) - .show_ui(ui, |ui| { - for (r, label) in - [(L::Off, "Off"), (L::Scale, "Scale"), (L::ReCenter, "Re-center"), (L::GuideBased, "Guide-based"), (L::ObjectBased, "Object-based")] - { - ui.selectable_value(&mut rule, r, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))); - } + ui.horizontal(|ui| { + crate::widgets::caption(ui, crate::i18n::tr(&lang, "Liquid Page Rule:")); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + crate::widgets::caption(ui, &format!("{} {}", crate::i18n::tr(&lang, "Page:"), d.page_name(page))); }); - if rule != pg.liquid { - let _ = app.run("liquid.pageRule", json!({"rule": rule, "pages": [page + 1]})); - } - if rule == L::ObjectBased - && let Some(id) = app.session.active().and_then(|s| s.selection.items.first().copied()) - && let Some(it) = d.item(id) - { - ui.separator(); - let l = it.liquid.unwrap_or(designcraft_doc::ObjectLiquid { resize_width: true, resize_height: true, ..Default::default() }); - let mut v = [l.resize_width, l.resize_height, l.pin_top, l.pin_bottom, l.pin_left, l.pin_right]; - let keys = ["resizeWidth", "resizeHeight", "pinTop", "pinBottom", "pinLeft", "pinRight"]; - let labels = ["Resize width", "Resize height", "Pin top", "Pin bottom", "Pin left", "Pin right"]; - ui.horizontal_wrapped(|ui| { - for ((on, key), label) in v.iter_mut().zip(keys).zip(labels) { - if ui.checkbox(on, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).changed() { - let _ = app.run("liquid.object", json!({key: *on})); + }); + egui::ComboBox::from_id_salt("liquid_rule") + .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&lang, liquid_rule_label(pg.liquid)))) + .width(190.0) + .show_ui(ui, |ui| { + for (r, label) in LIQUID_RULES { + if ui.selectable_label(pg.liquid == r, crate::rtl::widget(ui, crate::i18n::tr(&lang, label))).clicked() && r != pg.liquid { + let _ = app.run("liquid.pageRule", json!({"rule": r, "pages": [page + 1]})); } } }); - } + ui.separator(); + // The selected object (one on this page). + let item = app + .session + .active() + .and_then(|s| s.selection.items.first().copied()) + .and_then(|id| d.item(id)) + .filter(|it| d.page_of_item(it.id) == Some(page)); + // Content: Auto-Fit (graphic frames). + crate::widgets::caption(ui, crate::i18n::tr(&lang, "Content:")); + let graphic = item.and_then(|it| if let designcraft_doc::Content::Graphic(g) = &it.content { Some(g.auto_fit) } else { None }); + let mut auto = graphic.is_some_and(|f| f != designcraft_doc::Fitting::None); + ui.indent("liquid_content", |ui| { + let r = ui.add_enabled(graphic.is_some(), egui::Checkbox::new(&mut auto, crate::rtl::widget(ui, crate::i18n::tr(&lang, "Auto-Fit")))); + if r.changed() { + let _ = app.run("object.fittingOptions", json!({"autoFit": auto})); + } + }); + // Object Constraints: for an object on an object-based page. + crate::widgets::caption(ui, crate::i18n::tr(&lang, "Object Constraints:")); + let on = item.is_some() && object_based(&d, page); + let l = item + .filter(|_| on) + .map(|it| it.liquid.unwrap_or(designcraft_doc::ObjectLiquid { resize_width: true, resize_height: true, ..Default::default() })) + .unwrap_or_default(); + ui.indent("liquid_constraints", |ui| { + ui.add_enabled_ui(on, |ui| { + let rows: [(&str, [(&str, &str, bool); 2]); 2] = [ + ("Resize with Page:", [("Height", "resizeHeight", l.resize_height), ("Width", "resizeWidth", l.resize_width)]), + ("Pin:", [("Top", "pinTop", l.pin_top), ("Left", "pinLeft", l.pin_left)]), + ]; + for (title, boxes) in rows { + crate::widgets::caption(ui, crate::i18n::tr(&lang, title)); + let mut grid_boxes = vec![boxes.to_vec()]; + if title == "Pin:" { + grid_boxes.push(vec![("Bottom", "pinBottom", l.pin_bottom), ("Right", "pinRight", l.pin_right)]); + } + egui::Grid::new(("liquid_grid", title)).num_columns(2).min_col_width(90.0).show(ui, |ui| { + for row in grid_boxes { + for (label, key, value) in row { + let mut v = value; + if ui.checkbox(&mut v, crate::rtl::widget(ui, crate::i18n::tr(&lang, label))).changed() { + let _ = app.run("liquid.object", json!({key: v})); + } + } + ui.end_row(); + } + }); + } + }); + }); if !pg.guides.is_empty() { ui.separator(); - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Liquid guides")).size(11.0).color(t.text_dim)); + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&lang, "Liquid guides")).size(11.0).color(t.text_dim)); for (gi, g) in pg.guides.iter().enumerate() { let mut on = g.liquid; let label = format!("{:?} at {:.1}", g.orientation, g.position); @@ -552,22 +606,6 @@ pub fn liquid(app: &mut DesignApp, ui: &mut egui::Ui) { } } } - ui.separator(); - let key = egui::Id::new("alt_layout"); - let (mut name, mut w, mut h): (String, f64, f64) = ui.data(|x| x.get_temp(key)).unwrap_or(("Tablet H".into(), 1024.0, 768.0)); - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Create Alternate Layout")).size(11.0).color(t.text_dim)); - ui.horizontal(|ui| { - ui.add(egui::TextEdit::singleline(&mut name).desired_width(90.0)); - ui.add(egui::DragValue::new(&mut w).range(1.0..=15552.0).suffix(" pt")); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "×")); - ui.add(egui::DragValue::new(&mut h).range(1.0..=15552.0).suffix(" pt")); - }); - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Create"))).clicked() - && let Err(e) = app.run("layout.createAlternate", json!({"name": name, "width": w, "height": h})) - { - app.status(format!("Alternate layout: {e}")); - } - ui.data_mut(|x| x.insert_temp(key, (name, w, h))); } /// Media panel: the selected video or sound's playback options and poster. @@ -733,3 +771,45 @@ pub fn track_changes(app: &mut DesignApp, ui: &mut egui::Ui) { } }); } + +#[cfg(test)] +mod liquid_panel_tests { + use egui_kittest::{Harness, kittest::Queryable}; + use serde_json::json; + + use super::*; + + #[test] + fn object_constraints_set_the_selected_objects_pins_and_resizing() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].as_u64().unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(260.0, 420.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::new("test_fonts"); + if ui.data(|d| d.get_temp::(key)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + liquid(app, ui); + }, + app, + ); + h.run_steps(3); + let l = |h: &Harness<'_, DesignApp>| h.state().session.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().liquid; + // With the rule off the constraints don't apply. + h.get_by_label("Right").click(); + h.run_steps(2); + assert_eq!(l(&h), None); + h.state_mut().run("liquid.pageRule", json!({"rule": "objectBased"})).unwrap(); + h.run_steps(2); + h.get_by_label("Right").click(); + h.run_steps(2); + h.get_by_label("Width").click(); + h.run_steps(2); + let v = l(&h).unwrap(); + assert!(v.pin_right && !v.resize_width && v.resize_height, "{v:?}"); + let _ = h.get_by_label("Auto-Fit"); + } +} diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index ae323225..ad8a8ab8 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -114,11 +114,16 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let cur_spread = doc.page_loc(cur).map(|l| l.0); let center = ui.available_width() / 2.0; let dragging = egui::DragAndDrop::has_any_payload(ui.ctx()); + let tallest = doc.spreads.iter().flat_map(|sp| sp.pages.iter().map(|p| p.height)).fold(doc.settings.page_height, f64::max).max(1.0); for (si, sp) in doc.spreads.iter().enumerate() { let first = doc.first_page_of_spread(si); - let scale = THUMB_H / doc.settings.page_height.max(1.0) as f32; + // One scale for the whole document (pages of their own size show in proportion): the + // document's page size at the usual height, unless a taller page would pass 1.8 times it. + // Each row is as tall as its spread's tallest page. + let scale = (THUMB_H / doc.settings.page_height.max(1.0) as f32).min(1.8 * THUMB_H / tallest as f32); + let row_h = sp.pages.iter().map(|p| p.height as f32 * scale).fold(0.0, f32::max).max(THUMB_H * 0.25); let spine = sp.spine_x() as f32; - let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), 8.0 + THUMB_H + 22.0), Sense::hover()); + let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), 8.0 + row_h + 22.0), Sense::hover()); let top = row.min.y + 8.0; // Marker above the current spread. if cur_spread == Some(si) && !editing_parents { @@ -197,17 +202,57 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { .color(t.text_dim), ); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if icons::button(ui, "trash", 20.0, false, crate::i18n::tr(&app.ui.language, "Delete Page")).clicked() { + if icons::button(ui, "trash", 20.0, false, crate::i18n::tr(&app.ui.language, "Delete selected pages")).clicked() { let _ = app.run("layout.pages.delete", json!({"pages": [cur]})); } - if icons::button(ui, "plus", 20.0, false, crate::i18n::tr(&app.ui.language, "Insert Page")).clicked() { + // Create new page; a page dropped on it is duplicated at the end of the document. + let plus = icons::button(ui, "plus", 20.0, dragging, crate::i18n::tr(&app.ui.language, "Create new page")); + if let Some(payload) = plus.dnd_release_payload::() { + if let PageDrag::Page(from) = *payload + && let Some((si, _)) = doc.page_loc(from) + { + let _ = app.run("layout.pages.duplicateSpread", json!({"spread": si, "atEnd": true})); + } + } else if plus.clicked() { let _ = app.run("layout.pages.insert", json!({"after": cur, "count": 1})); } + // Edit page size: InDesign's presets for the current page, or Custom Page Size…. + let size = icons::button(ui, "panel-pages", 20.0, false, crate::i18n::tr(&app.ui.language, "Edit page size")); + egui::Popup::menu(&size).show(|ui| { + let Some((si, pi)) = doc.page_loc(cur) else { return }; + let pg = &doc.spreads[si].pages[pi]; + let landscape = pg.width > pg.height; + let current = crate::panels::properties::preset_name(pg.width, pg.height); + for (name, w, h) in crate::panels::properties::PAGE_PRESETS { + let (w, h) = if landscape { (h, w) } else { (w, h) }; + if ui.selectable_label(current == *name, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, name))).clicked() { + let _ = app.run("layout.pageSize", json!({"pages": [cur + 1], "width": w, "height": h})); + ui.close(); + } + } + ui.separator(); + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Custom Page Size…"))).clicked() { + app.ui.dialog = + Some(crate::dialogs::Dialog::new("cmd:layout.pageSize", json!({"pages": [cur + 1], "width": pg.width, "height": pg.height}))); + ui.close(); + } + }); }); }); + // Alt-drag copies a page that is a spread of its own to where it's dropped, in one undo + // step; a page from a larger spread moves as usual. + let alt = ui.input(|i| i.modifiers.alt); + let copy = |from: usize| { + let (si, _) = doc.page_loc(from)?; + (alt && doc.spreads.get(si).is_some_and(|s| s.pages.len() == 1)).then_some(si) + }; match drop { - Some((PageDrag::Page(from), to)) if from != to => { - let _ = app.run("layout.pages.move", json!({"from": from, "to": to})); + Some((PageDrag::Page(from), to)) => { + if let Some(si) = copy(from) { + let _ = app.run("layout.pages.duplicateSpread", json!({"spread": si, "to": to})); + } else if from != to { + let _ = app.run("layout.pages.move", json!({"from": from, "to": to})); + } } Some((PageDrag::Parent(prefix), to)) => { let _ = app.run("layout.pages.applyParent", json!({"pages": [to], "parent": prefix})); @@ -226,47 +271,43 @@ pub fn panel_menu(app: &mut DesignApp, ui: &mut egui::Ui) { } fn page_menu(app: &mut DesignApp, ui: &mut egui::Ui, doc: &designcraft_doc::Document, abs: usize, si: usize) { - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Insert Page After"))).clicked() { - let _ = app.run("layout.pages.insert", json!({"after": abs, "count": 1})); + let lang = app.ui.language.clone(); + let tr = |s: &str| crate::i18n::tr(&lang, s).to_owned(); + let pages_in_spread = doc.spreads.get(si).map_or(1, |sp| sp.pages.len()); + let item = |ui: &mut egui::Ui, label: &str| ui.button(crate::rtl::widget(ui, tr(label))).clicked(); + // InDesign's order: pages, parents, layouts and numbering, shuffling. + if item(ui, "Insert Pages…") { + app.ui.dialog = Some(crate::dialogs::Dialog::new("cmd:layout.pages.insert", json!({"after": abs, "count": 1}))); ui.close(); } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Delete Page"))).clicked() { - let _ = app.run("layout.pages.delete", json!({"pages": [abs]})); + if item(ui, "Move Pages…") { + app.ui.dialog = Some(crate::dialogs::Dialog::new("cmd:layout.pages.move", json!({"from": abs, "to": abs}))); ui.close(); } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Duplicate Spread"))).clicked() { + if item(ui, "Duplicate Spread") { let _ = app.run("layout.pages.duplicateSpread", json!({"spread": si})); ui.close(); } - if si > 0 - && ui - .button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Add to Previous Spread"))) - .on_hover_ui(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Spreads of up to 10 pages")); - }) - .clicked() - { - let _ = app.run("layout.pages.toSpread", json!({"page": abs + 1, "spread": si - 1})); + if item(ui, "Duplicate Spread at End of Document") { + let _ = app.run("layout.pages.duplicateSpread", json!({"spread": si, "atEnd": true})); ui.close(); } - let mut shuffle = doc.spreads.get(si).is_none_or(|sp| sp.allow_shuffle); - if ui.checkbox(&mut shuffle, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Allow Spread to Shuffle"))).clicked() { - let _ = app.run("layout.spreadShuffle", json!({"spread": si, "allow": shuffle})); + if item(ui, "Delete Page") { + let _ = app.run("layout.pages.delete", json!({"pages": [abs]})); + ui.close(); + } + if pages_in_spread > 1 && item(ui, "Delete Spread") { + let first = doc.first_page_of_spread(si); + let _ = app.run("layout.pages.delete", json!({"pages": (first..first.saturating_add(pages_in_spread)).collect::>()})); ui.close(); } ui.separator(); - for (id, label) in [ - ("layout.overrideParentItems", "Override All Parent Page Items"), - ("layout.removeOverrides", "Remove All Local Overrides"), - ("layout.detachAll", "Detach All Objects from Parent"), - ] { - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() { - let _ = app.run(id, json!({"page": abs})); - ui.close(); - } + if item(ui, "New Parent…") { + crate::menus::activate(app, "layout.parents.new", &serde_json::Value::Null); + ui.close(); } - crate::menus::menu_button(ui, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Apply Parent")), |ui| { - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "[None]"))).clicked() { + crate::menus::menu_button(ui, crate::rtl::widget(ui, tr("Apply Parent to Pages")), |ui| { + if ui.button(crate::rtl::widget(ui, tr("[None]"))).clicked() { let _ = app.run("layout.pages.applyParent", json!({"pages": [abs], "parent": null})); ui.close(); } @@ -278,6 +319,45 @@ fn page_menu(app: &mut DesignApp, ui: &mut egui::Ui, doc: &designcraft_doc::Docu } } }); + if item(ui, "Override All Parent Page Items") { + let _ = app.run("layout.overrideParentItems", json!({"page": abs})); + ui.close(); + } + crate::menus::menu_button(ui, crate::rtl::widget(ui, tr("Parent Pages")), |ui| { + for (id, label) in [("layout.removeOverrides", "Remove All Local Overrides"), ("layout.detachAll", "Detach All Objects from Parent")] { + if ui.button(crate::rtl::widget(ui, tr(label))).clicked() { + let _ = app.run(id, json!({"page": abs})); + ui.close(); + } + } + }); + ui.separator(); + if item(ui, "Create Alternate Layout…") { + crate::menus::activate(app, "layout.createAlternate", &serde_json::Value::Null); + ui.close(); + } + if item(ui, "Numbering & Section Options…") { + crate::menus::activate(app, "layout.section", &serde_json::Value::Null); + ui.close(); + } + ui.separator(); + let mut shuffle = doc.spreads.get(si).is_none_or(|sp| sp.allow_shuffle); + if ui.checkbox(&mut shuffle, crate::rtl::widget(ui, tr("Allow Selected Spread to Shuffle"))).clicked() { + let _ = app.run("layout.spreadShuffle", json!({"spread": si, "allow": shuffle})); + ui.close(); + } + // Not in InDesign's menu (it drags pages into spreads): kept for spreads of up to 10 pages. + if si > 0 + && ui + .button(crate::rtl::widget(ui, tr("Add to Previous Spread"))) + .on_hover_ui(|ui| { + crate::rtl::label(ui, tr("Spreads of up to 10 pages")); + }) + .clicked() + { + let _ = app.run("layout.pages.toSpread", json!({"page": abs + 1, "spread": si - 1})); + ui.close(); + } } /// A small label following the pointer while dragging. @@ -289,3 +369,109 @@ fn drag_ghost(ui: &egui::Ui, label: &str, t: &Tokens) { painter.rect(r, 2.0, t.panel_darker, Stroke::new(1.0, t.field_border), StrokeKind::Inside); painter.galley(r.min + vec2(5.0, 3.0), g, t.text_strong); } + +#[cfg(test)] +mod tests { + use egui_kittest::{Harness, kittest::Queryable}; + use serde_json::json; + + use super::*; + + fn harness(app: DesignApp) -> Harness<'static, DesignApp> { + let mut h = Harness::builder().with_size(vec2(280.0, 520.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::new("test_fonts"); + if ui.data(|d| d.get_temp::(key)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + show(app, ui); + }, + app, + ); + h.run_steps(3); + h + } + + /// Release a dragged page over `at`. + fn drop_page(h: &mut Harness<'_, DesignApp>, page: usize, at: egui::Pos2, alt: bool) { + let modifiers = egui::Modifiers { alt, ..Default::default() }; + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers }); + egui::DragAndDrop::set_payload(&h.ctx, PageDrag::Page(page)); + h.event(egui::Event::PointerMoved(at)); + h.run_steps(2); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers }); + h.run_steps(3); + } + + /// The page menu for page `abs` of spread `si`. + fn menu_harness(app: DesignApp, abs: usize, si: usize) -> Harness<'static, DesignApp> { + let mut h = Harness::builder().with_size(vec2(320.0, 600.0)).build_ui_state( + move |ui, app: &mut DesignApp| { + let key = egui::Id::new("test_fonts"); + if ui.data(|d| d.get_temp::(key)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + let doc = app.session.doc().unwrap().doc.clone(); + page_menu(app, ui, &doc, abs, si); + }, + app, + ); + h.run_steps(3); + h + } + + #[test] + fn a_page_in_a_spread_offers_delete_page_and_delete_spread() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"pages": 5, "facingPages": true})).unwrap(); + let doc = app.session.doc().unwrap().doc.clone(); + let (si, _) = doc.page_loc(2).unwrap(); + assert_eq!(doc.spreads[si].pages.len(), 2); + let mut h = menu_harness(app, 2, si); + let _ = h.get_by_label("Delete Spread"); + h.get_by_label("Delete Page").click(); + h.run_steps(3); + assert_eq!(h.state().session.doc().unwrap().doc.page_count(), 4, "only the clicked page"); + } + + #[test] + fn the_panel_menu_follows_indesigns_order() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"pages": 2, "facingPages": false})).unwrap(); + let mut h = menu_harness(app, 0, 0); + assert!(h.query_by_label("Delete Spread").is_none(), "a single-page spread has no Delete Spread"); + // InDesign's items, top to bottom. + let ys: Vec = ["Insert Pages…", "Move Pages…", "Duplicate Spread", "Duplicate Spread at End of Document", "Delete Page", "New Parent…"] + .iter() + .map(|l| h.get_by_label(l).rect().center().y) + .collect(); + assert!(ys.windows(2).all(|w| w[0] < w[1]), "{ys:?}"); + h.get_by_label("Duplicate Spread at End of Document").click(); + h.run_steps(3); + assert_eq!(h.state().session.doc().unwrap().doc.page_count(), 3); + h.get_by_label("Insert Pages…").click(); + h.run_steps(3); + assert!(h.state().ui.dialog.as_ref().is_some_and(|d| d.id == "cmd:layout.pages.insert")); + } + + #[test] + fn a_page_dropped_on_create_new_page_is_duplicated_at_the_end() { + // Chris Cicchino's Page Tool video (2:06): "click and drag onto my plus sign I can duplicate that". + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 252, "height": 144, "facingPages": false})).unwrap(); + app.run("layout.pages.insert", json!({"count": 1})).unwrap(); + app.run("layout.pageSize", json!({"pages": [2], "width": 612, "height": 792})).unwrap(); + let mut h = harness(app); + let plus = h.get_by_label("Create new page").rect().center(); + drop_page(&mut h, 1, plus, false); + let d = h.state().session.doc().unwrap().doc.clone(); + assert_eq!(d.page_count(), 3); + let (si, pi) = d.page_loc(2).unwrap(); + assert_eq!(d.spreads[si].pages[pi].width, 612.0, "a copy of the letterhead"); + let _ = h.get_by_label("Edit page size"); + } +} diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 4e207c44..358ec76d 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -160,7 +160,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { // ---------------------------------------------------------------- No Selection /// Page-size presets for the Document dropdown (portrait, points). -const PAGE_PRESETS: &[(&str, f64, f64)] = &[ +pub(crate) const PAGE_PRESETS: &[(&str, f64, f64)] = &[ ("Letter", 612.0, 792.0), ("Legal", 612.0, 1008.0), ("Tabloid", 792.0, 1224.0), @@ -171,9 +171,10 @@ const PAGE_PRESETS: &[(&str, f64, f64)] = &[ ("A5", 419.528, 595.276), ("A6", 297.638, 419.528), ("B5", 498.898, 708.661), + ("US Business Card", 144.0, 252.0), ]; -fn preset_name(w: f64, h: f64) -> &'static str { +pub(crate) fn preset_name(w: f64, h: f64) -> &'static str { let (a, b) = (w.min(h), w.max(h)); PAGE_PRESETS.iter().find(|p| (p.1 - a).abs() < 0.6 && (p.2 - b).abs() < 0.6).map(|p| p.0).unwrap_or("Custom") } @@ -386,7 +387,7 @@ fn document_sections(app: &mut DesignApp, ui: &mut Ui) { // ---------------------------------------------------------------- Objects /// The 9-point reference-point proxy; returns a newly picked point (0..8, row-major). -fn ref_point_proxy(ui: &mut Ui, r: Rect, cur: u8) -> Option { +pub(crate) fn ref_point_proxy(ui: &mut Ui, r: Rect, cur: u8) -> Option { let t = Tokens::get(ui.ctx()); let mut out = None; let pitch = r.width() / 3.0; diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 967ff38f..aea4e4ac 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -11,16 +11,10 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let Some(st) = app.session.active() else { return }; let doc = st.doc.clone(); - let text = super::colors_affect_text(app); - let fill_cur = super::color_target(app).map(|t| t.0); - ui.horizontal(|ui| { - crate::rtl::label( - ui, - egui::RichText::new(crate::i18n::tr(&app.ui.language, if text { "Applies to text" } else { "Applies to fill" })) - .size(11.0) - .color(t.text_dim), - ); - }); + let target = super::color_target(app); + header(app, ui, &doc, target.as_ref()); + // The row of the colour in front of the proxy is highlighted. + let fill_cur = target.map(|(fill, _, stroke, _)| if app.ui.stroke_front { stroke } else { fill }); // Unnamed colours (mixed in the Color panel) aren't listed until Add to Swatches; swatches in // colour groups are listed under their folder. let grouped: Vec<&str> = doc.color_groups.iter().flat_map(|g| g.swatches.iter().map(String::as_str)).collect(); @@ -64,15 +58,25 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } } ui.add_space(6.0); + // InDesign's bottom row: New Color Group, New Swatch, Delete Swatch (the one last clicked). + let picked: Option = ui.data(|d| d.get_temp(egui::Id::new(PICKED))).filter(|n: &String| doc.swatch(n).is_some_and(|w| !w.locked)); ui.horizontal(|ui| { - ui.label(crate::rtl::widget( - ui, - egui::RichText::new(crate::i18n::tr(&app.ui.language, "Right-click: stroke, groups")).size(10.5).color(t.text_disabled), - )); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let del = ui + .add_enabled_ui(picked.is_some(), |ui| icons::button(ui, "trash", 20.0, false, crate::i18n::tr(&app.ui.language, "Delete Swatch"))) + .inner; + if del.clicked() + && let Some(name) = &picked + && app.run("swatch.delete", json!({"name": name})).is_ok() + { + ui.data_mut(|d| d.remove::(egui::Id::new(PICKED))); + } if icons::button(ui, "plus", 20.0, false, crate::i18n::tr(&app.ui.language, "New Swatch")).clicked() { let _ = app.run("swatch.create", json!({"color": {"c": 0, "m": 50, "y": 100, "k": 0}})); } + if folder_button(ui, crate::i18n::tr(&app.ui.language, "New Color Group")).clicked() { + let _ = app.run("swatch.newColorGroup", json!({})); + } crate::menus::menu_button(ui, "☰", |ui| { crate::menus::menu_button(ui, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Ink Manager")), |ui| { let Ok(l) = app.session.execute("ink.list", &json!({})) else { return }; @@ -114,8 +118,99 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { }); } -/// One Swatches panel row: chip, name, type; click applies to the fill (of the frames or their -/// text, see [`super::colors_affect_text`]), the context menu to the stroke, colour groups and delete. +/// Temp-data key of the swatch last clicked (Delete Swatch removes it). +const PICKED: &str = "swatches_picked"; + +/// The Swatches panel's top row, as InDesign's: a Fill/Stroke proxy (click: the other in front), +/// Formatting Affects Container / Text, and the tint of the colour in front. `target` is +/// [`super::color_target`]: fill and stroke swatches and tints of the text or the first object. +fn header(app: &mut DesignApp, ui: &mut egui::Ui, doc: &designcraft_doc::Document, target: Option<&(String, f32, String, f32)>) { + let t = Tokens::get(ui.ctx()); + let (fill_sw, stroke_sw) = target.map_or(("[None]", "[Black]"), |(f, _, s, _)| (f.as_str(), s.as_str())); + let text = super::colors_affect_text(app) || app.ui.formatting_affects_text; + let stroke_front = app.ui.stroke_front; + ui.add_space(3.0); + ui.horizontal(|ui| { + let (r, resp) = ui.allocate_exact_size(vec2(27.0, 27.0), Sense::click()); + let tip = crate::i18n::tr(&app.ui.language, if stroke_front { "Stroke" } else { "Fill" }); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tip)); + let fill_r = egui::Rect::from_min_size(r.min + vec2(1.0, 2.0), vec2(16.0, 16.0)); + let stroke_r = egui::Rect::from_min_size(r.min + vec2(10.0, 10.0), vec2(16.0, 16.0)); + let paint = |stroke: bool| { + let p = ui.painter(); + if stroke { + // The stroke: a frame of its colour. + let (c, g) = crate::widgets::swatch_colors(doc, stroke_sw, 1.0); + crate::widgets::paint_chip(p, stroke_r, c, g); + p.rect_filled(stroke_r.shrink(4.0), 0.0, t.panel); + p.rect_stroke(stroke_r.shrink(4.0), 0.0, egui::Stroke::new(1.0, t.text_dim), egui::StrokeKind::Outside); + p.rect_stroke(stroke_r, 0.0, egui::Stroke::new(1.0, t.text_dim), egui::StrokeKind::Outside); + } else { + let (c, g) = crate::widgets::swatch_colors(doc, fill_sw, 1.0); + crate::widgets::paint_chip(p, fill_r, c, g); + p.rect_stroke(fill_r, 0.0, egui::Stroke::new(1.0, t.text_dim), egui::StrokeKind::Outside); + } + }; + // The one in front is drawn last. + paint(!stroke_front); + paint(stroke_front); + if resp + .on_hover_ui(|ui| { + crate::rtl::label(ui, tip); + }) + .clicked() + { + let _ = app.run("color.toggleFillStroke", json!({})); + } + ui.add_space(4.0); + for (k, (icon, tip)) in + [("format-container", "Formatting affects container (J)"), ("format-text", "Formatting affects text (J)")].into_iter().enumerate() + { + if icons::button(ui, icon, 20.0, text == (k == 1), crate::i18n::tr(&app.ui.language, tip)).clicked() { + let _ = app.run("app.formattingAffectsText", json!({"on": k == 1})); + } + } + ui.add_space(6.0); + crate::widgets::caption(ui, crate::i18n::tr(&app.ui.language, "Tint:")); + let tint = target.map(|(_, ft, _, st)| f64::from(if stroke_front { *st } else { *ft }) * 100.0); + if let Some(v) = crate::widgets::number(ui, "swatches_tint", tint, "", 44.0, 0) { + let _ = set_tint(app, v); + } + crate::widgets::caption(ui, "%"); + }); +} + +/// The Tint field: `percent` of the colour in front of the proxy, for the text or each selected +/// object, which keep their own swatches. +fn set_tint(app: &mut DesignApp, percent: f64) -> Result { + let v = percent.clamp(0.0, 100.0) / 100.0; + let stroke = app.ui.stroke_front; + if super::colors_affect_text(app) { + app.run("type.char", json!({"attrs": {if stroke { "strokeTint" } else { "fillTint" }: v}})) + } else { + app.run(if stroke { "object.stroke" } else { "object.fill" }, json!({"tint": v})) + } +} + +/// A folder button (New Color Group), drawn like the colour groups' folders. +fn folder_button(ui: &mut egui::Ui, tip: &str) -> egui::Response { + let t = Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(vec2(20.0, 20.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tip)); + if resp.hovered() { + ui.painter().rect_filled(r, 3.0, t.hover); + } + let f = egui::Rect::from_center_size(r.center(), vec2(14.0, 10.0)); + ui.painter().rect_stroke(f, 1.5, egui::Stroke::new(1.2, t.icon), egui::StrokeKind::Inside); + ui.painter().line_segment([f.left_top() + vec2(1.0, 3.0), f.right_top() + vec2(-1.0, 3.0)], egui::Stroke::new(1.0, t.icon)); + resp.on_hover_ui(|ui| { + crate::rtl::label(ui, tip); + }) +} + +/// One Swatches panel row: chip, name, type; click applies it to the fill or stroke in front of +/// the proxy (of the frames or their text, see [`super::colors_affect_text`]), the context menu +/// to the stroke, colour groups and delete. fn swatch_row( app: &mut DesignApp, ui: &mut egui::Ui, @@ -126,6 +221,7 @@ fn swatch_row( ) { let t = Tokens::get(ui.ctx()); let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &sw.name)); if fill_cur == Some(sw.name.as_str()) { ui.painter().rect_filled(row, 0.0, t.row_selected); } else if resp.hovered() { @@ -154,7 +250,8 @@ fn swatch_row( } ui.painter().text(row.right_center() - vec2(6.0, 0.0), egui::Align2::RIGHT_CENTER, kind, egui::FontId::proportional(10.5), t.text_dim); if resp.clicked() { - let _ = super::apply_swatch(app, false, &sw.name, None); + ui.data_mut(|d| d.insert_temp(egui::Id::new(PICKED), sw.name.clone())); + let _ = super::apply_swatch(app, app.ui.stroke_front, &sw.name, None); } resp.context_menu(|ui| { if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Apply to Stroke"))).clicked() { @@ -573,3 +670,92 @@ pub fn gradient_panel(app: &mut DesignApp, ui: &mut egui::Ui) { }); } } + +#[cfg(test)] +mod swatches_header_tests { + use egui_kittest::{Harness, kittest::Queryable}; + use serde_json::json; + + use super::*; + + fn harness(app: DesignApp) -> Harness<'static, DesignApp> { + Harness::builder().with_size(vec2(300.0, 520.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let id = egui::Id::new("test_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; + } + show(app, ui); + }, + app, + ) + } + + fn items(app: &DesignApp) -> Vec { + let st = app.session.doc().unwrap(); + st.selection.items.iter().map(|id| st.doc.item(*id).unwrap().clone()).collect() + } + + #[test] + fn swatches_go_to_the_fill_or_stroke_in_front_and_the_bottom_row_deletes_the_one_clicked() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect": [36, 36, 200, 200], "content": "unassigned"})).unwrap(); + app.run("swatch.create", json!({"name": "Brand", "color": "#108080"})).unwrap(); + let mut h = harness(app); + h.run_steps(3); + // Fill in front: a swatch is the fill. + h.get_by_label("C=0 M=100 Y=0 K=0").click(); + h.run_steps(2); + assert_eq!(items(h.state())[0].fill.swatch, "C=0 M=100 Y=0 K=0"); + // The proxy brings the stroke to the front; the next swatch is the stroke. + h.get_by_label("Fill").click(); + h.run_steps(2); + assert!(h.state().ui.stroke_front); + h.get_by_label("C=100 M=0 Y=0 K=0").click(); + h.run_steps(2); + let it = &items(h.state())[0]; + assert_eq!((it.fill.swatch.as_str(), it.stroke.swatch.as_str()), ("C=0 M=100 Y=0 K=0", "C=100 M=0 Y=0 K=0")); + // Formatting affects text, from the panel. + h.get_by_label("Formatting affects text (J)").click(); + h.run_steps(2); + assert!(h.state().ui.formatting_affects_text); + h.get_by_label("Formatting affects container (J)").click(); + h.run_steps(2); + assert!(!h.state().ui.formatting_affects_text); + // Delete Swatch removes the swatch last clicked. + h.get_by_label("Brand").click(); + h.run_steps(2); + h.get_by_label("Delete Swatch").click(); + h.run_steps(2); + assert!(h.state().session.doc().unwrap().doc.swatch("Brand").is_none()); + } + + #[test] + fn the_tint_field_keeps_each_objects_own_swatch() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let a = app.run("frame.create", json!({"rect": [36, 36, 100, 100], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); + let b = app.run("frame.create", json!({"rect": [136, 36, 200, 100], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); + app.run("object.fill", json!({"ids": [a], "swatch": "C=100 M=0 Y=0 K=0"})).unwrap(); + app.run("object.fill", json!({"ids": [b], "swatch": "C=0 M=100 Y=0 K=0"})).unwrap(); + app.run("object.stroke", json!({"ids": [a, b], "swatch": "[Black]"})).unwrap(); + app.run("selection.set", json!({"ids": [a, b]})).unwrap(); + let mut h = harness(app); + h.run_steps(3); + // The field shows the first selected object's fill tint. + assert!(h.query_all_by_value("100").next().is_some()); + set_tint(h.state_mut(), 40.0).unwrap(); + h.run_steps(2); + assert!(h.query_all_by_value("40").next().is_some(), "the field follows"); + // The stroke in front: the strokes' tints. + h.state_mut().ui.stroke_front = true; + set_tint(h.state_mut(), 70.0).unwrap(); + assert!(items(h.state()).iter().all(|i| i.stroke.tint == 0.7)); + let it = items(h.state()); + assert_eq!((it[0].fill.swatch.as_str(), it[0].fill.tint), ("C=100 M=0 Y=0 K=0", 0.4)); + assert_eq!((it[1].fill.swatch.as_str(), it[1].fill.tint), ("C=0 M=100 Y=0 K=0", 0.4)); + } +} diff --git a/crates/ui-egui/src/parent_canvas_tests.rs b/crates/ui-egui/src/parent_canvas_tests.rs new file mode 100644 index 00000000..b3197a7e --- /dev/null +++ b/crates/ui-egui/src/parent_canvas_tests.rs @@ -0,0 +1,104 @@ +use super::*; + +fn app_with_document_object() -> DesignApp { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 64, "height": 64, "pages": 1, "facingPages": true, "margins": 4})).unwrap(); + app.run("frame.create", json!({"rect": [24, 24, 40, 40], "content": "unassigned"})).unwrap(); + app.run("object.fill", json!({"swatch": "[Black]"})).unwrap(); + // Exercise the production texture cache without starting a background thread. + app.canvas.worker_started = true; + app +} + +fn render(app: &mut DesignApp, ctx: &egui::Context) -> egui::epaint::ImageDelta { + render_at_zoom(app, ctx, 1.0) +} + +fn render_at_zoom(app: &mut DesignApp, ctx: &egui::Context, zoom: f64) -> egui::epaint::ImageDelta { + let rect = Rect::from_min_size(Pos2::ZERO, vec2(128.0, 64.0)); + let view = View { zoom, origin: Point::new(-64.0, 0.0), fitted: true, rotation: 0 }; + let mut output = ctx.run_ui(Default::default(), |ui| { + let st = app.session.active().unwrap(); + let layout = CanvasLayout::new(&st.doc, st.editing_parents); + render_texture(app, ui.ctx(), rect, &Xf::new(rect, &view), &layout, false); + }); + let id = app.canvas.texture.as_ref().unwrap().id(); + let deltas = output.textures_delta.set.remove(&id); + output.textures_delta.clear(); + let mut deltas = deltas.expect("canvas pixels must update"); + assert_eq!(deltas.len(), 1, "one canvas render should produce one texture update"); + deltas.pop().unwrap() +} + +fn pixel(delta: &egui::epaint::ImageDelta, shown: crate::Shown, point: Point) -> Color32 { + let egui::ImageData::Color(image) = &delta.image; + let offset = delta.pos.unwrap_or([0, 0]); + let x = ((point.x - shown.origin.x) * shown.zoom).round() as usize - offset[0]; + let y = ((point.y - shown.origin.y) * shown.zoom).round() as usize - offset[1]; + image[(x, y)] +} + +#[test] +fn switching_parent_editing_repaints_pages_and_document_objects() { + let mut app = app_with_document_object(); + let ctx = egui::Context::default(); + let left_page = Point::new(-32.0, 32.0); + let document_object = Point::new(32.0, 32.0); + let before = render(&mut app, &ctx); + let shown = app.canvas.shown.unwrap(); + assert_eq!(pixel(&before, shown, left_page), Color32::TRANSPARENT); + let document_color = pixel(&before, shown, document_object); + assert_ne!(document_color, Color32::WHITE); + assert_eq!(document_color.a(), 255); + + let doc = app.session.active().unwrap().doc.clone(); + app.run("layout.parents.edit", json!({"on": true})).unwrap(); + assert!(std::sync::Arc::ptr_eq(&doc, &app.session.active().unwrap().doc), "switching canvas mode does not edit the document"); + let parent = render(&mut app, &ctx); + assert!(parent.pos.is_none(), "the parent canvas needs a complete repaint"); + let shown = app.canvas.shown.unwrap(); + assert_eq!(pixel(&parent, shown, left_page), Color32::WHITE, "the parent's left page must become visible"); + assert_eq!(pixel(&parent, shown, document_object), Color32::WHITE, "document-only objects must disappear in parent editing"); + + app.run("layout.parents.edit", json!({"on": false})).unwrap(); + let restored = render(&mut app, &ctx); + assert!(restored.pos.is_none()); + let shown = app.canvas.shown.unwrap(); + assert_eq!(pixel(&restored, shown, left_page), Color32::TRANSPARENT); + assert_eq!(pixel(&restored, shown, document_object), document_color); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn switching_parent_editing_with_a_new_zoom_repaints_before_the_worker() { + let mut app = app_with_document_object(); + let ctx = egui::Context::default(); + app.canvas.worker = Some(crate::render_worker::Worker::spawn(ctx.clone()).expect("render worker must start")); + let before = render(&mut app, &ctx); + assert_ne!(pixel(&before, app.canvas.shown.unwrap(), Point::new(32.0, 32.0)), Color32::WHITE); + + // Fitting the parent spread can change the zoom as well as the canvas mode. + app.run("layout.parents.edit", json!({"on": true})).unwrap(); + let parent = render_at_zoom(&mut app, &ctx, 0.75); + assert!(parent.pos.is_none(), "a mode switch must upload the new canvas in this frame"); + assert!(app.canvas.pending.is_none(), "parent pages must not wait behind an asynchronous zoom render"); + let shown = app.canvas.shown.unwrap(); + assert_eq!(shown.zoom, 0.75); + assert_eq!(pixel(&parent, shown, Point::new(-32.0, 32.0)), Color32::WHITE); + assert_eq!(pixel(&parent, shown, Point::new(32.0, 32.0)), Color32::WHITE); +} + +#[test] +fn small_edits_in_the_same_canvas_mode_still_repaint_partially() { + let mut app = app_with_document_object(); + let ctx = egui::Context::default(); + let before = render(&mut app, &ctx); + let point = Point::new(32.0, 32.0); + assert_ne!(pixel(&before, app.canvas.shown.unwrap(), point), Color32::WHITE); + + app.run("object.fill", json!({"swatch": "[Paper]"})).unwrap(); + let patch = render(&mut app, &ctx); + assert!(patch.pos.is_some(), "an ordinary small edit should keep the partial repaint optimization"); + assert_eq!(app.canvas.patches, 1); + assert_eq!(pixel(&patch, app.canvas.shown.unwrap(), point), Color32::WHITE); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 43998725..25136a80 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -39,7 +39,7 @@ so only enable it while you use it. Transport: `apps/designcraft/src/control_ser | `ui.tool.select` | `{tool}` | Select a tool (`selection`, `type`, `rectangleFrame`, …) | | `ui.pointer` | `{events:[{kind: down\|drag\|up\|move\|doubleclick, x, y, space?: "screen"\|"canvas", clicks?}], mods?}` | Drive the active tool through the same code path as the mouse; `clicks` on a `down` is the press count (3 = triple click); returns the selection, the tool and its cursor at the last event (e.g. `LoadedText`, `ThreadLink`, `Unthread`) | | `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | Synthetic keyboard input (typing into a text frame) | -| `ui.move` / `ui.click` / `ui.drag` | screen points, `button?: left\|right\|middle` | Real egui pointer input — reaches every widget, menu and panel | +| `ui.move` / `ui.click` / `ui.drag` | screen points, `button?: left\|right\|middle`, `count?: positive integer` (click/drag, default 1), `steps?: positive integer` (drag, default 8) | Real egui pointer input — reaches every widget, menu and panel | | `ui.set` | `{brightness?, panel?, rulers?, guides?, frameEdges?, baselineGrid?, textThreads?, screenMode?, zoom?, page?, fit?}` | UI state | | `ui.dialog.open` | `{id, fields?}` | Open a dialog by id (e.g. `paragraphStyleOptions` or `characterStyleOptions` with `{name, section}`, or `{new: true}` for a new style) | | `ui.dialog.set` / `ui.dialog.confirm` / `ui.dialog.cancel` | `{field, value}` | Fill and confirm the open dialog | @@ -57,6 +57,17 @@ Headless window screenshots (locked screen, hidden window): `cargo run -p design The MCP server (`designcraft-cli mcp`) wraps the same methods for Claude and other agents. +### Click and drag limits + +`ui.click` and `ui.drag` validate the whole gesture before queuing any input. They permit at +most 10,003 pending synthetic events, including input already waiting for native frames. A +click uses `1 + 2 * count` events; a drag uses `1 + steps + 2 * count`. The count includes the +initial click or drag; further clicks occur at its endpoint. `count` is at most 255; an empty +queue accepts a single drag with up to 10,000 steps. `count` and `steps` must be positive +JSON integers when supplied; invalid or over-budget requests return `ok: false` and leave pending +input unchanged. Oversized gestures are rejected rather than truncated. Wait for queued input +to finish before retrying a request that would exceed the pending-event limit. + ## Shared panel docking Panel arrangements support tab grouping and reordering, horizontal and vertical splits, diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index fc8fbc3a..06f59de9 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (shared print spooler; Windows CI passes) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (content-indexed asset deduplication, #727) · **Target:** Adobe InDesign 2026 (21.6) Every format InDesign 2026 reads or writes, with DesignCraft's support, fidelity and tests. The InDesign list comes from its bundle's `Info.plist` (`CFBundleDocumentTypes`, @@ -22,7 +22,7 @@ score below). | InDesign document | `.indd` | read/write (native) | ✗ (#21; converters proposed in #98, #114) | ✗ | — | 30% | | InDesign template | `.indt` | read/write | ✗ | ✗ | — | 2% | | InDesign Markup | `.idml` | read/write | ✓ | ✓ | Synthetic fixtures in `crates/idml/src/tests.rs` (49 tests); exported files verified by opening in InDesign 2026 by hand; indd-utils' 98-document run imports 811 spreads / 2,866 stories / 5,812 items. Open: #193, #338, #339, #340, #211. No real-file corpus test | 20% | -| DesignCraft native | `.designcraft` | — | ✓ | ✓ | zip + JSON, versioned; round-trip tests | — | +| DesignCraft native | `.designcraft` | — | ✓ | ✓ | zip + JSON, versioned; round-trip tests. Asset loading and compaction index by content hash, then confirm byte equality; equal-sized distinct files no longer all share one search bucket (#727). Separate links, MIME types and PDF pages retain their identity | — | | InDesign book | `.indb` | read/write | ✗ (own `.dcbook`) | ✗ | Books are shallow (see gaps.md) | 2% | | InDesign library | `.indl`, `.inlx` | read/write | ✗ (own `.dclib`) | ✗ | | 1% | | Snippet | `.idms` | read/write | ✗ (own snippets) | ✗ | | 1% | @@ -107,4 +107,5 @@ for real work** (estimated). INDD alone moves this dimension by up to 30 points. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Asset loading and compaction use content fingerprints with exact equality checks; tests cover equal-sized distinct buffers, shared copies, independent links and missing data (#727) | | 2026-10-10 | major | Created from InDesign 2026's declared document types and filters, `plan/indesign/08-file-formats.md` and the code | diff --git a/docs/gaps.md b/docs/gaps.md index 41a0af1f..4ba05bc4 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where DesignCraft falls short of InDesign -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (gap 0 shrunk: text threads by hand; threading gaps added) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (bounded control click/drag input) · **Target:** Adobe InDesign 2026 (21.6) Every known shortfall against InDesign 2026, one entry each, ranked by how much it stops an InDesign professional from doing real work. This is the work list: agents pick from the top. @@ -32,6 +32,8 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · open (#238, #187, #160, #312); double-click word selection in fields (#189); character formatting set at a caret is discarded (#289). - **Evidence:** 16 issues from 2026-10-05 to 2026-10-10, mostly against the 0.4/0.5 release builds. +- **Safety:** control-channel clicks and drags reject invalid or over-budget counts before + queuing input (#732), including combined drag steps, repeated clicks and already pending events. - **Impact:** a layout artist can't finish the simplest job. This dominates the ready-for-real-work number. - **Estimate:** 30–50 h (reproduce per platform through the control channel, regression tests). diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index daaa3f44..d4a97fad 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -268,7 +268,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Text engine: Knuth–Plass paragraph composer and single-line composer, dictionary hyphenation (public-domain Moby list + our own trained Liang patterns), justification with word/letter/glyph-scaling ranges, keep options (keep with next, keep lines together — at start/end of paragraph by default, as in InDesign — widows/orphans), balance ragged lines, optical margin alignment, hyphenation zone/limit, columns, threading across frames and spreads, tabs, bullets/numbering, rules, shading, baseline grid, first-baseline options, vertical justification, wrap exclusions, overset detection, caret/hit testing. - FID2: IDML underline/strikethrough Text Color resets survive style inheritance and roundtrip. Raster and PDF paint underlines behind glyphs, retain strikethrough above, and center rules on their offsets; synthetic overlap tests cover both backends. - Rendering: vello_cpu (SIMD, multithreaded) — pages, items, images with mip levels, gradients, composed text. -- UI (egui): application bar + menus, Control panel (object and text modes), Tools panel with flyouts and the Fill/Stroke proxy (D, X, Shift+X, / as `color.*` commands), document tabs, rulers, pasteboard, guides (margins, columns, bleed, baseline grid), frame edges, selection handles, text ports and threads, Properties / Pages / Layers dock, Swatches, Styles, Character, Paragraph, Stroke, Text Wrap panels, New Document and Preferences dialogs, Quick Apply (⌘Return), 4 brightness themes. +- UI (egui): application bar + menus, Control panel (object and text modes), Tools panel with flyouts and the Fill/Stroke proxy (D, X, Shift+X, / as `color.*` commands), document tabs, rulers, pasteboard, guides (margins, columns, bleed, baseline grid), frame edges, selection handles, text ports and threads, Properties / Pages / Layers dock, Swatches (InDesign's header: Fill/Stroke proxy, Formatting Affects Container/Text, Tint; a swatch goes to the fill or stroke in front; New Color Group / New Swatch / Delete Swatch row), Styles, Character, Paragraph, Stroke, Text Wrap panels, New Document and Preferences dialogs, Quick Apply (⌘Return), 4 brightness themes. - Pen tool, Direct Selection anchor/handle editing, rotate from corners, Rotate/Scale/Shear tools, Eyedropper, snapping & smart guides, Align/Distribute, Find/Change (text + GREP), Story Editor, Paragraph Style Options, Effects panel, hidden characters, native macOS menu bar, measured InDesign 2026 look (Medium Dark): Properties sections per selection state, 21 pt spinner fields with arithmetic, Contextual Task Bar, inverse (black) text selection, Pages panel with drag-and-drop reorder / parent apply, Layers panel with per-object rows. - Tools: Selection (click, marquee, move, Alt-duplicate, resize handles), Direct Selection, Type (draw frame, click caret, select, type), Rectangle/Ellipse/Polygon (+ frame variants), Line, Hand, Zoom. - PDF export (krilla): real selectable text with embedded font subsets, DeviceCMYK/RGB + spot Separations, bleed boxes, crop/bleed marks + page info, pages or spreads, PDF/A-2b (PDF/X-4 output intent pending) — `file.exportPdf`, File › Export PDF…, `designcraft-cli run --export out.pdf`. File › Export PDF… now opens an "Export PDF" options dialog (General / Compression / Marks and Bleeds / Advanced) and remembers the last-used settings across exports and sessions. @@ -398,7 +398,8 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Type on a Path (⇧T tool, `type.onPath`): a story along any line, curve or shape on screen and in PDF; Type on a Path Options (start, flip, baseline/ascender/descender/centre) and Delete Type from Path. Round-trips through IDML as TextPath; on-path caret drawing is still to come. - Preferences › Appearance of Black: 100% K on screen accurately (dark grey) or as rich black (`window.richBlack`); the printing/export side is still to come. - Spreads of up to 10 pages: Add to Previous Spread and Allow Spread to Shuffle (Pages panel; `layout.pages.toSpread`, `layout.spreadShuffle`); inserted pages go around kept spreads. -- Page tool (⇧P) and `layout.pageSize`: pages of their own size (or a preset); the spine stays put and objects keep their place on their page. +- Page tool (⇧P) and `layout.pageSize`: pages of their own size (or a preset); the spine stays put and objects keep their place on their page. A click selects a page (handles, its liquid page rule tagged above it); dragging a handle previews the liquid rule and springs back, Alt-drag resizes, Shift keeps proportions, with a W/H readout; `anchor` keeps a reference point in place; double-click for exact sizes. With the Page tool the Control panel shows the page (reference point, W/H, preset, orientation, Liquid Page Rule including Controlled by Parent, Show Parent Page Overlay); X/Y and Objects Move with Page are shown, but pages don't move within their spread yet. On an object-based page it selects objects and shows their pins and width/height locks on the canvas (click to toggle, with tooltips). +- Page tool (⇧P) and `layout.pageSize`: pages of their own size (or a preset); the spine stays put and objects keep their place on their page. A click selects a page (handles, its liquid page rule tagged above it); dragging a handle previews the liquid rule and springs back, Alt-drag resizes, Shift keeps proportions, with a W/H readout; `anchor` keeps a reference point in place; double-click for exact sizes. With the Page tool the Control panel shows the page (reference point, W/H, preset, orientation, Liquid Page Rule, Show Parent Page Overlay); X/Y are shown, but pages don't move within their spread yet. - File › Print (⌘P): printer, copies, page range, spreads, marks, bleed; printed as PDF through the system spooler (`file.print`, `file.printers`, `dryRun` for agents). Shortcuts now act like choosing the menu item (dialogs open), and UI commands win shortcut ties (⌘N opens New Document). - Nested styles (through/up to N sentences, words, characters, letters, digits, tabs, breaks, spaces or given characters) and GREP styles (regex → character style), edited in Paragraph Style Options and composed under local formatting; `ui.dialog.open` for agents. Nested line styles are still to come; nested and GREP styles round-trip through IDML. - Table and cell styles (round-trip through IDML): cell styles (fill, insets, vertical justification, strokes, paragraph style) and table styles (region cell styles, border, alternating rows, spacing); apply from the Table panel, edits re-apply to every user; `style.cell.*`, `style.table.*`. @@ -430,7 +431,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Parent item overrides: Cmd+Shift-click a parent item on a page (or Override All Parent Page Items, ⌥⇧⌘L) makes an editable local copy that hides the parent's; Remove All Local Overrides and Detach All Objects from Parent in the Pages panel menu. - Place PDF: placed PDF pages are sized by their crop box, drawn on screen through hayro (with mip levels) and embedded in exported PDFs as vector form XObjects (krilla), `` in IDML. - Menus expose the engine's features (TOC, numbering & sections, text variables, hyperlinks, cross-references, footnotes, step and repeat, spelling, fitting/content, effects, Data Merge, Preflight, EPUB); any menu command with parameters gets a dialog generated from its parameter documentation. -- Table of contents (generate/update, dot leaders) and text variables (running headers, last page number, chapter number, file name, dates, custom) resolved per page. +- Table of contents (Layout ▸ Table of Contents dialog: included styles, per-style entry style, page number position, separator and level, title style, Replace Existing; generate/update, dot leaders; the definition round-trips through IDML as a TOC style) and text variables (running headers, last page number, chapter number, file name, dates, custom) resolved per page. - Soft effects: blurred drop shadow, inner shadow, outer glow, basic feather. - ~200 commands, all reachable through the JSON control channel; headless CLI rendering to PNG. - MCP server (`designcraft-cli mcp [--connect PORT]`, docs/mcp.md): headless engine or the running app; commands, batch, document/story inspection, page renders as images, window screenshots, pointer/keyboard/dialog input. @@ -438,7 +439,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level Task notes that headed ROADMAP.md: -- 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. +- FID1: PDF export normalizes mixed emitted gradient color spaces to RGB with an explicit warning; homogeneous device-space gradients retain their components. Process stops (CMYK, grey) blend as inks on screen and at midpoints, so a midpoint keeps the shading CMYK. Linear/radial, midpoint, archival and print policies have synthetic regression coverage. - 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. - CJK1: IDML composite fonts now retain character mappings and metrics; explicit aki, tsume, jidori, em alignment/leading, named/custom kinsoku boundaries, hanging punctuation, tate-chu-yoko offsets and kenten symbols flow through composition and IDML export. Automatic kerning no longer imports inactive numeric values as enormous manual spacing. Composite definitions are editable through commands with undo/cache invalidation. Mojikumi definitions are retained and unsupported spacing-table / push-in-push-out policies are reported by preflight. Full InDesign CJK composition parity, frame-grid layout and detailed ruby remain unfinished. Warichu sets the selected run in smaller lines inside the line after the line is broken. diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh index 38fa2afb..c818ae6d 100755 --- a/packaging/macos/package.sh +++ b/packaging/macos/package.sh @@ -135,15 +135,40 @@ ln -s /Applications "$STAGE/Applications" mkdir -p "$STAGE/.background" cp "$HERE/dmg/background.tiff" "$STAGE/.background/background.tiff" cp "$HERE/dmg/dmg-layout.DS_Store" "$STAGE/.DS_Store" -rm -f "$DMG" "$WORK/raw.dmg" -# makehybrid + convert builds the image without attaching a device, unlike `create -srcfolder`, -# which is flaky on CI runners ("Resource busy") and hangs in sandboxed sessions. -# The volume name has no version: .DS_Store finds the background through an alias that includes it. -hdiutil makehybrid -hfs -hfs-volume-name "DesignCraft" -hfs-openfolder "$STAGE" -o "$WORK/raw.dmg" "$STAGE" -hdiutil convert "$WORK/raw.dmg" -format UDZO -imagekey zlib-level=9 -o "$DMG" -rm -f "$WORK/raw.dmg" +rm -f "$DMG" +# `hdiutil create -srcfolder` writes the files as they are. `makehybrid -hfs` gave every file a +# com.apple.FinderInfo, which `codesign --verify --strict` rejects as "Finder information, or +# similar detritus" on the app in the image and on a copy installed from it (as VectorCraft's +# storytold/vectorcraft#1085 and PdfCraft's storytold/pdfcraft#823). `create` attaches a device +# while it copies, which fails now and then on CI runners ("Resource busy"), so it gets a few +# tries. The volume name has no version: .DS_Store finds the background through an alias that +# includes it. +# Usage: hdiutil_retry ; tries five times with growing pauses. +hdiutil_retry() { + local what="$1" attempt + shift + for attempt in 1 2 3 4 5; do + if hdiutil "$@"; then + return 0 + fi + [ "$attempt" = 5 ] && { echo "hdiutil $what failed 5 times" >&2; return 1; } + echo "hdiutil $what failed (attempt $attempt of 5); retrying in $((attempt * 10))s" >&2 + sleep $((attempt * 10)) + done +} +hdiutil_retry create create -srcfolder "$STAGE" -volname "DesignCraft" -fs HFS+ -format UDZO -imagekey zlib-level=9 -ov "$DMG" sign "$DMG" codesign --verify --strict --verbose=2 "$DMG" +# The app inside the image must still pass --strict, so a makehybrid-style regression can't ship. +MOUNT="$WORK/dmg-check" +rm -rf "$MOUNT" +mkdir -p "$MOUNT" +hdiutil_retry attach attach -nobrowse -readonly -mountpoint "$MOUNT" "$DMG" >/dev/null +if ! codesign --verify --strict --deep --verbose=2 "$MOUNT/DesignCraft.app"; then + hdiutil_retry detach detach "$MOUNT" >/dev/null || true + exit 1 +fi +hdiutil_retry detach detach "$MOUNT" >/dev/null if [ "$NOTARIZE" = 1 ]; then notarize "$DMG" xcrun stapler staple "$DMG"