From dc712baeca972c6732af9e0af33aac4f156afb61 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 13 Oct 2026 20:38:38 +0200 Subject: [PATCH 1/7] fix(color): gradients between process colours blend as inks, as InDesign shows them LYH Studio episode 6 builds every gradient from C=0 M=100 Y=0 K=0 to C=100 M=0 Y=0 K=0. InDesign blends a gradient's process stops as inks: halfway is C 50 M 50, a light lilac. DesignCraft converted both ends to RGB and blended there, so the middle came out a darker violet (117, 88, 189 instead of 135, 119, 181), and every Episode 6 gradient looked different. A midpoint between CMYK stops also became an RGB colour, so the PDF shading turned RGB with a conversion warning. - color: `Color::lerp` blends CMYK with CMYK (or grey), and grey with grey, in ink space; anything with RGB still blends in RGB. - `Gradient::display_stops`: the expanded stops plus samples through each span that isn't RGB at both ends, for renderers that interpolate in RGB; the screen renderer uses it. - pdf: a midpoint's expanded stop now keeps the stops' space; the colour-space test expects no conversion for CMYK or grey midpoints. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/color/src/gradient.rs | 39 +++++++++++++++++++++++ crates/color/src/lib.rs | 10 ++++++ crates/pdf/tests/gradient_color_spaces.rs | 5 ++- crates/render/src/lib.rs | 4 ++- crates/render/tests/gradient_inks.rs | 36 +++++++++++++++++++++ docs/target-app-parity.md | 2 +- 7 files changed, 92 insertions(+), 5 deletions(-) create mode 100644 crates/render/tests/gradient_inks.rs diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..1fa84857e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **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-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. diff --git a/crates/color/src/gradient.rs b/crates/color/src/gradient.rs index 4da4f2d84..6c19aabb1 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 bd5e915de..73a8cb269 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -170,7 +170,17 @@ impl Color { 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/pdf/tests/gradient_color_spaces.rs b/crates/pdf/tests/gradient_color_spaces.rs index c523a4187..f9892965b 100644 --- a/crates/pdf/tests/gradient_color_spaces.rs +++ b/crates/pdf/tests/gradient_color_spaces.rs @@ -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); } } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 2cc5c548f..c49b3caf0 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; } diff --git a/crates/render/tests/gradient_inks.rs b/crates/render/tests/gradient_inks.rs new file mode 100644 index 000000000..8bb407a66 --- /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/docs/target-app-parity.md b/docs/target-app-parity.md index eb7d7e5c1..0d2f8b0f2 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -436,7 +436,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. From 08386d03b115dc1bd20ebf4f3c99158b5334a186 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 13 Oct 2026 21:12:57 +0200 Subject: [PATCH 2/7] feat(ui): Object > Fitting with InDesign's shortcuts LYH Studio episode 6 (7:00) fills its photo frames with the caption "Fit Content to Frame (Ctrl + Shift + Alt + C)", InDesign's shortcut for Fill Frame Proportionally. DesignCraft's Fitting items were object.fit variants without shortcuts (a menu shortcut belongs to a command id, and the five modes share one). - Five UI commands, one per mode, with InDesign's keys: 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 (Ctrl on Windows and Linux); each runs object.fit. - Object > Fitting lists them (with their shortcuts), enabled as object.fit is. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/menus.rs | 72 +++++++++++++++++++++++++++++++++---- 2 files changed, 67 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..ec341060e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m 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-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. diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 872cb81d0..657ccd3c5 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -163,6 +163,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, "{}"), @@ -552,11 +558,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", "<", @@ -1269,6 +1275,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) @@ -1856,6 +1872,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."); } @@ -2872,6 +2891,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()); @@ -3358,7 +3418,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] From f4f2f0bb08bd3fa5426bc77f2e8af1f0124796a1 Mon Sep 17 00:00:00 2001 From: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Date: Wed, 14 Oct 2026 06:20:41 +1000 Subject: [PATCH 3/7] FIX-TABLE-SELECTION: resolve selected merged-cell owners Co-Authored-By: Codex --- crates/doc/src/table.rs | 48 +++++ crates/engine/src/cmd/table.rs | 129 ++++++------- crates/engine/src/cmd/text.rs | 11 +- crates/engine/src/tests_table.rs | 315 +++++++++++++++++++++++++++++++ crates/ui-egui/src/canvas.rs | 76 +++++++- 5 files changed, 503 insertions(+), 76 deletions(-) diff --git a/crates/doc/src/table.rs b/crates/doc/src/table.rs index 9449be93c..087412bc2 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/table.rs b/crates/engine/src/cmd/table.rs index ad306be8e..c3df24420 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") @@ -1095,7 +1093,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 +1123,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 +1348,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"))?; diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index b57e9e88a..a53e6e8d2 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -823,14 +823,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/tests_table.rs b/crates/engine/src/tests_table.rs index 3934aabec..c3a575d0d 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/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 077ae2944..cc7cee3d7 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -946,11 +946,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)), @@ -1805,6 +1806,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()); From 0fd627aa44b268d935f04e1b094d46ba84815865 Mon Sep 17 00:00:00 2001 From: LabDennis <341377186+LabDennis@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:12:09 +0200 Subject: [PATCH 4/7] perf(assets): index duplicate candidates by content (#727) --- crates/doc/src/assets.rs | 64 ++++++++++++++++++++++++++++++++++++-- crates/format/src/lib.rs | 33 ++++++++++++++++++-- docs/file-format-parity.md | 5 +-- 3 files changed, 96 insertions(+), 6 deletions(-) diff --git a/crates/doc/src/assets.rs b/crates/doc/src/assets.rs index 24ba5c243..cff196fc0 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/format/src/lib.rs b/crates/format/src/lib.rs index 408bb3ccc..c1b6d805a 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -11,6 +11,7 @@ #![forbid(unsafe_code)] use std::collections::{HashMap, HashSet}; +use std::hash::BuildHasher; use std::io::{Cursor, Read, Write}; use std::sync::Arc; @@ -107,12 +108,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 => { @@ -338,6 +340,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/docs/file-format-parity.md b/docs/file-format-parity.md index fc8fbc3a5..06f59de94 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 | From f28456d9d33930fcdd48b1c74460d72fe47a6d77 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 13 Oct 2026 22:33:46 +0000 Subject: [PATCH 5/7] Integration: #836 a grey stop beside a CMYK stop exports as the K ink, keeping the PDF shading in DeviceCMYK --- crates/color/src/lib.rs | 1 - crates/pdf/src/export.rs | 10 +++++++++- crates/pdf/tests/gradient_color_spaces.rs | 4 +++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index cb40cc1c3..d085c5c48 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -173,7 +173,6 @@ 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 { diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index d26b236dc..d29676ffa 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -1053,7 +1053,15 @@ impl Exporter<'_> { let (swatch, tint) = (fill.swatch.as_str(), fill.tint); 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 f9892965b..05806a120 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); @@ -107,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( From e44a9dc2f140ce391250dd08f7e6507bb2b958d8 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 13 Oct 2026 22:40:35 +0000 Subject: [PATCH 6/7] Integration: #846 stroke edges of a partly selected merged cell are classified against the selection grown to whole cells, in table.setCell and table.getCellStroke --- crates/engine/src/cmd/table.rs | 57 +++++++++++++++++--------------- crates/engine/src/tests_table.rs | 30 +++++++++-------- 2 files changed, 48 insertions(+), 39 deletions(-) diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 4b215faa0..6c9092050 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -940,19 +940,23 @@ 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; + // Edges are inner or outer within the selection grown to whole merged cells, so a + // merged cell selected through one of its grid positions keeps its own outline. + let outline = t.expand_range(rg); // Each chosen edge starts from the stroke drawn there, which both copies of a shared // edge then take, so the edit changes only the attributes it names. let mut bases = std::collections::HashMap::new(); if let Some((_, edges)) = &stroke { for (r, c) in t.selected_owners(rg) { let Some(cell) = t.cell(r, c) else { continue }; - for (i, on) in edges.sides(rg, (r, c), (cell.row_span, cell.col_span), rtl).into_iter().enumerate() { + for (i, on) in edges.sides(outline, (r, c), (cell.row_span, cell.col_span), rtl).into_iter().enumerate() { if let Some(base) = drawn_side(t, &owners, (r, c), i).filter(|_| on) { bases.insert((r, c, i), base); } } } } + let mut edited = std::collections::HashSet::new(); for (r, c) in t.selected_owners(rg) { let Some(cell) = t.cell_mut(r, c) else { continue }; if let Some(f) = &fill { @@ -974,6 +978,7 @@ fn set_cell(s: &mut Session, p: &Value) -> Result { if let Some((edit, _)) = &stroke { for i in 0..4 { if let Some(base) = bases.remove(&(r, c, i)) { + edited.insert((r, c, i)); cell.strokes[i] = edit.apply(base); cell.stroke_defined[i] = true; cell.stroke_priorities[i] = priority; @@ -982,31 +987,36 @@ 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. + // Keep both copies of unmerged shared boundaries consistent: each edited edge is + // copied to its neighbour. Left/right name physical sides, also in RTL tables. + let mirror = |t: &mut Table, from: (usize, usize), edge: usize, to: (usize, usize), opposite: usize| { + if edited.contains(&(from.0, from.1, edge)) { + mirror_cell_edge(t, &owners, from, edge, to, opposite); + } + }; if let Some((_, edges)) = &stroke { if edges.top && let Some(row) = rg.r0.checked_sub(1) { for c in rg.c0..=rg.c1 { - mirror_cell_edge(t, &owners, (rg.r0, c), 0, (row, c), 2); + mirror(t, (rg.r0, c), 0, (row, c), 2); } } if edges.bottom && rg.r1.saturating_add(1) < nr { for c in rg.c0..=rg.c1 { - mirror_cell_edge(t, &owners, (rg.r1, c), 2, (rg.r1 + 1, c), 0); + mirror(t, (rg.r1, c), 2, (rg.r1 + 1, c), 0); } } if (if rtl { edges.right } else { edges.left }) && let Some(col) = rg.c0.checked_sub(1) { for r in rg.r0..=rg.r1 { - mirror_cell_edge(t, &owners, (r, rg.c0), if rtl { 3 } else { 1 }, (r, col), if rtl { 1 } else { 3 }); + mirror(t, (r, rg.c0), if rtl { 3 } else { 1 }, (r, col), if rtl { 1 } else { 3 }); } } if (if rtl { edges.left } else { edges.right }) && rg.c1.saturating_add(1) < nc { for r in rg.r0..=rg.r1 { - mirror_cell_edge(t, &owners, (r, rg.c1), if rtl { 1 } else { 3 }, (r, rg.c1 + 1), if rtl { 3 } else { 1 }); + mirror(t, (r, rg.c1), if rtl { 1 } else { 3 }, (r, rg.c1 + 1), if rtl { 3 } else { 1 }); } } } @@ -1023,7 +1033,7 @@ fn get_cell_stroke(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; let t = st.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).ok_or_else(|| bad(ID, "no table"))?; let owners = t.owners(); - let nc = t.ncols(); + let outline = t.expand_range(g.range); let rtl = t.options.direction == designcraft_doc::TextDirection::RightToLeft; // One value per key, or null once two edges (cells) disagree. fn merge(slot: &mut Option, v: Value) { @@ -1038,28 +1048,23 @@ fn get_cell_stroke(s: &mut Session, p: &Value) -> Result { let (mut fill, mut fill_tint) = (None, None); // Each drawn edge once: a shared edge is a side of both its cells. let mut seen = std::collections::HashSet::new(); - for r in g.range.r0..=g.range.r1 { - for c in g.range.c0..=g.range.c1 { - if owners.get(r * nc + c) != Some(&(r, c)) { + for (r, c) in t.selected_owners(g.range) { + let Some(cell) = t.cell(r, c) else { continue }; + merge(&mut fill, json!(cell.fill)); + merge(&mut fill_tint, json!(cell.fill_tint)); + for (i, on) in edges.sides(outline, (r, c), (cell.row_span, cell.col_span), rtl).into_iter().enumerate() { + if !on { continue; } - let Some(cell) = t.cell(r, c) else { continue }; - merge(&mut fill, json!(cell.fill)); - merge(&mut fill_tint, json!(cell.fill_tint)); - for (i, on) in edges.sides(g.range, (r, c), (cell.row_span, cell.col_span), rtl).into_iter().enumerate() { - if !on { + for side in compose::table::cell_side_strokes(t, &owners, (r, c), i) { + let (x, y) = (Some((r, c)), side.neighbor); + if !seen.insert((i % 2, side.boundary, x.min(y), x.max(y))) { continue; } - for side in compose::table::cell_side_strokes(t, &owners, (r, c), i) { - let (x, y) = (Some((r, c)), side.neighbor); - if !seen.insert((i % 2, side.boundary, x.min(y), x.max(y))) { - continue; - } - let e = side.stroke; - let values = [json!(e.weight), json!(e.color), json!(e.tint), json!(e.kind), json!(e.gap_color), json!(e.gap_tint)]; - for (slot, v) in stroke.iter_mut().zip(values) { - merge(slot, v); - } + let e = side.stroke; + let values = [json!(e.weight), json!(e.color), json!(e.tint), json!(e.kind), json!(e.gap_color), json!(e.gap_tint)]; + for (slot, v) in stroke.iter_mut().zip(values) { + merge(slot, v); } } } diff --git a/crates/engine/src/tests_table.rs b/crates/engine/src/tests_table.rs index c89ce9818..c63c67ecd 100644 --- a/crates/engine/src/tests_table.rs +++ b/crates/engine/src/tests_table.rs @@ -209,7 +209,9 @@ fn selected_merged_owners_receive_cell_options_and_unique_style_counts() { } #[test] -fn covered_owner_strokes_keep_literal_edge_masks_and_neighbors() { +fn selecting_part_of_a_merged_cell_strokes_its_whole_outline() { + // Row 0 is one merged cell; selecting its second grid position selects the whole cell, so + // all four of its edges are outer edges. for rtl in [false, true] { for outer in [false, true] { let (mut s, sid, tid, _) = selected_merged_table(true); @@ -218,24 +220,26 @@ fn covered_owner_strokes_keep_literal_edge_masks_and_neighbors() { } 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 edges = if outer { "outer" } else { "inner" }; + s.execute("table.setCell", &json!({"stroke": {"weight": 3, "edges": edges}})).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]); - } + if outer { + assert_eq!(owner.border_overrides, [true; 4]); + assert_eq!(owner.stroke_defined, [true; 4]); + assert!(owner.strokes.iter().all(|e| e.weight == 3.0)); + assert_eq!(owner.stroke_priorities, [1; 4]); + } else { + assert_eq!(Some(owner), before.cell(0, 0), "a single merged cell has no inner edges"); } 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}"); } + let shown = s.execute("table.getCellStroke", &json!({"edges": "outer"})).unwrap(); + assert!(shown["edges"].as_u64().unwrap() >= 4, "the merged cell's outline is read: {shown}"); + if outer { + assert_eq!(shown["weight"], 3.0); + } assert_eq!(s.doc().unwrap().selection.cells.unwrap().range, doc::CellRange::cell(0, 1)); check(&s); } From f0f9a4619c33a7f75ae183d548e85d5a862088ac Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 13 Oct 2026 22:43:44 +0000 Subject: [PATCH 7/7] Find/Change: first:true changes only the first match, not one per story --- crates/engine/src/cmd/find.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/crates/engine/src/cmd/find.rs b/crates/engine/src/cmd/find.rs index e97df64fc..465dd6d0c 100644 --- a/crates/engine/src/cmd/find.rs +++ b/crates/engine/src/cmd/find.rs @@ -188,6 +188,10 @@ fn change(s: &mut Session, p: &Value) -> Result { t.anchor = t.anchor.min(len); t.focus = t.focus.min(len); } + // `first` is the first match of the whole search, not of each story. + if first_only && count > 0 { + break; + } } Ok(json!({"count": count})) }) @@ -251,6 +255,21 @@ mod tests { assert!(s.doc().unwrap().selection.text.is_some()); } + #[test] + fn change_first_changes_one_match_in_the_whole_document() { + for grep in [false, true] { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + for y in [36, 336] { + s.execute("frame.create", &json!({"rect": [36, y, 300, y + 200], "content": "text", "text": "red and red"})).unwrap(); + } + let r = s.execute("find.change", &json!({"find": "red", "change": "blue", "first": true, "grep": grep})).unwrap(); + assert_eq!(r["count"], 1); + assert_eq!(s.execute("find.find", &json!({"find": "blue"})).unwrap().as_array().unwrap().len(), 1); + assert_eq!(s.execute("find.find", &json!({"find": "red"})).unwrap().as_array().unwrap().len(), 3); + } + } + #[test] fn grep_in_the_documents_dialect() { let mut s = Session::new();