From a97bd20a688f12c7107f239608f6d5ef76b7a4c1 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 16 Oct 2026 09:05:01 +0200 Subject: [PATCH 01/38] Cargo.lock: main's, with #500's designcraft-images dependency of idml --- Cargo.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.lock b/Cargo.lock index afd57fddd..90c55cc62 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -997,6 +997,7 @@ dependencies = [ "log", "quick-xml", "regex", + "same-file", "serde", "serde_json", "tempfile", From c708b080333751bb5711638caa8f2a319394f81f Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 16 Oct 2026 09:47:56 +0200 Subject: [PATCH 02/38] Compose: a line past the breaker's estimated wrap segments is re-broken for its segment On narrow segments beside an object, the breaker's estimate of the segments ran out; the lines after it were set for the whole column and placed in one segment, running across the object. Co-Authored-By: Claude Opus 5.5 --- crates/compose/src/lib.rs | 4 +++- crates/engine/src/tests.rs | 25 +++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index afabb6fad..bdec0b163 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -1268,7 +1268,9 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C // paragraph from here, starting with this line's segments as they are. A pass // re-breaks at its first line only for a new line, whose segments it carries, so // every re-break makes progress; their number is capped too. - if (k > 0 || !continues) && rebreaks < 256 && slots.get(k).is_some_and(|&(a, b)| ((b - a) - (x1 - x0)).abs() > 0.5) { + // Past the estimated slots the breaker set the line for the whole column. + let (sa, sb) = slots.get(k).copied().unwrap_or((col.x0, col.x1)); + if (k > 0 || !continues) && rebreaks < 256 && ((sb - sa) - (x1 - x0)).abs() > 0.5 { rebreaks += 1; carry.insert(0, (x0, x1)); if !continues { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index dd038caa5..8e1999ddd 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1155,3 +1155,28 @@ fn a_line_fits_when_its_baseline_does() { assert!(!overset(&mut s, 78.5), "the line is set; its descenders hang below the frame"); assert!(overset(&mut s, 50.0), "a baseline below the frame still oversets the line"); } + + +/// Beside an object with text on both sides, every line stays within its segment, however many +/// lines the paragraph needs (the breaker's segment estimate can run out on narrow segments; a +/// line it set for the whole column ran across the object). +#[test] +fn every_line_beside_a_wrap_stays_within_its_segment() { + let mut s = Session::new(); + s.execute("file.new", &json!({"facingPages": false})).unwrap(); + // Placeholder text: paragraphs of a few sentences, some of them starting beside the object. + let t = s.execute("frame.create", &json!({"rect": [36, 36, 576, 756], "content": "text", "text": ""})).unwrap(); + s.execute("type.fillWithPlaceholder", &json!({})).unwrap(); + s.execute("selection.set", &json!({"ids": []})).unwrap(); + s.execute("frame.create", &json!({"rect": [196, 160, 416, 490]})).unwrap(); + s.execute("object.textWrap", &json!({"mode": "boundingBox", "offset": 6})).unwrap(); + let d = &s.doc().unwrap().doc; + let c = s.cache.get(d, designcraft_doc::StoryId(t["story"].as_u64().unwrap()), None); + let mut beside = 0; + for l in c.frames.iter().flat_map(|f| &f.lines) { + let end = l.glyphs.iter().filter(|g| g.visible).map(|g| g.x + g.adv).fold(l.x0, f64::max); + assert!(end <= l.x1 + 0.5, "line at {} runs to {end}, past its segment {}..{}", l.baseline, l.x0, l.x1); + beside += usize::from(l.x1 < 400.0 || l.x0 > 400.0); + } + assert!(beside > 20, "lines on both sides of the object: {beside}"); +} From afa015f113239a61e83ad09266ca8d2f38051ec2 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 16 Oct 2026 09:53:40 +0200 Subject: [PATCH 03/38] Text wrap: Alpha Channel contour traced from the image, holes with Include Inside Edges, open gaps kept Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/compose/src/lib.rs | 184 ++++++++++++--- crates/compose/src/tests.rs | 27 +++ crates/doc/src/build.rs | 1 + crates/doc/src/item.rs | 26 ++- crates/doc/src/lib.rs | 23 ++ crates/engine/src/cmd/alpha_wrap.rs | 285 ++++++++++++++++++++++++ crates/engine/src/cmd/mod.rs | 1 + crates/engine/src/cmd/object.rs | 24 +- crates/engine/src/lib.rs | 5 +- crates/engine/src/tests.rs | 1 - crates/idml/src/export.rs | 6 +- crates/idml/src/import.rs | 2 + crates/idml/src/tests.rs | 7 + crates/render/src/blendspace.rs | 3 + crates/ui-egui/src/i18n.rs | 5 + crates/ui-egui/src/i18n/ar.rs | 1 + crates/ui-egui/src/i18n/de.rs | 1 + crates/ui-egui/src/i18n/it.rs | 2 + crates/ui-egui/src/i18n/ja.rs | 1 + crates/ui-egui/src/i18n/ka.rs | 1 + crates/ui-egui/src/i18n/pt_br.rs | 2 + crates/ui-egui/src/i18n/uk.rs | 2 + crates/ui-egui/src/panels/mod.rs | 3 + crates/ui-egui/src/panels/properties.rs | 19 +- 25 files changed, 584 insertions(+), 49 deletions(-) create mode 100644 crates/engine/src/cmd/alpha_wrap.rs diff --git a/ROADMAP.md b/ROADMAP.md index 2aacdaf9b..739e9bb17 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -347,6 +347,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- 2026-10-16: Text Wrap › Contour › Alpha Channel follows a placed image's transparency, as InDesign's: the outline is traced from the image and follows it when it moves or is refitted; text keeps the open gaps between its parts, and enters enclosed holes with Include Inside Edges. - **2026-10-14** · The Type tool draws a new text frame over a locked empty frame, as InDesign does, instead of turning the locked frame into a text frame. No readiness percentage change. - **2026-10-14** · 100% black shows as pure black on screen by default, as InDesign's Appearance of Black does out of the box (Display All Blacks as Rich Black); Preferences › Appearance of Black › Display All Blacks Accurately still shows it as the dark grey it prints, and a saved choice is kept. No readiness percentage change. - **2026-10-14** · Pathfinder keeps the frontmost object, as InDesign does: a photo placed over shapes and intersected with them shows through the shapes (it was dropped, and the shapes kept their black fill); Add, Exclude Overlap and Minus Back take the front object's fill too, Subtract still keeps the back one. No readiness percentage change. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index bdec0b163..eda488d83 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -369,6 +369,96 @@ impl Contour { } (lo <= hi).then_some((lo - self.offset, hi + self.offset)) } + + /// Occupied intervals across the whole line band, preserving gaps between parts of a + /// concave or compound outline. Project boundary edges and the filled middle slice: any + /// area that starts or ends inside the band is bounded by those projected edges. + fn band_spans(&self, y0: f64, y1: f64) -> Vec<(f64, f64)> { + let (low, high) = (y0 - self.offset, y1 + self.offset); + if !low.is_finite() || !high.is_finite() || high < low { + return self.band_extent(y0, y1).into_iter().collect(); + } + let mut spans = rings_band_spans(&self.rings, low, high); + // Extremely fragmented imported paths use the conservative outer extent, bounding the + // work of subtracting intervals from every line. A traced 128-column mask stays below it. + if spans.len() > 128 { + return self.band_extent(y0, y1).into_iter().collect(); + } + if !self.clip.is_empty() { + let clip = rings_band_spans(&self.clip, low, high); + let mut cut = Vec::new(); + let (mut i, mut j) = (0, 0); + while let (Some(&(a, b)), Some(&(c, d))) = (spans.get(i), clip.get(j)) { + if a.max(c) < b.min(d) { + cut.push((a.max(c), b.min(d))); + } + if b < d { + i += 1; + } else { + j += 1; + } + } + spans = cut; + } + merge_spans(spans.into_iter().map(|(a, b)| (a - self.offset, b + self.offset)).collect()) + } +} + +fn merge_spans(mut spans: Vec<(f64, f64)>) -> Vec<(f64, f64)> { + spans.retain(|(a, b)| a.is_finite() && b.is_finite() && b - a > 1e-9); + spans.sort_by(|a, b| a.0.total_cmp(&b.0)); + let mut out: Vec<(f64, f64)> = Vec::new(); + for (a, b) in spans { + if let Some(last) = out.last_mut().filter(|last| a <= last.1 + 1e-9) { + last.1 = last.1.max(b); + } else { + out.push((a, b)); + } + } + out +} + +fn rings_band_spans(rings: &[Vec], y0: f64, y1: f64) -> Vec<(f64, f64)> { + let mut spans = Vec::new(); + let mut crossings = Vec::new(); + let middle = y0 * 0.5 + y1 * 0.5; + for ring in rings { + let Some(&last) = ring.last() else { continue }; + let mut a = last; + for &b in ring { + let dy = b.y - a.y; + if a.y.max(b.y) >= y0 && a.y.min(b.y) <= y1 { + let (t0, t1) = if dy.abs() < 1e-12 { + (0.0, 1.0) + } else { + let (u, v) = ((y0 - a.y) / dy, (y1 - a.y) / dy); + (u.min(v).clamp(0.0, 1.0), u.max(v).clamp(0.0, 1.0)) + }; + let (x0, x1) = (a.x + t0 * (b.x - a.x), a.x + t1 * (b.x - a.x)); + spans.push((x0.min(x1), x0.max(x1))); + } + if (a.y <= middle && middle < b.y) || (b.y <= middle && middle < a.y) { + let x = a.x + (middle - a.y) / dy * (b.x - a.x); + if x.is_finite() { + crossings.push((x, if dy > 0.0 { 1i32 } else { -1 })); + } + } + a = b; + } + } + crossings.sort_by(|a, b| a.0.total_cmp(&b.0)); + let mut winding = 0i32; + let mut previous = None; + for (x, delta) in crossings { + if winding != 0 + && let Some(start) = previous + { + spans.push((start, x)); + } + winding += delta; + previous = Some(x); + } + merge_spans(spans) } /// The closed polylines of `path` (after `to_inner`), flattened; None if it is empty or too detailed. @@ -431,7 +521,13 @@ fn rings_band_extent(rings: &[Vec], y0: f64, y1: f64) -> Option<(f64, f64 /// The wrap exclusion of `other` (an item whose inner space maps to the spread by `xf`) for a /// frame whose inner space is reached from the spread by `inv`. `left_page` places the object on /// a left page (for the spine sides). -pub fn exclusion(other: &designcraft_doc::Item, xf: designcraft_geom::Affine, inv: designcraft_geom::Affine, left_page: bool) -> Exclusion { +pub fn exclusion( + doc: &Document, + other: &designcraft_doc::Item, + xf: designcraft_geom::Affine, + inv: designcraft_geom::Affine, + left_page: bool, +) -> Exclusion { let o = other.wrap.offsets; let b = if other.shape == designcraft_doc::Shape::Group { (xf * other.xf.inverse()).transform_rect_bbox(other.bounds()) @@ -452,7 +548,7 @@ pub fn exclusion(other: &designcraft_doc::Item, xf: designcraft_geom::Affine, in let contour = matches!(other.wrap.mode, WrapMode::BoundingBox | WrapMode::Contour).then_some(custom); return Exclusion { rect, mode: other.wrap.mode, side, contour }; } - let contour = (other.wrap.mode == WrapMode::Contour && other.shape != designcraft_doc::Shape::Group).then(|| contour_path(other)).and_then( + let contour = (other.wrap.mode == WrapMode::Contour && other.shape != designcraft_doc::Shape::Group).then(|| contour_path(doc, other)).and_then( |(path, frame)| { let c = Contour::of(&path, inv * xf, other.wrap.offsets[0])?; Some(match frame { @@ -466,11 +562,26 @@ pub fn exclusion(other: &designcraft_doc::Item, xf: designcraft_geom::Affine, in } /// The outline a contour wrap follows, in the item's inner space, and for an outline taken from -/// the placed graphic, the frame path that clips it. Graphic frames follow their contour type; -/// detected edges, alpha channels and image paths need the image, so they follow the frame's path -/// (as other frames do). -fn contour_path(it: &designcraft_doc::Item) -> (designcraft_geom::BezPath, Option) { +/// the placed graphic, the frame path that clips it. Graphic frames follow their contour type. An +/// alpha channel follows the image's traced outline, placed where the graphic is now (so it +/// follows the image when it moves or is refitted); its transparent holes count only with Include +/// Inside Edges. Detected edges and image paths, and an alpha channel not traced (yet), follow the +/// frame's path (as other frames do). +fn contour_path(doc: &Document, it: &designcraft_doc::Item) -> (designcraft_geom::BezPath, Option) { let frame = designcraft_geom::corners::apply(&it.path, &it.corners); + if it.wrap.contour == designcraft_doc::ContourType::AlphaChannel + && let Some(g) = it.graphic() + && let Some(o) = doc.alpha_outline(g.asset, it.wrap.alpha_threshold) + && o.width > 0 + && o.height > 0 + { + let to_graphic = designcraft_geom::Affine::scale_non_uniform(g.size.0 / f64::from(o.width), g.size.1 / f64::from(o.height)); + let mut path = o.outer.to_bezpath(); + if it.wrap.inside_edges { + path.extend(o.holes.to_bezpath().iter()); + } + return (g.xf * to_graphic * path, Some(frame)); + } if it.wrap.contour == designcraft_doc::ContourType::BoundingBox && let Some(g) = it.graphic() && let Some(fb) = it.path.bounds() @@ -573,7 +684,7 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec { .page_at_x(other.bounds().center().x) .and_then(|pi| sp.pages.get(pi)) .is_some_and(|p| p.side == designcraft_doc::PageSide::Left); - let ex = exclusion(other, other.xf, inv, left); + let ex = exclusion(doc, other, other.xf, inv, left); let reach = ex.rect.intersect(item.inner_bounds()); if reach.width() > 0.0 && reach.height() > 0.0 { exclusions.push(ex); @@ -2592,39 +2703,40 @@ fn free_slots(f: &FrameSpec, col: Rect, y0: f64, y1: f64, size: f64, rtl: bool) let blocked = match ex.mode { WrapMode::JumpToNextColumn if y1 > r.y0 && r.x0 < col.x1 && r.x1 > col.x0 => return vec![], WrapMode::BoundingBox | WrapMode::Contour if overlaps_band => match &ex.contour { - Some(c) => c.band_extent(y0, y1), - None => Some((r.x0, r.x1)), + Some(c) => c.band_spans(y0, y1), + None => vec![(r.x0, r.x1)], }, - _ => None, + _ => Vec::new(), }; - let Some((a, b)) = blocked.filter(|&(a, b)| a < col.x1 && b > col.x0) else { continue }; - cut = true; - // One-sided wraps also block the column on the other side of the object. - let text_left = match ex.side { - WrapSide::LeftSide => Some(true), - WrapSide::RightSide => Some(false), - WrapSide::LargestArea => Some(r.x0 - col.x0 >= col.x1 - r.x1), - WrapSide::BothSides | WrapSide::TowardsSpine | WrapSide::AwayFromSpine => None, - }; - let (a, b) = match text_left { - Some(true) => (a, b.max(col.x1)), - Some(false) => (a.min(col.x0), b), - None => (a, b), - }; - let mut next = Vec::with_capacity(free.len() + 1); - for (fa, fb) in free { - if b <= fa || a >= fb { - next.push((fa, fb)); - continue; - } - if a > fa { - next.push((fa, a)); - } - if b < fb { - next.push((b, fb)); + for (a, b) in blocked.into_iter().filter(|&(a, b)| a < col.x1 && b > col.x0) { + cut = true; + // One-sided wraps also block the column on the other side of the object. + let text_left = match ex.side { + WrapSide::LeftSide => Some(true), + WrapSide::RightSide => Some(false), + WrapSide::LargestArea => Some(r.x0 - col.x0 >= col.x1 - r.x1), + WrapSide::BothSides | WrapSide::TowardsSpine | WrapSide::AwayFromSpine => None, + }; + let (a, b) = match text_left { + Some(true) => (a, b.max(col.x1)), + Some(false) => (a.min(col.x0), b), + None => (a, b), + }; + let mut next = Vec::with_capacity(free.len() + 1); + for (fa, fb) in free { + if b <= fa || a >= fb { + next.push((fa, fb)); + continue; + } + if a > fa { + next.push((fa, a)); + } + if b < fb { + next.push((b, fb)); + } } + free = next; } - free = next; } if !cut { return vec![(col.x0, col.x1)]; diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index d08efe034..9491afb67 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -4,6 +4,33 @@ use designcraft_geom::Rect; use super::*; +#[test] +fn contour_intervals_preserve_the_gap_between_legs() { + let mut path = designcraft_geom::BezPath::new(); + path.move_to((0.0, 0.0)); + for p in [(40.0, 0.0), (40.0, 100.0), (30.0, 100.0), (30.0, 20.0), (10.0, 20.0), (10.0, 100.0), (0.0, 100.0)] { + path.line_to(p); + } + path.close_path(); + let c = Contour::of(&path, designcraft_geom::Affine::IDENTITY, 3.0).unwrap(); + assert_eq!(c.band_spans(50.0, 60.0), vec![(-3.0, 13.0), (27.0, 43.0)]); + assert_eq!(c.band_spans(5.0, 15.0), vec![(-3.0, 43.0)]); + let frame = designcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 35.0, 100.0)).to_bezpath(); + assert_eq!(c.within(&frame, designcraft_geom::Affine::IDENTITY).band_spans(50.0, 60.0), vec![(-3.0, 13.0), (27.0, 38.0)]); +} + +#[test] +fn contour_intervals_cover_the_whole_slanted_line_band() { + let mut path = designcraft_geom::BezPath::new(); + path.move_to((0.0, 0.0)); + for p in [(2.0, 0.0), (12.0, 10.0), (10.0, 10.0)] { + path.line_to(p); + } + path.close_path(); + let c = Contour::of(&path, designcraft_geom::Affine::IDENTITY, 0.0).unwrap(); + assert_eq!(c.band_spans(0.0, 10.0), vec![(0.0, 12.0)]); +} + const LOREM: &str = "Typography is the art and technique of arranging type to make written language legible, readable and appealing when displayed. \ The arrangement of type involves selecting typefaces, point sizes, line lengths, line spacing, and letter spacing, and adjusting the space between pairs of letters."; diff --git a/crates/doc/src/build.rs b/crates/doc/src/build.rs index 9fb075553..cc37907bd 100644 --- a/crates/doc/src/build.rs +++ b/crates/doc/src/build.rs @@ -160,6 +160,7 @@ impl Document { modified: 0, next_id: 0, font_scope: 0, + alpha_outlines: Vec::new(), }; let lid = LayerId(d.alloc()); d.layers.push(Layer { diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index 2d2cea760..bfaebe2c2 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -501,7 +501,7 @@ pub enum ContourType { } /// Text Wrap panel settings. -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] pub struct TextWrap { pub mode: WrapMode, @@ -515,6 +515,30 @@ pub struct TextWrap { /// item transforms it with the path; other path edits keep it as it is. #[serde(skip_serializing_if = "Option::is_none")] pub path: Option, + /// Contour Options › Include Inside Edges: an Alpha Channel contour also lets text into the + /// transparent holes it encloses (off: holes are blocked with the shape around them). + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub inside_edges: bool, + /// Alpha Channel contour: pixels more opaque than this (0..254) count as the image. + pub alpha_threshold: u8, +} + +/// InDesign's default alpha threshold for contour wraps. +pub const DEFAULT_ALPHA_THRESHOLD: u8 = 25; + +impl Default for TextWrap { + fn default() -> Self { + TextWrap { + mode: WrapMode::default(), + offsets: [0.0; 4], + invert: false, + side: WrapSide::default(), + contour: ContourType::default(), + path: None, + inside_edges: false, + alpha_threshold: DEFAULT_ALPHA_THRESHOLD, + } + } } /// Frame fitting options (Object → Fitting → Frame Fitting Options). diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index c0fdc8fe2..47f752322 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -448,6 +448,21 @@ pub struct Asset { pub page: u32, } +/// The outline of a raster image's visible pixels (Text Wrap › Contour › Alpha Channel), in the +/// image's pixel space (0..width, 0..height). Derived from the image, so not saved: the engine +/// traces it again when a document opens. +#[derive(Clone, Debug, PartialEq)] +pub struct AlphaOutline { + pub asset: AssetId, + pub threshold: u8, + pub width: u32, + pub height: u32, + /// The outer boundaries of the visible areas. + pub outer: designcraft_geom::PathData, + /// The boundaries of transparent holes those areas enclose (wound the other way). + pub holes: designcraft_geom::PathData, +} + impl Asset { /// A raster image (PNG, JPEG, GIF, WebP, TIFF, BMP, PSD), as opposed to a PDF, EPS or SVG. pub fn is_raster(&self) -> bool { @@ -501,6 +516,9 @@ pub struct Document { pub sections: Vec
, #[serde(default)] pub assets: BTreeMap>, + /// Alpha Channel contour outlines of placed images (see [`AlphaOutline`]). Not saved. + #[serde(skip)] + pub alpha_outlines: Vec>, /// Hyperlinks (Window → Interactive → Hyperlinks). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub hyperlinks: Vec, @@ -695,6 +713,11 @@ impl Document { if sec.include_prefix { format!("{}{}", sec.prefix, num) } else { num } } + /// The Alpha Channel outline of `asset` at `threshold`, once the engine has traced it. + pub fn alpha_outline(&self, asset: AssetId, threshold: u8) -> Option<&AlphaOutline> { + self.alpha_outlines.iter().map(|o| &**o).find(|o| o.asset == asset && o.threshold == threshold) + } + pub fn layer(&self, id: LayerId) -> Option<&Layer> { self.layers.iter().find(|l| l.id == id) } diff --git a/crates/engine/src/cmd/alpha_wrap.rs b/crates/engine/src/cmd/alpha_wrap.rs new file mode 100644 index 000000000..5f4abae58 --- /dev/null +++ b/crates/engine/src/cmd/alpha_wrap.rs @@ -0,0 +1,285 @@ +//! Text Wrap › Contour › Alpha Channel: the outline of a placed raster's visible pixels. +//! +//! The outline is traced once per image and threshold, in the image's pixel space, and kept on +//! the document ([`designcraft_doc::AlphaOutline`]). Composition maps it through the graphic's +//! current transform, so it follows the image when it is moved, scaled or refitted. +use std::sync::Arc; + +use designcraft_doc::{AlphaOutline, AssetId, ContourType, Document, WrapMode}; +use designcraft_geom::{BezPath, PathData, Point}; + +/// Images larger than this many pixels aren't traced (their contour follows the frame). +const MAX_PIXELS: u64 = 40_000_000; +/// The finest grid an outline is traced on; coarser grids when the outline would be too long. +const MAX_GRID: u32 = 256; +/// Boundary edges an outline may have. +const MAX_EDGES: usize = 1 << 16; + +/// Trace the outlines new Alpha Channel contours need and drop the ones nothing uses. Cheap when +/// nothing changed: tracing happens only for an image and threshold not seen before. +pub(crate) fn sync(doc: &mut Document) { + let mut wanted: Vec<(AssetId, u8)> = Vec::new(); + for sp in doc.spreads.iter().chain(doc.parents.iter()) { + for it in &sp.items { + if it.wrap.mode == WrapMode::Contour + && it.wrap.contour == ContourType::AlphaChannel + && let Some(g) = it.graphic() + && !wanted.contains(&(g.asset, it.wrap.alpha_threshold)) + { + wanted.push((g.asset, it.wrap.alpha_threshold)); + } + } + } + if wanted.is_empty() && doc.alpha_outlines.is_empty() { + return; + } + doc.alpha_outlines.retain(|o| wanted.contains(&(o.asset, o.threshold)) && doc.assets.contains_key(&o.asset)); + for (asset, threshold) in wanted { + if doc.alpha_outline(asset, threshold).is_some() { + continue; + } + let Some(a) = doc.assets.get(&asset).filter(|a| a.is_raster()) else { continue }; + if let Some(o) = trace(&a.data, asset, threshold) { + doc.alpha_outlines.push(Arc::new(o)); + } + } +} + +/// The outline of the pixels of `data` more opaque than `threshold`. None for an image that +/// can't be read, is too large, or has no transparency (its contour is its rectangle). +pub(crate) fn trace(data: &[u8], asset: AssetId, threshold: u8) -> Option { + let (w, h) = designcraft_images::pixel_size(data)?; + if w == 0 || h == 0 || u64::from(w) * u64::from(h) > MAX_PIXELS { + return None; + } + let img = designcraft_images::decode_rgba(data)?; + if img.width() != w || img.height() != h { + return None; + } + let mut grid = MAX_GRID; + while grid >= 16 { + let (cols, rows) = (w.min(grid), h.min(grid)); + let (mask, transparent) = occupancy(img.as_raw(), w, h, cols, rows, threshold); + if !transparent { + return None; + } + if let Some((outer, holes)) = rings(&mask, cols as usize, rows as usize) { + let (sx, sy) = (f64::from(w) / f64::from(cols), f64::from(h) / f64::from(rows)); + let path = |rings: &[Vec<(i32, i32)>]| { + let mut p = BezPath::new(); + for ring in rings { + let mut pts = ring.iter().map(|&(x, y)| Point::new(f64::from(x) * sx, f64::from(y) * sy)); + let Some(first) = pts.next() else { continue }; + p.move_to(first); + for q in pts { + p.line_to(q); + } + p.close_path(); + } + PathData::from_bezpath(&p) + }; + return Some(AlphaOutline { asset, threshold, width: w, height: h, outer: path(&outer), holes: path(&holes) }); + } + grid /= 2; + } + None +} + +/// Which cells of a `cols` × `rows` grid hold a pixel of the `w` × `h` RGBA image `rgba` more +/// opaque than `threshold` (a cell is visible when any of its pixels is), and whether the image +/// has any transparency. +fn occupancy(rgba: &[u8], w: u32, h: u32, cols: u32, rows: u32, threshold: u8) -> (Vec, bool) { + let mut mask = vec![false; (cols as usize) * (rows as usize)]; + let mut transparent = false; + for (i, px) in rgba.chunks_exact(4).enumerate() { + let alpha = px[3]; + transparent |= alpha < 255; + if alpha > threshold { + let (x, y) = ((i as u64) % u64::from(w), (i as u64) / u64::from(w)); + let (gx, gy) = (x * u64::from(cols) / u64::from(w), y * u64::from(rows) / u64::from(h)); + if let Some(c) = mask.get_mut((gy * u64::from(cols) + gx) as usize) { + *c = true; + } + } + } + (mask, transparent) +} + +/// The closed boundaries of the visible cells, split into outer boundaries and the boundaries of +/// holes (by winding: each boundary keeps the visible cells on the same side). None when the +/// outline would have more than [`MAX_EDGES`] edges. +#[allow(clippy::type_complexity)] +fn rings(mask: &[bool], cols: usize, rows: usize) -> Option<(Vec>, Vec>)> { + use std::collections::BTreeMap; + let visible = |x: i32, y: i32| { + x >= 0 && y >= 0 && (x as usize) < cols && (y as usize) < rows && mask.get(y as usize * cols + x as usize).copied().unwrap_or(false) + }; + let mut edges: BTreeMap<(i32, i32), Vec<(i32, i32)>> = BTreeMap::new(); + let mut count = 0usize; + for y in 0..rows as i32 { + for x in 0..cols as i32 { + if !visible(x, y) { + continue; + } + for (open, a, b) in [ + (!visible(x, y - 1), (x, y), (x + 1, y)), + (!visible(x + 1, y), (x + 1, y), (x + 1, y + 1)), + (!visible(x, y + 1), (x + 1, y + 1), (x, y + 1)), + (!visible(x - 1, y), (x, y + 1), (x, y)), + ] { + if open { + count += 1; + if count > MAX_EDGES { + return None; + } + edges.entry(a).or_default().push(b); + } + } + } + } + let (mut outer, mut holes) = (Vec::new(), Vec::new()); + while let Some((&start, _)) = edges.first_key_value() { + let mut ring = vec![start]; + let mut at = start; + for _ in 0..=MAX_EDGES { + let next = edges.get_mut(&at).and_then(Vec::pop)?; + if edges.get(&at).is_some_and(Vec::is_empty) { + edges.remove(&at); + } + // Drop the middle of straight runs. + if let [.., a, b] = ring[..] + && (b.0 - a.0) * (next.1 - b.1) == (b.1 - a.1) * (next.0 - b.0) + { + ring.pop(); + } + if next == start { + break; + } + ring.push(next); + at = next; + } + // Shoelace in y-down space: positive for outer boundaries, negative for holes. + let area: i64 = ring + .iter() + .zip(ring.iter().cycle().skip(1)) + .map(|(&(x0, y0), &(x1, y1))| i64::from(x0) * i64::from(y1) - i64::from(x1) * i64::from(y0)) + .sum(); + if ring.len() >= 3 { + if area > 0 { outer.push(ring) } else { holes.push(ring) } + } + } + Some((outer, holes)) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + use crate::Session; + + /// A 64 × 64 PNG: visible where `on(x, y)`. + fn png(on: impl Fn(usize, usize) -> bool) -> Vec { + let mut pixels = vec![0; 64 * 64 * 4]; + for y in 0..64 { + for x in 0..64 { + let i = (y * 64 + x) * 4; + pixels[i] = 120; + pixels[i + 3] = if on(x, y) { 255 } else { 0 }; + } + } + designcraft_render::Rendered { width: 64, height: 64, pixels }.to_png() + } + + #[test] + fn traces_outer_boundaries_and_holes_apart() { + // A ring: a square with a square hole. + let data = png(|x, y| (8..56).contains(&x) && (8..56).contains(&y) && !((24..40).contains(&x) && (24..40).contains(&y))); + let o = trace(&data, AssetId(1), 25).unwrap(); + assert_eq!((o.width, o.height), (64, 64)); + assert_eq!((o.outer.subpaths.len(), o.holes.subpaths.len()), (1, 1)); + let (a, b) = (o.outer.bounds().unwrap(), o.holes.bounds().unwrap()); + assert_eq!((a.x0, a.y0, a.x1, a.y1), (8.0, 8.0, 56.0, 56.0)); + assert_eq!((b.x0, b.y0, b.x1, b.y1), (24.0, 24.0, 40.0, 40.0)); + // An opaque image has no alpha outline. + assert!(trace(&png(|_, _| true), AssetId(1), 25).is_none()); + } + + #[test] + fn an_alpha_channel_contour_is_traced_when_set_and_kept_out_of_the_saved_file() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let data = png(|x, _| (16..48).contains(&x)); + let id = s + .execute("file.place", &json!({"base64": crate::cmd::file::base64_encode(&data), "name": "cut.png", "x": 100, "y": 100, "width": 128})) + .unwrap()["id"] + .clone(); + assert!(s.doc().unwrap().doc.alpha_outlines.is_empty()); + s.execute("object.textWrap", &json!({"ids": [id], "mode": "contour", "contour": "alphaChannel"})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.alpha_outlines.len(), 1); + // Saved and opened again: traced again on open. + let bytes = crate::cmd::file::to_bytes(d); + let back = crate::cmd::file::from_bytes(&bytes).unwrap(); + assert!(back.alpha_outlines.is_empty(), "not saved"); + let st = crate::DocState::new(back, None); + assert_eq!(st.doc.alpha_outlines.len(), 1, "traced on open"); + // Wrap off: the outline goes. + s.execute("object.textWrap", &json!({"ids": [id], "mode": "none"})).unwrap(); + assert!(s.doc().unwrap().doc.alpha_outlines.is_empty()); + } + + /// An image placed 128 pt wide at (100, 100) (2 pt per pixel), its wrap a contour with no + /// offset, and a caption frame at `caption` with 8 pt "Hi". + fn page(data: &[u8], caption: [f64; 4]) -> (Session, Value, u64) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let image = s + .execute("file.place", &json!({"base64": crate::cmd::file::base64_encode(data), "name": "cut.png", "x": 100, "y": 100, "width": 128})) + .unwrap()["id"] + .clone(); + let c = s.execute("frame.create", &json!({"rect": caption, "content": "text", "text": "Hi"})).unwrap(); + s.execute("selection.set", &json!({"ids": [c["id"]]})).unwrap(); + s.execute("type.char", &json!({"size": 8})).unwrap(); + s.execute("object.textWrap", &json!({"ids": [image], "mode": "contour", "offset": 0})).unwrap(); + (s, image, c["story"].as_u64().unwrap()) + } + + fn overset(s: &Session, story: u64) -> bool { + s.cache.get(&s.doc().unwrap().doc, designcraft_doc::StoryId(story), None).is_overset() + } + + use serde_json::Value; + + /// A caption fits between two legs of a cutout, and the contour follows the image when it's + /// moved within its frame (it isn't a boundary stored where it was traced). + #[test] + fn an_alpha_channel_contour_keeps_gaps_open_and_follows_the_image() { + // Legs at pixels 8..20 and 44..56: 116..140 and 188..212 on the page. + let (mut s, image, story) = page(&png(|x, _| (8..20).contains(&x) || (44..56).contains(&x)), [150.0, 120.0, 178.0, 170.0]); + assert!(overset(&s, story), "the frame's contour covers the gap"); + s.execute("object.textWrap", &json!({"ids": [image], "contour": "alphaChannel"})).unwrap(); + assert!(!overset(&s, story), "the gap between the legs stays open"); + // The graphic moved 30 pt right within its frame: its left leg now covers the caption. + s.execute("selection.set", &json!({"ids": [image], "content": true})).unwrap(); + s.execute("transform.move", &json!({"dx": 30, "dy": 0})).unwrap(); + assert!(overset(&s, story), "the contour moved with the image"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!overset(&s, story)); + } + + /// A hole enclosed by the image stays blocked unless Include Inside Edges is on. + #[test] + fn holes_are_blocked_unless_inside_edges_are_included() { + // A ring: outer 116..212, hole 148..180 on the page. + let ring = png(|x, y| (8..56).contains(&x) && (8..56).contains(&y) && !((24..40).contains(&x) && (24..40).contains(&y))); + let (mut s, image, story) = page(&ring, [152.0, 152.0, 176.0, 176.0]); + s.execute("object.textWrap", &json!({"ids": [image], "contour": "alphaChannel"})).unwrap(); + assert!(overset(&s, story), "the hole is blocked with the ring around it"); + s.execute("object.textWrap", &json!({"ids": [image], "insideEdges": true})).unwrap(); + assert!(!overset(&s, story), "Include Inside Edges lets text into the hole"); + for bad in [json!({"ids": [image], "threshold": 255}), json!({"ids": [image], "insideEdges": 1})] { + assert!(s.execute("object.textWrap", &bad).is_err()); + } + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 0bfb9727c..9147eef0d 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -1,5 +1,6 @@ //! The command registry. Ids follow InDesign's menu structure. +pub(crate) mod alpha_wrap; mod anchored; pub mod anchors; mod articles; diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 36bd1dac0..997a209ec 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -584,7 +584,7 @@ pub fn specs() -> Vec { "Text Wrap", ["Window"], Some("Cmd+Alt+W"), - "{mode?: none|boundingBox|contour|jumpObject|jumpToNextColumn (boundingBox when absent, unless side or contour is given: then unchanged), offset?: number|[t,l,b,r], invert?: bool, side?: bothSides|leftSide|rightSide|towardsSpine|awayFromSpine|largestArea, contour?: boundingBox|graphicFrame|sameAsClipping, ids?}; offset or contour drops an imported custom wrap boundary", + "{mode?: none|boundingBox|contour|jumpObject|jumpToNextColumn (boundingBox when absent, unless side, contour, insideEdges or threshold is given: then unchanged), offset?: number|[t,l,b,r], invert?: bool, side?: bothSides|leftSide|rightSide|towardsSpine|awayFromSpine|largestArea, contour?: boundingBox|graphicFrame|sameAsClipping|alphaChannel, insideEdges?: bool (alphaChannel: text may enter transparent holes), threshold?: 0..254 (alphaChannel: pixels more opaque count; default 25), ids?}; offset or contour drops an imported custom wrap boundary", has_selection, text_wrap ), @@ -1523,16 +1523,24 @@ fn text_wrap(s: &mut Session, p: &Value) -> Result { Some(v) => Some(serde_json::from_value(v.clone()).map_err(|_| bad("object.textWrap", "unknown side"))?), None => None, }; - // Detected edges, alpha channels and image paths need the image's pixels or paths, which - // composition doesn't read; those types aren't offered. + // Detected edges and image paths need the image's own analysis or paths, which aren't read; + // those types aren't offered. An alpha channel is traced from the image's pixels. let contour = match p.get("contour").map(|v| serde_json::from_value::(v.clone())) { - Some(Ok(c @ (ContourType::BoundingBox | ContourType::GraphicFrame | ContourType::SameAsClipping))) => Some(c), + Some(Ok(c @ (ContourType::BoundingBox | ContourType::GraphicFrame | ContourType::SameAsClipping | ContourType::AlphaChannel))) => Some(c), Some(_) => return Err(bad("object.textWrap", "unsupported contour type")), None => None, }; + let inside_edges = match p.get("insideEdges") { + None => None, + Some(v) => Some(v.as_bool().ok_or_else(|| bad("object.textWrap", "`insideEdges` is true or false"))?), + }; + let threshold = match p.get("threshold") { + None => None, + Some(v) => Some(v.as_u64().filter(|t| *t <= 254).ok_or_else(|| bad("object.textWrap", "`threshold` is 0..254"))? as u8), + }; let mode: Option = match p.get("mode") { Some(v) => Some(serde_json::from_value(v.clone()).map_err(|_| bad("object.textWrap", "unknown mode"))?), - None if side.is_some() || contour.is_some() => None, + None if side.is_some() || contour.is_some() || inside_edges.is_some() || threshold.is_some() => None, None => Some(WrapMode::BoundingBox), }; let off = match p.get("offset") { @@ -1560,6 +1568,12 @@ fn text_wrap(s: &mut Session, p: &Value) -> Result { if let Some(c) = contour { i.wrap.contour = c; } + if let Some(v) = inside_edges { + i.wrap.inside_edges = v; + } + if let Some(t) = threshold { + i.wrap.alpha_threshold = t; + } // A custom boundary has the offsets built in and replaces the contour, so setting // either one rebuilds the wrap from the frame. if off.is_some() || contour.is_some() { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 745e45647..6fa3daa04 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -131,7 +131,9 @@ impl Drop for FontScope { static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); impl DocState { - pub fn new(doc: Document, path: Option) -> Self { + pub fn new(mut doc: Document, path: Option) -> Self { + // Alpha Channel contour outlines aren't saved: trace them for the document as opened. + crate::cmd::alpha_wrap::sync(&mut doc); let active_layer = doc.default_layer(); let doc = Arc::new(doc); DocState { @@ -589,6 +591,7 @@ impl Session { let r = f(&mut doc, &mut sel)?; crate::cmd::sync_placeholders(&mut doc); crate::cmd::flex::sync(&mut doc); + crate::cmd::alpha_wrap::sync(&mut doc); doc.sync_endnote_story(); st.doc = Arc::new(doc); st.selection = sel; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 8e1999ddd..7a305c59f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1156,7 +1156,6 @@ fn a_line_fits_when_its_baseline_does() { assert!(overset(&mut s, 50.0), "a baseline below the frame still oversets the line"); } - /// Beside an object with text on both sides, every line stays within its segment, however many /// lines the paragraph needs (the breaker's segment estimate can run out on narrow segments; a /// line it set for the whole column ran across the object). diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 4e2c1bef1..ac26f5f85 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1852,7 +1852,11 @@ impl<'a> Ex<'a> { .attr("Right", num(w.offsets[3]))]) .collect(), ) - .child(El::new("ContourOption").attr("ContourType", names::contour_type_out(w.contour)).attr("IncludeInsideEdges", "false")), + .child( + El::new("ContourOption") + .attr("ContourType", names::contour_type_out(w.contour)) + .attr("IncludeInsideEdges", if w.inside_edges { "true" } else { "false" }), + ), ); match &it.content { Content::Group { items } => { diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index ad6b6447b..6fb042e1b 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -2429,6 +2429,7 @@ impl<'r> Importer<'r> { invert: w.get("Inverse") == Some("true"), side: names::wrap_side_in(w.get("TextWrapSide").unwrap_or("BothSides")), contour: names::contour_type_in(w.find("ContourOption").and_then(|c| c.get("ContourType")).unwrap_or("SameAsClipping")), + inside_edges: w.find("ContourOption").and_then(|c| c.get("IncludeInsideEdges")) == Some("true"), ..TextWrap::default() }; if let Some(o) = w.prop_el("TextWrapOffset") { @@ -2739,6 +2740,7 @@ impl<'r> Importer<'r> { modified: 0, next_id: self.next_id, font_scope: 0, + alpha_outlines: Vec::new(), }; Ok(d) } diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index cca7803df..355ec3e7d 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -2627,6 +2627,13 @@ fn imports_and_round_trips_text_wrap_side_and_contour_type() { let back = import_idml(&export_idml(&d)).unwrap(); let w = back.spreads[0].items.iter().find(|i| i.graphic().is_some()).unwrap().wrap.clone(); assert_eq!((w.side, w.contour), (designcraft_doc::WrapSide::AwayFromSpine, designcraft_doc::ContourType::DetectEdges)); + assert!(!w.inside_edges); + // An Alpha Channel contour with Include Inside Edges. + d.item_mut(id).unwrap().wrap.contour = designcraft_doc::ContourType::AlphaChannel; + d.item_mut(id).unwrap().wrap.inside_edges = true; + let back = import_idml(&export_idml(&d)).unwrap(); + let w = back.spreads[0].items.iter().find(|i| i.graphic().is_some()).unwrap().wrap.clone(); + assert_eq!((w.contour, w.inside_edges), (designcraft_doc::ContourType::AlphaChannel, true)); } /// One single-column text frame at (0, 0)-(300, 200) on page 1, beside a 100 pt wide rectangle diff --git a/crates/render/src/blendspace.rs b/crates/render/src/blendspace.rs index 4121831f7..cc861cabc 100644 --- a/crates/render/src/blendspace.rs +++ b/crates/render/src/blendspace.rs @@ -113,6 +113,7 @@ fn same_document(old: &Document, new: &Document) -> bool { modified, next_id, font_scope, + alpha_outlines, } = new; let same_spreads = |a: &[std::sync::Arc], b: &[std::sync::Arc]| { a.len() == b.len() && a.iter().zip(b).all(|(a, b)| std::sync::Arc::ptr_eq(a, b)) @@ -124,6 +125,8 @@ fn same_document(old: &Document, new: &Document) -> bool { && old.stories.iter().zip(stories).all(|((a, x), (b, y))| a == b && std::sync::Arc::ptr_eq(x, y)) && old.assets.len() == assets.len() && old.assets.iter().zip(assets).all(|((a, x), (b, y))| a == b && std::sync::Arc::ptr_eq(x, y)) + && old.alpha_outlines.len() == alpha_outlines.len() + && old.alpha_outlines.iter().zip(alpha_outlines).all(|(a, b)| std::sync::Arc::ptr_eq(a, b)) && old.title == *title && old.settings == *settings && old.layers == *layers diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 245a6e287..f65158e52 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -58,6 +58,11 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Cut", ["Ausschneiden", "Couper", "Cortar", "カット", "剪切"]), ("Copy", ["Kopieren", "Copier", "Copiar", "コピー", "复制"]), ("Paste", ["Einfügen", "Coller", "Pegar", "ペースト", "粘贴"]), + ( + "Include Inside Edges", + ["Innenkanten einschließen", "Inclure les bords intérieurs", "Incluir bordes interiores", "内側エッジを含める", "包含内边缘"], + ), + ("Alpha Channel", ["Alphakanal", "Couche alpha", "Canal alfa", "アルファチャンネル", "Alpha 通道"]), ( "Bulleted & Numbered Lists", [ diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index d1dcb7acf..984677f9b 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("Include Inside Edges", "تضمين الحواف الداخلية"), ("Bulleted & Numbered Lists", "القوائم ذات التعداد النقطي والرقمي"), ("Current Page Number", "رقم الصفحة الحالية"), ("Apply Color (,)", "تطبيق اللون (,)"), diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 0f55578b5..d4b2719f6 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -30,6 +30,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Ausschneiden"), ("Copy", "Kopieren"), ("Paste", "Einfügen"), + ("Include Inside Edges", "Innenkanten einschließen"), ("Clear", "Löschen"), ("Duplicate", "Duplizieren"), ("Select All", "Alles auswählen"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 00328ac9f..12f573390 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("Include Inside Edges", "Includi bordi interni"), + ("Alpha Channel", "Canale alfa"), ("Bulleted & Numbered Lists", "Elenchi puntati e numerati"), ("Current Page Number", "Numero di pagina corrente"), ("Apply Color (,)", "Applica colore (,)"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index fe9e213e7..8f1c5895a 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -30,6 +30,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "カット"), ("Copy", "コピー"), ("Paste", "ペースト"), + ("Include Inside Edges", "内側エッジを含める"), ("Bulleted & Numbered Lists", "箇条書き"), ("Apply Color (,)", "カラーを適用 (,)"), ("Apply Gradient (.)", "グラデーションを適用 (.)"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index b3379c6d7..e540ce17a 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -31,6 +31,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "ამოჭრა"), ("Copy", "კოპირება"), ("Paste", "ჩასმა"), + ("Include Inside Edges", "შიდა კიდეების ჩართვა"), ("Reverse Path", "ტრაექტორიის შებრუნება"), ("Set Anchor", "საყრდენი წერტილის დაყენება"), ("Clear", "გასუფთავება"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 0675a2f66..2cde5eca2 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("Include Inside Edges", "Incluir bordas internas"), + ("Alpha Channel", "Canal alfa"), ("Bulleted & Numbered Lists", "Listas com marcadores e numeradas"), ("Show Hidden Characters", "Mostrar caracteres ocultos"), ("Current Page Number", "Número da página atual"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 8f1d27f84..824086772 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -31,6 +31,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Include Inside Edges", "Включити внутрішні краї"), + ("Alpha Channel", "Альфа-канал"), ("Bulleted & Numbered Lists", "Марковані й нумеровані списки"), ("Current Page Number", "Номер поточної сторінки"), ("Apply Color (,)", "Застосувати колір (,)"), diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index fa40f5bec..54216f83f 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -51,6 +51,8 @@ pub struct SelInfo { pub wrap_offsets: [f64; 4], pub wrap_side: String, pub wrap_contour: String, + /// Contour Options › Include Inside Edges (an Alpha Channel contour). + pub wrap_inside_edges: bool, pub locked: bool, } @@ -161,6 +163,7 @@ pub fn sel_info(app: &DesignApp) -> Option { wrap_offsets: first.wrap.offsets, wrap_side: serde_json::to_value(first.wrap.side).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default(), wrap_contour: serde_json::to_value(first.wrap.contour).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default(), + wrap_inside_edges: first.wrap.inside_edges, locked: first.locked, }) } diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 8cbaa2ea6..0b5f3fd62 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1903,11 +1903,22 @@ pub fn wrap_panel(app: &mut DesignApp, ui: &mut egui::Ui) { ]; wrap_combo(app, ui, "wrap_side", "Wrap To", &sides, &[], &i.wrap_side, "side"); if i.wrap == "contour" && i.is_graphic { - // Detected edges, alpha channels and image paths aren't offered: composition doesn't read - // the image. An imported one shows by name and wraps around the frame. - let types = [("boundingBox", "Bounding Box"), ("graphicFrame", "Graphic Frame"), ("sameAsClipping", "Same as Clipping")]; - let shown_only = [("detectEdges", "Detect Edges"), ("alphaChannel", "Alpha Channel"), ("photoshopPath", "Photoshop Path")]; + // Detected edges and image paths aren't offered: they need the image's own analysis or + // paths. An imported one shows by name and wraps around the frame. + let types = [ + ("boundingBox", "Bounding Box"), + ("alphaChannel", "Alpha Channel"), + ("graphicFrame", "Graphic Frame"), + ("sameAsClipping", "Same as Clipping"), + ]; + let shown_only = [("detectEdges", "Detect Edges"), ("photoshopPath", "Photoshop Path")]; wrap_combo(app, ui, "wrap_contour", "Contour Type", &types, &shown_only, &i.wrap_contour, "contour"); + if i.wrap_contour == "alphaChannel" { + let mut inside = i.wrap_inside_edges; + if widgets::checkbox(ui, &mut inside, crate::i18n::tr(&app.ui.language, "Include Inside Edges")).changed() { + let _ = app.run("object.textWrap", json!({"insideEdges": inside})); + } + } } } From 10134b6f29c3ca3d0c69d4f25ec02bf9f12f12b1 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 20 Oct 2026 16:49:15 +0200 Subject: [PATCH 04/38] PR-1084: trace outlines for graphic and nested wraps, and again after a relink Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/lib.rs | 22 +++++++- crates/engine/src/cmd/alpha_wrap.rs | 84 ++++++++++++++++++++++++----- 2 files changed, 92 insertions(+), 14 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 2c4ecb52c..a5fec91c3 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -604,7 +604,7 @@ impl PdfCrop { /// The outline of a raster image's visible pixels (Text Wrap › Contour › Alpha Channel), in the /// image's pixel space (0..width, 0..height). Derived from the image, so not saved: the engine /// traces it again when a document opens. -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug)] pub struct AlphaOutline { pub asset: AssetId, pub threshold: u8, @@ -614,6 +614,26 @@ pub struct AlphaOutline { pub outer: designcraft_geom::PathData, /// The boundaries of transparent holes those areas enclose (wound the other way). pub holes: designcraft_geom::PathData, + /// The image data it was traced from: a relink or link update replaces the asset's data + /// under the same id, and the outline is traced again. (Weak, so it doesn't keep old image + /// data alive, and its address can't be reused by new data while it's held.) + pub source: std::sync::Weak>, +} + +impl AlphaOutline { + /// Traced from `data`, the asset's current image data. + pub fn traced_from(&self, data: &Arc>) -> bool { + std::ptr::eq(self.source.as_ptr(), Arc::as_ptr(data)) + } +} + +impl PartialEq for AlphaOutline { + fn eq(&self, other: &Self) -> bool { + (self.asset, self.threshold, self.width, self.height) == (other.asset, other.threshold, other.width, other.height) + && self.outer == other.outer + && self.holes == other.holes + && self.source.ptr_eq(&other.source) + } } impl Asset { diff --git a/crates/engine/src/cmd/alpha_wrap.rs b/crates/engine/src/cmd/alpha_wrap.rs index ea94aaf9f..717ae8ac5 100644 --- a/crates/engine/src/cmd/alpha_wrap.rs +++ b/crates/engine/src/cmd/alpha_wrap.rs @@ -20,20 +20,25 @@ const MAX_EDGES: usize = 1 << 16; pub(crate) fn sync(doc: &mut Document) { let mut wanted: Vec<(AssetId, u8)> = Vec::new(); for sp in doc.spreads.iter().chain(doc.parents.iter()) { - for it in &sp.items { - if it.wrap.mode == WrapMode::Contour - && it.wrap.contour == ContourType::AlphaChannel - && let Some(g) = it.graphic() - && !wanted.contains(&(g.asset, it.wrap.alpha_threshold)) - { - wanted.push((g.asset, it.wrap.alpha_threshold)); - } + // Every frame, in groups and Paste Into containers too, with the wrap set on the frame or + // on its placed graphic (Direct Selection sets the graphic's). + for top in &sp.items { + top.walk(&mut |it| { + let Some(g) = it.graphic() else { return }; + for w in [&it.wrap, &g.wrap] { + if w.mode == WrapMode::Contour && w.contour == ContourType::AlphaChannel && !wanted.contains(&(g.asset, w.alpha_threshold)) { + wanted.push((g.asset, w.alpha_threshold)); + } + } + }); } } if wanted.is_empty() && doc.alpha_outlines.is_empty() { return; } - doc.alpha_outlines.retain(|o| wanted.contains(&(o.asset, o.threshold)) && doc.assets.contains_key(&o.asset)); + // An outline traced from data the asset no longer has (relinked or updated) is traced again. + let assets = &doc.assets; + doc.alpha_outlines.retain(|o| wanted.contains(&(o.asset, o.threshold)) && assets.get(&o.asset).is_some_and(|a| o.traced_from(&a.data))); for (asset, threshold) in wanted { if doc.alpha_outline(asset, threshold).is_some() { continue; @@ -47,7 +52,7 @@ pub(crate) fn sync(doc: &mut Document) { /// The outline of the pixels of `data` more opaque than `threshold`. None for an image that /// can't be read, is too large, or has no transparency (its contour is its rectangle). -pub(crate) fn trace(data: &[u8], asset: AssetId, threshold: u8) -> Option { +pub(crate) fn trace(data: &Arc>, asset: AssetId, threshold: u8) -> Option { let (w, h) = designcraft_images::pixel_size(data)?; if w == 0 || h == 0 || u64::from(w) * u64::from(h) > MAX_PIXELS { return None; @@ -78,7 +83,8 @@ pub(crate) fn trace(data: &[u8], asset: AssetId, threshold: u8) -> Option 1, "the frame is nested"); + assert_eq!(d.alpha_outlines.len(), 1, "a nested frame keeps its outline"); + // A frame placed and grouped before its wrap is set: traced too. + let second = place(&mut s, 400); + let other2 = s.execute("frame.create", &json!({"rect": [600, 100, 650, 150]})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.group", &json!({"ids": [second, other2]})).unwrap(); + s.execute("object.textWrap", &json!({"ids": [second], "mode": "contour", "contour": "alphaChannel", "threshold": 200})).unwrap(); + assert_eq!(s.doc().unwrap().doc.alpha_outlines.len(), 2); + // The image data replaced under the same asset, as a relink does: traced again. + let Some(designcraft_doc::Content::Graphic(g)) = s.doc().unwrap().doc.item(designcraft_doc::ItemId(image)).map(|it| it.content.clone()) + else { + panic!("a graphic") + }; + let narrower = Arc::new(png(|x, _| (24..40).contains(&x))); + s.edit(|d, _| { + let a = d.assets.get(&g.asset).unwrap(); + let mut a = (**a).clone(); + a.data = narrower.clone(); + d.assets.insert(g.asset, Arc::new(a)); + Ok(()) + }) + .unwrap(); + let d = &s.doc().unwrap().doc; + let o = d.alpha_outline(g.asset, designcraft_doc::DEFAULT_ALPHA_THRESHOLD).unwrap(); + assert!(o.traced_from(&narrower)); + let b = o.outer.bounds().unwrap(); + assert_eq!((b.x0, b.x1), (24.0, 40.0), "the new image's outline"); + } + /// An image placed 128 pt wide at (100, 100) (2 pt per pixel), its wrap a contour with no /// offset, and a caption frame at `caption` with 8 pt "Hi". fn page(data: &[u8], caption: [f64; 4]) -> (Session, Value, u64) { From 65d19f87ed4345fe7b70c2dbfbd879335dcabdbf Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 22 Oct 2026 11:06:31 +0200 Subject: [PATCH 05/38] Alpha text wrap: read pixels with as_chunks Clippy 1.99 (CI) prefers `as_chunks::<4>()` to `chunks_exact(4)` for a constant chunk size (`chunks_exact_to_as_chunks`). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/alpha_wrap.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/alpha_wrap.rs b/crates/engine/src/cmd/alpha_wrap.rs index 717ae8ac5..54eefe7b7 100644 --- a/crates/engine/src/cmd/alpha_wrap.rs +++ b/crates/engine/src/cmd/alpha_wrap.rs @@ -97,7 +97,7 @@ pub(crate) fn trace(data: &Arc>, asset: AssetId, threshold: u8) -> Optio fn occupancy(rgba: &[u8], w: u32, h: u32, cols: u32, rows: u32, threshold: u8) -> (Vec, bool) { let mut mask = vec![false; (cols as usize) * (rows as usize)]; let mut transparent = false; - for (i, px) in rgba.chunks_exact(4).enumerate() { + for (i, px) in rgba.as_chunks::<4>().0.iter().enumerate() { let alpha = px[3]; transparent |= alpha < 255; if alpha > threshold { From 782c38e31a8bdc5e35177318803352d118d64d98 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 22 Oct 2026 12:07:37 +0200 Subject: [PATCH 06/38] Alpha text wrap: an image with no outline is remembered, not traced on every edit `sync` keeps a zero-size outline for an image `trace` finds none in (opaque, unreadable, too large); composition takes it as no outline (the frame), and like a traced one it is dropped when the asset data changes. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/alpha_wrap.rs | 33 ++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/alpha_wrap.rs b/crates/engine/src/cmd/alpha_wrap.rs index 54eefe7b7..8a94ae818 100644 --- a/crates/engine/src/cmd/alpha_wrap.rs +++ b/crates/engine/src/cmd/alpha_wrap.rs @@ -44,12 +44,19 @@ pub(crate) fn sync(doc: &mut Document) { continue; } let Some(a) = doc.assets.get(&asset).filter(|a| a.is_raster()) else { continue }; - if let Some(o) = trace(&a.data, asset, threshold) { - doc.alpha_outlines.push(Arc::new(o)); - } + // An image with no outline (opaque, unreadable, too large) is remembered as an empty + // one, so later edits don't decode it again; the contour is then the frame's. + let o = trace(&a.data, asset, threshold).unwrap_or_else(|| no_outline(&a.data, asset, threshold)); + doc.alpha_outlines.push(Arc::new(o)); } } +/// The empty outline standing for an image `trace` found none in: zero-size, which composition +/// takes as no outline, and dropped like a traced one when the asset's data changes. +fn no_outline(data: &Arc>, asset: AssetId, threshold: u8) -> AlphaOutline { + AlphaOutline { asset, threshold, width: 0, height: 0, outer: Default::default(), holes: Default::default(), source: Arc::downgrade(data) } +} + /// The outline of the pixels of `data` more opaque than `threshold`. None for an image that /// can't be read, is too large, or has no transparency (its contour is its rectangle). pub(crate) fn trace(data: &Arc>, asset: AssetId, threshold: u8) -> Option { @@ -235,6 +242,26 @@ mod tests { assert!(s.doc().unwrap().doc.alpha_outlines.is_empty()); } + /// An image with no outline (here opaque) is remembered as an empty one: later edits don't + /// trace it again, and the contour is the frame's. + #[test] + fn an_image_with_no_outline_is_not_traced_on_every_edit() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let data = png(|_, _| true); + let id = s + .execute("file.place", &json!({"base64": crate::cmd::file::base64_encode(&data), "name": "solid.png", "x": 100, "y": 100, "width": 128})) + .unwrap()["id"] + .clone(); + s.execute("object.textWrap", &json!({"ids": [id], "mode": "contour", "contour": "alphaChannel"})).unwrap(); + let first = s.doc().unwrap().doc.alpha_outlines.clone(); + assert_eq!(first.len(), 1, "remembered"); + assert_eq!((first[0].width, first[0].height), (0, 0), "as no outline"); + s.execute("frame.create", &json!({"rect": [300, 300, 350, 350]})).unwrap(); + let after = &s.doc().unwrap().doc.alpha_outlines; + assert!(Arc::ptr_eq(&first[0], &after[0]), "kept, not traced again"); + } + /// Outlines are traced for a wrap set on the placed graphic and for frames inside groups, and /// traced again when the image's data changes under the same asset (relink, link update). #[test] From 8574b55bce817a3c3735469f35b0c51a5ab0fb4f Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 22 Oct 2026 17:00:43 +0200 Subject: [PATCH 07/38] i18n: Include Inside Edges is an interface string (the catalog check from #1576) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 7d5b13e2a..79fdc3e80 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -4893,6 +4893,7 @@ mod tests { "Equal fixed heights for the selected rows; cell text may become overset.", "Found", "Grab bag", + "Include Inside Edges", "Key Object", "Load Styles", "layer", From 4bc6f8a730f1f12321f5a442b94f30513d7a3f93 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 25 Oct 2026 17:37:35 -0700 Subject: [PATCH 08/38] =?UTF-8?q?Tables:=20Table=20=E2=80=BA=20Go=20to=20R?= =?UTF-8?q?ow=E2=80=A6=20selects=20a=20row=20by=20its=20number,=20as=20in?= =?UTF-8?q?=20InDesign?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit InDesign's Table menu has Go to Row…, which jumps to a row given its number. When the table has header or footer rows, the user chooses Header or Footer from a menu. DesignCraft had no such command. table.goToRow {row, section?} selects the row, counted from 1 within its section, and reports the page that shows it. Table › Go to Row… opens a dialog on the selected row; OK selects the row typed and moves the view to its page when that page is not the one at the center of the view. A row past the section's last row, or a section the table does not have, is refused and the dialog stays open with the reason. --- ROADMAP.md | 1 + crates/engine/src/cmd/table.rs | 135 +++++++++++++++++++++++++++++++ crates/ui-egui/src/dialogs.rs | 104 ++++++++++++++++++++++++ crates/ui-egui/src/i18n.rs | 2 + crates/ui-egui/src/i18n/ar.rs | 2 + crates/ui-egui/src/i18n/cs.rs | 2 + crates/ui-egui/src/i18n/de.rs | 2 + crates/ui-egui/src/i18n/it.rs | 2 + crates/ui-egui/src/i18n/ja.rs | 2 + crates/ui-egui/src/i18n/ka.rs | 2 + crates/ui-egui/src/i18n/pl.rs | 2 + crates/ui-egui/src/i18n/pt_br.rs | 2 + crates/ui-egui/src/i18n/ru.rs | 2 + crates/ui-egui/src/i18n/uk.rs | 2 + crates/ui-egui/src/menus.rs | 6 ++ docs/gaps.md | 11 +-- 16 files changed, 272 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 6a3696361..8f466b9bc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -403,6 +403,7 @@ Newest first. - **2026-10-24** · Paragraph Style Options › Indents and Spacing has an Align to Grid menu: None, All Lines or First Line Only. It shows the style's current setting. A First Line Only style could be set only in the CJK Grid Settings section, which shows only with CJK features on, or through commands. No readiness percentage change. - **2026-10-24** · Anchored text frames, such as marginal notes, compose where their host story places them: they align to the baseline grid at that position, and page-number markers and spine-relative alignment inside them read the host's page. They composed against a grid measured from their stored position and on no page, so a note set to Only Align First Line to Grid could sit a fraction of a line off the body text's grid line. No readiness percentage change. - **2026-10-24** · Tables: a gradient swatch in a cell fill draws as a gradient on the canvas and in PDF, in one gradient space for the whole table, so it runs across cells without restarting. Cells showed the gradient's first stop as a solid colour. No readiness percentage change. +- **2026-10-25** · Table › Go to Row… (`table.goToRow {row, section?}`): as in InDesign, a dialog takes a row number and, when the table has header or footer rows, the section is chosen from a menu. The menu lists Body Rows and those of Header Rows and Footer Rows that the table has. DesignCraft counts the row from 1 within its section and selects it, and the view moves to the row's page when that page is not the one at the center of the view. A row number past the section's last row is refused. No readiness percentage change. - **2026-10-24** · New documents start with Hyphenate Capitalized Words, Hyphenate Last Word and Hyphenate Across Column off in [Basic Paragraph], as in InDesign. Opened and imported files keep the values they carry. No readiness percentage change. - **2026-10-24** · The baseline grid's Relative To setting applies: with Top Margin, the grid starts at each page's own top margin in text composition, on the canvas and for snapping. It started at the page top, so grid-aligned text sat on the wrong lines and a page could fit one line fewer. No readiness percentage change. - **2026-10-24** · Custom-positioned anchored objects honour Relative to Spine (`anchored.options {spineRelative}`, IDML `SpineRelative`): the settings describe a left-hand page and mirror on a right-hand page, so marginal notes stay in the outside margin on both pages of a spread. On right-hand pages they took the left-hand placement and landed in the inside margin, across the spine or over the body text. No readiness percentage change. diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 54d8e5a41..881487629 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -401,6 +401,15 @@ fn table_specs() -> Vec { cmd!(noundo "table.selectColumn", "Select Column", ["Table", "Select"], None, "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", in_table, |s, p| { select(s, &super::with_param(p, "what", json!("column"))) }), + cmd!( + noundo "table.goToRow", + "Go to Row…", + ["Table"], + None, + "{row: number (from 1, counted within the section), section?: header|body|footer (default: body), story?, table?}: selects the row → {story, table, range, page: the page showing the row (from 1), or null when the row is overset}", + in_table, + go_to_row + ), cmd!(noundo "table.nextCell", "Next Cell", [], None, "{}", in_cell, |s, _| step_cell(s, true)), cmd!(noundo "table.prevCell", "Previous Cell", [], None, "{}", in_cell, |s, _| step_cell(s, false)), cmd!(query "table.get", "Get Table", [], None, "{story?, table?, rows?: [a,b] | row?, cols?: [a,b] | col?} → rows, columns, cells (text, spans, fill), options", in_table_or_ids, get), @@ -1531,6 +1540,54 @@ fn select(s: &mut Session, p: &Value) -> Result { Ok(json!({"story": g.story.0, "table": g.table, "range": r})) } +/// Table › Go to Row…: select row `row` (from 1) of the header, body or footer rows. +fn go_to_row(s: &mut Session, p: &Value) -> Result { + const ID: &str = "table.goToRow"; + // Only the table comes from `p`: `row` here counts within a section, not from the table's top. + let mut at = serde_json::Map::new(); + for key in ["story", "table"] { + if let Some(v) = p.get(key) { + at.insert(key.into(), v.clone()); + } + } + let g = target(s, &Value::Object(at), ID)?; + let n = p.get("row").and_then(Value::as_u64).filter(|&n| n >= 1).ok_or_else(|| bad(ID, "row must be a whole number from 1"))?; + let (index, owners, nc) = { + 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 (h, f, nr) = (t.header_rows(), t.footer_rows(), t.nrows()); + let (section, first, count) = match str_param(p, "section").unwrap_or("body") { + "header" => ("header", 0, h), + "body" => ("body", h, nr.saturating_sub(h + f)), + "footer" => ("footer", nr.saturating_sub(f), f), + other => return Err(bad(ID, format!("unknown section `{other}` (header, body or footer)"))), + }; + if count == 0 { + return Err(bad(ID, format!("the table has no {section} rows"))); + } + let n = usize::try_from(n).unwrap_or(usize::MAX); + if n > count { + return Err(bad(ID, format!("row {n} is past the last of the table's {count} {section} rows"))); + } + (first + n - 1, t.owners(), t.ncols()) + }; + let r = select(s, &json!({"story": g.story.0, "table": g.table, "rows": [index, index], "what": "row"}))?; + // The page of the frame that shows the row: the cells that start in it or reach it from above. + let start = index.saturating_mul(nc); + let cells: Vec<(usize, usize)> = owners.get(start..start.saturating_add(nc)).map(<[_]>::to_vec).unwrap_or_default(); + let st = s.doc()?; + let cs = s.cache.get(&st.doc, g.story, None); + let frame = cs.frames.iter().find_map(|ft| { + ft.tables + .iter() + .filter(|t| t.table == g.table) + .any(|t| t.cells.iter().any(|c| !c.repeated && cells.contains(&(c.row, c.col)))) + .then_some(ft.frame) + }); + let page = frame.and_then(|f| st.doc.page_of_item(f)).map(|p| p + 1); + Ok(json!({"story": r["story"], "table": r["table"], "range": r["range"], "page": page})) +} + /// Move the caret to the next/previous cell (Tab / Shift-Tab), selecting its text. Tab in the last /// cell adds a row. pub(crate) fn step_cell(s: &mut Session, forward: bool) -> Result { @@ -2321,6 +2378,84 @@ mod sort_tests { } } +#[cfg(test)] +mod go_to_row_tests { + use designcraft_doc::CellRange; + use serde_json::json; + + use crate::Session; + + /// A table of 1 header, 3 body and 1 footer rows, 2 columns wide, with the caret in its first cell. + fn session() -> Session { + 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": 3, "cols": 2, "headerRows": 1, "footerRows": 1})).unwrap(); + s + } + + fn selected(s: &Session) -> CellRange { + s.doc().unwrap().selection.cells.unwrap().range + } + + #[test] + fn go_to_row_selects_a_row_counted_within_its_section() { + let mut s = session(); + let r = s.execute("table.goToRow", &json!({"row": 2})).unwrap(); + assert_eq!(selected(&s), CellRange { r0: 2, c0: 0, r1: 2, c1: 1 }, "the second body row, under the header row"); + assert_eq!(r["page"], 1); + s.execute("table.goToRow", &json!({"row": 1, "section": "header"})).unwrap(); + assert_eq!(selected(&s), CellRange { r0: 0, c0: 0, r1: 0, c1: 1 }); + s.execute("table.goToRow", &json!({"row": 1, "section": "footer"})).unwrap(); + assert_eq!(selected(&s), CellRange { r0: 4, c0: 0, r1: 4, c1: 1 }); + s.execute("table.goToRow", &json!({"row": 3, "section": "body"})).unwrap(); + assert_eq!(selected(&s), CellRange { r0: 3, c0: 0, r1: 3, c1: 1 }); + // The caret goes to the row's first cell. + assert_eq!(s.doc().unwrap().selection.text.and_then(|t| t.cell).map(|c| (c.row, c.col)), Some((3, 0))); + } + + #[test] + fn go_to_row_refuses_rows_outside_the_section() { + let mut s = session(); + s.execute("table.goToRow", &json!({"row": 1})).unwrap(); + let before = selected(&s); + for p in [ + json!({"row": 4}), + json!({"row": 0}), + json!({"row": -1}), + json!({"row": "2"}), + json!({}), + json!({"row": 2, "section": "footer"}), + json!({"row": 1, "section": "side"}), + ] { + assert!(s.execute("table.goToRow", &p).is_err(), "{p}"); + assert_eq!(selected(&s), before, "{p} leaves the selection"); + } + s.execute("table.options", &json!({"headerRows": 0, "footerRows": 0})).unwrap(); + assert!(s.execute("table.goToRow", &json!({"row": 1, "section": "header"})).is_err()); + assert!(s.execute("table.goToRow", &json!({"row": 1, "section": "footer"})).is_err()); + } + + #[test] + fn go_to_row_names_the_page_that_shows_the_row() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 2, "facingPages": false})).unwrap(); + let a = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": ""})).unwrap(); + let b = s.execute("frame.create", &json!({"spread": 1, "rect": [72, 72, 400, 600], "content": "unassigned"})).unwrap(); + s.execute("text.thread", &json!({"from": a["id"], "to": b["id"]})).unwrap(); + s.execute("text.select", &json!({"story": a["story"], "anchor": 0, "focus": 0})).unwrap(); + s.execute("table.insert", &json!({"rows": 20, "cols": 2, "headerRows": 1})).unwrap(); + for row in 0..21 { + s.execute("table.setRowHeight", &json!({"row": row, "height": 20, "mode": "exactly"})).unwrap(); + } + assert_eq!(s.execute("table.goToRow", &json!({"row": 1})).unwrap()["page"], 1); + assert_eq!(s.execute("table.goToRow", &json!({"row": 20})).unwrap()["page"], 2); + // The header row repeats on page 2; the row itself is the one on page 1. + assert_eq!(s.execute("table.goToRow", &json!({"row": 1, "section": "header"})).unwrap()["page"], 1); + } +} + #[cfg(test)] mod arabic_tests { use super::*; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index e93e64679..cf384faf6 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1005,6 +1005,52 @@ fn doc_unit(app: &DesignApp) -> Unit { app.session.active().map(|s| s.doc.settings.horizontal_units).unwrap_or(Unit::Picas) } +/// Table › Go to Row…: opens on the target table's first selected row, numbered within its header, +/// body or footer rows. +pub fn open_go_to_row(app: &mut DesignApp) { + let Ok(t) = app.run("table.get", json!({})) else { return }; + let (h, f, r) = (count_at(&t, "headerRows"), count_at(&t, "footerRows"), count_at(&t["range"], "r0")); + let nr = t["rows"].as_array().map_or(0, Vec::len); + let (section, n) = if r < h { + ("header", r + 1) + } else if r >= nr.saturating_sub(f) { + ("footer", r + 1 - nr.saturating_sub(f)) + } else { + ("body", r + 1 - h) + }; + app.ui.dialog = Some(Dialog::new( + "goToRow", + json!({"story": t["story"], "table": t["table"], "section": section, "row": n.to_string(), "hasHeader": h > 0, "hasFooter": f > 0}), + )); +} + +fn count_at(v: &Value, k: &str) -> usize { + v[k].as_u64().and_then(|n| usize::try_from(n).ok()).unwrap_or(0) +} + +/// The Go to Row dialog: the section menu shows when the table has header or footer rows. +fn go_to_row_dialog(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { + let tr = |s: &'static str| crate::i18n::tr(&app.ui.language, s); + ui.horizontal(|ui| { + if d.b("hasHeader") || d.b("hasFooter") { + let mut opts = Vec::new(); + if d.b("hasHeader") { + opts.push(("header", tr("Header Rows"))); + } + opts.push(("body", tr("Body Rows"))); + if d.b("hasFooter") { + opts.push(("footer", tr("Footer Rows"))); + } + combo(ui, d, "section", &opts); + } + crate::rtl::label(ui, tr("Row")); + text_field_named(ui, d, "row", 60.0, tr("Row")); + }); + 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)); + } +} + /// Layout › Create Guides…: guides go on the page in view (or the parent page being edited), not /// on the first page, and the gutters are typed in the document's ruler unit. pub fn open_create_guides(app: &mut DesignApp) { @@ -2266,6 +2312,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "ruby" => crate::i18n::tr(&app.ui.language, "Ruby"), "newDocument" => crate::i18n::tr(&app.ui.language, "New Document"), "frameSize" => crate::i18n::tr(&app.ui.language, "Rectangle"), + "goToRow" => crate::i18n::tr(&app.ui.language, "Go to Row"), "goToPage" => crate::i18n::tr(&app.ui.language, "Go to Page"), "insertTable" => crate::i18n::tr(&app.ui.language, "Create Table"), "cellOptions" => crate::i18n::tr(&app.ui.language, "Cell Options"), @@ -3027,6 +3074,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { text_field(ui, &mut d, "page", 80.0); }); } + "goToRow" => go_to_row_dialog(app, ui, &mut d), "ruby" => style_cjk::ruby_dialog(app, ui, &mut d), "insertTable" => { crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Table Dimensions")).font(semibold(12.0))); @@ -3342,6 +3390,29 @@ pub fn confirm(app: &mut DesignApp) -> Result { ) } "ruby" => app.run("type.ruby", style_cjk::ruby_params(&d)), + "goToRow" => { + // Anything but a whole number goes as typed, and the command's message names the fault. + let typed = d.s("row").trim().to_string(); + let row = typed.parse::().map_or_else(|_| json!(typed), |n| json!(n)); + let p = json!({"story": d.fields.get("story"), "table": d.fields.get("table"), "section": d.s("section"), "row": row}); + let r = app.run("table.goToRow", p); + match &r { + Ok(v) => { + // The view moves when the row's page is not the page at the center of the view. + if let Some(page) = v["page"].as_u64().and_then(|p| usize::try_from(p).ok()).and_then(|p| p.checked_sub(1)) + && crate::canvas::current_page(app) != Some(page) + { + crate::canvas::go_to_page(app, page); + } + } + Err(e) => { + let mut d = d.clone(); + d.fields.insert("status".into(), json!(e)); + app.ui.dialog = Some(d); + } + } + r + } "goToPage" => { // A page name ("iv", "A-3"), a number, or "+n" for an absolute position. let typed = match d.fields.get("page") { @@ -10930,6 +11001,39 @@ mod tests { assert_eq!(guide_counts(&app), vec![0, 2, 2, 0, 4, 0]); } + #[test] + fn go_to_row_dialog_selects_the_row_and_shows_its_page() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"pages": 2, "facingPages": false})).unwrap(); + app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))); + let a = app.run("frame.create", json!({"rect": [72, 72, 400, 200], "content": "text", "text": ""})).unwrap(); + let b = app.run("frame.create", json!({"spread": 1, "rect": [72, 72, 400, 600], "content": "unassigned"})).unwrap(); + app.run("text.thread", json!({"from": a["id"], "to": b["id"]})).unwrap(); + app.run("text.select", json!({"story": a["story"], "anchor": 0, "focus": 0})).unwrap(); + app.run("table.insert", json!({"rows": 20, "cols": 2, "headerRows": 1})).unwrap(); + for row in 0..21 { + app.run("table.setRowHeight", json!({"row": row, "height": 20, "mode": "exactly"})).unwrap(); + } + app.run("table.select", json!({"rows": [2, 2]})).unwrap(); + crate::canvas::go_to_page(&mut app, 0); + crate::menus::activate(&mut app, "table.goToRow", &Value::Null); + let d = app.ui.dialog.clone().unwrap(); + assert_eq!(d.id, "goToRow"); + assert_eq!((d.s("section"), d.s("row")), ("body".to_string(), "2".to_string()), "it opens on the selected row"); + assert!(d.b("hasHeader") && !d.b("hasFooter")); + // A row that is not a whole number keeps the dialog open with the reason, and the view stays. + ctl(&mut app, "ui.dialog.set", json!({"field": "row", "value": "x"})); + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], false); + assert!(app.ui.dialog.as_ref().is_some_and(|d| d.id == "goToRow" && d.fields.contains_key("status"))); + assert_eq!(crate::canvas::current_page(&app), Some(0)); + ctl(&mut app, "ui.dialog.set", json!({"field": "row", "value": "20"})); + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); + assert!(app.ui.dialog.is_none()); + let range = app.session.active().unwrap().selection.cells.unwrap().range; + assert_eq!((range.r0, range.r1, range.c0, range.c1), (20, 20, 0, 1)); + assert_eq!(crate::canvas::current_page(&app), Some(1), "the view goes to the page that shows the row"); + } + #[test] fn parent_dialogs_cover_both_pages_of_the_parent_spread_by_default() { let mut app = facing_app(4); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index d1b462ab5..b1c001457 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -1104,6 +1104,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Generate QR Code", ["Generate QR Code", "Générer un code QR", "Generate QR Code", "Generate QR Code", "生成二维码"]), ("Go To", ["Go To", "Atteindre", "Go To", "Go To", "转到"]), ("Go to Page", ["Go to Page", "Atteindre la page", "Go to Page", "Go to Page", "转到页面"]), + ("Go to Row", ["Go to Row", "Atteindre la ligne", "Go to Row", "Go to Row", "转到行"]), ("Gutter", ["Gutter", "Gouttière", "Gutter", "Gutter", "栏间距"]), ("Header Rows", ["Header Rows", "Lignes d'en-tête", "Header Rows", "Header Rows", "表头行"]), ("Height", ["Height", "Hauteur", "Height", "Height", "高度"]), @@ -2082,6 +2083,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ], ), ("Go to Page…", ["Go to Page…", "Atteindre la page…", "Go to Page…", "Go to Page…", "转到页面…"]), + ("Go to Row…", ["Go to Row…", "Atteindre la ligne…", "Go to Row…", "Go to Row…", "转到行…"]), ("Gradient Feather", ["Gradient Feather", "Contour progressif dégradé", "Gradient Feather", "Gradient Feather", "渐变羽化"]), ("Gridify", ["Gridify", "Mettre en grille", "Gridify", "Gridify", "网格化"]), ("Group Transparency", ["Group Transparency", "Transparence du groupe", "Group Transparency", "Group Transparency", "编组透明度"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index f1dbe0acd..ec9d19fb2 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -203,6 +203,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Numbering & Section Options…", "خيارات الترقيم والأقسام…"), ("Go to Page…", "انتقال إلى صفحة…"), ("Go to Page", "انتقال إلى صفحة"), + ("Go to Row…", "انتقال إلى صف…"), + ("Go to Row", "انتقال إلى صف"), ("Next Page", "الصفحة التالية"), ("Previous Page", "الصفحة السابقة"), ("First Page", "الصفحة الأولى"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 2969d4cff..a33caff35 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -578,6 +578,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Go To Source", "Přejít na zdroj"), ("Go to Footnote Reference", "Přejít na odkaz poznámky pod čarou"), ("Go to Page…", "Přejít na stránku…"), + ("Go to Row…", "Přejít na řádek…"), + ("Go to Row", "Přejít na řádek"), ("Gradient Feather", "Přechodové zjemnění okraje"), ("Gridify", "Rozmístit do mřížky"), ("Group Transparency", "Průhlednost skupiny"), diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index aaf49bdb3..62905e04c 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -278,6 +278,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Generate QR Code", "QR-Code erzeugen"), ("Go To", "Gehe zu"), ("Go to Page", "Gehe zu Seite"), + ("Go to Row", "Gehe zu Zeile"), ("Gutter", "Spaltenabstand"), ("Header Rows", "Tabellenkopfzeilen"), ("Height", "Höhe"), @@ -615,6 +616,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Go To Source", "Gehe zu Quelle"), ("Go to Footnote Reference", "Gehe zu Fußnotenverweis"), ("Go to Page…", "Gehe zu Seite …"), + ("Go to Row…", "Gehe zu Zeile …"), ("Gradient Feather", "Weiche Verlaufskante"), ("Gridify", "Rastern"), ("Group Transparency", "Gruppentransparenz"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index db671bc83..170cef352 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -706,6 +706,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Go To Source", "Vai all'origine"), ("Go to Footnote Reference", "Vai al riferimento della nota a piè di pagina"), ("Go to Page…", "Vai alla pagina…"), + ("Go to Row…", "Vai alla riga…"), + ("Go to Row", "Vai alla riga"), ("Gradient Feather", "Bordi sfocati con sfumatura"), ("Gridify", "Disponi a griglia"), ("Group Transparency", "Trasparenza gruppo"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 01814f271..c5c988f94 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -463,6 +463,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Generate QR Code", "QR コードを生成"), ("Go To", "移動"), ("Go to Page", "ページへ移動"), + ("Go to Row", "行へ移動"), ("Gutter", "段間"), ("Header Rows", "ヘッダー行"), ("Height", "高さ"), @@ -807,6 +808,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Go To Source", "リンク元へ移動"), ("Go to Footnote Reference", "脚注参照番号へ移動"), ("Go to Page…", "ページへ移動…"), + ("Go to Row…", "行へ移動…"), ("Gradient Feather", "グラデーションぼかし"), ("Gridify", "グリッド化"), ("Group Transparency", "グループの透明"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index e4136880b..be140a61a 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -634,6 +634,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Go To Source", "წყაროზე გადასვლა"), ("Go to Footnote Reference", "სქოლიოს მითითებაზე გადასვლა"), ("Go to Page…", "გვერდზე გადასვლა…"), + ("Go to Row…", "სტრიქონზე გადასვლა…"), + ("Go to Row", "სტრიქონზე გადასვლა"), ("Gradient Feather", "კიდეების გრადიენტული შერბილება"), ("Gridify", "ბადედ განლაგება"), ("Group Transparency", "ჯგუფის გამჭვირვალობა"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index bd3d96b9a..36a56e169 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -585,6 +585,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Go To Source", "Przejdź do źródła"), ("Go to Footnote Reference", "Przejdź do odwołania przypisu"), ("Go to Page…", "Przejdź do strony…"), + ("Go to Row…", "Przejdź do wiersza…"), + ("Go to Row", "Przejdź do wiersza"), ("Gradient Feather", "Wtapianie gradientowe"), ("Gridify", "Rozmieść w siatce"), ("Group Transparency", "Przezroczystość grupy"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index d8125cfe4..367022022 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -208,6 +208,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Create Guides…", "Criar guias…"), ("Numbering & Section Options…", "Opções de numeração e de seção…"), ("Go to Page…", "Ir para página…"), + ("Go to Row…", "Ir para linha…"), + ("Go to Row", "Ir para linha"), ("Go to Page", "Ir para página"), ("Next Page", "Próxima página"), ("Previous Page", "Página anterior"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 5e8b58806..c13694de9 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -565,6 +565,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Go To Source", "Перейти к источнику"), ("Go to Footnote Reference", "Перейти к ссылке на сноску"), ("Go to Page…", "Перейти к странице…"), + ("Go to Row…", "Перейти к строке…"), + ("Go to Row", "Перейти к строке"), ("Gradient Feather", "Градиентная растушевка"), ("Gridify", "Разместить в виде сетки"), ("Group Transparency", "Прозрачность группы"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index f0cf41017..237067ed3 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -709,6 +709,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Go To Source", "Перейти до джерела"), ("Go to Footnote Reference", "Перейти до посилання на виноску"), ("Go to Page…", "Перейти до сторінки…"), + ("Go to Row…", "Перейти до рядка…"), + ("Go to Row", "Перейти до рядка"), ("Gradient Feather", "Градієнтне розтушовування"), ("Gridify", "Розмістити сіткою"), ("Group Transparency", "Прозорість групи"), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index e3226044b..58c9a24a0 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -818,6 +818,8 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:table.distributeColumns", "cmd:table.distributeRows", "-", + "cmd:table.goToRow", + "-", "ui:app.tablePanel", ], ), @@ -2662,6 +2664,10 @@ fn activate_inner(app: &mut DesignApp, id: &str, params: &Value) { app.ui.dialog = Some(crate::dialogs::Dialog::new("textFrameOptions", json!({}))); return; } + if params.is_null() && id == "table.goToRow" { + crate::dialogs::open_go_to_row(app); + return; + } // New Paragraph/Character Style…: the style options for the new style. if params.is_null() && matches!(id, "style.paragraph.create" | "style.character.create") { crate::dialogs::open_new_style(app, id == "style.paragraph.create"); diff --git a/docs/gaps.md b/docs/gaps.md index ea5b70840..85274ecd4 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -150,8 +150,8 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · ### 12. Tables in depth (F) - **Missing:** anchored objects in cells not drawn (#163); Table ▸ Table Options and Cell Options - submenus as InDesign lays them out, Diagonal Lines, Edit Header/Footer, - Go to Row; general reliability (#20). + submenus as InDesign lays them out, Diagonal Lines, Edit Header/Footer; general reliability + (#20). - **Estimate:** 25–40 h. ### 13. Hyphenation and spelling languages (T, L) @@ -177,13 +177,10 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · - **Estimate:** 30–50 h. ### 16. Menu items still missing (U) -- **Missing:** 149 of 482 in-scope items of InDesign 21.6's menu dump have no match (measured 2026-10-10; some exist under other labels): Type ▸ Notes -- **Missing:** 150 of 482 in-scope items of InDesign 21.6's menu dump have no match (measured 2026-10-10; some exist under other labels): Type ▸ Notes +- **Missing:** 147 of 482 in-scope items of InDesign 21.6's menu dump have no match (measured 2026-10-10; some exist under other labels): Type ▸ Notes and Track Changes submenus (the panels and commands exist), hyperlink and cross-reference - update commands, Convert Bullets/Numbering to Text, Convert Variable to Text, Object ▸ Clear - Effects / Clear All Transparency / Rotate 180° / Reverse Path, update commands, Convert Bullets/Numbering to Text, Object ▸ Clear - Effects / Clear All Transparency / Rotate 180° / Reverse Path, Layout ▸ Go Back/Forward, + Effects / Clear All Transparency / Rotate 180°, Window ▸ Arrange (2-up … 6-up), Trap Presets, Overlays, Edit ▸ Assign Profiles / Convert to Profile / Edit Original, File ▸ Open PDF / Document and Print Presets / User Settings, Type ▸ Size presets, Table ▸ Table Options / Cell Options submenus, Paste Before/After, From 3b2373bf8d8a09c4d210d943d452280ea6f26f88 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 25 Oct 2026 21:10:04 -0700 Subject: [PATCH 09/38] Tables: Go to Row runs on a named table and stays with its document table.goToRow given a story and table now selects the row when the selection is outside a table, as table.select does. The Table menu item still needs the selection in a table. The Go to Row dialog keeps the document it was opened for. If another document became active before OK, that document becomes active again and the row is selected there; if it has closed, OK selects nothing. --- crates/engine/src/cmd/table.rs | 13 ++++++++- crates/ui-egui/src/dialogs.rs | 48 +++++++++++++++++++++++++++++++++- crates/ui-egui/src/menus.rs | 15 +++++++++++ 3 files changed, 74 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 881487629..c84d370c8 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -407,7 +407,7 @@ fn table_specs() -> Vec { ["Table"], None, "{row: number (from 1, counted within the section), section?: header|body|footer (default: body), story?, table?}: selects the row → {story, table, range, page: the page showing the row (from 1), or null when the row is overset}", - in_table, + in_table_or_ids, go_to_row ), cmd!(noundo "table.nextCell", "Next Cell", [], None, "{}", in_cell, |s, _| step_cell(s, true)), @@ -2437,6 +2437,17 @@ mod go_to_row_tests { assert!(s.execute("table.goToRow", &json!({"row": 1, "section": "footer"})).is_err()); } + #[test] + fn go_to_row_selects_a_row_of_a_named_table_outside_the_selection() { + let mut s = session(); + let t = s.execute("table.get", &json!({})).unwrap(); + let other = s.execute("frame.create", &json!({"rect": [72, 720, 500, 760], "content": "text", "text": "Notes"})).unwrap(); + s.execute("text.select", &json!({"story": other["story"], "anchor": 0, "focus": 0})).unwrap(); + assert!(s.execute("table.goToRow", &json!({"row": 2})).is_err(), "no table named and none selected"); + s.execute("table.goToRow", &json!({"story": t["story"], "table": t["table"], "row": 2})).unwrap(); + assert_eq!(selected(&s), CellRange { r0: 2, c0: 0, r1: 2, c1: 1 }); + } + #[test] fn go_to_row_names_the_page_that_shows_the_row() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index cf384faf6..e62249b26 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1020,7 +1020,15 @@ pub fn open_go_to_row(app: &mut DesignApp) { }; app.ui.dialog = Some(Dialog::new( "goToRow", - json!({"story": t["story"], "table": t["table"], "section": section, "row": n.to_string(), "hasHeader": h > 0, "hasFooter": f > 0}), + json!({ + "uid": app.session.active().map(|doc| doc.uid), + "story": t["story"], + "table": t["table"], + "section": section, + "row": n.to_string(), + "hasHeader": h > 0, + "hasFooter": f > 0 + }), )); } @@ -3391,6 +3399,13 @@ pub fn confirm(app: &mut DesignApp) -> Result { } "ruby" => app.run("type.ruby", style_cjk::ruby_params(&d)), "goToRow" => { + // The story and table belong to the document the dialog was opened for: that document + // becomes active first, and when it has closed, nothing is selected. + let uid = d.fields.get("uid").and_then(Value::as_u64); + if uid.is_some() && uid != app.session.active().map(|doc| doc.uid) { + let Some(i) = app.session.documents().iter().position(|doc| Some(doc.uid) == uid) else { return Ok(Value::Null) }; + app.run("file.activate", json!({"index": i}))?; + } // Anything but a whole number goes as typed, and the command's message names the fault. let typed = d.s("row").trim().to_string(); let row = typed.parse::().map_or_else(|_| json!(typed), |n| json!(n)); @@ -11034,6 +11049,37 @@ mod tests { assert_eq!(crate::canvas::current_page(&app), Some(1), "the view goes to the page that shows the row"); } + /// Go to Row's OK goes to the row of the table in the document the dialog was opened for, even + /// when another document became active meanwhile; when that document has closed, nothing is selected. + #[test] + fn go_to_row_dialog_goes_to_the_document_it_was_opened_for() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + let table_doc = |app: &mut DesignApp, title: &str| { + app.run("file.new", json!({"title": title})).unwrap(); + let r = app.run("frame.create", json!({"rect": [72, 72, 500, 700], "content": "text", "text": ""})).unwrap(); + app.run("text.select", json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); + app.run("table.insert", json!({"rows": 3, "cols": 2})).unwrap(); + }; + table_doc(&mut app, "first"); + crate::menus::activate(&mut app, "table.goToRow", &Value::Null); + table_doc(&mut app, "second"); + setup_fields(&mut app, json!({"row": "3"})); + confirm(&mut app).unwrap(); + let active = app.session.active().unwrap(); + assert_eq!(active.doc.title, "first", "the dialog's document becomes active"); + assert_eq!(active.selection.cells.map(|c| (c.range.r0, c.range.r1)), Some((2, 2))); + let second = app.session.documents().iter().find(|d| d.doc.title == "second").unwrap(); + assert!(second.selection.cells.is_none(), "the other document's selection stays"); + + crate::menus::activate(&mut app, "table.goToRow", &Value::Null); + app.run("file.close", json!({})).unwrap(); + let before = app.session.active().unwrap().selection.clone(); + setup_fields(&mut app, json!({"row": "1"})); + assert_eq!(confirm(&mut app), Ok(Value::Null)); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.session.active().unwrap().selection, before, "the dialog's document closed"); + } + #[test] fn parent_dialogs_cover_both_pages_of_the_parent_spread_by_default() { let mut app = facing_app(4); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 58c9a24a0..7f970b798 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -2576,6 +2576,10 @@ pub fn menu_enabled(app: &DesignApp, id: &str) -> bool { if id.starts_with("fit.") { return enabled(app, "object.fit"); } + // Go to Row runs on a named table from anywhere; the menu item needs the selection in a table. + if id == "table.goToRow" { + return enabled(app, "table.selectColumn"); + } if id == "guide.selectAll" && (!app.ui.guides || app.ui.guides_locked) { return false; } @@ -3751,6 +3755,17 @@ mod tests { } } + #[test] + fn go_to_row_is_enabled_in_a_table() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let r = app.run("frame.create", json!({"rect": [72, 72, 500, 700], "content": "text", "text": "Notes"})).unwrap(); + app.run("text.select", json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); + assert!(!menu_enabled(&app, "table.goToRow"), "the insertion point is not in a table"); + app.run("table.insert", json!({"rows": 2, "cols": 2})).unwrap(); + assert!(menu_enabled(&app, "table.goToRow")); + } + #[test] fn pdf_export_menu_item_opens_the_dialog_or_exports() { // No params: the menu item opens the options dialog. From 5b51b16b05c2c03b4e6c712279638ca0208322e4 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 21:10:35 +0100 Subject: [PATCH 10/38] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 68e674b6e..591974004 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -301,7 +301,7 @@ fn interface_strings() -> HashSet<&'static str> { "Equal fixed heights for the selected rows; cell text may become overset.", "Found", "Grab bag", - "Include Inside Edges", + "Include Inside Edges", "Key Object", "Load Styles", "layer", From 9e9efb3ba3b9d047b3782a22282cd807a33c3872 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 21:31:11 +0100 Subject: [PATCH 11/38] Alpha text wrap: the default threshold re-exported from main's split item module Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/item.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index 1d2feb6a8..fc997825b 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -29,7 +29,7 @@ pub use paint::{ path_length_bound, }; pub use text_frame::{ - AutoSize, ColumnsKind, ContourType, FirstBaseline, FrameBaselineGrid, FrameGridRelative, PathAlign, PathType, TextFrame, TextFrameOptions, + AutoSize, ColumnsKind, DEFAULT_ALPHA_THRESHOLD, ContourType, FirstBaseline, FrameBaselineGrid, FrameGridRelative, PathAlign, PathType, TextFrame, TextFrameOptions, TextWrap, VerticalJustification, WrapMode, WrapSide, vertical_text_xf, }; From c31ad07f38e0403a5a0fc687cbbdf7331aa5353f Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 16 Oct 2026 14:19:00 +0200 Subject: [PATCH 12/38] =?UTF-8?q?InDesign's=20type=20shortcuts:=20Justify?= =?UTF-8?q?=20All=20Lines=20=E2=87=A7=E2=8C=98F,=20Em=20Space=20=E2=87=A7?= =?UTF-8?q?=E2=8C=98M,=20En=20Space=20=E2=87=A7=E2=8C=98N,=20Paragraph=20R?= =?UTF-8?q?ules=20=E2=8C=A5=E2=8C=98J?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - type.justifyAll ("Justify all lines", ⇧⌘F) next to Justify Left ⇧⌘J. - Insert White Space › Em Space and En Space become app.insertEmSpace / app.insertEnSpace, so the menu (and the Mac menu bar) shows ⇧⌘M / ⇧⌘N; they are enabled at an insertion point only. - Paragraph Rules… gets ⌥⌘J, shown in the Paragraph panel menu. - On the Mac a menu-bar insert (Em/En Space, Current Page Number) does nothing while a text field has focus, instead of typing at the canvas caret behind it. --- ROADMAP.md | 4 +- crates/engine/src/cmd/text.rs | 26 +++++ crates/ui-egui/src/menus/invoke.rs | 8 ++ crates/ui-egui/src/menus/run.rs | 2 + crates/ui-egui/src/menus/state.rs | 3 + crates/ui-egui/src/menus/tables.rs | 4 +- crates/ui-egui/src/menus/tests.rs | 94 +++++++++++++++++++ crates/ui-egui/src/menus/ui_commands.rs | 4 +- .../src/panels/properties/typography.rs | 7 +- docs/target-app-parity.md | 2 +- 10 files changed, 148 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index ad3f52a14..004b957c2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -63,7 +63,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - EPUB 3 (reflowable) export: stories in reading order, CSS from paragraph/character styles, images, navigation (`file.exportEpub`, CLI `--export x.epub`). - Data Merge (one linked CSV, TSV, text, or Excel file; text, image, QR, and hyperlink placeholders; preview; a new document with one record per page or records tiled on a single page), spell checking (public-domain Moby list + document dictionary, suggestions), Step and Repeat (count or grid), snippets, object styles, Numbering & Section Options. - Footnotes: Type › Insert Footnote (caret moves into the note), Document Footnote Options (numbering style incl. symbols, start/restart per page/spread/section, prefix/suffix, reference position/character style, paragraph style, separator, spacing, first baseline, rule above); notes composed at the bottom of the referencing column with the body text making room; edited in place on the canvas; rendered, exported to PDF (real text) and IDML (InDesign's structure, verified opening in InDesign 2026) and imported from IDML; `footnote.*` commands. -- Paragraph Rules: Rule Above / Rule Below (on, weight, colour, tint, column or text width, offset, left/right indent) set in Type › Paragraph Rules…, the Paragraph panel menu and Paragraph Style Options › Paragraph Rules; inherited style values shown, and `type.para` / `style.paragraph.edit` change only the rule fields named. +- Paragraph Rules: Rule Above / Rule Below (on, weight, colour, tint, column or text width, offset, left/right indent) set in Type › Paragraph Rules…, the Paragraph panel menu (⌥⌘J) and Paragraph Style Options › Paragraph Rules; inherited style values shown, and `type.para` / `style.paragraph.edit` change only the rule fields named. - Cross-references and text anchors: Insert Cross-Reference (paragraph by style, or text anchor), 11 formats with InDesign's building blocks (full/partial paragraph, paragraph text/number, page number, anchor name, chapter, file name) plus user formats; resolved live at composition — never out of date, no Update step; unresolved destinations show `??`; PDF link annotations to the destination page; IDML export/import as InDesign cross-reference sources, hyperlinks, text destinations and formats (verified in InDesign 2026); `xref.*` / `anchor.*` commands. - Index: page references (topic from the selection or up to 4 levels, sort keys; current page, to end of story, next n paragraphs, suppressed, See / See also), Generate / Update Index (section headings, nested or run-in, page ranges merged, Index Title / Section Head / Level 1–4 styles); IDML export/import as InDesign topics, page references and topic cross-references (verified in InDesign 2026); `index.*` commands. - Text clipboard keeps formatting (character/paragraph formats, footnotes, cross-references, index markers, tables) and falls back to plain text when the system clipboard changed; Paste without Formatting (⇧⌘V); Change Case (UPPERCASE, lowercase, Title Case, Sentence case — formatting kept); Type › Insert Special Character / White Space / Break Character submenus. @@ -756,3 +756,5 @@ The shared craft-installer UI preserves this revision's install options and prov - **2026-10-15** · Windows recovery folders validate the identity of the live locked file without reading it through a second handle. Missing or replaced locks disable writes and cleanup; regression tests cover retained exclusive locking and replacement-file protection. No readiness percentage change. - PDF reader infrastructure: pinned shared parsing/rasterization, transformed page-box geometry and explicit failures for known incomplete layered-PDF exports. Application ownership and parity targets are unchanged. + +- **2026-10-16** · InDesign's type shortcuts: Justify All Lines ⇧⌘F, Insert White Space › Em Space ⇧⌘M and En Space ⇧⌘N (shown in the menu, enabled at an insertion point), Paragraph Rules… ⌥⌘J (shown in the Paragraph panel menu). On the Mac a menu-bar insert (Em/En Space, Current Page Number) no longer types into the document while a text field has focus. No readiness percentage change. diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 8e6038741..9109e98fd 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -241,6 +241,10 @@ pub fn specs() -> Vec { s, &json!({"align": "leftJustified"}) )), + cmd!("type.justifyAll", "Justify all lines", [], Some("Cmd+Shift+F"), "{}", has_text_or_frames, |s, _| format_paras( + s, + &json!({"align": "fullyJustified"}) + )), cmd!( "type.bold", "Bold", @@ -432,3 +436,25 @@ pub fn specs() -> Vec { ), ] } + +#[cfg(test)] +mod alignment_shortcut_tests { + use serde_json::json; + + use crate::Session; + + /// InDesign's Justify All Lines is ⇧⌘F (Shift+Ctrl+F), next to Justify Left ⇧⌘J. + #[test] + fn justify_all_lines_has_indesigns_shortcut_and_justifies_the_last_line_too() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text", "text": "Spring issue"})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 3, "focus": 3})).unwrap(); + // From Justify Left, so the result is not a default or a neighbouring justification. + s.execute("type.justify", &json!({})).unwrap(); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["para"]["align"], "leftJustified"); + s.execute("type.justifyAll", &json!({})).unwrap(); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["para"]["align"], "fullyJustified"); + assert_eq!(crate::find_command("type.justifyAll").and_then(|c| c.shortcut), Some("Cmd+Shift+F")); + } +} diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index d11b8deaa..628dff472 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -141,6 +141,11 @@ pub fn activate_native(app: &mut DesignApp, ctx: &egui::Context, id: &str, param if params.is_null() && ctx.text_edit_focused() && forward_to_text_edit(app, ctx, id) { return; } + // The menu bar takes ⇧⌘M before the window: with a field focused (Story Editor, Find/Change) + // the character would land at the canvas caret, not where the person is typing. + if params.is_null() && ctx.text_edit_focused() && TEXT_INSERTS.contains(&id) { + return; + } // The canvas text editor is not an egui TextEdit, so its clipboard events must pass through // egui for the desktop integration to read/write the macOS clipboard. if params.is_null() && app.session.wants_text() && matches!(id, "edit.copy" | "edit.cut" | "edit.paste") && forward_to_text_edit(app, ctx, id) { @@ -149,6 +154,9 @@ pub fn activate_native(app: &mut DesignApp, ctx: &egui::Context, id: &str, param activate(app, id, params); } +/// Menu items that type a character at the canvas insertion point. +const TEXT_INSERTS: [&str; 3] = ["app.insertCurrentPageNumber", "app.insertEmSpace", "app.insertEnSpace"]; + /// The editing commands a focused text field takes from the native menu bar. pub(super) const TEXT_FIELD_COMMANDS: [&str; 6] = ["edit.selectAll", "edit.copy", "edit.cut", "edit.paste", "edit.undo", "edit.redo"]; diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 6a8d797b5..41188e16e 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -778,6 +778,8 @@ fn run_ui_part4(app: &mut DesignApp, id: &str, p: &Value) -> Option app.run("text.insert", json!({"text": "\u{e000}", "raw": true})), + "app.insertEmSpace" => app.run("text.insert", json!({"text": "\u{2003}", "raw": true})), + "app.insertEnSpace" => app.run("text.insert", json!({"text": "\u{2002}", "raw": true})), // `panel_docking::command` takes the dock panels (and the Window › Styles group) first. "window.panel" => { let panel = p.get("panel").and_then(Value::as_str).unwrap_or("properties"); diff --git a/crates/ui-egui/src/menus/state.rs b/crates/ui-egui/src/menus/state.rs index f9e78c28f..8c7a84d88 100644 --- a/crates/ui-egui/src/menus/state.rs +++ b/crates/ui-egui/src/menus/state.rs @@ -172,6 +172,9 @@ pub fn menu_enabled(app: &DesignApp, id: &str) -> bool { if id.starts_with("fit.") { return enabled(app, "object.fit"); } + if matches!(id, "app.insertEmSpace" | "app.insertEnSpace") { + return enabled(app, "text.insert"); + } if id == "guide.selectAll" && (!app.ui.guides || app.ui.guides_locked) { return false; } diff --git a/crates/ui-egui/src/menus/tables.rs b/crates/ui-egui/src/menus/tables.rs index 4d63d485f..f3d02596b 100644 --- a/crates/ui-egui/src/menus/tables.rs +++ b/crates/ui-egui/src/menus/tables.rs @@ -244,8 +244,8 @@ pub const MENUS: &[(&str, &[&str])] = &[ "<", "<", ">Insert White Space", - "cmd:text.insert|Em Space|{\"text\": \"\u{2003}\", \"raw\": true}", - "cmd:text.insert|En Space|{\"text\": \"\u{2002}\", \"raw\": true}", + "ui:app.insertEmSpace", + "ui:app.insertEnSpace", "cmd:text.insert|Nonbreaking Space|{\"text\": \"\u{a0}\", \"raw\": true}", "cmd:text.insert|Hair Space|{\"text\": \"\u{200a}\", \"raw\": true}", "cmd:text.insert|Sixth Space|{\"text\": \"\u{2006}\", \"raw\": true}", diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index 5e82f6d3f..b0f6af869 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -2202,6 +2202,100 @@ fn type_panel_items_open_their_panels_and_the_marker_shortcut_inserts_a_page_num assert_eq!(d.story(designcraft_doc::StoryId(story.as_u64().unwrap())).unwrap().text, "Page \u{e000}"); } +/// A key press with `modifiers` through the window's shortcut dispatch, one frame. +fn press(app: &mut crate::DesignApp, ctx: &egui::Context, key: egui::Key, modifiers: egui::Modifiers) { + let events = [true, false].map(|pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }).to_vec(); + native_frame(app, ctx, events, None); +} + +fn story_text(app: &crate::DesignApp, story: &Value) -> String { + let d = &app.session.doc().unwrap().doc; + d.story(designcraft_doc::StoryId(story.as_u64().unwrap())).unwrap().text.clone() +} + +#[test] +fn indesigns_type_shortcuts_insert_spaces_justify_all_lines_and_open_paragraph_rules() { + use egui::{Key, Modifiers}; + let ctx = egui::Context::default(); + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let frame = app.run("frame.create", json!({"rect": [36, 36, 300, 200], "content": "text", "text": "ab"})).unwrap(); + let story = frame["story"].clone(); + // A selected frame and no insertion point: the spaces have nowhere to go, as in InDesign. + app.run("selection.set", json!({"ids": [frame["id"]]})).unwrap(); + assert!(app.session.doc().unwrap().selection.text.is_none()); + assert!(!menu_enabled(&app, "app.insertEmSpace") && !menu_enabled(&app, "app.insertEnSpace")); + press(&mut app, &ctx, Key::M, Modifiers::COMMAND | Modifiers::SHIFT); + assert_eq!(story_text(&app, &story), "ab"); + app.run("text.select", json!({"story": story, "anchor": 1, "focus": 1})).unwrap(); + assert!(menu_enabled(&app, "app.insertEmSpace") && menu_enabled(&app, "app.insertEnSpace")); + press(&mut app, &ctx, Key::M, Modifiers::COMMAND | Modifiers::SHIFT); + assert_eq!(story_text(&app, &story), "a\u{2003}b", "⇧⌘M types an em space at the caret"); + press(&mut app, &ctx, Key::N, Modifiers::COMMAND | Modifiers::SHIFT); + assert_eq!(story_text(&app, &story), "a\u{2003}\u{2002}b", "⇧⌘N an en space after it"); + app.run("type.justify", json!({})).unwrap(); + press(&mut app, &ctx, Key::F, Modifiers::COMMAND | Modifiers::SHIFT); + assert_eq!(app.run("type.selectionAttrs", json!({})).unwrap()["para"]["align"], "fullyJustified", "⇧⌘F justifies all lines"); + // Guard: ⌥⇧⌘J is another chord (InDesign's Justification…); the rules want Shift up. + press(&mut app, &ctx, Key::J, Modifiers::COMMAND | Modifiers::ALT | Modifiers::SHIFT); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("justification"), "⌥⇧⌘J opens Justification"); + app.ui.dialog = None; + press(&mut app, &ctx, Key::J, Modifiers::COMMAND | Modifiers::ALT); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("paragraphRules"), "⌥⌘J opens Paragraph Rules"); + // Each chord belongs to one command only, so the editor shows no conflict and nothing else fires. + let all = effective_shortcuts(&app); + for (sc, id) in [ + ("Cmd+Shift+F", "type.justifyAll"), + ("Cmd+Shift+M", "app.insertEmSpace"), + ("Cmd+Shift+N", "app.insertEnSpace"), + ("Cmd+Alt+J", "app.paragraphRulesDialog"), + ] { + let owners: Vec<&str> = all.iter().filter(|(_, s)| s == sc).map(|(i, _)| i.as_str()).collect(); + assert_eq!(owners, [id], "{sc}"); + } +} + +#[test] +fn insert_white_space_shows_the_em_and_en_space_shortcuts() { + let ty = menu_tree().into_iter().find(|(m, _)| *m == "Type").unwrap().1; + let white = ty + .iter() + .find_map(|i| match i { + Item::Sub(n, c) if n == "Insert White Space" => Some(c.clone()), + _ => None, + }) + .unwrap(); + // Null params, so the macOS menu bar carries the accelerator too. + for (label, sc) in [("Em Space", "Cmd+Shift+M"), ("En Space", "Cmd+Shift+N")] { + assert!( + white.iter().any(|i| matches!(i, Item::Cmd { label: l, shortcut: Some(s), params, .. } if l == label && *s == sc && params.is_null())), + "{label}" + ); + } +} + +#[test] +fn native_insert_shortcuts_leave_the_document_alone_while_a_field_has_focus() { + // On the Mac the menu bar takes ⇧⌘M before the window: with Quick Apply's field focused it + // must not type into the story behind it. + let ctx = egui::Context::default(); + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", &json!({})).unwrap(); + let story = app.run("frame.create", json!({"rect": [36, 36, 300, 200], "content": "text", "text": "ab"})).unwrap()["story"].clone(); + app.run("text.select", json!({"story": story, "anchor": 1, "focus": 1})).unwrap(); + app.ui.palette = Some("x".into()); + for _ in 0..3 { + native_frame(&mut app, &ctx, vec![], None); + } + assert!(ctx.text_edit_focused()); + for id in ["app.insertEmSpace", "app.insertEnSpace", "app.insertCurrentPageNumber"] { + native_frame(&mut app, &ctx, vec![], Some(id)); + native_frame(&mut app, &ctx, vec![], None); + assert_eq!(story_text(&app, &story), "ab", "{id}"); + } + assert_eq!(app.ui.palette.as_deref(), Some("x")); +} + #[test] fn custom_workspaces_save_apply_and_delete() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 649469f30..1defda7f5 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -121,7 +121,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ( "app.paragraphRulesDialog", "Paragraph Rules…", - None, + Some("Cmd+Alt+J"), "{rule?: ruleAbove|ruleBelow} — Rule Above / Rule Below of the selected paragraphs (type.para)", ), ( @@ -165,6 +165,8 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("app.characterStylesPanel", "Character Styles", Some("Cmd+Shift+F11"), "{} — the Character Styles panel"), ("app.paragraphStylesPanel", "Paragraph Styles", Some("Cmd+F11"), "{} — the Paragraph Styles panel"), ("app.insertCurrentPageNumber", "Current Page Number", Some("Cmd+Alt+Shift+N"), "{} — the Current Page Number marker at the insertion point"), + ("app.insertEmSpace", "Em Space", Some("Cmd+Shift+M"), "{} — an em space at the insertion point"), + ("app.insertEnSpace", "En Space", Some("Cmd+Shift+N"), "{} — an en space at the insertion point"), ("app.storyEditor", "Edit in Story Editor", Some("Cmd+Y"), "{story?}"), ("view.zoomIn", "Zoom In", Some("Cmd+="), "{}"), ("view.zoomOut", "Zoom Out", Some("Cmd+-"), "{}"), diff --git a/crates/ui-egui/src/panels/properties/typography.rs b/crates/ui-egui/src/panels/properties/typography.rs index d4959c406..178dfb529 100644 --- a/crates/ui-egui/src/panels/properties/typography.rs +++ b/crates/ui-egui/src/panels/properties/typography.rs @@ -381,7 +381,12 @@ fn paragraph_panel_menu(app: &mut DesignApp, ui: &mut Ui, p: &Value) { ("Paragraph Rules…", "app.paragraphRulesDialog"), ("Paragraph Borders and Shading…", "app.paragraphBordersDialog"), ] { - if item(ui, label, None, true) { + let mut b = egui::Button::new(crate::rtl::widget(ui, crate::i18n::tr(&lang, label))); + if let Some(sc) = crate::menus::shortcut_of(app, id) { + b = b.shortcut_text(crate::menus::shortcut_text(&sc)); + } + if ui.add(b).clicked() { + ui.close(); command = Some(id); } } diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 239920184..26d13d3de 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -285,7 +285,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - EPUB 3 (reflowable) export: stories in reading order, CSS from paragraph/character styles, images, navigation (`file.exportEpub`, CLI `--export x.epub`). - Data Merge (one linked CSV, TSV, text, or Excel file; text, image, QR, and hyperlink placeholders; preview; a new document with one record per page or records tiled on a single page), spell checking (public-domain Moby list + document dictionary, suggestions), Step and Repeat (count or grid, with a previewing dialog), snippets, object styles, Numbering & Section Options. - Footnotes: Type › Insert Footnote (caret moves into the note), Document Footnote Options (numbering style incl. symbols, start/restart per page/spread/section, prefix/suffix, reference position/character style, paragraph style, separator, spacing, first baseline, rule above); notes composed at the bottom of the referencing column with the body text making room, or across all the columns of the frame with Span Footnotes Across Columns; edited in place on the canvas; rendered, exported to PDF (real text) and IDML (InDesign's structure, verified opening in InDesign 2026) and imported from IDML; `footnote.*` commands. -- Paragraph Rules: Rule Above / Rule Below (on, weight, colour, tint, column or text width, offset, left/right indent) set in the Paragraph panel menu and Paragraph Style Options › Paragraph Rules; inherited style values shown, and `type.para` / `style.paragraph.edit` change only the rule fields named. +- Paragraph Rules: Rule Above / Rule Below (on, weight, colour, tint, column or text width, offset, left/right indent) set in the Paragraph panel menu (⌥⌘J) and Paragraph Style Options › Paragraph Rules; inherited style values shown, and `type.para` / `style.paragraph.edit` change only the rule fields named. - Cross-references and text anchors: Insert Cross-Reference (paragraph by style, or text anchor), 11 formats with InDesign's building blocks (full/partial paragraph, paragraph text/number, page number, anchor name, chapter, file name) plus user formats; resolved live at composition — never out of date, no Update step; unresolved destinations show `??`; PDF link annotations to the destination page; IDML export/import as InDesign cross-reference sources, hyperlinks, text destinations and formats (verified in InDesign 2026); `xref.*` / `anchor.*` commands. - Index: page references (topic from the selection or up to 4 levels, sort keys; current page, to end of story, next n paragraphs, suppressed, See / See also), Generate / Update Index (section headings, nested or run-in, page ranges merged, Index Title / Section Head / Level 1–4 styles); IDML export/import as InDesign topics, page references and topic cross-references (verified in InDesign 2026); `index.*` commands. - Text clipboard keeps formatting (character/paragraph formats, footnotes, cross-references, index markers, tables) and falls back to plain text when the system clipboard changed; Paste without Formatting (⇧⌘V); Change Case (UPPERCASE, lowercase, Title Case, Sentence case — formatting kept); Type › Insert Special Character / White Space / Break Character submenus. From 21267510dc2656959dffdd841ad1408be29df2a5 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 13:57:36 +0100 Subject: [PATCH 13/38] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/item.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index fc997825b..30711b5d9 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -29,8 +29,8 @@ pub use paint::{ path_length_bound, }; pub use text_frame::{ - AutoSize, ColumnsKind, DEFAULT_ALPHA_THRESHOLD, ContourType, FirstBaseline, FrameBaselineGrid, FrameGridRelative, PathAlign, PathType, TextFrame, TextFrameOptions, - TextWrap, VerticalJustification, WrapMode, WrapSide, vertical_text_xf, + AutoSize, ColumnsKind, ContourType, DEFAULT_ALPHA_THRESHOLD, FirstBaseline, FrameBaselineGrid, FrameGridRelative, PathAlign, PathType, TextFrame, + TextFrameOptions, TextWrap, VerticalJustification, WrapMode, WrapSide, vertical_text_xf, }; fn is_zero_usize(v: &usize) -> bool { From c2b4514ee6ed434c822ac81ecc52fa53b154dac7 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 21:25:42 -0700 Subject: [PATCH 14/38] Tables: Go to Row shows the caret in the frame that shows the row table.goToRow and table.select naming a table in another story kept the previous caret's frame, so frame commands that follow acted on the other story's frame. The caret's frame is now kept only within the same story, and Go to Row puts it in the frame that shows the row. The Table menu order test lists Go to Row after Distribute Columns Evenly. --- crates/engine/src/cmd/table/selection.rs | 22 ++++++++++++++++++++-- crates/ui-egui/src/menus/tests.rs | 2 ++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/table/selection.rs b/crates/engine/src/cmd/table/selection.rs index 833a84e22..f29b3ca58 100644 --- a/crates/engine/src/cmd/table/selection.rs +++ b/crates/engine/src/cmd/table/selection.rs @@ -24,7 +24,8 @@ pub(super) fn select(s: &mut Session, p: &Value) -> Result { _ => {} } let st = s.doc_mut()?; - let frame = st.selection.text.and_then(|t| t.frame); + // The caret's frame is kept only within the same story. + let frame = st.selection.text.filter(|t| t.story == g.story).and_then(|t| t.frame); st.selection = Selection { cells: Some(TableSel { story: g.story, table: g.table, range: r }), text: Some(TextSel { story: g.story, anchor: 0, focus: 0, frame, cell: Some(CellAddr { table: g.table, row: r.r0, col: r.c0 }) }), @@ -79,6 +80,12 @@ pub(super) fn go_to_row(s: &mut Session, p: &Value) -> Result { .then_some(ft.frame) }); let page = frame.and_then(|f| st.doc.page_of_item(f)).map(|p| p + 1); + // The caret is shown in the frame that shows the row. + if let Some(f) = frame + && let Some(t) = s.doc_mut()?.selection.text.as_mut() + { + t.frame = Some(f); + } Ok(json!({"story": r["story"], "table": r["table"], "range": r["range"], "page": page})) } @@ -251,10 +258,19 @@ mod go_to_row_tests { let mut s = session(); let t = s.execute("table.get", &json!({})).unwrap(); let other = s.execute("frame.create", &json!({"rect": [72, 720, 500, 760], "content": "text", "text": "Notes"})).unwrap(); - s.execute("text.select", &json!({"story": other["story"], "anchor": 0, "focus": 0})).unwrap(); + // A click in the other frame: the caret is shown in that frame. + s.execute("text.placeCaret", &json!({"frame": other["id"], "point": [80, 730]})).unwrap(); + assert_eq!(s.doc().unwrap().selection.text.and_then(|t| t.frame).map(|f| f.0), other["id"].as_u64()); assert!(s.execute("table.goToRow", &json!({"row": 2})).is_err(), "no table named and none selected"); s.execute("table.goToRow", &json!({"story": t["story"], "table": t["table"], "row": 2})).unwrap(); assert_eq!(selected(&s), CellRange { r0: 2, c0: 0, r1: 2, c1: 1 }); + let frame = s.doc().unwrap().selection.text.and_then(|t| t.frame).map(|f| f.0); + assert_ne!(frame, other["id"].as_u64(), "the caret is not left in the other story's frame"); + // table.select naming the table from another story does not keep that story's frame either. + s.execute("text.placeCaret", &json!({"frame": other["id"], "point": [80, 730]})).unwrap(); + s.execute("table.select", &json!({"story": t["story"], "table": t["table"], "what": "table"})).unwrap(); + let frame = s.doc().unwrap().selection.text.and_then(|t| t.frame).map(|f| f.0); + assert_ne!(frame, other["id"].as_u64()); } #[test] @@ -271,6 +287,8 @@ mod go_to_row_tests { } assert_eq!(s.execute("table.goToRow", &json!({"row": 1})).unwrap()["page"], 1); assert_eq!(s.execute("table.goToRow", &json!({"row": 20})).unwrap()["page"], 2); + let frame = s.doc().unwrap().selection.text.and_then(|t| t.frame).map(|f| f.0); + assert_eq!(frame, b["id"].as_u64(), "the caret is in the frame that shows the row"); // The header row repeats on page 2; the row itself is the one on page 1. assert_eq!(s.execute("table.goToRow", &json!({"row": 1, "section": "header"})).unwrap()["page"], 1); } diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index e4a111a8b..352eb3a58 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -2255,6 +2255,8 @@ fn the_table_menu_follows_indesigns_order() { "-", "Distribute Rows Evenly", "Distribute Columns Evenly", + "-", + "Go to Row…", ]; assert_eq!(labels, expected); // Cmd+Alt+Shift+T opens the dialog, as InDesign's; the engine command has no shortcut of From cc4939569636cb2dc1fd2d40c8898d27231f0309 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 21:41:12 -0700 Subject: [PATCH 15/38] Tables: Go to Row names the page under the row and reads no cell text table.goToRow took the page from the frame's center, so a row in a frame that spans two pages of a spread could name the other page, and the view moved there. It now takes the page under the row's first cell. table.get takes cells: false to leave out the cells, and the Go to Row dialog uses it, so opening the dialog on a large table does not copy every cell's text. --- crates/engine/src/cmd/table/options.rs | 30 +++++++++------- crates/engine/src/cmd/table/registry.rs | 2 +- crates/engine/src/cmd/table/selection.rs | 44 +++++++++++++++++++++--- crates/ui-egui/src/dialogs.rs | 2 +- 4 files changed, 59 insertions(+), 19 deletions(-) diff --git a/crates/engine/src/cmd/table/options.rs b/crates/engine/src/cmd/table/options.rs index 7f9ed7786..867c7b87d 100644 --- a/crates/engine/src/cmd/table/options.rs +++ b/crates/engine/src/cmd/table/options.rs @@ -70,19 +70,23 @@ pub(super) fn get(s: &mut Session, p: &Value) -> Result { let g = target(s, p, "table.get")?; let st = s.doc()?; let t = st.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).ok_or_else(|| bad("table.get", "no table"))?; - let owners = t.owners(); - let cells: Vec = (0..t.nrows()) - .flat_map(|r| (0..t.ncols()).map(move |c| (r, c))) - .filter(|&(r, c)| owners[r * t.ncols() + c] == (r, c)) - .filter_map(|(r, c)| { - let cell = t.cell(r, c)?; - Some(json!({"row": r, "col": c, "rowSpan": cell.row_span, "colSpan": cell.col_span, "text": cell.text.text, "fill": cell.fill, - "insets": cell.insets, "vj": cell.vj, "firstBaseline": cell.first_baseline, "firstBaselineMin": cell.first_baseline_min, "rotation": cell.rotation, "style": cell.style})) - }) - .collect(); - Ok(json!({ + let mut out = json!({ "story": g.story.0, "table": g.table, "anchor": st.doc.story(g.story).and_then(|x| x.table_anchor(g.table)), "rows": t.rows, "columns": t.columns, "headerRows": t.header_rows(), "footerRows": t.footer_rows(), - "cells": cells, "options": t.options, "range": g.range, "style": t.style, - })) + "options": t.options, "range": g.range, "style": t.style, + }); + if p.get("cells").and_then(Value::as_bool) != Some(false) { + let owners = t.owners(); + let cells: Vec = (0..t.nrows()) + .flat_map(|r| (0..t.ncols()).map(move |c| (r, c))) + .filter(|&(r, c)| owners[r * t.ncols() + c] == (r, c)) + .filter_map(|(r, c)| { + let cell = t.cell(r, c)?; + Some(json!({"row": r, "col": c, "rowSpan": cell.row_span, "colSpan": cell.col_span, "text": cell.text.text, "fill": cell.fill, + "insets": cell.insets, "vj": cell.vj, "firstBaseline": cell.first_baseline, "firstBaselineMin": cell.first_baseline_min, "rotation": cell.rotation, "style": cell.style})) + }) + .collect(); + out["cells"] = json!(cells); + } + Ok(out) } diff --git a/crates/engine/src/cmd/table/registry.rs b/crates/engine/src/cmd/table/registry.rs index 43c32f292..c42470681 100644 --- a/crates/engine/src/cmd/table/registry.rs +++ b/crates/engine/src/cmd/table/registry.rs @@ -416,6 +416,6 @@ fn table_specs() -> Vec { in_cell, |s, _| toggle_selection(s) ), - cmd!(query "table.get", "Get Table", [], None, "{story?, table?, rows?: [a,b] | row?, cols?: [a,b] | col?} → rows, columns, cells (text, spans, fill), options", in_table_or_ids, get), + cmd!(query "table.get", "Get Table", [], None, "{story?, table?, rows?: [a,b] | row?, cols?: [a,b] | col?, cells?: bool (false leaves out the cells)} → rows, columns, cells (text, spans, fill), options", in_table_or_ids, get), ] } diff --git a/crates/engine/src/cmd/table/selection.rs b/crates/engine/src/cmd/table/selection.rs index f29b3ca58..c6f3d31ae 100644 --- a/crates/engine/src/cmd/table/selection.rs +++ b/crates/engine/src/cmd/table/selection.rs @@ -72,14 +72,17 @@ pub(super) fn go_to_row(s: &mut Session, p: &Value) -> Result { let cells: Vec<(usize, usize)> = owners.get(start..start.saturating_add(nc)).map(<[_]>::to_vec).unwrap_or_default(); let st = s.doc()?; let cs = s.cache.get(&st.doc, g.story, None); - let frame = cs.frames.iter().find_map(|ft| { + let placed = cs.frames.iter().find_map(|ft| { ft.tables .iter() .filter(|t| t.table == g.table) - .any(|t| t.cells.iter().any(|c| !c.repeated && cells.contains(&(c.row, c.col)))) - .then_some(ft.frame) + .flat_map(|t| t.cells.iter()) + .find(|c| !c.repeated && cells.contains(&(c.row, c.col))) + .map(|c| (ft.frame, c.rect.center())) }); - let page = frame.and_then(|f| st.doc.page_of_item(f)).map(|p| p + 1); + let frame = placed.map(|(f, _)| f); + // The page under the row's first cell: a frame can span two pages of a spread. + let page = placed.and_then(|(f, at)| page_under(&st.doc, f, at)).map(|p| p + 1); // The caret is shown in the frame that shows the row. if let Some(f) = frame && let Some(t) = s.doc_mut()?.selection.text.as_mut() @@ -89,6 +92,15 @@ pub(super) fn go_to_row(s: &mut Session, p: &Value) -> Result { Ok(json!({"story": r["story"], "table": r["table"], "range": r["range"], "page": page})) } +/// The page (from 0) under `at`, a point in the inner space of text frame `frame`. +fn page_under(doc: &designcraft_doc::Document, frame: designcraft_doc::ItemId, at: designcraft_geom::Point) -> Option { + let loc = doc.find(frame)?; + let designcraft_doc::SpreadRef::Doc(si) = loc.spread else { return None }; + let it = doc.item_at(&loc)?; + let p = doc.parent_xf(&loc) * doc.text_xf(it) * at; + Some(doc.first_page_of_spread(si) + doc.spread(loc.spread)?.page_at_x(p.x).unwrap_or(0)) +} + /// Move the caret to the next/previous cell (Tab / Shift-Tab), selecting its text. Tab in the last /// cell adds a row. pub(crate) fn step_cell(s: &mut Session, forward: bool) -> Result { @@ -292,4 +304,28 @@ mod go_to_row_tests { // The header row repeats on page 2; the row itself is the one on page 1. assert_eq!(s.execute("table.goToRow", &json!({"row": 1, "section": "header"})).unwrap()["page"], 1); } + + #[test] + fn table_get_without_cells_reads_the_rows_alone() { + // The Go to Row dialog reads the row counts and the selection, not every cell's text. + let mut s = session(); + s.execute("table.goToRow", &json!({"row": 2})).unwrap(); + let t = s.execute("table.get", &json!({"cells": false})).unwrap(); + assert!(t.get("cells").is_none(), "{t}"); + assert_eq!((t["headerRows"].clone(), t["footerRows"].clone(), t["range"]["r0"].clone()), (json!(1), json!(1), json!(2))); + assert_eq!(t["rows"].as_array().map(Vec::len), Some(5)); + assert_eq!(s.execute("table.get", &json!({})).unwrap()["cells"].as_array().map(Vec::len), Some(10)); + } + + #[test] + fn go_to_row_names_the_page_under_the_row_in_a_frame_across_the_spine() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 3, "facingPages": true})).unwrap(); + // Pages 2 and 3 meet at x = 612; the frame's center is on page 3, the narrow table on page 2. + let f = s.execute("frame.create", &json!({"spread": 1, "rect": [400, 72, 900, 400], "content": "text", "text": ""})).unwrap(); + s.execute("text.select", &json!({"story": f["story"], "anchor": 0, "focus": 0})).unwrap(); + s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap(); + s.execute("table.setColumnWidth", &json!({"cols": [0, 1], "width": 50})).unwrap(); + assert_eq!(s.execute("table.goToRow", &json!({"row": 2})).unwrap()["page"], 2); + } } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index dae827fb2..79343e9ed 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1506,7 +1506,7 @@ fn doc_unit(app: &DesignApp) -> Unit { /// Table › Go to Row…: opens on the target table's first selected row, numbered within its header, /// body or footer rows. pub fn open_go_to_row(app: &mut DesignApp) { - let Ok(t) = app.run("table.get", json!({})) else { return }; + let Ok(t) = app.run("table.get", json!({"cells": false})) else { return }; let (h, f, r) = (count_at(&t, "headerRows"), count_at(&t, "footerRows"), count_at(&t["range"], "r0")); let nr = t["rows"].as_array().map_or(0, Vec::len); let (section, n) = if r < h { From 327b9cc7a34e9e72264b12f4e84bd756aae18d83 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 21:46:49 -0700 Subject: [PATCH 16/38] Tables: selecting a row puts the caret in the merged cell that covers it table.select and table.goToRow put the caret at the range's first grid position, which has no cell of its own when a merged cell from the row above covers it, so the caret was not drawn. The caret now goes in the cell that covers that position. --- crates/engine/src/cmd/table/selection.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/table/selection.rs b/crates/engine/src/cmd/table/selection.rs index c6f3d31ae..8c23d698a 100644 --- a/crates/engine/src/cmd/table/selection.rs +++ b/crates/engine/src/cmd/table/selection.rs @@ -24,11 +24,13 @@ pub(super) fn select(s: &mut Session, p: &Value) -> Result { _ => {} } let st = s.doc_mut()?; + // The caret goes in the cell that covers the range's first position, a merged one included. + let (row, col) = st.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).map_or((r.r0, r.c0), |t| t.owner(r.r0, r.c0)); // The caret's frame is kept only within the same story. let frame = st.selection.text.filter(|t| t.story == g.story).and_then(|t| t.frame); st.selection = Selection { cells: Some(TableSel { story: g.story, table: g.table, range: r }), - text: Some(TextSel { story: g.story, anchor: 0, focus: 0, frame, cell: Some(CellAddr { table: g.table, row: r.r0, col: r.c0 }) }), + text: Some(TextSel { story: g.story, anchor: 0, focus: 0, frame, cell: Some(CellAddr { table: g.table, row, col }) }), ..Default::default() }; st.bump_revision(); @@ -305,6 +307,16 @@ mod go_to_row_tests { assert_eq!(s.execute("table.goToRow", &json!({"row": 1, "section": "header"})).unwrap()["page"], 1); } + #[test] + fn go_to_row_puts_the_caret_in_a_merged_cell_that_covers_the_row() { + let mut s = session(); + // Body rows 1 and 2 (table rows 1 and 2) share one merged cell in column 0. + s.execute("table.merge", &json!({"rows": [1, 2], "col": 0})).unwrap(); + s.execute("table.goToRow", &json!({"row": 2})).unwrap(); + assert_eq!(selected(&s), CellRange { r0: 2, c0: 0, r1: 2, c1: 1 }); + assert_eq!(s.doc().unwrap().selection.text.and_then(|t| t.cell).map(|c| (c.row, c.col)), Some((1, 0))); + } + #[test] fn table_get_without_cells_reads_the_rows_alone() { // The Go to Row dialog reads the row counts and the selection, not every cell's text. From 2ba27b840bb1a5342b75b1199050af22f71ec1d5 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 22:28:48 -0700 Subject: [PATCH 17/38] Tables: Go to Row scrolls the row to the middle of the view Go to Row's OK moved the view only when the row's page was not the page at the center of the view, and then centered that page. When zoomed in, a row on the page in view stayed off screen, and a row on another page could stay off screen too. The view now scrolls so the row is in the middle of the view from top to bottom, at the same zoom, on the page in view or another. It scrolls sideways only when no part of the row is in view across. table.goToRow also returns the spread that shows the row, a document or parent spread, and the row's bounds on that spread. A composed table fragment lists its rows with their top and bottom, so a row inside cells merged across several rows is bounded by its own height. A row in an anchored text frame is found where the host story places the frame. --- ROADMAP.md | 2 +- crates/compose/src/cache.rs | 7 ++ crates/compose/src/table.rs | 8 ++ crates/engine/src/cmd/table/registry.rs | 2 +- crates/engine/src/cmd/table/selection.rs | 131 ++++++++++++++++++++--- crates/ui-egui/src/canvas.rs | 3 +- crates/ui-egui/src/canvas/view.rs | 21 ++++ crates/ui-egui/src/dialogs.rs | 13 ++- crates/ui-egui/src/dialogs/tests.rs | 43 ++++++++ 9 files changed, 205 insertions(+), 25 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9b9c7d284..ac0849624 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -429,7 +429,7 @@ Newest first. - **2026-10-24** · A guide pulled out of a ruler keeps the resize cursor across it until it is dropped; the tool's cursor took over as soon as the pointer left the ruler, so the drag looked as if it had stopped. No readiness percentage change. - **2026-10-24** · Anchored text frames, such as marginal notes, compose where their host story places them: they align to the baseline grid at that position, and page-number markers and spine-relative alignment inside them read the host's page. They composed against a grid measured from their stored position and on no page, so a note set to Only Align First Line to Grid could sit a fraction of a line off the body text's grid line. No readiness percentage change. - **2026-10-24** · Tables: a gradient swatch in a cell fill draws as a gradient on the canvas and in PDF, in one gradient space for the whole table, so it runs across cells without restarting. Cells showed the gradient's first stop as a solid colour. No readiness percentage change. -- **2026-10-25** · Table › Go to Row… (`table.goToRow {row, section?}`): as in InDesign, a dialog takes a row number and, when the table has header or footer rows, the section is chosen from a menu. The menu lists Body Rows and those of Header Rows and Footer Rows that the table has. DesignCraft counts the row from 1 within its section and selects it, and the view moves to the row's page when that page is not the one at the center of the view. A row number past the section's last row is refused. No readiness percentage change. +- **2026-10-25** · Table › Go to Row… (`table.goToRow {row, section?}`): as in InDesign, a dialog takes a row number and, when the table has header or footer rows, the section is chosen from a menu. The menu lists Body Rows and those of Header Rows and Footer Rows that the table has. DesignCraft counts the row from 1 within its section and selects it, and the view scrolls the row to its middle at the same zoom, on the page in view or another. A row number past the section's last row is refused. No readiness percentage change. - **2026-10-22** · Paragraph Style Options › Paragraph Shading and Paragraph Border are laid out as InDesign's: headed with the page's name; Tint is a spinner with InDesign's presets and has Overprint beside it (shading, border and gap; a new attribute each, kept through IDML; PDF export warns it doesn't overprint yet); Corner Size and Shape is InDesign's two-by-two grid of corner icons, sizes and shape menus showing each shape's glyph, with the link in the middle; offsets and weights have spinners. Found in the Learn Skills Daily course, chapter 41. No readiness percentage change. - **2026-10-24** · New documents start with Hyphenate Capitalized Words, Hyphenate Last Word and Hyphenate Across Column off in [Basic Paragraph], as in InDesign. Opened and imported files keep the values they carry. No readiness percentage change. - **2026-10-24** · The baseline grid's Relative To setting applies: with Top Margin, the grid starts at each page's own top margin in text composition, on the canvas and for snapping. It started at the page top, so grid-aligned text sat on the wrong lines and a page could fit one line fewer. No readiness percentage change. diff --git a/crates/compose/src/cache.rs b/crates/compose/src/cache.rs index 66122ccf4..88421ceb5 100644 --- a/crates/compose/src/cache.rs +++ b/crates/compose/src/cache.rs @@ -103,6 +103,13 @@ impl Cache { self.get_with(doc, sid, on, true, 0) } + /// Where the host story places anchored text frame `fid`, one of story `sid`'s frames; `None` for + /// a frame on a spread. + pub fn placement(&self, doc: &Document, sid: StoryId, fid: designcraft_doc::ItemId) -> Option { + let placed = crate::placed::placements(doc, sid, &mut |host| self.get_with(doc, host, ShownOn::Own, true, 1)); + placed.into_iter().find_map(|(id, p)| (id == fid).then_some(p)) + } + /// [`Cache::get_on`], wrapped by other stories' anchored objects only when `cross` is set, for /// a story `depth` hosts down from the story asked for. fn get_with(&self, doc: &Document, sid: StoryId, on: ShownOn, cross: bool, depth: usize) -> Arc { diff --git a/crates/compose/src/table.rs b/crates/compose/src/table.rs index dc579d8a7..f0c6fb59b 100644 --- a/crates/compose/src/table.rs +++ b/crates/compose/src/table.rs @@ -52,6 +52,9 @@ pub struct TableFrag { pub column: u32, pub rect: Rect, pub cells: Vec, + /// The table rows in the fragment, each with its top and bottom; repeated header and footer + /// rows are left out. + pub rows: Vec<(usize, f64, f64)>, pub strokes: Vec, /// Index of the fragment's (glyph-less) line in the frame's lines. pub line: usize, @@ -88,6 +91,10 @@ impl TableFrag { c.clip = c.clip + v; c.origin += v; } + for r in &mut self.rows { + r.1 += dy; + r.2 += dy; + } for s in &mut self.strokes { s.a += v; s.b += v; @@ -492,6 +499,7 @@ fn emit( column: cur.col as u32, rect: Rect::new(x0, top, right, bottom), cells, + rows: rows.iter().filter(|r| !r.2).map(|&(r, y, _)| (r, y, y + heights[r])).collect(), strokes, line, first, diff --git a/crates/engine/src/cmd/table/registry.rs b/crates/engine/src/cmd/table/registry.rs index c42470681..04b4dee30 100644 --- a/crates/engine/src/cmd/table/registry.rs +++ b/crates/engine/src/cmd/table/registry.rs @@ -401,7 +401,7 @@ fn table_specs() -> Vec { "Go to Row…", ["Table"], None, - "{row: number (from 1, counted within the section), section?: header|body|footer (default: body), story?, table?}: selects the row → {story, table, range, page: the page showing the row (from 1), or null when the row is overset}", + "{row: number (from 1, counted within the section), section?: header|body|footer (default: body), story?, table?}: selects the row → {story, table, range, page: the document page showing the row (from 1), spread: {kind: doc|parent, index} of the spread showing it, rowBounds: [x0, y0, x1, y1] of the row on that spread; each null when the row is overset, and page on a parent spread}", in_table_or_ids, go_to_row ), diff --git a/crates/engine/src/cmd/table/selection.rs b/crates/engine/src/cmd/table/selection.rs index 8c23d698a..524106373 100644 --- a/crates/engine/src/cmd/table/selection.rs +++ b/crates/engine/src/cmd/table/selection.rs @@ -74,33 +74,63 @@ pub(super) fn go_to_row(s: &mut Session, p: &Value) -> Result { let cells: Vec<(usize, usize)> = owners.get(start..start.saturating_add(nc)).map(<[_]>::to_vec).unwrap_or_default(); let st = s.doc()?; let cs = s.cache.get(&st.doc, g.story, None); + // The part of the table that shows the row, its frame, and the cells that cover the row there. let placed = cs.frames.iter().find_map(|ft| { - ft.tables - .iter() - .filter(|t| t.table == g.table) - .flat_map(|t| t.cells.iter()) - .find(|c| !c.repeated && cells.contains(&(c.row, c.col))) - .map(|c| (ft.frame, c.rect.center())) + ft.tables.iter().filter(|t| t.table == g.table).find_map(|t| { + let covering: Vec = + t.cells.iter().filter(|c| !c.repeated && cells.contains(&(c.row, c.col))).map(|c| c.rect).collect(); + (!covering.is_empty()).then_some((ft.frame, t, covering)) + }) }); - let frame = placed.map(|(f, _)| f); - // The page under the row's first cell: a frame can span two pages of a spread. - let page = placed.and_then(|(f, at)| page_under(&st.doc, f, at)).map(|p| p + 1); + let frame = placed.as_ref().map(|(f, _, _)| *f); + let on_spread = frame.and_then(|f| frame_on_spread(&st.doc, &s.cache, g.story, f)); + // The document page under the row's first cell: a frame can span two pages of a spread. + let page = placed.as_ref().zip(on_spread).and_then(|((_, _, covering), (spread, xf))| { + let designcraft_doc::SpreadRef::Doc(si) = spread else { return None }; + let at = xf * covering.first()?.center(); + Some(st.doc.first_page_of_spread(si) + st.doc.spreads.get(si)?.page_at_x(at.x).unwrap_or(0) + 1) + }); + // The row on its spread: across, the cells that cover it; from top to bottom, the row alone, also + // inside cells merged across several rows. + let bounds = placed.as_ref().zip(on_spread).and_then(|((_, t, covering), (_, xf))| { + let across = covering.iter().copied().reduce(|a, b| a.union(b))?; + let &(_, top, bottom) = t.rows.iter().find(|r| r.0 == index)?; + Some(xf.transform_rect_bbox(designcraft_geom::Rect::new(across.x0, top, across.x1, bottom))) + }); + let spread = on_spread.map(|(si, _)| si); // The caret is shown in the frame that shows the row. if let Some(f) = frame && let Some(t) = s.doc_mut()?.selection.text.as_mut() { t.frame = Some(f); } - Ok(json!({"story": r["story"], "table": r["table"], "range": r["range"], "page": page})) + Ok(json!({ + "story": r["story"], + "table": r["table"], + "range": r["range"], + "page": page, + "spread": spread, + "rowBounds": bounds.map(|b| [b.x0, b.y0, b.x1, b.y1]), + })) } -/// The page (from 0) under `at`, a point in the inner space of text frame `frame`. -fn page_under(doc: &designcraft_doc::Document, frame: designcraft_doc::ItemId, at: designcraft_geom::Point) -> Option { - let loc = doc.find(frame)?; - let designcraft_doc::SpreadRef::Doc(si) = loc.spread else { return None }; - let it = doc.item_at(&loc)?; - let p = doc.parent_xf(&loc) * doc.text_xf(it) * at; - Some(doc.first_page_of_spread(si) + doc.spread(loc.spread)?.page_at_x(p.x).unwrap_or(0)) +/// The spread (a document or parent spread) that text frame `frame` of story `sid` is on, and the +/// transform from the frame's inner space to that spread's coordinates. An anchored frame is where +/// its host story places it. +fn frame_on_spread( + doc: &designcraft_doc::Document, + cache: &designcraft_compose::Cache, + sid: StoryId, + frame: designcraft_doc::ItemId, +) -> Option<(designcraft_doc::SpreadRef, designcraft_geom::Affine)> { + let (spread, parent, it) = match doc.find(frame) { + Some(loc) => (loc.spread, doc.parent_xf(&loc), doc.item_at(&loc)?), + None => { + let p = cache.placement(doc, sid, frame)?; + (p.spread, p.xf, doc.anchored_item(frame)?) + } + }; + Some((spread, parent * doc.text_xf(it))) } /// Move the caret to the next/previous cell (Tab / Shift-Tab), selecting its text. Tab in the last @@ -303,6 +333,11 @@ mod go_to_row_tests { assert_eq!(s.execute("table.goToRow", &json!({"row": 20})).unwrap()["page"], 2); let frame = s.doc().unwrap().selection.text.and_then(|t| t.frame).map(|f| f.0); assert_eq!(frame, b["id"].as_u64(), "the caret is in the frame that shows the row"); + // The row on its spread: one row tall, in the frame on the second spread. + let r = s.execute("table.goToRow", &json!({"row": 20})).unwrap(); + assert_eq!(r["spread"], json!({"kind": "doc", "index": 1})); + let at: Vec = r["rowBounds"].as_array().map(|a| a.iter().filter_map(serde_json::Value::as_f64).collect()).unwrap_or_default(); + assert!(at.len() == 4 && (at[3] - at[1] - 20.0).abs() < 1e-6 && at[0] >= 72.0 && at[2] <= 401.0 && at[1] >= 72.0 && at[3] <= 600.0, "{at:?}"); // The header row repeats on page 2; the row itself is the one on page 1. assert_eq!(s.execute("table.goToRow", &json!({"row": 1, "section": "header"})).unwrap()["page"], 1); } @@ -317,6 +352,68 @@ mod go_to_row_tests { assert_eq!(s.doc().unwrap().selection.text.and_then(|t| t.cell).map(|c| (c.row, c.col)), Some((1, 0))); } + #[test] + fn go_to_row_bounds_a_row_inside_cells_merged_across_rows() { + let mut s = session(); + for (row, height) in [20, 10, 40, 20, 20].into_iter().enumerate() { + s.execute("table.setRowHeight", &json!({"row": row, "height": height, "mode": "exactly"})).unwrap(); + } + // Every column's cell covers body rows 1 to 3 (table rows 1 to 3), so no cell starts in body row 2. + s.execute("table.merge", &json!({"rows": [1, 3], "cols": [0, 0]})).unwrap(); + s.execute("table.merge", &json!({"rows": [1, 3], "cols": [1, 1]})).unwrap(); + let bounds = |s: &mut Session, p: serde_json::Value| -> Vec { + let r = s.execute("table.goToRow", &p).unwrap(); + r["rowBounds"].as_array().map(|a| a.iter().filter_map(serde_json::Value::as_f64).collect()).unwrap_or_default() + }; + let header = bounds(&mut s, json!({"row": 1, "section": "header"})); + let row = bounds(&mut s, json!({"row": 2})); + assert_eq!((header.len(), row.len()), (4, 4)); + assert!((row[1] - (header[3] + 10.0)).abs() < 1e-6 && (row[3] - row[1] - 40.0).abs() < 1e-6, "{header:?} {row:?}"); + assert_eq!((row[0], row[2]), (header[0], header[2]), "the row spans the table across"); + } + + #[test] + fn go_to_row_finds_a_row_in_an_anchored_text_frame() { + use designcraft_doc::{AnchorPosition, AnchoredObject, Content, Item, ItemId, Shape, Story, StoryId, TextFrame}; + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let host = s.execute("frame.create", &json!({"rect": [72, 72, 500, 400], "content": "text", "text": "Host text"})).unwrap(); + // A text frame anchored in the host's text, its own story holding the table. + let sid = { + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let (fid, sid) = (ItemId(d.alloc()), StoryId(d.alloc())); + let mut story = Story::with_text(sid, "", Default::default()); + story.frames.push(fid); + d.stories.insert(sid, std::sync::Arc::new(story)); + let layer = d.item(ItemId(host["id"].as_u64().unwrap())).unwrap().layer; + let mut it = + Item::new(fid, layer, Shape::Rectangle, designcraft_geom::shapes::rectangle(designcraft_geom::Rect::new(0.0, 0.0, 200.0, 100.0))); + it.content = Content::Text(TextFrame { story: sid, options: Default::default() }); + let host_story = StoryId(host["story"].as_u64().unwrap()); + d.story_mut(host_story).unwrap().insert_object(4, AnchoredObject::new(it, AnchorPosition::default())); + sid + }; + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); + s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap(); + let r = s.execute("table.goToRow", &json!({"row": 2})).unwrap(); + assert_eq!((r["page"].clone(), r["spread"].clone()), (json!(1), json!({"kind": "doc", "index": 0})), "{r}"); + assert_eq!(r["rowBounds"].as_array().map(Vec::len), Some(4), "{r}"); + } + + #[test] + fn go_to_row_finds_a_row_on_a_parent_spread() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let f = s + .execute("frame.create", &json!({"spread": {"kind": "parent", "index": 0}, "rect": [72, 72, 400, 400], "content": "text", "text": ""})) + .unwrap(); + s.execute("text.select", &json!({"story": f["story"], "anchor": 0, "focus": 0})).unwrap(); + s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap(); + let r = s.execute("table.goToRow", &json!({"row": 2})).unwrap(); + assert_eq!((r["page"].clone(), r["spread"].clone()), (json!(null), json!({"kind": "parent", "index": 0})), "{r}"); + assert_eq!(r["rowBounds"].as_array().map(Vec::len), Some(4), "{r}"); + } + #[test] fn table_get_without_cells_reads_the_rows_alone() { // The Go to Row dialog reads the row counts and the selection, not every cell's text. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ed207c34b..565454f91 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -62,7 +62,8 @@ pub(crate) use view::page_in_view; use view::present_fit; pub use view::{ PageBox, apply_view_request, can_go_through_history, current_page, current_parent_page, current_slot, current_spread, current_spread_page, - drop_point, fit, go_through_history, go_to_page, page_box, page_box_go, set_zoom, show_spread, step_spread, view_centre, zoom_at, + drop_point, fit, go_through_history, go_to_page, go_to_spread_rect, page_box, page_box_go, set_zoom, show_spread, step_spread, view_centre, + zoom_at, }; pub const RULER: f32 = 15.0; diff --git a/crates/ui-egui/src/canvas/view.rs b/crates/ui-egui/src/canvas/view.rs index 2ba0876a1..d1fe151d2 100644 --- a/crates/ui-egui/src/canvas/view.rs +++ b/crates/ui-egui/src/canvas/view.rs @@ -218,6 +218,27 @@ fn centre_page(app: &mut DesignApp, abs: usize) { } } +/// Scroll so `r`, a rectangle on spread `spread` as the canvas lays it out now, is in the middle of +/// the view from top to bottom, and from side to side when no part of it is in view across (keeps +/// zoom). A jump Go Back can return from. +pub fn go_to_spread_rect(app: &mut DesignApp, spread: SpreadRef, r: DRect) { + note_jump(app, false); + centre_spread_rect(app, spread, r); + note_jump(app, true); +} + +fn centre_spread_rect(app: &mut DesignApp, spread: SpreadRef, r: DRect) { + let Some(st) = app.session.active() else { return }; + let layout = CanvasLayout::new(&st.doc, st.editing_parents); + let Some(b) = layout.slot(spread).map(|s| s.xf.transform_rect_bbox(r)) else { return }; + let Some(rect) = app.canvas_rect else { return }; + if let Some(v) = app.view_mut() { + let (w, h) = (rect.width() as f64 / v.zoom, rect.height() as f64 / v.zoom); + let x = if b.x1 > v.origin.x && b.x0 < v.origin.x + w { v.origin.x } else { b.center().x - w / 2.0 }; + v.origin = Point::new(x, b.center().y - h / 2.0); + } +} + /// The document's pages by id, in page order. fn page_ids(doc: &Document) -> Vec { doc.spreads.iter().flat_map(|s| s.pages.iter().map(|p| p.id)).collect() diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 79343e9ed..b3f18834f 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -4298,11 +4298,14 @@ fn confirm_part1(app: &mut DesignApp, d: Dialog) -> Result { let r = app.run("table.goToRow", p); match &r { Ok(v) => { - // The view moves when the row's page is not the page at the center of the view. - if let Some(page) = v["page"].as_u64().and_then(|p| usize::try_from(p).ok()).and_then(|p| p.checked_sub(1)) - && crate::canvas::current_page(app) != Some(page) - { - crate::canvas::go_to_page(app, page); + // The view scrolls the row to its middle, at the same zoom, on the row's page or another. + let spread = serde_json::from_value::(v["spread"].clone()).ok(); + let bounds = v["rowBounds"].as_array().and_then(|a| { + let n: Vec = a.iter().filter_map(Value::as_f64).collect(); + (n.len() == 4).then(|| designcraft_geom::Rect::new(n[0], n[1], n[2], n[3])) + }); + if let (Some(spread), Some(b)) = (spread, bounds) { + crate::canvas::go_to_spread_rect(app, spread, b); } } Err(e) => { diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 0b6179060..1a65b9f9d 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3750,6 +3750,49 @@ fn go_to_row_dialog_selects_the_row_and_shows_its_page() { assert_eq!(crate::canvas::current_page(&app), Some(1), "the view goes to the page that shows the row"); } +/// Go to Row's OK scrolls the row to the middle of the view at the same zoom: a row off screen on the +/// page in view, and a row on another page away from that page's center. +#[test] +fn go_to_row_dialog_scrolls_the_row_to_the_middle_of_the_view() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"pages": 2, "facingPages": false})).unwrap(); + app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))); + let a = app.run("frame.create", json!({"rect": [72, 72, 400, 200], "content": "text", "text": ""})).unwrap(); + let b = app.run("frame.create", json!({"spread": 1, "rect": [72, 72, 400, 600], "content": "unassigned"})).unwrap(); + app.run("text.thread", json!({"from": a["id"], "to": b["id"]})).unwrap(); + app.run("text.select", json!({"story": a["story"], "anchor": 0, "focus": 0})).unwrap(); + app.run("table.insert", json!({"rows": 20, "cols": 2, "headerRows": 1})).unwrap(); + for row in 0..21 { + app.run("table.setRowHeight", json!({"row": row, "height": 20, "mode": "exactly"})).unwrap(); + } + app.run("table.select", json!({"rows": [1, 1]})).unwrap(); + crate::canvas::go_to_page(&mut app, 0); + crate::canvas::set_zoom(&mut app, 4.0); + // The row's middle and the view's middle, from top to bottom, on the canvas. + let middles = |app: &mut DesignApp, row: u64| { + let r = app.run("table.goToRow", json!({"row": row})).unwrap(); + let spread: designcraft_doc::SpreadRef = serde_json::from_value(r["spread"].clone()).unwrap(); + let n: Vec = r["rowBounds"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect(); + let st = app.session.active().unwrap(); + let slot = designcraft_tools::CanvasLayout::new(&st.doc, false).slot(spread).unwrap().xf; + let row_y = slot.transform_rect_bbox(designcraft_geom::Rect::new(n[0], n[1], n[2], n[3])).center().y; + let (v, rect) = (app.view().unwrap(), app.canvas_rect.unwrap()); + (row_y, v.origin.y + rect.height() as f64 / 2.0 / v.zoom) + }; + let x = app.view().unwrap().origin.x; + for row in ["3", "20"] { + crate::menus::activate(&mut app, "table.goToRow", &Value::Null); + ctl(&mut app, "ui.dialog.set", json!({"field": "row", "value": row})); + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); + let (row_y, view_y) = middles(&mut app, row.parse().unwrap()); + assert!((row_y - view_y).abs() < 0.5, "row {row} is in the middle of the view: {row_y} against {view_y}"); + let v = app.view().unwrap(); + assert!((v.zoom - 4.0).abs() < 1e-9, "the zoom stays"); + assert!((v.origin.x - x).abs() < 1e-9, "the row was in view across, so the view does not scroll sideways"); + } + assert_eq!(crate::canvas::current_page(&app), Some(1)); +} + /// Go to Row's OK goes to the row of the table in the document the dialog was opened for, even /// when another document became active meanwhile; when that document has closed, nothing is selected. #[test] From 5b0e0295b3535411dadcbda1d03bc767b82b9bea Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 2 Nov 2026 08:48:57 -0800 Subject: [PATCH 18/38] =?UTF-8?q?i18n:=20Swedish=20row=20for=20Go=20to=20R?= =?UTF-8?q?ow=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swedish became a complete-menu language on main, so the Table menu's Go to Row… item and the dialog title need Swedish rows. --- crates/ui-egui/src/i18n/sv.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/ui-egui/src/i18n/sv.rs b/crates/ui-egui/src/i18n/sv.rs index 1a7550a35..4f7488ff6 100644 --- a/crates/ui-egui/src/i18n/sv.rs +++ b/crates/ui-egui/src/i18n/sv.rs @@ -102,6 +102,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Numbering & Section Options…", "Numrerings- och avsnittsalternativ…"), ("Go to Page…", "Gå till sida…"), ("Go to Page", "Gå till sida"), + ("Go to Row…", "Gå till rad…"), + ("Go to Row", "Gå till rad"), ("Next Page", "Nästa sida"), ("Previous Page", "Föregående sida"), ("First Page", "Första sidan"), From 8b109879d2f5b96ff84ea23a32fe0eeeac35f901 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Tue, 3 Nov 2026 19:55:22 +0000 Subject: [PATCH 19/38] Apply review fixes - crates/ui-egui/src/menus/invoke.rs --- crates/ui-egui/src/menus/invoke.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index 45120484a..33096849d 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -147,8 +147,8 @@ pub fn activate_native(app: &mut DesignApp, ctx: &egui::Context, id: &str, param return; } // The menu bar takes ⇧⌘M before the window: with a field focused (Story Editor, Find/Change) - // the character would land at the canvas caret, not where the person is typing. - if params.is_null() && ctx.text_edit_focused() && TEXT_INSERTS.contains(&id) { + // or a dialog open, the character would land at the canvas caret behind it. + if params.is_null() && (ctx.text_edit_focused() || app.ui.dialog.is_some()) && TEXT_INSERTS.contains(&id) { return; } // The canvas text editor is not an egui TextEdit, so its clipboard events must pass through From db8e1b63644f226cd7846d960f52cf4fc690b482 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 3 Nov 2026 13:48:13 -0800 Subject: [PATCH 20/38] Docs: leave gap 16 of docs/gaps.md as it is on main The Go to Row change only removes Go to Row from gap 12. --- docs/gaps.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/gaps.md b/docs/gaps.md index 7010c807b..398288062 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -178,10 +178,13 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · - **Estimate:** 30–50 h. ### 16. Menu items still missing (U) -- **Missing:** 147 of 482 in-scope items of InDesign 21.6's menu dump have no match (measured 2026-10-10; some exist under other labels): Type ▸ Notes +- **Missing:** 149 of 482 in-scope items of InDesign 21.6's menu dump have no match (measured 2026-10-10; some exist under other labels): Type ▸ Notes +- **Missing:** 150 of 482 in-scope items of InDesign 21.6's menu dump have no match (measured 2026-10-10; some exist under other labels): Type ▸ Notes and Track Changes submenus (the panels and commands exist), hyperlink and cross-reference + update commands, Convert Bullets/Numbering to Text, Convert Variable to Text, Object ▸ Clear + Effects / Clear All Transparency / Rotate 180° / Reverse Path, update commands, Convert Bullets/Numbering to Text, Object ▸ Clear - Effects / Clear All Transparency / Rotate 180°, + Effects / Clear All Transparency / Rotate 180° / Reverse Path, Layout ▸ Go Back/Forward, Window ▸ Arrange (2-up … 6-up), Trap Presets, Overlays, Edit ▸ Assign Profiles / Convert to Profile / Edit Original, File ▸ Open PDF / Document and Print Presets / User Settings, Type ▸ Size presets, Table ▸ Table Options / Cell Options submenus, Paste Before/After, From 240f423392c17a88a4b22c58198cec9817be8cbd Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 3 Nov 2026 15:41:31 -0800 Subject: [PATCH 21/38] Tables: Go to Row leaves no caret frame for an overset row When no frame showed the row, the caret kept an earlier frame of the story, so frame commands acted on a frame that does not show the row. --- crates/engine/src/cmd/table/selection.rs | 26 +++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/table/selection.rs b/crates/engine/src/cmd/table/selection.rs index 524106373..320b21350 100644 --- a/crates/engine/src/cmd/table/selection.rs +++ b/crates/engine/src/cmd/table/selection.rs @@ -98,11 +98,9 @@ pub(super) fn go_to_row(s: &mut Session, p: &Value) -> Result { Some(xf.transform_rect_bbox(designcraft_geom::Rect::new(across.x0, top, across.x1, bottom))) }); let spread = on_spread.map(|(si, _)| si); - // The caret is shown in the frame that shows the row. - if let Some(f) = frame - && let Some(t) = s.doc_mut()?.selection.text.as_mut() - { - t.frame = Some(f); + // The caret is shown in the frame that shows the row, and in no frame when the row is overset. + if let Some(t) = s.doc_mut()?.selection.text.as_mut() { + t.frame = frame; } Ok(json!({ "story": r["story"], @@ -342,6 +340,24 @@ mod go_to_row_tests { assert_eq!(s.execute("table.goToRow", &json!({"row": 1, "section": "header"})).unwrap()["page"], 1); } + #[test] + fn go_to_row_leaves_no_caret_frame_for_an_overset_row() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": ""})).unwrap(); + s.execute("text.select", &json!({"story": a["story"], "anchor": 0, "focus": 0})).unwrap(); + s.execute("table.insert", &json!({"rows": 20, "cols": 2})).unwrap(); + for row in 0..20 { + s.execute("table.setRowHeight", &json!({"row": row, "height": 20, "mode": "exactly"})).unwrap(); + } + let frame = |s: &Session| s.doc().unwrap().selection.text.and_then(|t| t.frame).map(|f| f.0); + s.execute("table.goToRow", &json!({"row": 1})).unwrap(); + assert_eq!(frame(&s), a["id"].as_u64()); + let r = s.execute("table.goToRow", &json!({"row": 20})).unwrap(); + assert_eq!((r["page"].clone(), r["spread"].clone()), (json!(null), json!(null)), "{r}"); + assert_eq!(frame(&s), None, "no frame shows the overset row"); + } + #[test] fn go_to_row_puts_the_caret_in_a_merged_cell_that_covers_the_row() { let mut s = session(); From 5b89a13c2e979bb8f66211a62d503779910ebb61 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 20:04:50 -0800 Subject: [PATCH 22/38] Text wrap: anchored images keep their Alpha Channel outline Outlines are traced for images anchored in text as well as for those on the spreads, so an anchored image's Alpha Channel contour follows its visible pixels instead of falling back to the frame. Tracing reads the pixels once: a coarser grid, used when the outline has too many edges, is made from the finer grid's mask when each side halves, and the cell of each pixel is counted along the row instead of divided. --- crates/engine/src/cmd/alpha_wrap.rs | 145 +++++++++++++++++++++++----- 1 file changed, 123 insertions(+), 22 deletions(-) diff --git a/crates/engine/src/cmd/alpha_wrap.rs b/crates/engine/src/cmd/alpha_wrap.rs index 8a94ae818..cd6a1d00b 100644 --- a/crates/engine/src/cmd/alpha_wrap.rs +++ b/crates/engine/src/cmd/alpha_wrap.rs @@ -5,7 +5,7 @@ //! current transform, so it follows the image when it is moved, scaled or refitted. use std::sync::Arc; -use designcraft_doc::{AlphaOutline, AssetId, ContourType, Document, WrapMode}; +use designcraft_doc::{AlphaOutline, AssetId, ContourType, Document, Item, WrapMode}; use designcraft_geom::{BezPath, PathData, Point}; /// Images larger than this many pixels aren't traced (their contour follows the frame). @@ -19,18 +19,26 @@ const MAX_EDGES: usize = 1 << 16; /// nothing changed: tracing happens only for an image and threshold not seen before. pub(crate) fn sync(doc: &mut Document) { let mut wanted: Vec<(AssetId, u8)> = Vec::new(); + // Every frame, in groups and Paste Into containers too, with the wrap set on the frame or on + // its placed graphic (Direct Selection sets the graphic's). + let mut want = |it: &Item| { + let Some(g) = it.graphic() else { return }; + for w in [&it.wrap, &g.wrap] { + if w.mode == WrapMode::Contour && w.contour == ContourType::AlphaChannel && !wanted.contains(&(g.asset, w.alpha_threshold)) { + wanted.push((g.asset, w.alpha_threshold)); + } + } + }; for sp in doc.spreads.iter().chain(doc.parents.iter()) { - // Every frame, in groups and Paste Into containers too, with the wrap set on the frame or - // on its placed graphic (Direct Selection sets the graphic's). for top in &sp.items { - top.walk(&mut |it| { - let Some(g) = it.graphic() else { return }; - for w in [&it.wrap, &g.wrap] { - if w.mode == WrapMode::Contour && w.contour == ContourType::AlphaChannel && !wanted.contains(&(g.asset, w.alpha_threshold)) { - wanted.push((g.asset, w.alpha_threshold)); - } - } - }); + top.walk(&mut want); + } + } + // Objects anchored in text wrap the lines after their anchor; anchored_items lists their + // children too. + if doc.has_anchored_items() { + for (_, it) in doc.anchored_items() { + want(it); } } if wanted.is_empty() && doc.alpha_outlines.is_empty() { @@ -69,12 +77,20 @@ pub(crate) fn trace(data: &Arc>, asset: AssetId, threshold: u8) -> Optio return None; } let mut grid = MAX_GRID; + // The previous grid's mask: a grid half as fine is made from it without reading the pixels again. + let mut finer: Option<(Vec, u32, u32)> = None; while grid >= 16 { let (cols, rows) = (w.min(grid), h.min(grid)); - let (mask, transparent) = occupancy(img.as_raw(), w, h, cols, rows, threshold); - if !transparent { - return None; - } + let mask = match finer.as_ref().and_then(|(m, fc, fr)| coarsen(m, *fc, *fr, cols, rows)) { + Some(m) => m, + None => { + let (mask, transparent) = occupancy(img.as_raw(), w, h, cols, rows, threshold); + if !transparent { + return None; + } + mask + } + }; if let Some((outer, holes)) = rings(&mask, cols as usize, rows as usize) { let (sx, sy) = (f64::from(w) / f64::from(cols), f64::from(h) / f64::from(rows)); let path = |rings: &[Vec<(i32, i32)>]| { @@ -93,24 +109,55 @@ pub(crate) fn trace(data: &Arc>, asset: AssetId, threshold: u8) -> Optio let source = Arc::downgrade(data); return Some(AlphaOutline { asset, threshold, width: w, height: h, outer: path(&outer), holes: path(&holes), source }); } + finer = Some((mask, cols, rows)); grid /= 2; } None } +/// The `cols` × `rows` mask made from a `fc` × `fr` one, when each side has the same number of +/// cells or twice as many (a pixel's coarse cell is then its fine cell, or half of it). None +/// otherwise: the pixels are read again. +fn coarsen(mask: &[bool], fc: u32, fr: u32, cols: u32, rows: u32) -> Option> { + let step = |fine: u32, coarse: u32| match fine { + f if f == coarse => Some(1), + f if u64::from(f) == 2 * u64::from(coarse) => Some(2), + _ => None, + }; + let (sx, sy) = (step(fc, cols)?, step(fr, rows)?); + let (fc, cols) = (fc as usize, cols as usize); + let mut out = vec![false; cols * rows as usize]; + for (i, _) in mask.iter().enumerate().filter(|(_, v)| **v) { + let (x, y) = (i % fc, i / fc); + if let Some(c) = out.get_mut(y / sy * cols + x / sx) { + *c = true; + } + } + Some(out) +} + /// Which cells of a `cols` × `rows` grid hold a pixel of the `w` × `h` RGBA image `rgba` more /// opaque than `threshold` (a cell is visible when any of its pixels is), and whether the image /// has any transparency. fn occupancy(rgba: &[u8], w: u32, h: u32, cols: u32, rows: u32, threshold: u8) -> (Vec, bool) { let mut mask = vec![false; (cols as usize) * (rows as usize)]; let mut transparent = false; - for (i, px) in rgba.as_chunks::<4>().0.iter().enumerate() { - let alpha = px[3]; - transparent |= alpha < 255; - if alpha > threshold { - let (x, y) = ((i as u64) % u64::from(w), (i as u64) / u64::from(w)); - let (gx, gy) = (x * u64::from(cols) / u64::from(w), y * u64::from(rows) / u64::from(h)); - if let Some(c) = mask.get_mut((gy * u64::from(cols) + gx) as usize) { + // A pixel's cell is (x * cols / w, y * rows / h). Along a row the column is counted up as x + // passes each cell's edge (cols <= w, so at most one edge per pixel) instead of divided out. + for (y, row) in rgba.chunks_exact(w as usize * 4).take(h as usize).enumerate() { + let base = (y as u64 * u64::from(rows) / u64::from(h)) as usize * cols as usize; + let (mut gx, mut acc, mut edge) = (0usize, 0u64, u64::from(w)); + for px in row.as_chunks::<4>().0 { + if acc >= edge { + gx += 1; + edge += u64::from(w); + } + acc += u64::from(cols); + let alpha = px[3]; + transparent |= alpha < 255; + if alpha > threshold + && let Some(c) = mask.get_mut(base + gx) + { *c = true; } } @@ -218,6 +265,39 @@ mod tests { assert!(trace(&Arc::new(png(|_, _| true)), AssetId(1), 25).is_none()); } + /// The cell masks match a pixel-by-pixel count (cell = x * cols / w, y * rows / h), and a + /// mask half as fine made from the finer one matches the mask read from the pixels. + #[test] + fn cell_masks_match_the_pixels_and_coarser_masks_match_the_finer_ones() { + let (w, h) = (600u32, 300u32); + // An irregular pattern: visible where a small hash says so. + let on = |x: u32, y: u32| (x.wrapping_mul(2_654_435_761) ^ y.wrapping_mul(40_503)).rotate_left(7).is_multiple_of(7); + let mut rgba = vec![0u8; (w * h * 4) as usize]; + for y in 0..h { + for x in 0..w { + rgba[((y * w + x) * 4 + 3) as usize] = if on(x, y) { 255 } else { 0 }; + } + } + for (cols, rows) in [(256, 256), (128, 128), (100, 37), (600, 300)] { + let mut expect = vec![false; (cols * rows) as usize]; + for y in 0..h { + for x in 0..w { + if on(x, y) { + expect[((y * rows / h) * cols + x * cols / w) as usize] = true; + } + } + } + assert_eq!(occupancy(&rgba, w, h, cols, rows, 25), (expect, true), "{cols} x {rows}"); + } + let (fine, _) = occupancy(&rgba, w, h, 256, 256, 25); + assert_eq!(coarsen(&fine, 256, 256, 128, 128), Some(occupancy(&rgba, w, h, 128, 128, 25).0)); + // Same count on one side, twice on the other. + let (fine, _) = occupancy(&rgba, w, h, 128, 256, 25); + assert_eq!(coarsen(&fine, 128, 256, 128, 128), Some(occupancy(&rgba, w, h, 128, 128, 25).0)); + // Any other ratio is read from the pixels. + assert_eq!(coarsen(&fine, 128, 256, 100, 128), None); + } + #[test] fn an_alpha_channel_contour_is_traced_when_set_and_kept_out_of_the_saved_file() { let mut s = Session::new(); @@ -314,6 +394,27 @@ mod tests { assert_eq!((b.x0, b.x1), (24.0, 40.0), "the new image's outline"); } + /// An image anchored in text keeps its outline: anchored objects wrap the lines after their + /// anchor, and they are kept in the story, not on the spread. + #[test] + fn an_anchored_image_keeps_its_outline() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let data = png(|x, _| (16..48).contains(&x)); + let image = s + .execute("file.place", &json!({"base64": crate::cmd::file::base64_encode(&data), "name": "cut.png", "x": 100, "y": 100, "width": 128})) + .unwrap()["id"] + .as_u64() + .unwrap(); + s.execute("object.textWrap", &json!({"ids": [image], "mode": "contour", "contour": "alphaChannel"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.alpha_outlines.len(), 1); + let t = s.execute("frame.create", &json!({"rect": [36, 300, 400, 500], "content": "text", "text": "Before after."})).unwrap(); + s.execute("anchored.insert", &json!({"ids": [image], "story": t["story"], "pos": 7})).unwrap(); + let d = &s.doc().unwrap().doc; + assert!(d.anchored_item(designcraft_doc::ItemId(image)).is_some(), "the image is in the text"); + assert_eq!(d.alpha_outlines.len(), 1, "the anchored image keeps its outline"); + } + /// An image placed 128 pt wide at (100, 100) (2 pt per pixel), its wrap a contour with no /// offset, and a caption frame at `caption` with 8 pt "Hi". fn page(data: &[u8], caption: [f64; 4]) -> (Session, Value, u64) { From 9fcab8e66baca0cce1406b783c05f418d74ce216 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 20:13:21 -0800 Subject: [PATCH 23/38] Text wrap: Alpha Channel outlines fit composition, and an empty one wraps nothing An outline with more path elements than composition follows is traced on a coarser grid, so a fragmented image keeps an Alpha Channel contour instead of falling back to the frame. An image with no pixel more opaque than the threshold leaves the text alone. --- crates/compose/src/lib.rs | 38 +++++++++++++++++++----- crates/engine/src/cmd/alpha_wrap.rs | 46 +++++++++++++++++++++++++---- 2 files changed, 71 insertions(+), 13 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 47798d7a3..f0451f938 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -386,7 +386,7 @@ pub struct Contour { } /// Points a contour outline may have; a larger outline wraps around its bounding box. -const MAX_CONTOUR_POINTS: usize = 8192; +pub const MAX_CONTOUR_POINTS: usize = 8192; impl Contour { /// The outline of `path` (after `to_inner`), flattened; None if it is empty or too detailed. @@ -587,7 +587,8 @@ pub fn exclusion(doc: &Document, other: &Item, xf: Affine, inv: Affine, left_pag let group = other.shape == designcraft_doc::Shape::Group; let b = if group { (xf * other.xf.inverse()).transform_rect_bbox(other.bounds()) } else { other.path.transformed(xf).bounds().unwrap_or(Rect::ZERO) }; - wrap_exclusion(&other.wrap, b, || (!group).then(|| contour_path(doc, other, &other.wrap)), xf, inv, left_page) + let e = wrap_exclusion(&other.wrap, b, || (!group).then(|| contour_path(doc, other, &other.wrap)), xf, inv, left_page); + nothing_visible_wraps_nothing(doc, other, &other.wrap, e) } /// The wrap exclusion of the graphic placed in frame `it` (arguments as for [`exclusion`]): around @@ -595,7 +596,26 @@ pub fn exclusion(doc: &Document, other: &Item, xf: Affine, inv: Affine, left_pag pub fn graphic_exclusion(doc: &Document, it: &Item, xf: Affine, inv: Affine, left_page: bool) -> Option { let g = it.graphic()?; let shown = g.shown_bounds(it.inner_bounds())?; - Some(wrap_exclusion(&g.wrap, xf.transform_rect_bbox(shown), || Some(contour_path(doc, it, &g.wrap)), xf, inv, left_page)) + let e = wrap_exclusion(&g.wrap, xf.transform_rect_bbox(shown), || Some(contour_path(doc, it, &g.wrap)), xf, inv, left_page); + Some(nothing_visible_wraps_nothing(doc, it, &g.wrap, e)) +} + +/// An Alpha Channel contour around an image with no pixel more opaque than the threshold leaves +/// the text alone (the exclusion's mode becomes None). +fn nothing_visible_wraps_nothing(doc: &Document, it: &Item, wrap: &designcraft_doc::TextWrap, mut e: Exclusion) -> Exclusion { + if wrap.mode == WrapMode::Contour + && wrap.contour == designcraft_doc::ContourType::AlphaChannel + && wrap.path.is_none() + && let Some(g) = it.graphic() + && let Some(o) = doc.alpha_outline(g.asset, wrap.alpha_threshold) + && o.width > 0 + && o.height > 0 + && o.outer.subpaths.is_empty() + { + e.mode = WrapMode::None; + e.contour = None; + } + e } /// The exclusion of `wrap` around an object with spread-space bounds `b`; `outline` gives its @@ -637,8 +657,9 @@ fn wrap_exclusion( /// The outline a contour wrap of `kind` around frame `it` follows, in the frame's inner space, and /// for an outline taken from the placed graphic, the frame path that clips it. Bounding Box /// follows the part of the placed graphic the frame shows; Same As Clipping follows the graphic's -/// clipping path when it has one; detected edges, alpha channels and image paths need the image, -/// so they follow the frame's path (as other frames do). +/// clipping path when it has one; Alpha Channel follows the image's traced outline when the +/// engine has traced one; detected edges and image paths follow the frame's path (as other +/// frames do). fn contour_path(doc: &Document, it: &Item, wrap: &designcraft_doc::TextWrap) -> (designcraft_geom::BezPath, Option) { let frame = designcraft_geom::corners::apply(&it.path, &it.corners); let kind = wrap.contour; @@ -765,11 +786,14 @@ fn wrap_obstacles( } let xf = parent * it.xf; let left = || left_page(parent.transform_rect_bbox(it.bounds()).center().x); + // An exclusion whose mode came back None (nothing visible to wrap) is left out. if it.wrap.mode != WrapMode::None { - out.push(exclusion(doc, it, xf, inv, left())); + out.extend(Some(exclusion(doc, it, xf, inv, left())).filter(|e| e.mode != WrapMode::None)); } match &it.content { - Content::Graphic(g) if g.wrap.mode != WrapMode::None => out.extend(graphic_exclusion(doc, it, xf, inv, left())), + Content::Graphic(g) if g.wrap.mode != WrapMode::None => { + out.extend(graphic_exclusion(doc, it, xf, inv, left()).filter(|e| e.mode != WrapMode::None)) + } Content::Group { .. } => { for c in it.shown_children() { wrap_obstacles(doc, c, xf, inv, skip, left_page, depth + 1, out); diff --git a/crates/engine/src/cmd/alpha_wrap.rs b/crates/engine/src/cmd/alpha_wrap.rs index cd6a1d00b..b4009de1d 100644 --- a/crates/engine/src/cmd/alpha_wrap.rs +++ b/crates/engine/src/cmd/alpha_wrap.rs @@ -91,7 +91,12 @@ pub(crate) fn trace(data: &Arc>, asset: AssetId, threshold: u8) -> Optio mask } }; - if let Some((outer, holes)) = rings(&mask, cols as usize, rows as usize) { + // Composition follows an outline of at most MAX_CONTOUR_POINTS path elements (holes + // included, for Include Inside Edges): a ring of k points is k + 1 elements with its close. + let fits = |outer: &[Vec<(i32, i32)>], holes: &[Vec<(i32, i32)>]| { + outer.iter().chain(holes).map(|r| r.len() + 1).sum::() <= designcraft_compose::MAX_CONTOUR_POINTS + }; + if let Some((outer, holes)) = rings(&mask, cols as usize, rows as usize).filter(|(o, h)| fits(o, h)) { let (sx, sy) = (f64::from(w) / f64::from(cols), f64::from(h) / f64::from(rows)); let path = |rings: &[Vec<(i32, i32)>]| { let mut p = BezPath::new(); @@ -240,15 +245,44 @@ mod tests { /// A 64 × 64 PNG: visible where `on(x, y)`. fn png(on: impl Fn(usize, usize) -> bool) -> Vec { - let mut pixels = vec![0; 64 * 64 * 4]; - for y in 0..64 { - for x in 0..64 { - let i = (y * 64 + x) * 4; + png_sized(64, on) + } + + /// A `size` × `size` PNG: visible where `on(x, y)`. + fn png_sized(size: usize, on: impl Fn(usize, usize) -> bool) -> Vec { + let mut pixels = vec![0; size * size * 4]; + for y in 0..size { + for x in 0..size { + let i = (y * size + x) * 4; pixels[i] = 120; pixels[i + 3] = if on(x, y) { 255 } else { 0 }; } } - designcraft_render::Rendered { width: 64, height: 64, pixels }.to_png() + designcraft_render::Rendered { width: size as u32, height: size as u32, pixels }.to_png() + } + + /// An outline too detailed for composition (here 4,096 separate dots, 20,480 path elements + /// on the finest grid) is traced on a coarser grid until composition can follow it. + #[test] + fn a_fragmented_outline_is_traced_coarser_until_composition_can_follow_it() { + let dots = png_sized(256, |x, y| x % 4 == 0 && y % 4 == 0); + let o = trace(&Arc::new(dots), AssetId(1), 25).unwrap(); + let elements = o.outer.to_bezpath().elements().len() + o.holes.to_bezpath().elements().len(); + assert!(elements > 0 && elements <= designcraft_compose::MAX_CONTOUR_POINTS, "{elements} path elements"); + } + + /// An image with no pixel more opaque than the threshold leaves the text alone: with the + /// frame's contour the caption over it is overset, with Alpha Channel it is set. + #[test] + fn an_image_with_nothing_visible_wraps_no_text() { + let (mut s, image, story) = page(&png(|_, _| false), [150.0, 150.0, 178.0, 178.0]); + assert!(overset(&s, story), "the frame's contour covers the caption"); + s.execute("object.textWrap", &json!({"ids": [image], "contour": "alphaChannel"})).unwrap(); + assert!(!overset(&s, story), "nothing visible: nothing wraps"); + // The same for the wrap set on the placed graphic. + s.execute("object.textWrap", &json!({"ids": [image], "mode": "none"})).unwrap(); + s.execute("object.textWrap", &json!({"ids": [image], "content": true, "mode": "contour", "contour": "alphaChannel"})).unwrap(); + assert!(!overset(&s, story)); } #[test] From 97ed25093f03ff03b9fc7c73466e8df64ad8581d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 20:23:59 -0800 Subject: [PATCH 24/38] Text wrap: a vertical open path keeps its offset, and previewed images are traced A contour's zero-width spans (a vertical open path) are kept until the wrap offset widens them, so text no longer crosses such a path. A record previewed into an image placeholder gets the image's Alpha Channel outline, as an edit would. --- crates/compose/src/lib.rs | 10 ++++++--- crates/compose/src/tests.rs | 15 +++++++++++++ crates/engine/src/cmd/datamerge.rs | 2 ++ crates/engine/src/cmd/datamerge/tests.rs | 27 ++++++++++++++++++++++++ 4 files changed, 51 insertions(+), 3 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index f0451f938..62e319f36 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -451,8 +451,10 @@ impl Contour { let mut cut = Vec::new(); let (mut i, mut j) = (0, 0); while let (Some(&(a, b)), Some(&(c, d))) = (spans.get(i), clip.get(j)) { - if a.max(c) < b.min(d) { - cut.push((a.max(c), b.min(d))); + let (lo, hi) = (a.max(c), b.min(d)); + // A zero-width span (a vertical open path) is kept where the clip covers it. + if lo < hi || (a == b && lo == hi) { + cut.push((lo, hi)); } if b < d { i += 1; @@ -466,8 +468,10 @@ impl Contour { } } +/// Sorted, overlapping spans joined. Zero-width spans (a vertical edge of an open path) are kept: +/// the wrap offset widens them, and without one they still split the line there. fn merge_spans(mut spans: Vec<(f64, f64)>) -> Vec<(f64, f64)> { - spans.retain(|(a, b)| a.is_finite() && b.is_finite() && b - a > 1e-9); + spans.retain(|(a, b)| a.is_finite() && b.is_finite() && b >= a); spans.sort_by(|a, b| a.0.total_cmp(&b.0)); let mut out: Vec<(f64, f64)> = Vec::new(); for (a, b) in spans { diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index e96e33cf4..3df861733 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -8464,6 +8464,21 @@ fn contour_intervals_cover_the_whole_slanted_line_band() { assert_eq!(c.band_spans(0.0, 10.0), vec![(0.0, 12.0)]); } +/// A vertical open path blocks the offset on both sides of it, and splits the line with no +/// offset; clipped to a frame that holds it, it still does. +#[test] +fn a_vertical_open_path_keeps_its_offset() { + let mut path = designcraft_geom::BezPath::new(); + path.move_to((10.0, 0.0)); + path.line_to((10.0, 100.0)); + let c = Contour::of(&path, designcraft_geom::Affine::IDENTITY, 3.0).unwrap(); + assert_eq!(c.band_spans(40.0, 50.0), vec![(7.0, 13.0)]); + let frame = designcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 100.0)).to_bezpath(); + assert_eq!(c.within(&frame, designcraft_geom::Affine::IDENTITY).band_spans(40.0, 50.0), vec![(7.0, 13.0)]); + let c = Contour::of(&path, designcraft_geom::Affine::IDENTITY, 0.0).unwrap(); + assert_eq!(c.band_spans(40.0, 50.0), vec![(10.0, 10.0)]); +} + /// Rule Above's offset runs from the first line's baseline up to the rule's bottom edge, as in /// InDesign: a rule set clear of the cap height sits between the rows, not across the row above /// (an INDD project list whose rules crossed the previous paragraph's text). diff --git a/crates/engine/src/cmd/datamerge.rs b/crates/engine/src/cmd/datamerge.rs index 7cb332fd3..aad5b72f3 100644 --- a/crates/engine/src/cmd/datamerge.rs +++ b/crates/engine/src/cmd/datamerge.rs @@ -210,6 +210,8 @@ fn preview_cmd(s: &mut Session, p: &Value) -> Result { let images = ImageCache::default(); let sources = Sources { options: &options, data_dir: data_dir.as_deref(), doc_dir: doc_dir.as_deref(), images: &images }; fill_row(&mut doc, &source.fields, &row, record as u32, &maps, &sources); + // An image the record placed gets its Alpha Channel outline, as an edit's would. + crate::cmd::alpha_wrap::sync(&mut doc); st.doc = Arc::new(doc); st.preview_record = Some(record as u32); st.bump_revision(); diff --git a/crates/engine/src/cmd/datamerge/tests.rs b/crates/engine/src/cmd/datamerge/tests.rs index 4793aac17..dad43af66 100644 --- a/crates/engine/src/cmd/datamerge/tests.rs +++ b/crates/engine/src/cmd/datamerge/tests.rs @@ -1310,3 +1310,30 @@ fn merged_records_give_anchored_objects_their_own_ids() { doc.check().unwrap(); designcraft_format::load(&designcraft_format::save(doc).unwrap()).unwrap(); } + +/// A record previewed into an image placeholder whose frame wraps with an Alpha Channel contour +/// gets the image's outline, as an edit would. +#[test] +fn a_previewed_image_gets_its_alpha_channel_outline() { + let dir = scratch("alpha-preview"); + let mut pixels = vec![0u8; 64 * 64 * 4]; + for y in 0..64 { + for x in 16..48 { + pixels[(y * 64 + x) * 4 + 3] = 255; + } + } + std::fs::write(dir.join("cut.png"), designcraft_render::Rendered { width: 64, height: 64, pixels }.to_png()).unwrap(); + write(&dir.join("pics.csv"), "Photo\ncut.png\n"); + let mut s = Session::new(); + new_doc(&mut s, 1); + exec(&mut s, "data.source.select", json!({"path": dir.join("pics.csv")})); + let frame = exec(&mut s, "frame.create", json!({"rect": [0.0, 0.0, 200.0, 100.0], "content": "graphic"})); + exec(&mut s, "data.placeholder.add", json!({"field": "Photo", "role": "image", "item": frame["id"]})); + exec(&mut s, "object.textWrap", json!({"ids": [frame["id"]], "mode": "contour", "contour": "alphaChannel"})); + assert!(s.doc().unwrap().doc.alpha_outlines.is_empty(), "no image placed yet"); + exec(&mut s, "data.preview", json!({"record": 1})); + let d = &s.doc().unwrap().doc; + assert_eq!(d.alpha_outlines.len(), 1, "the previewed image is traced"); + assert!(d.alpha_outlines[0].width > 0 && !d.alpha_outlines[0].outer.subpaths.is_empty()); + let _ = std::fs::remove_dir_all(&dir); +} From f27e2c380bac9221e1234fc17423b2c35573f3bb Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 20:30:25 -0800 Subject: [PATCH 25/38] Data merge: overset count composes with the merged images' Alpha Channel outlines --- crates/engine/src/cmd/datamerge.rs | 2 ++ crates/engine/src/cmd/datamerge/tests.rs | 27 ++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/crates/engine/src/cmd/datamerge.rs b/crates/engine/src/cmd/datamerge.rs index aad5b72f3..84e8b65e3 100644 --- a/crates/engine/src/cmd/datamerge.rs +++ b/crates/engine/src/cmd/datamerge.rs @@ -263,6 +263,8 @@ fn merge_cmd(s: &mut Session, p: &Value) -> Result { merged.data_merge.placeholders.clear(); merged.title = format!("{} merged", src.title); let pages = merged.page_count(); + // The merged images' Alpha Channel outlines first: the overset count composes with them. + crate::cmd::alpha_wrap::sync(&mut merged); let overset = overset_count(&merged); let records = selected.len(); let missing_json: Vec = missing.iter().map(|m| json!({"record": m.record, "field": m.field, "path": m.path})).collect(); diff --git a/crates/engine/src/cmd/datamerge/tests.rs b/crates/engine/src/cmd/datamerge/tests.rs index dad43af66..062ff9483 100644 --- a/crates/engine/src/cmd/datamerge/tests.rs +++ b/crates/engine/src/cmd/datamerge/tests.rs @@ -1337,3 +1337,30 @@ fn a_previewed_image_gets_its_alpha_channel_outline() { assert!(d.alpha_outlines[0].width > 0 && !d.alpha_outlines[0].outer.subpaths.is_empty()); let _ = std::fs::remove_dir_all(&dir); } + +/// The overset count data merge reports is composed with the merged images' Alpha Channel +/// outlines: a caption over the image's transparent part is set, not counted as overset. +#[test] +fn a_merge_counts_overset_text_with_the_merged_images_outlines() { + let dir = scratch("alpha-merge"); + // Visible only in the left quarter. + let mut pixels = vec![0u8; 64 * 64 * 4]; + for y in 0..64 { + for x in 0..16 { + pixels[(y * 64 + x) * 4 + 3] = 255; + } + } + std::fs::write(dir.join("cut.png"), designcraft_render::Rendered { width: 64, height: 64, pixels }.to_png()).unwrap(); + write(&dir.join("pics.csv"), "Photo\ncut.png\n"); + let mut s = Session::new(); + new_doc(&mut s, 1); + exec(&mut s, "data.source.select", json!({"path": dir.join("pics.csv")})); + let frame = exec(&mut s, "frame.create", json!({"rect": [100.0, 100.0, 300.0, 300.0], "content": "graphic"})); + exec(&mut s, "data.placeholder.add", json!({"field": "Photo", "role": "image", "item": frame["id"]})); + exec(&mut s, "data.options", json!({"fitting": "fitContentToFrame"})); + exec(&mut s, "object.textWrap", json!({"ids": [frame["id"]], "mode": "contour", "contour": "alphaChannel", "offset": 0})); + text_frame(&mut s, [220.0, 180.0, 290.0, 220.0], "Hi", None); + let r = exec(&mut s, "data.merge", json!({})); + assert_eq!(r["oversetStories"], 0, "the caption sits beside the image's visible pixels: {r}"); + let _ = std::fs::remove_dir_all(&dir); +} From 1455093812ee039a3e67acd495821b098f368791 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 20:37:03 -0800 Subject: [PATCH 26/38] Text wrap: the alpha threshold default is described as DesignCraft's own --- crates/doc/src/item/text_frame.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/doc/src/item/text_frame.rs b/crates/doc/src/item/text_frame.rs index c87b74e0a..62f12eb24 100644 --- a/crates/doc/src/item/text_frame.rs +++ b/crates/doc/src/item/text_frame.rs @@ -306,7 +306,7 @@ pub struct TextWrap { pub alpha_threshold: u8, } -/// InDesign's default alpha threshold for contour wraps. +/// DesignCraft's default alpha threshold for Alpha Channel contour wraps. pub const DEFAULT_ALPHA_THRESHOLD: u8 = 25; impl Default for TextWrap { From 9cea62b2380e447342fbddc0a5ecd29b87f6be55 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 20:48:43 -0800 Subject: [PATCH 27/38] Type: a repeated alignment records no undo step; menu-bar inserts stop with the window's shortcuts - Align Left, Center, Right, Justify Left, Justify All Lines and type.align leave the document and the undo list alone when every target paragraph already has that alignment as its own value. - On the Mac, Em Space, En Space and Current Page Number from the menu bar also do nothing while Quick Apply or a dialog is open, an input method is composing or a canvas context menu is open, the cases that stop the window's own shortcuts. - Tests for both, and for Justify All Lines across threaded frames, over a paragraph style, through IDML export and import, and with parameters it does not take. --- ROADMAP.md | 3 +- crates/engine/src/cmd/text.rs | 174 ++++++++++++++++++++++++++--- crates/ui-egui/src/menus/invoke.rs | 8 +- crates/ui-egui/src/menus/keys.rs | 2 +- crates/ui-egui/src/menus/tests.rs | 27 ++++- 5 files changed, 187 insertions(+), 27 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b5967dd01..8dc79c909 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -387,6 +387,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m ## Progress log +- **2026-11-12** · Type shortcuts: Justify All Lines ⇧⌘F, Insert White Space › Em Space ⇧⌘M and En Space ⇧⌘N (shown in the menu, enabled at an insertion point), Paragraph Rules… ⌥⌘J (shown in the Paragraph panel menu). On the Mac a menu-bar insert (Em/En Space, Current Page Number) no longer types into the document while a text field has focus, a dialog or Quick Apply is open, or an input method is composing. An alignment the paragraphs already have records no undo step (#1138). No readiness percentage change. - **2026-11-04** · Striped strokes retain out-and-back cubic segments whose second control point is away from the coincident endpoints and first control point. A geometry regression checks the offset extent and both stripe bands. No readiness percentage change. - **2026-11-04** · Esperanto is a text language (Character › Language, `type.char {language: "Esperanto"}`, IDML `$ID/Esperanto`): tag `eo`, English-style quotes; not hyphenated yet (#1201). No readiness percentage change. @@ -860,5 +861,3 @@ The shared craft-installer UI preserves this revision's install options and prov - **2026-10-15** · Windows recovery folders validate the identity of the live locked file without reading it through a second handle. Missing or replaced locks disable writes and cleanup; regression tests cover retained exclusive locking and replacement-file protection. No readiness percentage change. - PDF reader infrastructure: pinned shared parsing/rasterization, transformed page-box geometry and explicit failures for known incomplete layered-PDF exports. Application ownership and parity targets are unchanged. - -- **2026-10-16** · InDesign's type shortcuts: Justify All Lines ⇧⌘F, Insert White Space › Em Space ⇧⌘M and En Space ⇧⌘N (shown in the menu, enabled at an insertion point), Paragraph Rules… ⌥⌘J (shown in the Paragraph panel menu). On the Mac a menu-bar insert (Em/En Space, Current Page Number) no longer types into the document while a text field has focus. No readiness percentage change. diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index eb6e3e0b5..7f8def96c 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -2,7 +2,7 @@ use designcraft_compose as compose; use designcraft_doc::story::floor_char_boundary; -use designcraft_doc::{CellAddr, CharAttrs, Content, ItemId, ParaAttrs, Selection, StoryId, TextSel}; +use designcraft_doc::{Align, CellAddr, CharAttrs, Content, ItemId, ParaAttrs, Selection, StoryId, TextSel}; use designcraft_geom::Point; use serde_json::{Value, json}; @@ -240,21 +240,19 @@ pub fn specs() -> Vec { ), cmd!("type.align", "Align", [], None, "{align: left|center|right|leftJustified|…|null} (required)", has_text_or_frames, |s, p| { let align = p.get("align").cloned().ok_or_else(|| bad("type.align", "`align` required"))?; - format_paras(s, &json!({ "align": align })) + // A value that isn't an alignment goes to `format_paras`, which names the error. + match serde_json::from_value::>(align.clone()) { + Ok(a) => align_paras(s, a), + Err(_) => format_paras(s, &json!({ "align": align })), + } }), - cmd!("type.alignLeft", "Align Left", [], Some("Cmd+Shift+L"), "{}", has_text_or_frames, |s, _| format_paras(s, &json!({"align": "left"}))), - cmd!("type.alignCenter", "Align Center", [], Some("Cmd+Shift+C"), "{}", has_text_or_frames, |s, _| format_paras( + cmd!("type.alignLeft", "Align Left", [], Some("Cmd+Shift+L"), "{}", has_text_or_frames, |s, _| align_paras(s, Some(Align::Left))), + cmd!("type.alignCenter", "Align Center", [], Some("Cmd+Shift+C"), "{}", has_text_or_frames, |s, _| align_paras(s, Some(Align::Center))), + cmd!("type.alignRight", "Align Right", [], Some("Cmd+Shift+R"), "{}", has_text_or_frames, |s, _| align_paras(s, Some(Align::Right))), + cmd!("type.justify", "Justify Left", [], Some("Cmd+Shift+J"), "{}", has_text_or_frames, |s, _| align_paras(s, Some(Align::LeftJustified))), + cmd!("type.justifyAll", "Justify all lines", [], Some("Cmd+Shift+F"), "{}", has_text_or_frames, |s, _| align_paras( s, - &json!({"align": "center"}) - )), - cmd!("type.alignRight", "Align Right", [], Some("Cmd+Shift+R"), "{}", has_text_or_frames, |s, _| format_paras(s, &json!({"align": "right"}))), - cmd!("type.justify", "Justify Left", [], Some("Cmd+Shift+J"), "{}", has_text_or_frames, |s, _| format_paras( - s, - &json!({"align": "leftJustified"}) - )), - cmd!("type.justifyAll", "Justify all lines", [], Some("Cmd+Shift+F"), "{}", has_text_or_frames, |s, _| format_paras( - s, - &json!({"align": "fullyJustified"}) + Some(Align::FullyJustified) )), cmd!( "type.bold", @@ -448,24 +446,164 @@ pub fn specs() -> Vec { ] } +/// Sets the alignment of the paragraphs a formatting command targets (`None` removes the +/// override, back to the style's value). When each of them already carries that value as its +/// own, or there is no paragraph to change, the document is left as it is and no undo step is +/// recorded. +fn align_paras(s: &mut Session, align: Option) -> Result { + let targets = format_targets(s); + let doc = &s.doc()?.doc; + let unchanged = targets.iter().all(|t| { + doc.text_story(t.story, t.cell).is_none_or(|st| { + let (a, b) = (st.para_at(t.range.start), st.para_at(t.range.end.max(t.range.start))); + st.paras.get(a..=b).is_none_or(|paras| paras.iter().all(|p| p.para.align == align)) + }) + }); + if unchanged { + return ok(); + } + format_paras(s, &json!({ "align": align })) +} + #[cfg(test)] mod alignment_shortcut_tests { - use serde_json::json; + use designcraft_doc::{Align, Document}; + use serde_json::{Value, json}; use crate::Session; - /// InDesign's Justify All Lines is ⇧⌘F (Shift+Ctrl+F), next to Justify Left ⇧⌘J. + fn undo_steps(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() + } + + fn selection_align(s: &mut Session) -> Value { + s.execute("type.selectionAttrs", &json!({})).unwrap()["para"]["align"].clone() + } + + /// The resolved alignment of every paragraph of the story whose text starts with `start`. + fn story_aligns(d: &Document, start: &str) -> Vec { + let st = d.stories.values().find(|st| st.text.starts_with(start)).unwrap(); + st.paras.iter().map(|p| d.styles.resolve_para(p).0.align).collect() + } + + /// Justify All Lines is ⇧⌘F (Shift+Ctrl+F), next to Justify Left ⇧⌘J. #[test] - fn justify_all_lines_has_indesigns_shortcut_and_justifies_the_last_line_too() { + fn justify_all_lines_has_its_shortcut_and_justifies_the_last_line_too() { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text", "text": "Spring issue"})).unwrap(); s.execute("text.select", &json!({"story": r["story"], "anchor": 3, "focus": 3})).unwrap(); - // From Justify Left, so the result is not a default or a neighbouring justification. + // From Justify Left, so the result is not a default or a neighboring justification. s.execute("type.justify", &json!({})).unwrap(); assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["para"]["align"], "leftJustified"); s.execute("type.justifyAll", &json!({})).unwrap(); assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["para"]["align"], "fullyJustified"); assert_eq!(crate::find_command("type.justifyAll").and_then(|c| c.shortcut), Some("Cmd+Shift+F")); } + + /// A second press of an alignment shortcut, or `type.align` with the value the paragraphs + /// already have, leaves the document and the undo list alone. + #[test] + fn an_alignment_the_paragraphs_already_have_records_no_undo_step() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text", "text": "One\nTwo"})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 5})).unwrap(); + for (id, params, align) in [ + ("type.justifyAll", json!({}), "fullyJustified"), + ("type.align", json!({"align": "center"}), "center"), + ("type.alignLeft", json!({}), "left"), + ("type.alignCenter", json!({}), "center"), + ("type.alignRight", json!({}), "right"), + ("type.justify", json!({}), "leftJustified"), + ] { + let before = undo_steps(&s); + s.execute(id, ¶ms).unwrap(); + assert_eq!(selection_align(&mut s), align, "{id}"); + assert_eq!(undo_steps(&s), before + 1, "{id}: one step"); + let rev = s.doc().unwrap().doc.clone(); + s.execute(id, ¶ms).unwrap(); + assert_eq!(undo_steps(&s), before + 1, "{id} again: no step"); + assert!(std::sync::Arc::ptr_eq(&rev, &s.doc().unwrap().doc), "{id} again: the document is unchanged"); + } + // Removing the override: once it is gone, a second removal does nothing. + s.execute("type.align", &json!({"align": null})).unwrap(); + let before = undo_steps(&s); + s.execute("type.align", &json!({"align": null})).unwrap(); + assert_eq!(undo_steps(&s), before); + // One paragraph that differs is enough to apply it, to both. + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); + s.execute("type.justifyAll", &json!({})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 5})).unwrap(); + let before = undo_steps(&s); + s.execute("type.justifyAll", &json!({})).unwrap(); + assert_eq!(undo_steps(&s), before + 1); + assert_eq!(story_aligns(&s.doc().unwrap().doc, "One"), [Align::FullyJustified; 2]); + } + + /// The shortcut commands take no parameters: a misspelled call is refused and changes nothing. + #[test] + fn justify_all_lines_refuses_parameters() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text", "text": "Spring"})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 1, "focus": 1})).unwrap(); + let before = undo_steps(&s); + assert!(s.execute("type.justifyAll", &json!({"align": "left"})).is_err()); + assert_eq!(selection_align(&mut s), "left"); + assert_eq!(undo_steps(&s), before); + } + + /// With text frames selected, every paragraph of their stories, across threaded frames; + /// other selected frames are skipped, and a selection with no text frame is refused. + #[test] + fn justify_all_lines_on_frames_reaches_threaded_text_and_skips_other_frames() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = s.execute("frame.create", &json!({"rect": [36, 36, 300, 60], "content": "text", "text": "Alpha\nBeta\nGamma\nDelta"})).unwrap(); + let b = s.execute("frame.create", &json!({"rect": [36, 300, 300, 500], "content": "text"})).unwrap(); + s.execute("text.thread", &json!({"from": a["id"], "to": b["id"]})).unwrap(); + let rect = s.execute("frame.create", &json!({"rect": [400, 36, 500, 136]})).unwrap(); + s.execute("selection.set", &json!({"ids": [rect["id"]]})).unwrap(); + let before = undo_steps(&s); + assert!(s.execute("type.justifyAll", &json!({})).is_err(), "no text frame selected"); + assert_eq!(undo_steps(&s), before); + // The second frame of the thread and the rectangle. + s.execute("selection.set", &json!({"ids": [b["id"], rect["id"]]})).unwrap(); + s.execute("type.justifyAll", &json!({})).unwrap(); + assert_eq!(undo_steps(&s), before + 1); + assert_eq!(story_aligns(&s.doc().unwrap().doc, "Alpha"), [Align::FullyJustified; 4]); + } + + /// Over a paragraph style's alignment, Justify All Lines is a local override: the style keeps + /// its value, and removing the override goes back to it. + #[test] + fn justify_all_lines_overrides_the_paragraph_style() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text", "text": "Spring issue"})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 2, "focus": 2})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Caption", "para": {"align": "right"}})).unwrap(); + s.execute("style.paragraph.apply", &json!({"name": "Caption"})).unwrap(); + assert_eq!(selection_align(&mut s), "right"); + s.execute("type.justifyAll", &json!({})).unwrap(); + assert_eq!(selection_align(&mut s), "fullyJustified"); + let d = &s.doc().unwrap().doc; + assert_eq!(d.styles.resolve_para_style("Caption").0.align, Align::Right); + s.execute("type.align", &json!({"align": null})).unwrap(); + assert_eq!(selection_align(&mut s), "right"); + } + + /// IDML writes the paragraph's Justification as FullyJustified and reads it back. + #[test] + fn justify_all_lines_survives_idml_export_and_import() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text", "text": "Spring\nissue"})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); + s.execute("type.justifyAll", &json!({})).unwrap(); + let bytes = designcraft_idml::export_idml(&s.doc().unwrap().doc); + s.execute("file.openIdml", &json!({"base64": crate::cmd::base64_encode(&bytes), "name": "in.idml"})).unwrap(); + assert_eq!(story_aligns(&s.doc().unwrap().doc, "Spring"), [Align::FullyJustified, Align::Left]); + } } diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index 33096849d..492cdc7e2 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -146,9 +146,11 @@ pub fn activate_native(app: &mut DesignApp, ctx: &egui::Context, id: &str, param if params.is_null() && ctx.text_edit_focused() && forward_to_text_edit(app, ctx, id) { return; } - // The menu bar takes ⇧⌘M before the window: with a field focused (Story Editor, Find/Change) - // or a dialog open, the character would land at the canvas caret behind it. - if params.is_null() && (ctx.text_edit_focused() || app.ui.dialog.is_some()) && TEXT_INSERTS.contains(&id) { + // The menu bar takes ⇧⌘M before the window: with a field focused (Story Editor, Find/Change), + // a dialog or Quick Apply open, an input method composing or a context menu open, the + // character would land at the canvas caret behind it. The window's shortcuts stop for the + // same cases. + if params.is_null() && (ctx.text_edit_focused() || super::keys::keys_taken(app, ctx)) && TEXT_INSERTS.contains(&id) { return; } // The canvas text editor is not an egui TextEdit, so its clipboard events must pass through diff --git a/crates/ui-egui/src/menus/keys.rs b/crates/ui-egui/src/menus/keys.rs index 8b2179469..a4cf59eed 100644 --- a/crates/ui-egui/src/menus/keys.rs +++ b/crates/ui-egui/src/menus/keys.rs @@ -66,7 +66,7 @@ pub fn forwarded_shortcuts(app: &mut DesignApp, ctx: &egui::Context, events: Vec /// Whether something other than the shortcuts has the keyboard: a dialog or Quick Apply, an input /// method composing at the text caret, an open canvas context menu (Escape closes it, as a /// native menu would) or the menu search. -fn keys_taken(app: &DesignApp, ctx: &egui::Context) -> bool { +pub(super) fn keys_taken(app: &DesignApp, ctx: &egui::Context) -> bool { app.ui.dialog.is_some() || app.ui.palette.is_some() || app.session.tool_composing() diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index 5958bcd4d..24ccb02a0 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -2499,14 +2499,14 @@ fn story_text(app: &crate::DesignApp, story: &Value) -> String { } #[test] -fn indesigns_type_shortcuts_insert_spaces_justify_all_lines_and_open_paragraph_rules() { +fn type_shortcuts_insert_spaces_justify_all_lines_and_open_paragraph_rules() { use egui::{Key, Modifiers}; let ctx = egui::Context::default(); let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); app.run("file.new", json!({})).unwrap(); let frame = app.run("frame.create", json!({"rect": [36, 36, 300, 200], "content": "text", "text": "ab"})).unwrap(); let story = frame["story"].clone(); - // A selected frame and no insertion point: the spaces have nowhere to go, as in InDesign. + // A selected frame and no insertion point: the spaces have nowhere to go. app.run("selection.set", json!({"ids": [frame["id"]]})).unwrap(); assert!(app.session.doc().unwrap().selection.text.is_none()); assert!(!menu_enabled(&app, "app.insertEmSpace") && !menu_enabled(&app, "app.insertEnSpace")); @@ -2521,7 +2521,7 @@ fn indesigns_type_shortcuts_insert_spaces_justify_all_lines_and_open_paragraph_r app.run("type.justify", json!({})).unwrap(); press(&mut app, &ctx, Key::F, Modifiers::COMMAND | Modifiers::SHIFT); assert_eq!(app.run("type.selectionAttrs", json!({})).unwrap()["para"]["align"], "fullyJustified", "⇧⌘F justifies all lines"); - // Guard: ⌥⇧⌘J is another chord (InDesign's Justification…); the rules want Shift up. + // Guard: ⌥⇧⌘J is another chord (Justification…); the rules want Shift up. press(&mut app, &ctx, Key::J, Modifiers::COMMAND | Modifiers::ALT | Modifiers::SHIFT); assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("justification"), "⌥⇧⌘J opens Justification"); app.ui.dialog = None; @@ -2581,6 +2581,27 @@ fn native_insert_shortcuts_leave_the_document_alone_while_a_field_has_focus() { assert_eq!(app.ui.palette.as_deref(), Some("x")); } +#[test] +fn native_insert_shortcuts_leave_the_document_alone_while_a_dialog_or_quick_apply_is_open() { + // The same cases stop the window's shortcuts. In the first frame nothing has keyboard focus + // yet, so only the open dialog or Quick Apply keeps the character out of the story. + for open in ["", "dialog", "palette"] { + let ctx = egui::Context::default(); + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", &json!({})).unwrap(); + let story = app.run("frame.create", json!({"rect": [36, 36, 300, 200], "content": "text", "text": "ab"})).unwrap()["story"].clone(); + app.run("text.select", json!({"story": story, "anchor": 1, "focus": 1})).unwrap(); + match open { + "dialog" => app.ui.dialog = Some(crate::dialogs::Dialog::new("paragraphRules", json!({}))), + "palette" => app.ui.palette = Some(String::new()), + _ => {} + } + native_frame(&mut app, &ctx, vec![], Some("app.insertEmSpace")); + let want = if open.is_empty() { "a\u{2003}b" } else { "ab" }; + assert_eq!(story_text(&app, &story), want, "{open:?}"); + } +} + #[test] fn custom_workspaces_save_apply_and_delete() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); From b6d6d7e0911a92dd0a1779a7912ba2fababe8c40 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 21:26:11 -0800 Subject: [PATCH 28/38] Text wrap: relinked images and graphic wraps on anchored frames update the layout - The composition cache compares each Alpha Channel outline by a serial number, so a relink or link update lays the text out again around the new image. - A contour clipped by its frame blocks only the part of the outline the frame shows, even where the two share x positions in one line band. A fragmented frame, or shapes too detailed for a line's budget, keep the intersection of the two projections. - An anchored frame whose wrap is set only on its placed graphic wraps text. - The default alpha threshold is not saved. - docs/typography-parity.md describes the Alpha Channel contour. --- crates/compose/src/cache.rs | 10 +- crates/compose/src/lib.rs | 193 +++++++++++++++++++++++++--- crates/compose/src/tests.rs | 77 +++++++++++ crates/compose/src/wrap.rs | 5 +- crates/doc/src/item/text_frame.rs | 29 ++++- crates/doc/src/lib.rs | 10 ++ crates/engine/src/cmd/alpha_wrap.rs | 38 +++++- docs/typography-parity.md | 2 +- 8 files changed, 337 insertions(+), 27 deletions(-) diff --git a/crates/compose/src/cache.rs b/crates/compose/src/cache.rs index 5e4cb0911..72ef7fff4 100644 --- a/crates/compose/src/cache.rs +++ b/crates/compose/src/cache.rs @@ -5,7 +5,8 @@ //! wrapping item on the frames' spreads, and the same for every story whose anchored objects wrap //! its text ([`crate::wrap::neighbours`]). An anchored frame's entry also records where its host //! story's composition places it. Entries hold clones of those `Arc`s, so the pointers -//! can't be reused by other allocations while the entry lives. +//! can't be reused by other allocations while the entry lives. The document's Alpha Channel +//! outlines count by serial number, which no other outline shares. use std::collections::{HashMap, HashSet}; use std::sync::{Arc, Mutex, PoisonError}; @@ -85,6 +86,9 @@ fn doc_signature(doc: &Document) -> u64 { (Arc::as_ptr(&doc.styles) as usize).hash(&mut h); format!("{:?}", doc.text_variables).hash(&mut h); sections_hash(doc).hash(&mut h); + for o in &doc.alpha_outlines { + o.serial.hash(&mut h); + } h.finish() } @@ -407,6 +411,10 @@ fn signature(doc: &Document, story: &Arc) -> (Vec, Vec>) usize::from(g.relative_to == designcraft_doc::GridRelative::TopMargin), ]; sig.extend([g.start, g.increment].into_iter().flat_map(bits)); + // Alpha Channel outlines: a relink traces one again without changing the story or the items + // that wrap it. + sig.extend(doc.alpha_outlines.iter().flat_map(|o| [(o.serial & 0xffff_ffff) as usize, (o.serial >> 32) as usize])); + sig.push(usize::MAX); let mut keep = Vec::new(); let mut spreads = Vec::new(); for f in &story.frames { diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 62e319f36..ac563b616 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -388,6 +388,10 @@ pub struct Contour { /// Points a contour outline may have; a larger outline wraps around its bounding box. pub const MAX_CONTOUR_POINTS: usize = 8192; +/// Edge visits a clipped contour may take for one line band; past it, the line's spans come from +/// the two shapes' projections instead of their intersection. +const MAX_CLIPPED_WORK: usize = 1 << 20; + impl Contour { /// The outline of `path` (after `to_inner`), flattened; None if it is empty or too detailed. fn of(path: &designcraft_geom::BezPath, to_inner: designcraft_geom::Affine, offset: f64) -> Option { @@ -448,26 +452,164 @@ impl Contour { } if !self.clip.is_empty() { let clip = rings_band_spans(&self.clip, low, high); - let mut cut = Vec::new(); - let (mut i, mut j) = (0, 0); - while let (Some(&(a, b)), Some(&(c, d))) = (spans.get(i), clip.get(j)) { - let (lo, hi) = (a.max(c), b.min(d)); - // A zero-width span (a vertical open path) is kept where the clip covers it. - if lo < hi || (a == b && lo == hi) { - cut.push((lo, hi)); - } - if b < d { - i += 1; - } else { - j += 1; - } - } - spans = cut; + // A fragmented frame, or shapes too detailed to compare within each line's budget, + // take the common part of the two projections, which can block more than the frame + // shows. + let exact = if clip.len() > 128 { None } else { clipped_band_spans(&self.rings, &self.clip, low, high) }; + spans = exact.unwrap_or_else(|| intersect_spans(&spans, &clip)); } merge_spans(spans.into_iter().map(|(a, b)| (a - self.offset, b + self.offset)).collect()) } } +/// The sorted spans in both `a` and `b` (each sorted and merged). A zero-width span (a vertical +/// open path) is kept where the other covers it. +fn intersect_spans(a: &[(f64, f64)], b: &[(f64, f64)]) -> Vec<(f64, f64)> { + let mut out = Vec::new(); + let (mut i, mut j) = (0, 0); + while let (Some(&(p, q)), Some(&(r, s))) = (a.get(i), b.get(j)) { + let (lo, hi) = (p.max(r), q.min(s)); + if lo < hi || (lo == hi && (p == q || r == s)) { + out.push((lo, hi)); + } + if q < s { + i += 1; + } else { + j += 1; + } + } + out +} + +/// Occupied intervals across the band `y0..y1` of the part of `rings` inside `clip` (both filled +/// by the non-zero rule). A vertical line through a point of that part either crosses the middle +/// of the band inside both shapes, or stops inside the band on an edge of one shape that lies +/// inside the other. The spans are the middle slice of both shapes and the parts of each +/// shape's edges in the band that lie inside the other, so a part of the outline outside the +/// clip blocks nothing, even where it shares x positions with the clip in the same band. +/// None when that takes more than [`MAX_CLIPPED_WORK`] edge visits. +fn clipped_band_spans(rings: &[Vec], clip: &[Vec], y0: f64, y1: f64) -> Option> { + let middle = y0 * 0.5 + y1 * 0.5; + let mut spans = intersect_spans(&merge_spans(middle_spans(rings, middle)), &merge_spans(middle_spans(clip, middle))); + let mut budget = MAX_CLIPPED_WORK; + edges_inside(rings, clip, y0, y1, &mut budget, &mut spans)?; + edges_inside(clip, rings, y0, y1, &mut budget, &mut spans)?; + Some(merge_spans(spans)) +} + +/// Takes `n` edge visits from `budget`; None when it doesn't hold that many. +fn spend(budget: &mut usize, n: usize) -> Option<()> { + *budget = budget.checked_sub(n)?; + Some(()) +} + +/// Pushes to `out` the x-ranges of the parts of `rings`' edges within the band `y0..y1` that lie +/// inside `other` or on its boundary. None when `budget` runs out. +fn edges_inside(rings: &[Vec], other: &[Vec], y0: f64, y1: f64, budget: &mut usize, out: &mut Vec<(f64, f64)>) -> Option<()> { + let n = other.iter().map(Vec::len).sum::().max(1); + for ring in rings { + let Some(&last) = ring.last() else { continue }; + let mut a = last; + for &b in ring { + let dy = b.y - a.y; + if a.y.max(b.y) >= y0 && a.y.min(b.y) <= y1 { + let (t0, t1) = if dy.abs() < 1e-12 { + (0.0, 1.0) + } else { + let (u, v) = ((y0 - a.y) / dy, (y1 - a.y) / dy); + (u.min(v).clamp(0.0, 1.0), u.max(v).clamp(0.0, 1.0)) + }; + let (p, q) = (a + (b - a) * t0, a + (b - a) * t1); + segment_inside(p, q, other, n, budget, out)?; + } + a = b; + } + } + Some(()) +} + +/// Pushes to `out` the x-ranges of the parts of segment `p`–`q` inside `rings` (`n` edges) or on +/// their boundary: the segment is split where it meets their edges, and each piece is tested at +/// its middle. Each pass over the edges takes `n` from `budget`; None when it runs out. +fn segment_inside(p: Point, q: Point, rings: &[Vec], n: usize, budget: &mut usize, out: &mut Vec<(f64, f64)>) -> Option<()> { + let r = q - p; + let len2 = r.hypot2(); + if !len2.is_finite() { + return Some(()); + } + let at = |t: f64| p + r * t; + spend(budget, n)?; + if len2 < 1e-24 { + if inside_or_on(rings, p) { + out.push((p.x, p.x)); + } + return Some(()); + } + let (ylo, yhi) = (p.y.min(q.y), p.y.max(q.y)); + let mut ts = vec![0.0, 1.0]; + for ring in rings { + let Some(&last) = ring.last() else { continue }; + let mut c = last; + for &d in ring { + if c.y.max(d.y) >= ylo - 1e-9 && c.y.min(d.y) <= yhi + 1e-9 { + let e = d - c; + let denom = r.cross(e); + let w = c - p; + if denom.abs() > 1e-12 * (len2 * e.hypot2()).sqrt() { + let (t, u) = (w.cross(e) / denom, w.cross(r) / denom); + if (-1e-9..=1.0 + 1e-9).contains(&u) && (0.0..=1.0).contains(&t) { + ts.push(t); + } + } else if w.cross(r).abs() <= 1e-9 * len2.sqrt() { + // Collinear: the edge's ends split the segment. + for t in [w.dot(r) / len2, (d - p).dot(r) / len2] { + if (0.0..=1.0).contains(&t) { + ts.push(t); + } + } + } + } + c = d; + } + } + ts.sort_by(f64::total_cmp); + ts.dedup_by(|a, b| (*a - *b).abs() < 1e-12); + for w in ts.windows(2) { + let (u, v) = (w[0], w[1]); + spend(budget, n)?; + if inside_or_on(rings, at(u * 0.5 + v * 0.5)) { + let (x0, x1) = (at(u).x, at(v).x); + out.push((x0.min(x1), x0.max(x1))); + } + } + Some(()) +} + +/// Is `pt` inside `rings` (non-zero rule) or within a hair of one of their edges? +fn inside_or_on(rings: &[Vec], pt: Point) -> bool { + let mut winding = 0i32; + for ring in rings { + let Some(&last) = ring.last() else { continue }; + let mut a = last; + for &b in ring { + let e = b - a; + let len2 = e.hypot2(); + let t = if len2 > 0.0 { ((pt - a).dot(e) / len2).clamp(0.0, 1.0) } else { 0.0 }; + if (a + e * t - pt).hypot2() < 1e-18 { + return true; + } + if (a.y <= pt.y && pt.y < b.y) || (b.y <= pt.y && pt.y < a.y) { + let x = a.x + (pt.y - a.y) / e.y * e.x; + if x > pt.x { + winding += if e.y > 0.0 { 1 } else { -1 }; + } + } + a = b; + } + } + winding != 0 +} + /// Sorted, overlapping spans joined. Zero-width spans (a vertical edge of an open path) are kept: /// the wrap offset widens them, and without one they still split the line there. fn merge_spans(mut spans: Vec<(f64, f64)>) -> Vec<(f64, f64)> { @@ -485,9 +627,7 @@ fn merge_spans(mut spans: Vec<(f64, f64)>) -> Vec<(f64, f64)> { } fn rings_band_spans(rings: &[Vec], y0: f64, y1: f64) -> Vec<(f64, f64)> { - let mut spans = Vec::new(); - let mut crossings = Vec::new(); - let middle = y0 * 0.5 + y1 * 0.5; + let mut spans = middle_spans(rings, y0 * 0.5 + y1 * 0.5); for ring in rings { let Some(&last) = ring.last() else { continue }; let mut a = last; @@ -503,6 +643,21 @@ fn rings_band_spans(rings: &[Vec], y0: f64, y1: f64) -> Vec<(f64, f64)> { let (x0, x1) = (a.x + t0 * (b.x - a.x), a.x + t1 * (b.x - a.x)); spans.push((x0.min(x1), x0.max(x1))); } + a = b; + } + } + merge_spans(spans) +} + +/// The intervals of the line at height `middle` inside `rings` (non-zero rule), unsorted. +fn middle_spans(rings: &[Vec], middle: f64) -> Vec<(f64, f64)> { + let mut spans = Vec::new(); + let mut crossings = Vec::new(); + for ring in rings { + let Some(&last) = ring.last() else { continue }; + let mut a = last; + for &b in ring { + let dy = b.y - a.y; if (a.y <= middle && middle < b.y) || (b.y <= middle && middle < a.y) { let x = a.x + (middle - a.y) / dy * (b.x - a.x); if x.is_finite() { @@ -524,7 +679,7 @@ fn rings_band_spans(rings: &[Vec], y0: f64, y1: f64) -> Vec<(f64, f64)> { winding += delta; previous = Some(x); } - merge_spans(spans) + spans } /// The closed polylines of `path` (after `to_inner`), flattened; None if it is empty or too detailed. diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 3df861733..ab108b03b 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -7581,6 +7581,38 @@ fn anchored_object_wraps_another_storys_text() { assert!(lines_in_band(&moved, 200.0, 260.0).iter().all(|l| l.x0 >= left + 100.0 - 1e-6)); } +/// An anchored frame whose wrap is set only on its placed graphic (Direct Selection) wraps +/// another story's text as a wrap set on the frame does. +#[test] +fn anchored_graphic_wrap_wraps_another_storys_text() { + let long = [LOREM; 3].join(" "); + let (mut d, a, fa) = doc_with("Anchor here.", Rect::new(0.0, 0.0, 300.0, 400.0), ParaAttrs::default()); + let lid = d.default_layer(); + let (_, b) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(320.0, 0.0, 620.0, 400.0), lid, &long, ParaFormat::default()).unwrap(); + let left = all_lines(&compose_story(&d, b, &ComposeOptions::default()))[0].x0; + let area = d.item(fa).unwrap().text_area(); + anchor_box(&mut d, a, 0, left - area.x0, 100.0 - area.y0, false, WrapMode::None); + let wrap = designcraft_doc::TextWrap { mode: WrapMode::BoundingBox, ..Default::default() }; + let obj = std::sync::Arc::make_mut(&mut d.story_mut(a).unwrap().objects[0]); + obj.item.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic { + layer_options: None, + asset: designcraft_doc::AssetId(1), + size: (100.0, 60.0), + xf: designcraft_geom::Affine::IDENTITY, + clip: None, + auto_fit: designcraft_doc::Fitting::None, + fit_align: 0, + crop: [0.0; 4], + wrap, + fill: Default::default(), + }); + let cs = compose_story(&d, b, &ComposeOptions::default()); + let band = lines_in_band(&cs, 100.0, 160.0); + assert!(!band.is_empty()); + assert!(band.iter().all(|l| l.x0 >= left + 100.0 - 1e-6), "{:?}", band.iter().map(|l| l.x0).collect::>()); + assert!(lines_in_band(&cs, 0.0, 80.0).iter().all(|l| (l.x0 - left).abs() < 1e-6)); +} + /// An anchored object's wrap is built as a page item's: a contour on both sides of an oval sets /// text left and right of it, closer in where the oval narrows. #[test] @@ -8479,6 +8511,51 @@ fn a_vertical_open_path_keeps_its_offset() { assert_eq!(c.band_spans(40.0, 50.0), vec![(10.0, 10.0)]); } +/// An outline counts only where its frame shows it: a part of it in a notch of the frame blocks +/// nothing, even where the frame covers the same x positions lower in the same line band. +#[test] +fn a_clipped_contour_blocks_only_what_its_frame_shows() { + let poly = |pts: &[(f64, f64)]| { + let mut p = designcraft_geom::BezPath::new(); + p.move_to(pts[0]); + for &q in &pts[1..] { + p.line_to(q); + } + p.close_path(); + p + }; + let id = designcraft_geom::Affine::IDENTITY; + // An L-shaped frame with a notch at x 10..40 above y = 6. + let frame = poly(&[(0.0, 0.0), (10.0, 0.0), (10.0, 6.0), (40.0, 6.0), (40.0, 10.0), (0.0, 10.0)]); + // An outline in the notch only. + let c = Contour::of(&poly(&[(20.0, 0.0), (40.0, 0.0), (40.0, 4.0), (20.0, 4.0)]), id, 0.0).unwrap(); + assert_eq!(c.band_spans(0.0, 10.0), vec![(20.0, 40.0)]); + assert_eq!(c.within(&frame, id).band_spans(0.0, 10.0), Vec::<(f64, f64)>::new(), "the frame shows none of it"); + // Reaching below the notch, where the frame shows it from y = 6 to 8. + let c = Contour::of(&poly(&[(20.0, 0.0), (40.0, 0.0), (40.0, 8.0), (20.0, 8.0)]), id, 0.0).unwrap(); + assert_eq!(c.within(&frame, id).band_spans(0.0, 10.0), vec![(20.0, 40.0)]); + // A band that crosses both shapes whole keeps their common part, widened by the offset. + let c = Contour::of(&poly(&[(5.0, -10.0), (30.0, -10.0), (30.0, 20.0), (5.0, 20.0)]), id, 2.0).unwrap(); + assert_eq!(c.within(&frame, id).band_spans(7.0, 9.0), vec![(3.0, 32.0)]); + // A frame in more than 128 pieces across the band takes the common part of the projections. + let mut fragmented = frame.clone(); + for i in 0..200 { + let x = 1000.0 + 2.0 * f64::from(i); + fragmented.extend(poly(&[(x, 0.0), (x + 1.0, 0.0), (x + 1.0, 10.0), (x, 10.0)]).elements().iter().copied()); + } + let c = Contour::of(&poly(&[(20.0, 0.0), (40.0, 0.0), (40.0, 4.0), (20.0, 4.0)]), id, 0.0).unwrap(); + assert_eq!(c.within(&fragmented, id).band_spans(0.0, 10.0), vec![(20.0, 40.0)]); + // So does a frame and an outline too detailed to compare within a line's budget: here 2,000 + // points on the frame's bottom edge and on the outline's top edge. + let mut detailed = vec![(0.0, 0.0), (10.0, 0.0), (10.0, 6.0), (40.0, 6.0), (40.0, 10.0)]; + detailed.extend((1..2000).map(|i| (40.0 - 0.02 * f64::from(i), 10.0))); + detailed.push((0.0, 10.0)); + let mut notch: Vec<(f64, f64)> = (0..2000).map(|i| (20.0 + 0.01 * f64::from(i), 0.0)).collect(); + notch.extend([(40.0, 0.0), (40.0, 4.0), (20.0, 4.0)]); + let c = Contour::of(&poly(¬ch), id, 0.0).unwrap(); + assert_eq!(c.within(&poly(&detailed), id).band_spans(0.0, 10.0), vec![(20.0, 40.0)]); +} + /// Rule Above's offset runs from the first line's baseline up to the rule's bottom edge, as in /// InDesign: a rule set clear of the cap height sits between the rows, not across the row above /// (an INDD project list whose rules crossed the previous paragraph's text). diff --git a/crates/compose/src/wrap.rs b/crates/compose/src/wrap.rs index 98219b066..6de1d3ace 100644 --- a/crates/compose/src/wrap.rs +++ b/crates/compose/src/wrap.rs @@ -18,7 +18,7 @@ //! is composed at most that many more times (at least [`MIN_PASSES`]), and never more than //! [`MAX_PASSES`]. -use designcraft_doc::{AnchorPosition, AnchoredObject, Document, Item, OBJECT_MARK, PageSide, SpreadRef, Story, StoryId, WrapMode}; +use designcraft_doc::{AnchorPosition, AnchoredObject, Document, Item, OBJECT_MARK, PageSide, SpreadRef, Story, StoryId}; use designcraft_geom::Affine; use crate::{ComposeOptions, ComposedStory, Exclusion, FrameSpec}; @@ -48,8 +48,9 @@ pub(crate) struct Obstacle<'a> { anchor: Option, } +/// The wrap may be set on the frame or on its placed graphic (Direct Selection). fn wraps(o: &AnchoredObject) -> bool { - o.item.wrap.mode != WrapMode::None && !o.item.hidden + o.item.has_wrap() && !o.item.hidden } /// Does `story` have an anchored object whose wrap can push text? diff --git a/crates/doc/src/item/text_frame.rs b/crates/doc/src/item/text_frame.rs index 62f12eb24..f31f14060 100644 --- a/crates/doc/src/item/text_frame.rs +++ b/crates/doc/src/item/text_frame.rs @@ -272,7 +272,7 @@ pub enum ContourType { BoundingBox, /// Edges detected in the image. Composition follows the frame path. DetectEdges, - /// The image's alpha channel. Composition follows the frame path. + /// The image's alpha channel. Composition follows the outline of the image's visible pixels. AlphaChannel, /// A path saved in the image. Composition follows the frame path. PhotoshopPath, @@ -302,10 +302,16 @@ pub struct TextWrap { /// transparent holes it encloses (off: holes are blocked with the shape around them). #[serde(skip_serializing_if = "std::ops::Not::not")] pub inside_edges: bool, - /// Alpha Channel contour: pixels more opaque than this (0..254) count as the image. + /// Alpha Channel contour: pixels more opaque than this (0..254) count as the image. Saved only + /// when it isn't the default. + #[serde(skip_serializing_if = "is_default_alpha_threshold")] pub alpha_threshold: u8, } +fn is_default_alpha_threshold(t: &u8) -> bool { + *t == DEFAULT_ALPHA_THRESHOLD +} + /// DesignCraft's default alpha threshold for Alpha Channel contour wraps. pub const DEFAULT_ALPHA_THRESHOLD: u8 = 25; @@ -343,3 +349,22 @@ pub struct TextFrame { #[serde(default)] pub options: TextFrameOptions, } + +#[cfg(test)] +mod tests { + use super::*; + + /// The alpha threshold is saved only when it isn't the default, so a wrap saved before it + /// existed is saved the same way, and reads back as the default. + #[test] + fn the_default_alpha_threshold_is_not_saved() { + let wrap = TextWrap { mode: WrapMode::Contour, ..Default::default() }; + let v = serde_json::to_value(&wrap).unwrap(); + assert!(v.get("alphaThreshold").is_none(), "{v}"); + assert_eq!(serde_json::from_value::(v).unwrap(), wrap); + let set = TextWrap { alpha_threshold: 200, ..wrap }; + let v = serde_json::to_value(&set).unwrap(); + assert_eq!(v["alphaThreshold"], 200); + assert_eq!(serde_json::from_value::(v).unwrap(), set); + } +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 8f105457c..f26ef10ad 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -674,9 +674,19 @@ pub struct AlphaOutline { /// under the same id, and the outline is traced again. (Weak, so it doesn't keep old image /// data alive, and its address can't be reused by new data while it's held.) pub source: std::sync::Weak>, + /// A number no other outline made in this process has ([`AlphaOutline::next_serial`]). + /// Composition caches compare it: an outline traced again after a relink has a new one, + /// while a dropped outline's address can be reused. + pub serial: u64, } impl AlphaOutline { + /// A serial number not given out before in this process. + pub fn next_serial() -> u64 { + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + } + /// Traced from `data`, the asset's current image data. pub fn traced_from(&self, data: &Arc>) -> bool { std::ptr::eq(self.source.as_ptr(), Arc::as_ptr(data)) diff --git a/crates/engine/src/cmd/alpha_wrap.rs b/crates/engine/src/cmd/alpha_wrap.rs index b4009de1d..1ee2919a1 100644 --- a/crates/engine/src/cmd/alpha_wrap.rs +++ b/crates/engine/src/cmd/alpha_wrap.rs @@ -62,7 +62,16 @@ pub(crate) fn sync(doc: &mut Document) { /// The empty outline standing for an image `trace` found none in: zero-size, which composition /// takes as no outline, and dropped like a traced one when the asset's data changes. fn no_outline(data: &Arc>, asset: AssetId, threshold: u8) -> AlphaOutline { - AlphaOutline { asset, threshold, width: 0, height: 0, outer: Default::default(), holes: Default::default(), source: Arc::downgrade(data) } + AlphaOutline { + asset, + threshold, + width: 0, + height: 0, + outer: Default::default(), + holes: Default::default(), + source: Arc::downgrade(data), + serial: AlphaOutline::next_serial(), + } } /// The outline of the pixels of `data` more opaque than `threshold`. None for an image that @@ -112,7 +121,8 @@ pub(crate) fn trace(data: &Arc>, asset: AssetId, threshold: u8) -> Optio PathData::from_bezpath(&p) }; let source = Arc::downgrade(data); - return Some(AlphaOutline { asset, threshold, width: w, height: h, outer: path(&outer), holes: path(&holes), source }); + let serial = AlphaOutline::next_serial(); + return Some(AlphaOutline { asset, threshold, width: w, height: h, outer: path(&outer), holes: path(&holes), source, serial }); } finer = Some((mask, cols, rows)); grid /= 2; @@ -502,4 +512,28 @@ mod tests { assert!(s.execute("object.textWrap", &bad).is_err()); } } + + /// Replacing the image data under the same asset, as a relink or link update does, lays the + /// text out again around the new outline: neither the story nor the frames change. + #[test] + fn a_relinked_image_lays_the_text_out_again() { + let (mut s, image, story) = page(&png(|x, _| (8..20).contains(&x) || (44..56).contains(&x)), [150.0, 120.0, 178.0, 170.0]); + s.execute("object.textWrap", &json!({"ids": [image], "contour": "alphaChannel"})).unwrap(); + assert!(!overset(&s, story), "the gap between the legs stays open"); + let Some(designcraft_doc::Content::Graphic(g)) = + s.doc().unwrap().doc.item(designcraft_doc::ItemId(image.as_u64().unwrap())).map(|it| it.content.clone()) + else { + panic!("a graphic") + }; + // The new image has no gap: one band across both legs. + let solid = Arc::new(png(|x, _| (8..56).contains(&x))); + s.edit(|d, _| { + let mut a = (**d.assets.get(&g.asset).unwrap()).clone(); + a.data = solid.clone(); + d.assets.insert(g.asset, Arc::new(a)); + Ok(()) + }) + .unwrap(); + assert!(overset(&s, story), "the new image covers the caption"); + } } diff --git a/docs/typography-parity.md b/docs/typography-parity.md index 8e17cd2a9..599c20b39 100644 --- a/docs/typography-parity.md +++ b/docs/typography-parity.md @@ -59,7 +59,7 @@ Fidelity: **unmeasured** unless stated. | Paragraph rules, borders, shading | ✓ | ✓ | ✓ | | | Balance ragged lines | ✓ | ✓ | ✓ | | | Optical margin alignment | per story with a size, Ignore Optical Margin per paragraph and style | ✓ (hang table of our own, scaled by the story's size; Story panel; IDML `StoryPreference`, `IgnoreEdgeAlignment`) | ~ | hang amounts are ours, InDesign's unknown (#947) | -| Text wrap | bounding box, shape, jump object/column, wrap sides (text on both sides of an object), contour options, on page items and anchored objects | ✓ | ~ | Detect Edges, Alpha Channel and Photoshop Path contours follow the frame's path (composition doesn't read the image) | +| Text wrap | bounding box, shape, jump object/column, wrap sides (text on both sides of an object), contour options, on page items and anchored objects | ✓ | ~ | Alpha Channel contours follow the image's visible pixels within the frame (threshold, Include Inside Edges); Detect Edges and Photoshop Path contours follow the frame's path (composition doesn't read the image) | | Anchored objects | inline, above line, custom | ✓ | ~ | anchored objects in table cells not drawn (#163) | | Footnotes, endnotes | ✓ | ✓ | ~ | footnotes don't split across columns yet | | Type on a path | ✓ | ✓ | ✓ | | From 544ebaf571db68f904a10b896f41e063bba94b96 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 21:33:53 -0800 Subject: [PATCH 29/38] Text wrap: a relinked image's new outline recomposes the text around it - each traced Alpha Channel outline has a serial number, and the composition signatures include the outlines in use, so a relink or link update that changes only the image data no longer leaves text laid out around the old image - a wrap with the default alpha threshold is saved without it, as before thresholds were stored - docs/typography-parity.md says Alpha Channel contours follow the image's visible pixels --- crates/compose/src/cache.rs | 13 ++++++++ crates/doc/src/item/text_frame.rs | 5 +++ crates/doc/src/lib.rs | 9 ++++++ crates/engine/src/cmd/alpha_wrap.rs | 48 +++++++++++++++++++++++++++-- docs/typography-parity.md | 2 +- 5 files changed, 74 insertions(+), 3 deletions(-) diff --git a/crates/compose/src/cache.rs b/crates/compose/src/cache.rs index 5e4cb0911..c1f52d162 100644 --- a/crates/compose/src/cache.rs +++ b/crates/compose/src/cache.rs @@ -85,6 +85,18 @@ fn doc_signature(doc: &Document) -> u64 { (Arc::as_ptr(&doc.styles) as usize).hash(&mut h); format!("{:?}", doc.text_variables).hash(&mut h); sections_hash(doc).hash(&mut h); + outlines_hash(doc).hash(&mut h); + h.finish() +} + +/// The Alpha Channel outlines text wraps around: a relink traces a new one while the items and +/// stories stay the same. +fn outlines_hash(doc: &Document) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + for o in &doc.alpha_outlines { + o.serial.hash(&mut h); + } h.finish() } @@ -405,6 +417,7 @@ fn signature(doc: &Document, story: &Arc) -> (Vec, Vec>) sections_hash(doc) as usize, doc.font_scope as usize, usize::from(g.relative_to == designcraft_doc::GridRelative::TopMargin), + outlines_hash(doc) as usize, ]; sig.extend([g.start, g.increment].into_iter().flat_map(bits)); let mut keep = Vec::new(); diff --git a/crates/doc/src/item/text_frame.rs b/crates/doc/src/item/text_frame.rs index 62f12eb24..2e7506f26 100644 --- a/crates/doc/src/item/text_frame.rs +++ b/crates/doc/src/item/text_frame.rs @@ -303,12 +303,17 @@ pub struct TextWrap { #[serde(skip_serializing_if = "std::ops::Not::not")] pub inside_edges: bool, /// Alpha Channel contour: pixels more opaque than this (0..254) count as the image. + #[serde(skip_serializing_if = "is_default_alpha_threshold")] pub alpha_threshold: u8, } /// DesignCraft's default alpha threshold for Alpha Channel contour wraps. pub const DEFAULT_ALPHA_THRESHOLD: u8 = 25; +fn is_default_alpha_threshold(t: &u8) -> bool { + *t == DEFAULT_ALPHA_THRESHOLD +} + impl Default for TextWrap { fn default() -> Self { TextWrap { diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 8f105457c..2b18c8f47 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -674,9 +674,18 @@ pub struct AlphaOutline { /// under the same id, and the outline is traced again. (Weak, so it doesn't keep old image /// data alive, and its address can't be reused by new data while it's held.) pub source: std::sync::Weak>, + /// A number no other outline traced in this process has ([`AlphaOutline::next_serial`]): + /// composition caches compare it to tell a new outline from the one they used. + pub serial: u64, } impl AlphaOutline { + /// A serial number for a new outline, unique within the process. + pub fn next_serial() -> u64 { + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + } + /// Traced from `data`, the asset's current image data. pub fn traced_from(&self, data: &Arc>) -> bool { std::ptr::eq(self.source.as_ptr(), Arc::as_ptr(data)) diff --git a/crates/engine/src/cmd/alpha_wrap.rs b/crates/engine/src/cmd/alpha_wrap.rs index b4009de1d..585f29350 100644 --- a/crates/engine/src/cmd/alpha_wrap.rs +++ b/crates/engine/src/cmd/alpha_wrap.rs @@ -62,7 +62,16 @@ pub(crate) fn sync(doc: &mut Document) { /// The empty outline standing for an image `trace` found none in: zero-size, which composition /// takes as no outline, and dropped like a traced one when the asset's data changes. fn no_outline(data: &Arc>, asset: AssetId, threshold: u8) -> AlphaOutline { - AlphaOutline { asset, threshold, width: 0, height: 0, outer: Default::default(), holes: Default::default(), source: Arc::downgrade(data) } + AlphaOutline { + asset, + threshold, + width: 0, + height: 0, + outer: Default::default(), + holes: Default::default(), + source: Arc::downgrade(data), + serial: AlphaOutline::next_serial(), + } } /// The outline of the pixels of `data` more opaque than `threshold`. None for an image that @@ -112,7 +121,8 @@ pub(crate) fn trace(data: &Arc>, asset: AssetId, threshold: u8) -> Optio PathData::from_bezpath(&p) }; let source = Arc::downgrade(data); - return Some(AlphaOutline { asset, threshold, width: w, height: h, outer: path(&outer), holes: path(&holes), source }); + let serial = AlphaOutline::next_serial(); + return Some(AlphaOutline { asset, threshold, width: w, height: h, outer: path(&outer), holes: path(&holes), source, serial }); } finer = Some((mask, cols, rows)); grid /= 2; @@ -428,6 +438,40 @@ mod tests { assert_eq!((b.x0, b.x1), (24.0, 40.0), "the new image's outline"); } + /// A wrap with the default threshold saves as it did before thresholds were stored; another + /// threshold is saved. + #[test] + fn the_default_threshold_is_left_out_of_a_saved_wrap() { + let v = serde_json::to_value(designcraft_doc::TextWrap::default()).unwrap(); + assert!(v.get("alphaThreshold").is_none(), "{v}"); + let back: designcraft_doc::TextWrap = serde_json::from_value(v).unwrap(); + assert_eq!(back.alpha_threshold, designcraft_doc::DEFAULT_ALPHA_THRESHOLD); + let w = designcraft_doc::TextWrap { alpha_threshold: 200, ..Default::default() }; + assert_eq!(serde_json::to_value(w).unwrap()["alphaThreshold"], 200); + } + + /// A relink that changes only the image data recomposes the text around the new outline. + #[test] + fn text_is_composed_again_around_a_relinked_images_outline() { + let (mut s, image, story) = page(&png(|x, _| x < 32), [180.0, 150.0, 220.0, 178.0]); + s.execute("object.textWrap", &json!({"ids": [image], "contour": "alphaChannel"})).unwrap(); + assert!(!overset(&s, story), "the left half is visible: the caption on the right is set"); + let Some(designcraft_doc::Content::Graphic(g)) = + s.doc().unwrap().doc.item(designcraft_doc::ItemId(image.as_u64().unwrap())).map(|it| it.content.clone()) + else { + panic!("a graphic") + }; + let right = Arc::new(png(|x, _| x >= 32)); + s.edit(|d, _| { + let mut a = (**d.assets.get(&g.asset).unwrap()).clone(); + a.data = right.clone(); + d.assets.insert(g.asset, Arc::new(a)); + Ok(()) + }) + .unwrap(); + assert!(overset(&s, story), "the right half is visible now: it covers the caption"); + } + /// An image anchored in text keeps its outline: anchored objects wrap the lines after their /// anchor, and they are kept in the story, not on the spread. #[test] diff --git a/docs/typography-parity.md b/docs/typography-parity.md index 8e17cd2a9..0b3c030cf 100644 --- a/docs/typography-parity.md +++ b/docs/typography-parity.md @@ -59,7 +59,7 @@ Fidelity: **unmeasured** unless stated. | Paragraph rules, borders, shading | ✓ | ✓ | ✓ | | | Balance ragged lines | ✓ | ✓ | ✓ | | | Optical margin alignment | per story with a size, Ignore Optical Margin per paragraph and style | ✓ (hang table of our own, scaled by the story's size; Story panel; IDML `StoryPreference`, `IgnoreEdgeAlignment`) | ~ | hang amounts are ours, InDesign's unknown (#947) | -| Text wrap | bounding box, shape, jump object/column, wrap sides (text on both sides of an object), contour options, on page items and anchored objects | ✓ | ~ | Detect Edges, Alpha Channel and Photoshop Path contours follow the frame's path (composition doesn't read the image) | +| Text wrap | bounding box, shape, jump object/column, wrap sides (text on both sides of an object), contour options, on page items and anchored objects | ✓ | ~ | Alpha Channel contours follow the outline of the image's visible pixels; Detect Edges and Photoshop Path contours follow the frame's path | | Anchored objects | inline, above line, custom | ✓ | ~ | anchored objects in table cells not drawn (#163) | | Footnotes, endnotes | ✓ | ✓ | ~ | footnotes don't split across columns yet | | Type on a path | ✓ | ✓ | ✓ | | From 113d2e960d04472e068cb16eb0fb12d843346ea9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 21:44:13 -0800 Subject: [PATCH 30/38] Type: Em Space and En Space go in at an insertion point only With text selected, Type > Insert White Space > Em Space and En Space are disabled, and their shortcuts leave the selection as it is instead of replacing it with the space. --- crates/ui-egui/src/menus/run.rs | 9 +++++++-- crates/ui-egui/src/menus/state.rs | 4 +++- crates/ui-egui/src/menus/tests.rs | 6 ++++++ 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index e661b2e11..b6310b88e 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -844,8 +844,13 @@ fn run_ui_part4(app: &mut DesignApp, id: &str, p: &Value) -> Option app.run("text.insert", json!({"text": "\u{e000}", "raw": true})), - "app.insertEmSpace" => app.run("text.insert", json!({"text": "\u{2003}", "raw": true})), - "app.insertEnSpace" => app.run("text.insert", json!({"text": "\u{2002}", "raw": true})), + "app.insertEmSpace" | "app.insertEnSpace" => { + if !menu_enabled(app, id) { + return Some(Err("place the insertion point in text to insert a space".into())); + } + let space = if id == "app.insertEmSpace" { "\u{2003}" } else { "\u{2002}" }; + app.run("text.insert", json!({"text": space, "raw": true})) + } // `panel_docking::command` takes the dock panels (and the Window › Styles group) first. "window.panel" => { let panel = p.get("panel").and_then(Value::as_str).unwrap_or("properties"); diff --git a/crates/ui-egui/src/menus/state.rs b/crates/ui-egui/src/menus/state.rs index 3cbec35b8..8d41a4a43 100644 --- a/crates/ui-egui/src/menus/state.rs +++ b/crates/ui-egui/src/menus/state.rs @@ -175,8 +175,10 @@ pub fn menu_enabled(app: &DesignApp, id: &str) -> bool { if id.starts_with("fit.") { return enabled(app, "object.fit"); } + // Em Space and En Space go in at an insertion point only; they do not replace selected text. if matches!(id, "app.insertEmSpace" | "app.insertEnSpace") { - return enabled(app, "text.insert"); + let caret = app.session.active().and_then(|d| d.selection.text).is_some_and(|t| t.is_caret()); + return caret && enabled(app, "text.insert"); } if id == "guide.selectAll" && (!app.ui.guides || app.ui.guides_locked) { return false; diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index 24ccb02a0..876b5e74d 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -2512,6 +2512,12 @@ fn type_shortcuts_insert_spaces_justify_all_lines_and_open_paragraph_rules() { assert!(!menu_enabled(&app, "app.insertEmSpace") && !menu_enabled(&app, "app.insertEnSpace")); press(&mut app, &ctx, Key::M, Modifiers::COMMAND | Modifiers::SHIFT); assert_eq!(story_text(&app, &story), "ab"); + // Selected text: the spaces go in at an insertion point only, so the selection stays. + app.run("text.select", json!({"story": story, "anchor": 0, "focus": 2})).unwrap(); + assert!(!menu_enabled(&app, "app.insertEmSpace") && !menu_enabled(&app, "app.insertEnSpace")); + press(&mut app, &ctx, Key::M, Modifiers::COMMAND | Modifiers::SHIFT); + press(&mut app, &ctx, Key::N, Modifiers::COMMAND | Modifiers::SHIFT); + assert_eq!(story_text(&app, &story), "ab", "a selection is not replaced by a space"); app.run("text.select", json!({"story": story, "anchor": 1, "focus": 1})).unwrap(); assert!(menu_enabled(&app, "app.insertEmSpace") && menu_enabled(&app, "app.insertEnSpace")); press(&mut app, &ctx, Key::M, Modifiers::COMMAND | Modifiers::SHIFT); From ae1de74270e747cddeb60c112487edf3a6c72265 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 21:45:24 -0800 Subject: [PATCH 31/38] Text wrap: an Alpha Channel contour counts only inside the graphic's clipping path Opaque pixels outside the placed graphic's clipping path (inverted or not) are not drawn, and they no longer push text aside. An outline that only touches a clip's edge blocks nothing. --- crates/compose/src/lib.rs | 237 +++++++++++++++++++++++++----------- crates/compose/src/tests.rs | 56 +++++++++ 2 files changed, 223 insertions(+), 70 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index ac563b616..76df87568 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -382,9 +382,36 @@ pub struct Contour { /// The frame path of an outline taken from a placed graphic: the frame clips the graphic, so /// the outline counts only within it (empty: no clip). pub clip: Vec>, + /// The placed graphic's clipping path, for an outline traced from the graphic's pixels: the + /// outline counts only inside it too (empty: none). An inverted clipping path is filled + /// even-odd ([`designcraft_doc::Graphic::clip_region`]). + pub graphic_clip: Vec>, + pub graphic_clip_even_odd: bool, pub offset: f64, } +/// A filled shape: closed polylines and their fill rule (non-zero, or even-odd). +#[derive(Clone, Copy)] +struct Region<'a> { + rings: &'a [Vec], + even_odd: bool, +} + +impl Region<'_> { + fn nonzero(rings: &[Vec]) -> Region<'_> { + Region { rings, even_odd: false } + } + + /// Is a point with this winding number filled? + fn fills(self, winding: i32) -> bool { + if self.even_odd { winding % 2 != 0 } else { winding != 0 } + } + + fn edges(self) -> usize { + self.rings.iter().map(Vec::len).sum() + } +} + /// Points a contour outline may have; a larger outline wraps around its bounding box. pub const MAX_CONTOUR_POINTS: usize = 8192; @@ -396,7 +423,13 @@ impl Contour { /// The outline of `path` (after `to_inner`), flattened; None if it is empty or too detailed. fn of(path: &designcraft_geom::BezPath, to_inner: designcraft_geom::Affine, offset: f64) -> Option { let rings = flatten_rings(path, to_inner)?; - offset.is_finite().then_some(Contour { rings, clip: Vec::new(), offset: offset.max(0.0) }) + offset.is_finite().then_some(Contour { + rings, + clip: Vec::new(), + graphic_clip: Vec::new(), + graphic_clip_even_odd: false, + offset: offset.max(0.0), + }) } /// This outline counted only within `frame` (after `to_inner`); unclipped if the frame is @@ -408,18 +441,41 @@ impl Contour { } } + /// This outline counted only inside the graphic's clipping region `clip` (after `to_inner`), + /// filled even-odd when `even_odd` is set; unchanged if the region is empty or too detailed. + fn within_graphic_clip(self, clip: &designcraft_geom::BezPath, even_odd: bool, to_inner: designcraft_geom::Affine) -> Contour { + match flatten_rings(clip, to_inner) { + Some(graphic_clip) => Contour { graphic_clip, graphic_clip_even_odd: even_odd, ..self }, + None => self, + } + } + /// The outline mapped by `a`. fn transformed(self, a: designcraft_geom::Affine) -> Contour { let map = |rings: Vec>| rings.into_iter().map(|r| r.into_iter().map(|p| a * p).collect()).collect(); - Contour { rings: map(self.rings), clip: map(self.clip), offset: self.offset } + Contour { + rings: map(self.rings), + clip: map(self.clip), + graphic_clip: map(self.graphic_clip), + graphic_clip_even_odd: self.graphic_clip_even_odd, + offset: self.offset, + } + } + + /// The clips the outline counts within. + fn clips(&self) -> impl Iterator> { + let frame = (!self.clip.is_empty()).then(|| Region::nonzero(&self.clip)); + let graphic = (!self.graphic_clip.is_empty()).then_some(Region { rings: &self.graphic_clip, even_odd: self.graphic_clip_even_odd }); + frame.into_iter().chain(graphic) } fn bounds(&self) -> Rect { - let Some(r) = rings_bounds(&self.rings) else { return Rect::ZERO }; - let r = match rings_bounds(&self.clip) { - Some(c) => r.intersect(c), - None => r, - }; + let Some(mut r) = rings_bounds(&self.rings) else { return Rect::ZERO }; + for c in self.clips() { + if let Some(b) = rings_bounds(c.rings) { + r = r.intersect(b); + } + } r.inflate(self.offset, self.offset) } @@ -429,8 +485,8 @@ impl Contour { fn band_extent(&self, y0: f64, y1: f64) -> Option<(f64, f64)> { let (y0, y1) = (y0 - self.offset, y1 + self.offset); let (mut lo, mut hi) = rings_band_extent(&self.rings, y0, y1)?; - if !self.clip.is_empty() { - let (a, b) = rings_band_extent(&self.clip, y0, y1)?; + for c in self.clips() { + let (a, b) = rings_band_extent(c.rings, y0, y1)?; (lo, hi) = (lo.max(a), hi.min(b)); } (lo <= hi).then_some((lo - self.offset, hi + self.offset)) @@ -444,19 +500,21 @@ impl Contour { if !low.is_finite() || !high.is_finite() || high < low { return self.band_extent(y0, y1).into_iter().collect(); } - let mut spans = rings_band_spans(&self.rings, low, high); + let mut spans = rings_band_spans(Region::nonzero(&self.rings), low, high); // Extremely fragmented imported paths use the conservative outer extent, bounding the // work of subtracting intervals from every line. A traced 128-column mask stays below it. if spans.len() > 128 { return self.band_extent(y0, y1).into_iter().collect(); } - if !self.clip.is_empty() { - let clip = rings_band_spans(&self.clip, low, high); - // A fragmented frame, or shapes too detailed to compare within each line's budget, - // take the common part of the two projections, which can block more than the frame - // shows. - let exact = if clip.len() > 128 { None } else { clipped_band_spans(&self.rings, &self.clip, low, high) }; - spans = exact.unwrap_or_else(|| intersect_spans(&spans, &clip)); + let clips: Vec = self.clips().collect(); + if !clips.is_empty() { + let projected: Vec> = clips.iter().map(|c| rings_band_spans(*c, low, high)).collect(); + // A fragmented clip, or shapes too detailed to compare within each line's budget, + // take the common part of the projections, which can block more than the clips show. + let mut shapes = vec![Region::nonzero(&self.rings)]; + shapes.extend(&clips); + let exact = if projected.iter().any(|p| p.len() > 128) { None } else { clipped_band_spans(&shapes, low, high) }; + spans = exact.unwrap_or_else(|| projected.iter().fold(spans, |s, p| intersect_spans(&s, p))); } merge_spans(spans.into_iter().map(|(a, b)| (a - self.offset, b + self.offset)).collect()) } @@ -481,19 +539,22 @@ fn intersect_spans(a: &[(f64, f64)], b: &[(f64, f64)]) -> Vec<(f64, f64)> { out } -/// Occupied intervals across the band `y0..y1` of the part of `rings` inside `clip` (both filled -/// by the non-zero rule). A vertical line through a point of that part either crosses the middle -/// of the band inside both shapes, or stops inside the band on an edge of one shape that lies -/// inside the other. The spans are the middle slice of both shapes and the parts of each -/// shape's edges in the band that lie inside the other, so a part of the outline outside the -/// clip blocks nothing, even where it shares x positions with the clip in the same band. -/// None when that takes more than [`MAX_CLIPPED_WORK`] edge visits. -fn clipped_band_spans(rings: &[Vec], clip: &[Vec], y0: f64, y1: f64) -> Option> { +/// Occupied intervals across the band `y0..y1` of the part of the plane inside every one of +/// `shapes` (an outline and its clips). A vertical line through a point of that part either +/// crosses the middle of the band inside all the shapes, or stops inside the band on an edge of +/// one shape that lies inside the others. The spans are the middle slice of all the shapes and +/// the parts of each shape's edges in the band that lie inside the others, so a part of the +/// outline outside a clip blocks nothing, even where it shares x positions with the clip in the +/// same band. None when that takes more than [`MAX_CLIPPED_WORK`] edge visits. +fn clipped_band_spans(shapes: &[Region], y0: f64, y1: f64) -> Option> { let middle = y0 * 0.5 + y1 * 0.5; - let mut spans = intersect_spans(&merge_spans(middle_spans(rings, middle)), &merge_spans(middle_spans(clip, middle))); + let mut slices = shapes.iter().map(|s| merge_spans(middle_spans(*s, middle))); + let first = slices.next()?; + let mut spans = slices.fold(first, |a, b| intersect_spans(&a, &b)); let mut budget = MAX_CLIPPED_WORK; - edges_inside(rings, clip, y0, y1, &mut budget, &mut spans)?; - edges_inside(clip, rings, y0, y1, &mut budget, &mut spans)?; + for k in 0..shapes.len() { + edges_inside(shapes, k, y0, y1, &mut budget, &mut spans)?; + } Some(merge_spans(spans)) } @@ -503,10 +564,12 @@ fn spend(budget: &mut usize, n: usize) -> Option<()> { Some(()) } -/// Pushes to `out` the x-ranges of the parts of `rings`' edges within the band `y0..y1` that lie -/// inside `other` or on its boundary. None when `budget` runs out. -fn edges_inside(rings: &[Vec], other: &[Vec], y0: f64, y1: f64, budget: &mut usize, out: &mut Vec<(f64, f64)>) -> Option<()> { - let n = other.iter().map(Vec::len).sum::().max(1); +/// Pushes to `out` the x-ranges of the parts of the edges of `shapes[k]` within the band `y0..y1` +/// that bound the part of the plane inside all of `shapes` ([`segment_inside`]). None when +/// `budget` runs out. +fn edges_inside(shapes: &[Region], k: usize, y0: f64, y1: f64, budget: &mut usize, out: &mut Vec<(f64, f64)>) -> Option<()> { + let n = shapes.iter().map(|o| o.edges()).sum::().max(1); + let rings = shapes.get(k)?.rings; for ring in rings { let Some(&last) = ring.last() else { continue }; let mut a = last; @@ -520,7 +583,7 @@ fn edges_inside(rings: &[Vec], other: &[Vec], y0: f64, y1: f64, bu (u.min(v).clamp(0.0, 1.0), u.max(v).clamp(0.0, 1.0)) }; let (p, q) = (a + (b - a) * t0, a + (b - a) * t1); - segment_inside(p, q, other, n, budget, out)?; + segment_inside(p, q, shapes, k, n, budget, out)?; } a = b; } @@ -528,26 +591,41 @@ fn edges_inside(rings: &[Vec], other: &[Vec], y0: f64, y1: f64, bu Some(()) } -/// Pushes to `out` the x-ranges of the parts of segment `p`–`q` inside `rings` (`n` edges) or on -/// their boundary: the segment is split where it meets their edges, and each piece is tested at -/// its middle. Each pass over the edges takes `n` from `budget`; None when it runs out. -fn segment_inside(p: Point, q: Point, rings: &[Vec], n: usize, budget: &mut usize, out: &mut Vec<(f64, f64)>) -> Option<()> { +/// Pushes to `out` the x-ranges of the parts of segment `p`–`q`, an edge of `shapes[k]`, that +/// bound the part of the plane inside all of `shapes` (`n` edges in all). The segment is split +/// where it meets the other shapes' edges, and each piece is tested at its middle: it counts when +/// the points just beside it on one side are inside all the shapes. Where `shapes[k]` has no area +/// on either side (an open path), it counts when it is inside the other shapes or on their +/// boundary. Each pass over the edges takes `n` from `budget`; None when it runs out. +fn segment_inside(p: Point, q: Point, shapes: &[Region], k: usize, n: usize, budget: &mut usize, out: &mut Vec<(f64, f64)>) -> Option<()> { let r = q - p; let len2 = r.hypot2(); if !len2.is_finite() { return Some(()); } let at = |t: f64| p + r * t; + let in_others = |pt: Point| shapes.iter().enumerate().all(|(i, s)| i == k || inside_or_on(*s, pt)); spend(budget, n)?; if len2 < 1e-24 { - if inside_or_on(rings, p) { + if in_others(p) { out.push((p.x, p.x)); } return Some(()); } + // A step to either side of the segment, well above rounding at page coordinates. + let side = r.turn_90() * (1e-6 / len2.sqrt()); + let bounds_the_part = |m: Point| { + let own = shapes.get(k).copied(); + let in_own = |pt: Point| own.is_some_and(|s| inside_or_on(s, pt)); + let (a, b) = (m + side, m - side); + match (in_own(a), in_own(b)) { + (false, false) => in_others(m), + (ia, ib) => (ia && in_others(a)) || (ib && in_others(b)), + } + }; let (ylo, yhi) = (p.y.min(q.y), p.y.max(q.y)); let mut ts = vec![0.0, 1.0]; - for ring in rings { + for ring in shapes.iter().enumerate().filter(|(i, _)| *i != k).flat_map(|(_, s)| s.rings) { let Some(&last) = ring.last() else { continue }; let mut c = last; for &d in ring { @@ -576,8 +654,8 @@ fn segment_inside(p: Point, q: Point, rings: &[Vec], n: usize, budget: &m ts.dedup_by(|a, b| (*a - *b).abs() < 1e-12); for w in ts.windows(2) { let (u, v) = (w[0], w[1]); - spend(budget, n)?; - if inside_or_on(rings, at(u * 0.5 + v * 0.5)) { + spend(budget, 3 * n)?; + if bounds_the_part(at(u * 0.5 + v * 0.5)) { let (x0, x1) = (at(u).x, at(v).x); out.push((x0.min(x1), x0.max(x1))); } @@ -585,10 +663,10 @@ fn segment_inside(p: Point, q: Point, rings: &[Vec], n: usize, budget: &m Some(()) } -/// Is `pt` inside `rings` (non-zero rule) or within a hair of one of their edges? -fn inside_or_on(rings: &[Vec], pt: Point) -> bool { +/// Is `pt` inside `shape` or within a hair of one of its edges? +fn inside_or_on(shape: Region, pt: Point) -> bool { let mut winding = 0i32; - for ring in rings { + for ring in shape.rings { let Some(&last) = ring.last() else { continue }; let mut a = last; for &b in ring { @@ -607,7 +685,7 @@ fn inside_or_on(rings: &[Vec], pt: Point) -> bool { a = b; } } - winding != 0 + shape.fills(winding) } /// Sorted, overlapping spans joined. Zero-width spans (a vertical edge of an open path) are kept: @@ -626,9 +704,9 @@ fn merge_spans(mut spans: Vec<(f64, f64)>) -> Vec<(f64, f64)> { out } -fn rings_band_spans(rings: &[Vec], y0: f64, y1: f64) -> Vec<(f64, f64)> { - let mut spans = middle_spans(rings, y0 * 0.5 + y1 * 0.5); - for ring in rings { +fn rings_band_spans(shape: Region, y0: f64, y1: f64) -> Vec<(f64, f64)> { + let mut spans = middle_spans(shape, y0 * 0.5 + y1 * 0.5); + for ring in shape.rings { let Some(&last) = ring.last() else { continue }; let mut a = last; for &b in ring { @@ -649,11 +727,11 @@ fn rings_band_spans(rings: &[Vec], y0: f64, y1: f64) -> Vec<(f64, f64)> { merge_spans(spans) } -/// The intervals of the line at height `middle` inside `rings` (non-zero rule), unsorted. -fn middle_spans(rings: &[Vec], middle: f64) -> Vec<(f64, f64)> { +/// The intervals of the line at height `middle` inside `shape`, unsorted. +fn middle_spans(shape: Region, middle: f64) -> Vec<(f64, f64)> { let mut spans = Vec::new(); let mut crossings = Vec::new(); - for ring in rings { + for ring in shape.rings { let Some(&last) = ring.last() else { continue }; let mut a = last; for &b in ring { @@ -671,7 +749,7 @@ fn middle_spans(rings: &[Vec], middle: f64) -> Vec<(f64, f64)> { let mut winding = 0i32; let mut previous = None; for (x, delta) in crossings { - if winding != 0 + if shape.fills(winding) && let Some(start) = previous { spans.push((start, x)); @@ -782,7 +860,7 @@ fn nothing_visible_wraps_nothing(doc: &Document, it: &Item, wrap: &designcraft_d fn wrap_exclusion( wrap: &designcraft_doc::TextWrap, b: Rect, - outline: impl FnOnce() -> Option<(designcraft_geom::BezPath, Option)>, + outline: impl FnOnce() -> Option, xf: Affine, inv: Affine, left_page: bool, @@ -802,24 +880,42 @@ fn wrap_exclusion( let contour = matches!(wrap.mode, WrapMode::BoundingBox | WrapMode::Contour).then_some(custom); return Exclusion { rect, mode: wrap.mode, side, contour, after: None }; } - let contour = if wrap.mode == WrapMode::Contour { outline() } else { None }.and_then(|(path, frame)| { - let c = Contour::of(&path, inv * xf, o[0])?; - Some(match frame { - Some(frame) => c.within(&frame, inv * xf), - None => c, - }) + let contour = if wrap.mode == WrapMode::Contour { outline() } else { None }.and_then(|src| { + let mut c = Contour::of(&src.path, inv * xf, o[0])?; + if let Some(frame) = &src.frame { + c = c.within(frame, inv * xf); + } + if let Some((clip, even_odd)) = &src.graphic_clip { + c = c.within_graphic_clip(clip, *even_odd, inv * xf); + } + Some(c) }); let rect = contour.as_ref().map_or_else(|| inv.transform_rect_bbox(r), Contour::bounds); Exclusion { rect, mode: wrap.mode, side, contour, after: None } } -/// The outline a contour wrap of `kind` around frame `it` follows, in the frame's inner space, and -/// for an outline taken from the placed graphic, the frame path that clips it. Bounding Box -/// follows the part of the placed graphic the frame shows; Same As Clipping follows the graphic's -/// clipping path when it has one; Alpha Channel follows the image's traced outline when the -/// engine has traced one; detected edges and image paths follow the frame's path (as other -/// frames do). -fn contour_path(doc: &Document, it: &Item, wrap: &designcraft_doc::TextWrap) -> (designcraft_geom::BezPath, Option) { +/// The outline a contour wrap follows, in the frame's inner space, with what clips it. +struct Outline { + path: designcraft_geom::BezPath, + /// For an outline taken from the placed graphic: the frame path that clips it. + frame: Option, + /// For an outline traced from the graphic's pixels: the graphic's clipping region, and + /// whether it is filled even-odd. + graphic_clip: Option<(designcraft_geom::BezPath, bool)>, +} + +impl Outline { + fn clipped_by(path: designcraft_geom::BezPath, frame: designcraft_geom::BezPath) -> Outline { + Outline { path, frame: Some(frame), graphic_clip: None } + } +} + +/// The outline a contour wrap of `kind` around frame `it` follows. Bounding Box follows the part +/// of the placed graphic the frame shows; Same As Clipping follows the graphic's clipping path +/// when it has one; Alpha Channel follows the image's traced outline when the engine has traced +/// one, within the graphic's clipping path too; detected edges and image paths follow the frame's +/// path (as other frames do). +fn contour_path(doc: &Document, it: &Item, wrap: &designcraft_doc::TextWrap) -> Outline { let frame = designcraft_geom::corners::apply(&it.path, &it.corners); let kind = wrap.contour; if kind == designcraft_doc::ContourType::AlphaChannel @@ -833,19 +929,20 @@ fn contour_path(doc: &Document, it: &Item, wrap: &designcraft_doc::TextWrap) -> if wrap.inside_edges { path.extend(o.holes.to_bezpath().iter()); } - return (g.xf * to_graphic * path, Some(frame)); + // The clipping path hides the pixels outside it. + return Outline { graphic_clip: g.clip_region(), ..Outline::clipped_by(g.xf * to_graphic * path, frame) }; } if kind == designcraft_doc::ContourType::SameAsClipping && let Some((path, _)) = it.graphic().and_then(|g| g.clip_region()) { - return (path, Some(frame)); + return Outline::clipped_by(path, frame); } if kind == designcraft_doc::ContourType::BoundingBox && let Some(r) = it.graphic().and_then(|g| g.shown_bounds(it.inner_bounds())) { - return (designcraft_geom::kurbo::Shape::to_path(&r, 0.1), Some(frame)); + return Outline::clipped_by(designcraft_geom::kurbo::Shape::to_path(&r, 0.1), frame); } - (frame, None) + Outline { path: frame, frame: None, graphic_clip: None } } /// One frame of the thread, ready for composition. diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index ab108b03b..e8fb2aac8 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -573,6 +573,62 @@ fn wrap_on_both_sides_sets_text_left_then_right_of_the_object() { assert!(!cs.is_overset()); } +/// An Alpha Channel contour counts only where the graphic's clipping path shows the image: here +/// the visible pixels are in the right half and the clipping path shows the left half, so the +/// image wraps nothing; inverted, the clipping path shows the right half and the image wraps. +#[test] +fn an_alpha_channel_contour_stops_at_the_clipping_path() { + use designcraft_geom::{PathData, Point, SubPath}; + let rect = |x0: f64, x1: f64| { + PathData::single(SubPath::polyline(&[Point::new(x0, 0.0), Point::new(x1, 0.0), Point::new(x1, 200.0), Point::new(x0, 200.0)], true)) + }; + let lines_beside = |invert: bool| { + let (mut d, sid, _) = doc_with(&[LOREM; 4].join(" "), Rect::new(0.0, 0.0, 300.0, 800.0), ParaAttrs::default()); + let lid = d.default_layer(); + let asset = designcraft_doc::AssetId(d.alloc()); + let mut it = designcraft_doc::Item::new( + ItemId(d.alloc()), + lid, + designcraft_doc::Shape::Rectangle, + designcraft_geom::shapes::rectangle(Rect::new(150.0, 100.0, 300.0, 300.0)), + ); + let mut clip = designcraft_doc::ClippingPath::new(designcraft_doc::ClippingKind::DetectEdges, rect(0.0, 75.0)); + clip.invert = invert; + it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic { + layer_options: None, + asset, + size: (150.0, 200.0), + xf: designcraft_geom::Affine::translate((150.0, 100.0)), + clip: Some(std::sync::Arc::new(clip)), + auto_fit: Default::default(), + fit_align: 0, + crop: [0.0; 4], + wrap: Default::default(), + fill: Default::default(), + }); + it.wrap.mode = WrapMode::Contour; + it.wrap.side = WrapSide::LeftSide; + it.wrap.contour = designcraft_doc::ContourType::AlphaChannel; + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + d.alpha_outlines.push(std::sync::Arc::new(designcraft_doc::AlphaOutline { + asset, + threshold: designcraft_doc::DEFAULT_ALPHA_THRESHOLD, + width: 150, + height: 200, + outer: rect(75.0, 150.0), + holes: PathData::default(), + source: std::sync::Weak::new(), + serial: designcraft_doc::AlphaOutline::next_serial(), + })); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + cs.frames[0].lines.iter().filter(|l| l.baseline - l.ascent > 100.0 && l.baseline + l.descent < 300.0).map(|l| l.x1).collect::>() + }; + let shown_left = lines_beside(false); + assert!(!shown_left.is_empty() && shown_left.iter().all(|x| *x > 260.0), "nothing visible wraps nothing: {shown_left:?}"); + let shown_right = lines_beside(true); + assert!(!shown_right.is_empty() && shown_right.iter().all(|x| *x <= 225.0 + 1e-6), "the right half wraps: {shown_right:?}"); +} + /// Same As Clipping follows a placed graphic's clipping path: beside a triangle that widens /// downwards, text reaches further in at the narrow top than at the wide bottom. #[test] From 68549972945974011ace631f428ea283a1f2f72e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 21:53:45 -0800 Subject: [PATCH 32/38] Render: a feathered object's bevel and inner effects fade with it A basic, directional or gradient feather faded only the object's body. Its bevel, inner glow, inner shadow and satin were drawn afterwards over the whole shape, so where the feather let the page show through, the bevel's highlight and the inner glow screened whatever lay behind the object. Text under the feathered edge of a beveled frame turned lighter until the frame was moved off it. Each inner effect is now faded by the same feathers before it blends with what is behind the object, so a feathered object fades to transparent at its edge with its effects. --- crates/render/src/fx.rs | 565 +++++++++++++++++++++++++--------------- 1 file changed, 349 insertions(+), 216 deletions(-) diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 7c7c91fe1..f9b40ba9b 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -15,7 +15,8 @@ //! - Inner glow, bevel and emboss, satin: built like the inner shadow (an offset inverse of the //! shape, blurred and clipped) with screen / multiply blending; satin XORs two offset copies. //! - Feather: the object is masked by its shape inset by half the width and blurred, so the -//! edge fades from ~0 at the path to full strength `width` inside. +//! edge fades from ~0 at the path to full strength `width` inside. The basic, directional and +//! gradient feathers fade the inner effects with the object. use designcraft_doc::Item; use designcraft_geom::{Affine, BezPath, Rect, Shape, Vec2}; @@ -46,6 +47,17 @@ pub(crate) fn outset(it: &Item) -> f64 { o } +/// Whether `it` has a basic feather: a width on a closed shape. +fn basic_feather(it: &Item) -> bool { + it.effects.feather > 0.0 && it.path.is_closed() +} + +/// Whether any of `it`'s feathers (basic, directional, gradient) fades it. +fn fades(it: &Item) -> bool { + let e = &it.effects; + basic_feather(it) || e.directional_feather.on && e.directional_feather.widths.iter().any(|w| *w > 0.0) || e.gradient_feather.on +} + /// (blur σ, growth) in points for an effect `size` with `pct` percent hardened. fn split(size: f64, pct: f64) -> (f64, f64) { let size = bound(size).max(0.0); @@ -149,17 +161,245 @@ impl Renderer { ctx.pop_layer(); } } - // Gradient feather: the object (feathered or not) drawn in its own layer, then kept as - // much as the opacity gradient says. - let gf = &e.gradient_feather; - let directional = e.directional_feather.on && e.directional_feather.widths.iter().any(|w| *w > 0.0); - if gf.on || directional { + // The object, feathered or not, drawn in its own layer and faded there. A basic feather + // clips the body to the shape at device resolution; only its blurred mask may be drawn + // offscreen at a lower one. + let fading = fades(it); + let basic = basic_feather(it); + if fading { ctx.set_transform(Affine::IDENTITY); ctx.push_layer(None, None, None, None, None); } - // The object, feathered or not. Choke keeps that much of the width opaque (a shorter - // fade); Sharp and Rounded corners follow the inset shape more tightly than Diffused. - if e.feather > 0.0 && it.path.is_closed() { + if basic { + ctx.set_transform(f.view * xf); + ctx.push_clip_layer(bp); + } + self.draw_body(ctx, f, it, bp, xf, page); + if basic { + ctx.pop_layer(); + } + if fading { + self.fade(ctx, f, it, bp, xf, reach); + ctx.pop_layer(); + } + if !it.path.is_closed() { + return; + } + // The feathers fade the inner effects with the object: each one is faded in its own + // layer before it blends with what is behind. + let fade = fading.then_some(it); + // An image's edge is where it is opaque, not the frame: inner shadows, glows and bevels + // follow what is painted when the frame holds a graphic. + let painted = matches!(it.content, designcraft_doc::Content::Graphic(_)).then_some((it, page)); + // Inner shadow, clipped to the shape. + if e.inner_shadow.on + && let Some(c) = doc.resolve_color(&e.inner_shadow.color, 1.0) + { + let s = &e.inner_shadow; + let (sigma, choke) = split(s.size, s.choke); + let off = offset(doc.light_angle(s.angle, s.global_light), s.distance); + self.inner_edge( + ctx, + f, + reach, + bp, + xf, + off, + sigma, + choke, + color_of(&c, s.opacity.clamp(0.0, 1.0)), + crate::blend_mode(s.mode).mix, + painted, + fade, + ); + } + // Inner glow: from the edges inwards (or out from the centre), screened over the object. + if e.inner_glow.on + && let Some(c) = doc.resolve_color(&e.inner_glow.color, 1.0) + { + let g = &e.inner_glow; + let (sigma, choke) = split(g.size, g.choke); + // Precise keeps the glow close to the edge: a tighter blur. + let sigma = if g.technique == designcraft_doc::GlowTechnique::Precise { sigma * 0.4 } else { sigma }; + let mix = crate::blend_mode(g.mode).mix; + let paint = color_of(&c, g.opacity.clamp(0.0, 1.0)); + if g.center { + self.with_blended_filters(ctx, f, reach, sigma, mix, (bp, xf), fade, |_, c, fr| { + c.set_transform(fr.view); + c.push_layer(None, None, None, None, Some(blur(sigma))); + c.set_transform(fr.view * xf); + c.set_paint(paint); + c.fill_path(bp); + // Fade towards the edge: cut a band of the glow's size away. + c.set_transform(Affine::IDENTITY); + c.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestOut)), None, None, None); + c.set_transform(fr.view * xf); + c.set_paint(peniko::Color::BLACK); + c.set_stroke(kurbo::Stroke::new((bound(g.size) - choke).max(0.5) * 2.0).with_join(kurbo::Join::Round)); + c.stroke_path(bp); + c.pop_layer(); + c.pop_layer(); + }); + } else { + self.inner_edge(ctx, f, reach, bp, xf, Vec2::ZERO, sigma, choke, paint, mix, painted, fade); + } + } + // Satin: the shape shifted both ways along the angle, blurred, where exactly one covers. + if e.satin.on + && let Some(c) = doc.resolve_color(&e.satin.color, 1.0) + { + let st = &e.satin; + let off = offset(st.angle, st.distance / 2.0); + let sigma = bound(st.size).max(0.0) / 2.0; + let paint = color_of(&c, st.opacity.clamp(0.0, 1.0)); + let invert = st.invert; + self.with_blended_filters(ctx, f, reach, sigma, Mix::Multiply, (bp, xf), fade, |_, c, fr| { + c.set_transform(fr.view); + c.push_layer(None, None, None, None, Some(blur(sigma))); + if invert { + c.set_transform(fr.view * xf); + c.set_paint(paint); + c.fill_path(bp); + } + c.set_transform(Affine::IDENTITY); + c.push_layer(None, Some(BlendMode::new(Mix::Normal, if invert { Compose::DestOut } else { Compose::SrcOver })), None, None, None); + c.set_transform(fr.view * Affine::translate(off) * xf); + c.set_paint(paint); + c.fill_path(bp); + c.set_transform(Affine::IDENTITY); + c.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::Xor)), None, None, None); + c.set_transform(fr.view * Affine::translate(-off) * xf); + c.fill_path(bp); + c.pop_layer(); + c.pop_layer(); + c.pop_layer(); + }); + } + // Bevel and Emboss (inner bevel): highlight from the lit side, shadow from the other. + if e.bevel.on { + let b = &e.bevel; + let off = offset(doc.light_angle(b.angle, b.global_light), bound(b.size) * (b.depth / 100.0).clamp(0.01, 10.0) * 0.5); + let sigma = bound(b.size).max(0.0) / 2.0; + if let Some(c) = doc.resolve_color(&b.highlight, 1.0) { + // The inverse shape moved away from the light lights the edges facing it. + let paint = color_of(&c, b.highlight_opacity.clamp(0.0, 1.0)); + self.inner_edge(ctx, f, reach, bp, xf, off, sigma, 0.0, paint, Mix::Screen, painted, fade); + } + if let Some(c) = doc.resolve_color(&b.shadow, 1.0) { + let paint = color_of(&c, b.shadow_opacity.clamp(0.0, 1.0)); + self.inner_edge(ctx, f, reach, bp, xf, -off, sigma, 0.0, paint, Mix::Multiply, painted, fade); + } + } + } + + /// An inner shadow-like edge: everything outside the shape moved by `off`, blurred by + /// `sigma`, clipped to the shape and blended with `mix`. With `painted` (an object and the + /// page it shows on), the shape is what the object paints, so a transparent part of an image + /// is outside it: the edge runs along the image's opaque pixels, not the frame. With `fade`, + /// the edge fades with that object's feathers. + #[allow(clippy::too_many_arguments)] + fn inner_edge( + &mut self, + ctx: &mut RenderContext, + f: &Frame, + reach: Rect, + bp: &BezPath, + xf: Affine, + off: Vec2, + sigma: f64, + choke: f64, + paint: peniko::Color, + mix: Mix, + painted: Option<(&Item, ShownOn)>, + fade: Option<&Item>, + ) { + let sigma = bound(sigma).max(0.0); + let choke = bound(choke); + if let Some((it, page)) = painted { + self.with_blended_filters(ctx, f, reach, sigma, mix, (bp, xf), fade, |me, c, fr| { + let pad = sigma * 4.0 + choke + off.hypot() + 4.0 * fr.px; + let area = reach.inflate(pad, pad); + // The colour everywhere but under the painted pixels moved by `off`, blurred, ... + c.set_transform(fr.view); + c.push_layer(None, None, None, None, None); + c.push_layer(None, None, None, None, Some(blur(sigma))); + c.set_paint(paint); + c.fill_rect(&area); + c.set_transform(Affine::IDENTITY); + c.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestOut)), None, None, None); + let moved = Frame { view: fr.view * Affine::translate(off), ..*fr }; + me.draw_body(c, &moved, it, bp, xf, page); + c.pop_layer(); + c.pop_layer(); + // ... kept only where the object paints. + c.set_transform(Affine::IDENTITY); + c.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestIn)), None, None, None); + me.draw_body(c, fr, it, bp, xf, page); + c.pop_layer(); + c.pop_layer(); + }); + return; + } + self.with_blended_filters(ctx, f, reach, sigma, mix, (bp, xf), fade, |_, c, fr| { + c.set_transform(fr.view); + c.push_layer(None, None, None, None, Some(blur(sigma))); + // Everything outside the offset shape, so the colour bleeds in from the edges. + let m = Affine::translate(off) * xf; + let pad = sigma * 4.0 + choke + off.hypot() + 4.0 * fr.px; + let mut inv = m.transform_rect_bbox(bp.bounding_box()).inflate(pad, pad).to_path(0.1); + let mut shape = bp.clone(); + shape.apply_affine(m); + inv.extend(shape.iter()); + c.set_paint(paint); + c.set_fill_rule(peniko::Fill::EvenOdd); + c.fill_path(&inv); + c.set_fill_rule(peniko::Fill::NonZero); + if choke > 0.0 { + c.set_stroke(kurbo::Stroke::new(choke * 2.0).with_join(kurbo::Join::Round)); + c.stroke_path(&shape); + } + c.pop_layer(); + }); + } + + /// [`Renderer::with_filters`] clipped to `clip` (a path and its spread transform), blended with + /// `mix`. The effect's blend must see the object's backdrop, outside its clipping and offscreen + /// filter layers. Putting it inside the clip blends with transparent pixels instead. With + /// `fade`, what `draw` paints is faded by that object's feathers before it blends. + #[allow(clippy::too_many_arguments)] + fn with_blended_filters( + &mut self, + ctx: &mut RenderContext, + f: &Frame, + reach: Rect, + sigma: f64, + mix: Mix, + clip: (&BezPath, Affine), + fade: Option<&Item>, + draw: impl FnOnce(&mut Self, &mut RenderContext, &Frame), + ) { + let layered = mix != Mix::Normal || fade.is_some(); + if layered { + ctx.set_transform(Affine::IDENTITY); + ctx.push_layer(None, Some(BlendMode::new(mix, Compose::SrcOver)), None, None, None); + } + self.with_filters(ctx, f, reach, sigma, Some(clip), draw); + if let Some(it) = fade { + self.fade(ctx, f, it, clip.0, clip.1, reach); + } + if layered { + ctx.pop_layer(); + } + } + + /// Fade what was drawn in the current layer by `it`'s feathers: the basic feather's blurred + /// inset shape, a ramp for each directional feather side and the gradient feather. `bp` and + /// `xf` are the object's shape and spread transform, `reach` (spread space) what it paints. + fn fade(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine, reach: Rect) { + let e = &it.effects; + // Choke keeps that much of the width opaque (a shorter fade); Sharp and Rounded corners + // follow the inset shape more tightly than Diffused. + if basic_feather(it) { let fo = &e.feather_options; let w = bound(e.feather) * (1.0 - fo.choke.clamp(0.0, 100.0) / 100.0).max(0.01); let (sigma, join) = match fo.corners { @@ -167,12 +407,7 @@ impl Renderer { designcraft_doc::FeatherCorners::Rounded => (w / 3.0, kurbo::Join::Round), designcraft_doc::FeatherCorners::Sharp => (w / 6.0, kurbo::Join::Miter), }; - // The body and its clip stay at device resolution; only the blurred mask may be drawn - // offscreen at a lower one. - ctx.set_transform(f.view * xf); - ctx.push_clip_layer(bp); - self.draw_body(ctx, f, it, bp, xf, page); - // Keep the body where the blurred inset silhouette is. + // Keep what was drawn where the blurred inset silhouette is. ctx.set_transform(Affine::IDENTITY); ctx.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestIn)), None, None, None); self.with_filters(ctx, f, reach, sigma, None, |_, c, fr| { @@ -190,9 +425,6 @@ impl Renderer { c.pop_layer(); }); ctx.pop_layer(); - ctx.pop_layer(); - } else { - self.draw_body(ctx, f, it, bp, xf, page); } // Directional feather: one fading ramp per side, multiplied in. let df = &e.directional_feather; @@ -222,206 +454,9 @@ impl Renderer { ctx.pop_layer(); } } - if gf.on { + if e.gradient_feather.on { gradient_feather_mask(ctx, f, it, xf); } - if gf.on || directional { - ctx.pop_layer(); - } - if !it.path.is_closed() { - return; - } - // An image's edge is where it is opaque, not the frame: inner shadows, glows and bevels - // follow what is painted when the frame holds a graphic. - let painted = matches!(it.content, designcraft_doc::Content::Graphic(_)).then_some((it, page)); - // Inner shadow, clipped to the shape. - if e.inner_shadow.on - && let Some(c) = doc.resolve_color(&e.inner_shadow.color, 1.0) - { - let s = &e.inner_shadow; - let (sigma, choke) = split(s.size, s.choke); - let off = offset(doc.light_angle(s.angle, s.global_light), s.distance); - self.inner_edge( - ctx, - f, - reach, - bp, - xf, - off, - sigma, - choke, - color_of(&c, s.opacity.clamp(0.0, 1.0)), - crate::blend_mode(s.mode).mix, - painted, - ); - } - // Inner glow: from the edges inwards (or out from the centre), screened over the object. - if e.inner_glow.on - && let Some(c) = doc.resolve_color(&e.inner_glow.color, 1.0) - { - let g = &e.inner_glow; - let (sigma, choke) = split(g.size, g.choke); - // Precise keeps the glow close to the edge: a tighter blur. - let sigma = if g.technique == designcraft_doc::GlowTechnique::Precise { sigma * 0.4 } else { sigma }; - let mix = crate::blend_mode(g.mode).mix; - let paint = color_of(&c, g.opacity.clamp(0.0, 1.0)); - if g.center { - self.with_blended_filters(ctx, f, reach, sigma, mix, (bp, xf), |_, c, fr| { - c.set_transform(fr.view); - c.push_layer(None, None, None, None, Some(blur(sigma))); - c.set_transform(fr.view * xf); - c.set_paint(paint); - c.fill_path(bp); - // Fade towards the edge: cut a band of the glow's size away. - c.set_transform(Affine::IDENTITY); - c.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestOut)), None, None, None); - c.set_transform(fr.view * xf); - c.set_paint(peniko::Color::BLACK); - c.set_stroke(kurbo::Stroke::new((bound(g.size) - choke).max(0.5) * 2.0).with_join(kurbo::Join::Round)); - c.stroke_path(bp); - c.pop_layer(); - c.pop_layer(); - }); - } else { - self.inner_edge(ctx, f, reach, bp, xf, Vec2::ZERO, sigma, choke, paint, mix, painted); - } - } - // Satin: the shape shifted both ways along the angle, blurred, where exactly one covers. - if e.satin.on - && let Some(c) = doc.resolve_color(&e.satin.color, 1.0) - { - let st = &e.satin; - let off = offset(st.angle, st.distance / 2.0); - let sigma = bound(st.size).max(0.0) / 2.0; - let paint = color_of(&c, st.opacity.clamp(0.0, 1.0)); - let invert = st.invert; - self.with_blended_filters(ctx, f, reach, sigma, Mix::Multiply, (bp, xf), |_, c, fr| { - c.set_transform(fr.view); - c.push_layer(None, None, None, None, Some(blur(sigma))); - if invert { - c.set_transform(fr.view * xf); - c.set_paint(paint); - c.fill_path(bp); - } - c.set_transform(Affine::IDENTITY); - c.push_layer(None, Some(BlendMode::new(Mix::Normal, if invert { Compose::DestOut } else { Compose::SrcOver })), None, None, None); - c.set_transform(fr.view * Affine::translate(off) * xf); - c.set_paint(paint); - c.fill_path(bp); - c.set_transform(Affine::IDENTITY); - c.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::Xor)), None, None, None); - c.set_transform(fr.view * Affine::translate(-off) * xf); - c.fill_path(bp); - c.pop_layer(); - c.pop_layer(); - c.pop_layer(); - }); - } - // Bevel and Emboss (inner bevel): highlight from the lit side, shadow from the other. - if e.bevel.on { - let b = &e.bevel; - let off = offset(doc.light_angle(b.angle, b.global_light), bound(b.size) * (b.depth / 100.0).clamp(0.01, 10.0) * 0.5); - let sigma = bound(b.size).max(0.0) / 2.0; - if let Some(c) = doc.resolve_color(&b.highlight, 1.0) { - // The inverse shape moved away from the light lights the edges facing it. - self.inner_edge(ctx, f, reach, bp, xf, off, sigma, 0.0, color_of(&c, b.highlight_opacity.clamp(0.0, 1.0)), Mix::Screen, painted); - } - if let Some(c) = doc.resolve_color(&b.shadow, 1.0) { - self.inner_edge(ctx, f, reach, bp, xf, -off, sigma, 0.0, color_of(&c, b.shadow_opacity.clamp(0.0, 1.0)), Mix::Multiply, painted); - } - } - } - - /// An inner shadow-like edge: everything outside the shape moved by `off`, blurred by - /// `sigma`, clipped to the shape and blended with `mix`. With `painted` (an object and the - /// page it shows on), the shape is what the object paints, so a transparent part of an image - /// is outside it: the edge runs along the image's opaque pixels, not the frame. - #[allow(clippy::too_many_arguments)] - fn inner_edge( - &mut self, - ctx: &mut RenderContext, - f: &Frame, - reach: Rect, - bp: &BezPath, - xf: Affine, - off: Vec2, - sigma: f64, - choke: f64, - paint: peniko::Color, - mix: Mix, - painted: Option<(&Item, ShownOn)>, - ) { - let sigma = bound(sigma).max(0.0); - let choke = bound(choke); - if let Some((it, page)) = painted { - self.with_blended_filters(ctx, f, reach, sigma, mix, (bp, xf), |me, c, fr| { - let pad = sigma * 4.0 + choke + off.hypot() + 4.0 * fr.px; - let area = reach.inflate(pad, pad); - // The colour everywhere but under the painted pixels moved by `off`, blurred, ... - c.set_transform(fr.view); - c.push_layer(None, None, None, None, None); - c.push_layer(None, None, None, None, Some(blur(sigma))); - c.set_paint(paint); - c.fill_rect(&area); - c.set_transform(Affine::IDENTITY); - c.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestOut)), None, None, None); - let moved = Frame { view: fr.view * Affine::translate(off), ..*fr }; - me.draw_body(c, &moved, it, bp, xf, page); - c.pop_layer(); - c.pop_layer(); - // ... kept only where the object paints. - c.set_transform(Affine::IDENTITY); - c.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestIn)), None, None, None); - me.draw_body(c, fr, it, bp, xf, page); - c.pop_layer(); - c.pop_layer(); - }); - return; - } - self.with_blended_filters(ctx, f, reach, sigma, mix, (bp, xf), |_, c, fr| { - c.set_transform(fr.view); - c.push_layer(None, None, None, None, Some(blur(sigma))); - // Everything outside the offset shape, so the colour bleeds in from the edges. - let m = Affine::translate(off) * xf; - let pad = sigma * 4.0 + choke + off.hypot() + 4.0 * fr.px; - let mut inv = m.transform_rect_bbox(bp.bounding_box()).inflate(pad, pad).to_path(0.1); - let mut shape = bp.clone(); - shape.apply_affine(m); - inv.extend(shape.iter()); - c.set_paint(paint); - c.set_fill_rule(peniko::Fill::EvenOdd); - c.fill_path(&inv); - c.set_fill_rule(peniko::Fill::NonZero); - if choke > 0.0 { - c.set_stroke(kurbo::Stroke::new(choke * 2.0).with_join(kurbo::Join::Round)); - c.stroke_path(&shape); - } - c.pop_layer(); - }); - } - - /// [`Renderer::with_filters`] clipped to `clip` (a path and its spread transform), blended with - /// `mix`. The effect's blend must see the object's backdrop, outside its clipping and offscreen - /// filter layers. Putting it inside the clip blends with transparent pixels instead. - #[allow(clippy::too_many_arguments)] - fn with_blended_filters( - &mut self, - ctx: &mut RenderContext, - f: &Frame, - reach: Rect, - sigma: f64, - mix: Mix, - clip: (&BezPath, Affine), - draw: impl FnOnce(&mut Self, &mut RenderContext, &Frame), - ) { - if mix != Mix::Normal { - ctx.set_transform(Affine::IDENTITY); - ctx.push_layer(None, Some(BlendMode::new(mix, Compose::SrcOver)), None, None, None); - } - self.with_filters(ctx, f, reach, sigma, Some(clip), draw); - if mix != Mix::Normal { - ctx.pop_layer(); - } } /// Run `draw`, which pushes filter layers. Single-threaded contexts draw directly. On a @@ -607,4 +642,102 @@ mod tests { // The transparent half shows the page: no shadow painted there. assert!(lum(150) > 700, "nothing over the transparent part: {}", lum(150)); } + + /// A feathered object fades to transparent at its edge, its bevel and inner glow included: where + /// the feather shows what is behind the object, the highlight and shadow leave it as it is. + #[test] + fn feathers_fade_the_inner_effects_with_the_object() { + use designcraft_doc::build::NewDocument; + use designcraft_doc::{Document, Fill, SpreadRef}; + let doc = |image: bool, feather: u8, inner: u8| { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let mut back = Item::new( + designcraft_doc::ItemId(d.alloc()), + lid, + designcraft_doc::Shape::Rectangle, + designcraft_geom::shapes::rectangle(Rect::new(50.0, 50.0, 350.0, 350.0)), + ); + back.fill = Fill::swatch("C=0 M=100 Y=0 K=0"); + d.insert_item(SpreadRef::Doc(0), back, None).unwrap(); + let mut it = Item::new( + designcraft_doc::ItemId(d.alloc()), + lid, + designcraft_doc::Shape::Rectangle, + designcraft_geom::shapes::rectangle(Rect::new(100.0, 100.0, 300.0, 300.0)), + ); + if image { + let img = image::RgbaImage::from_pixel(20, 20, image::Rgba([40, 160, 90, 255])); + let mut png = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let aid = designcraft_doc::AssetId(d.alloc()); + d.assets.insert( + aid, + std::sync::Arc::new(designcraft_doc::Asset { + id: aid, + name: "green.png".into(), + mime: "image/png".into(), + data: std::sync::Arc::new(png), + pixels: Some((20, 20)), + ..Default::default() + }), + ); + it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic { + layer_options: None, + asset: aid, + size: (200.0, 200.0), + xf: Affine::translate((100.0, 100.0)), + clip: None, + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + wrap: Default::default(), + fill: Default::default(), + }); + } else { + it.fill = Fill::swatch("C=100 M=0 Y=0 K=0"); + } + let e = &mut it.effects; + match feather { + 0 => e.feather = 30.0, + 1 => (e.directional_feather.on, e.directional_feather.widths) = (true, [30.0; 4]), + _ => e.gradient_feather.on = true, + } + match inner { + 0 => {} + 1 => (e.bevel.on, e.bevel.size) = (true, 12.0), + _ => (e.inner_glow.on, e.inner_glow.color, e.inner_glow.opacity, e.inner_glow.size) = (true, "[Paper]".into(), 1.0, 20.0), + } + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + d + }; + let px = |d: &Document, threads: u16, x: u32| { + let mut r = Renderer::new(); + r.threads = threads; + r.render_page(d, &crate::Cache::new(), 0, 1.0, false, &crate::RenderOptions::default()).unwrap().pixel(x, 200) + }; + let diff = |a: [u8; 4], b: [u8; 4]| a.iter().zip(b).map(|(p, q)| u32::from(p.abs_diff(q))).sum::(); + for image in [false, true] { + for feather in 0..3 { + for threads in [0, 4] { + let plain = doc(image, feather, 0); + // The object's faded edges: the left and right ones for the basic and directional + // feathers, the right one for the gradient feather (opaque on the left). + let edges: &[u32] = if feather == 2 { &[299] } else { &[100, 299] }; + for &x in edges { + let behind = px(&plain, threads, 70); + let faded = px(&plain, threads, x); + assert!(diff(faded, behind) < 40, "image {image} feather {feather}: the edge at {x} shows what is behind"); + for inner in [1, 2] { + let with = px(&doc(image, feather, inner), threads, x); + assert!( + diff(with, faded) < 30, + "image {image} feather {feather} inner {inner} threads {threads}: {with:?} over the faded edge at {x}, {faded:?} without" + ); + } + } + } + } + } + } } From ad828bd6503f92992f4751b50e2960153bc191b9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 22:00:14 -0800 Subject: [PATCH 33/38] Text wrap: an opaque image's Alpha Channel contour is its rectangle Before, an image with no transparency wrapped like its frame, so a smaller image or one moved within its frame kept text out of the frame's empty part. --- crates/engine/src/cmd/alpha_wrap.rs | 61 +++++++++++++++-------------- 1 file changed, 32 insertions(+), 29 deletions(-) diff --git a/crates/engine/src/cmd/alpha_wrap.rs b/crates/engine/src/cmd/alpha_wrap.rs index 585f29350..6ca0e78f9 100644 --- a/crates/engine/src/cmd/alpha_wrap.rs +++ b/crates/engine/src/cmd/alpha_wrap.rs @@ -52,7 +52,7 @@ pub(crate) fn sync(doc: &mut Document) { continue; } let Some(a) = doc.assets.get(&asset).filter(|a| a.is_raster()) else { continue }; - // An image with no outline (opaque, unreadable, too large) is remembered as an empty + // An image with no outline (unreadable, too large) is remembered as an empty // one, so later edits don't decode it again; the contour is then the frame's. let o = trace(&a.data, asset, threshold).unwrap_or_else(|| no_outline(&a.data, asset, threshold)); doc.alpha_outlines.push(Arc::new(o)); @@ -74,8 +74,8 @@ fn no_outline(data: &Arc>, asset: AssetId, threshold: u8) -> AlphaOutlin } } -/// The outline of the pixels of `data` more opaque than `threshold`. None for an image that -/// can't be read, is too large, or has no transparency (its contour is its rectangle). +/// The outline of the pixels of `data` more opaque than `threshold`: an image with no +/// transparency gives its rectangle. None for an image that can't be read or is too large. pub(crate) fn trace(data: &Arc>, asset: AssetId, threshold: u8) -> Option { let (w, h) = designcraft_images::pixel_size(data)?; if w == 0 || h == 0 || u64::from(w) * u64::from(h) > MAX_PIXELS { @@ -92,13 +92,7 @@ pub(crate) fn trace(data: &Arc>, asset: AssetId, threshold: u8) -> Optio let (cols, rows) = (w.min(grid), h.min(grid)); let mask = match finer.as_ref().and_then(|(m, fc, fr)| coarsen(m, *fc, *fr, cols, rows)) { Some(m) => m, - None => { - let (mask, transparent) = occupancy(img.as_raw(), w, h, cols, rows, threshold); - if !transparent { - return None; - } - mask - } + None => occupancy(img.as_raw(), w, h, cols, rows, threshold), }; // Composition follows an outline of at most MAX_CONTOUR_POINTS path elements (holes // included, for Include Inside Edges): a ring of k points is k + 1 elements with its close. @@ -152,11 +146,9 @@ fn coarsen(mask: &[bool], fc: u32, fr: u32, cols: u32, rows: u32) -> Option (Vec, bool) { +/// opaque than `threshold` (a cell is visible when any of its pixels is). +fn occupancy(rgba: &[u8], w: u32, h: u32, cols: u32, rows: u32, threshold: u8) -> Vec { let mut mask = vec![false; (cols as usize) * (rows as usize)]; - let mut transparent = false; // A pixel's cell is (x * cols / w, y * rows / h). Along a row the column is counted up as x // passes each cell's edge (cols <= w, so at most one edge per pixel) instead of divided out. for (y, row) in rgba.chunks_exact(w as usize * 4).take(h as usize).enumerate() { @@ -168,16 +160,14 @@ fn occupancy(rgba: &[u8], w: u32, h: u32, cols: u32, rows: u32, threshold: u8) - edge += u64::from(w); } acc += u64::from(cols); - let alpha = px[3]; - transparent |= alpha < 255; - if alpha > threshold + if px[3] > threshold && let Some(c) = mask.get_mut(base + gx) { *c = true; } } } - (mask, transparent) + mask } /// The closed boundaries of the visible cells, split into outer boundaries and the boundaries of @@ -305,8 +295,10 @@ mod tests { let (a, b) = (o.outer.bounds().unwrap(), o.holes.bounds().unwrap()); assert_eq!((a.x0, a.y0, a.x1, a.y1), (8.0, 8.0, 56.0, 56.0)); assert_eq!((b.x0, b.y0, b.x1, b.y1), (24.0, 24.0, 40.0, 40.0)); - // An opaque image has no alpha outline. - assert!(trace(&Arc::new(png(|_, _| true)), AssetId(1), 25).is_none()); + // An opaque image's outline is its rectangle. + let o = trace(&Arc::new(png(|_, _| true)), AssetId(1), 25).unwrap(); + let a = o.outer.bounds().unwrap(); + assert_eq!((a.x0, a.y0, a.x1, a.y1, o.holes.subpaths.len()), (0.0, 0.0, 64.0, 64.0, 0)); } /// The cell masks match a pixel-by-pixel count (cell = x * cols / w, y * rows / h), and a @@ -331,13 +323,13 @@ mod tests { } } } - assert_eq!(occupancy(&rgba, w, h, cols, rows, 25), (expect, true), "{cols} x {rows}"); + assert_eq!(occupancy(&rgba, w, h, cols, rows, 25), expect, "{cols} x {rows}"); } - let (fine, _) = occupancy(&rgba, w, h, 256, 256, 25); - assert_eq!(coarsen(&fine, 256, 256, 128, 128), Some(occupancy(&rgba, w, h, 128, 128, 25).0)); + let fine = occupancy(&rgba, w, h, 256, 256, 25); + assert_eq!(coarsen(&fine, 256, 256, 128, 128), Some(occupancy(&rgba, w, h, 128, 128, 25))); // Same count on one side, twice on the other. - let (fine, _) = occupancy(&rgba, w, h, 128, 256, 25); - assert_eq!(coarsen(&fine, 128, 256, 128, 128), Some(occupancy(&rgba, w, h, 128, 128, 25).0)); + let fine = occupancy(&rgba, w, h, 128, 256, 25); + assert_eq!(coarsen(&fine, 128, 256, 128, 128), Some(occupancy(&rgba, w, h, 128, 128, 25))); // Any other ratio is read from the pixels. assert_eq!(coarsen(&fine, 128, 256, 100, 128), None); } @@ -366,10 +358,9 @@ mod tests { assert!(s.doc().unwrap().doc.alpha_outlines.is_empty()); } - /// An image with no outline (here opaque) is remembered as an empty one: later edits don't - /// trace it again, and the contour is the frame's. + /// An outline is traced once: later edits keep it. #[test] - fn an_image_with_no_outline_is_not_traced_on_every_edit() { + fn an_outline_is_not_traced_on_every_edit() { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); let data = png(|_, _| true); @@ -380,12 +371,24 @@ mod tests { s.execute("object.textWrap", &json!({"ids": [id], "mode": "contour", "contour": "alphaChannel"})).unwrap(); let first = s.doc().unwrap().doc.alpha_outlines.clone(); assert_eq!(first.len(), 1, "remembered"); - assert_eq!((first[0].width, first[0].height), (0, 0), "as no outline"); s.execute("frame.create", &json!({"rect": [300, 300, 350, 350]})).unwrap(); let after = &s.doc().unwrap().doc.alpha_outlines; assert!(Arc::ptr_eq(&first[0], &after[0]), "kept, not traced again"); } + /// An opaque image's Alpha Channel contour is its rectangle, not the frame: moved within its + /// frame, it leaves the empty part of the frame to the text. + #[test] + fn an_opaque_image_wraps_its_rectangle() { + let (mut s, image, story) = page(&png(|_, _| true), [105.0, 150.0, 125.0, 178.0]); + s.execute("object.textWrap", &json!({"ids": [image], "contour": "alphaChannel"})).unwrap(); + assert!(overset(&s, story), "the image covers the caption"); + // The graphic moved 30 pt right within its frame: the frame's left 30 pt are empty. + s.execute("selection.set", &json!({"ids": [image], "content": true})).unwrap(); + s.execute("transform.move", &json!({"dx": 30, "dy": 0})).unwrap(); + assert!(!overset(&s, story), "the empty part of the frame takes the caption"); + } + /// Outlines are traced for a wrap set on the placed graphic and for frames inside groups, and /// traced again when the image's data changes under the same asset (relink, link update). #[test] From 4122adc65f60eb79402e3a73e9ed07aeb65e9a87 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 22:04:10 -0800 Subject: [PATCH 34/38] Text wrap: Alpha Channel outlines in use are looked up in sets --- crates/engine/src/cmd/alpha_wrap.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/alpha_wrap.rs b/crates/engine/src/cmd/alpha_wrap.rs index 6ca0e78f9..86b864a61 100644 --- a/crates/engine/src/cmd/alpha_wrap.rs +++ b/crates/engine/src/cmd/alpha_wrap.rs @@ -3,6 +3,7 @@ //! The outline is traced once per image and threshold, in the image's pixel space, and kept on //! the document ([`designcraft_doc::AlphaOutline`]). Composition maps it through the graphic's //! current transform, so it follows the image when it is moved, scaled or refitted. +use std::collections::BTreeSet; use std::sync::Arc; use designcraft_doc::{AlphaOutline, AssetId, ContourType, Document, Item, WrapMode}; @@ -18,14 +19,14 @@ const MAX_EDGES: usize = 1 << 16; /// Trace the outlines new Alpha Channel contours need and drop the ones nothing uses. Cheap when /// nothing changed: tracing happens only for an image and threshold not seen before. pub(crate) fn sync(doc: &mut Document) { - let mut wanted: Vec<(AssetId, u8)> = Vec::new(); + let mut wanted: BTreeSet<(AssetId, u8)> = BTreeSet::new(); // Every frame, in groups and Paste Into containers too, with the wrap set on the frame or on // its placed graphic (Direct Selection sets the graphic's). let mut want = |it: &Item| { let Some(g) = it.graphic() else { return }; for w in [&it.wrap, &g.wrap] { - if w.mode == WrapMode::Contour && w.contour == ContourType::AlphaChannel && !wanted.contains(&(g.asset, w.alpha_threshold)) { - wanted.push((g.asset, w.alpha_threshold)); + if w.mode == WrapMode::Contour && w.contour == ContourType::AlphaChannel { + wanted.insert((g.asset, w.alpha_threshold)); } } }; @@ -47,8 +48,9 @@ pub(crate) fn sync(doc: &mut Document) { // An outline traced from data the asset no longer has (relinked or updated) is traced again. let assets = &doc.assets; doc.alpha_outlines.retain(|o| wanted.contains(&(o.asset, o.threshold)) && assets.get(&o.asset).is_some_and(|a| o.traced_from(&a.data))); + let traced: BTreeSet<(AssetId, u8)> = doc.alpha_outlines.iter().map(|o| (o.asset, o.threshold)).collect(); for (asset, threshold) in wanted { - if doc.alpha_outline(asset, threshold).is_some() { + if traced.contains(&(asset, threshold)) { continue; } let Some(a) = doc.assets.get(&asset).filter(|a| a.is_raster()) else { continue }; From 23eb593498364037fb7d95a5efc052ae1b07799f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 22:18:21 -0800 Subject: [PATCH 35/38] Render: the feather test checks the inner effects still draw inside The edge comparisons pass when a bevel or inner glow is not drawn at all. A pixel inside the bevel and glow bands now has to differ from the same object without the effect, for every feather kind. --- crates/render/src/fx.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index f9b40ba9b..8fd4b0d1d 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -736,6 +736,16 @@ mod tests { ); } } + // Inside the bevel and glow bands the effect still shows, faded with the object + // where the feather reaches. + let inside = px(&plain, threads, 110); + for inner in [1, 2] { + let with = px(&doc(image, feather, inner), threads, 110); + assert!( + diff(with, inside) > 0, + "image {image} feather {feather} inner {inner} threads {threads}: {with:?} at 110, {inside:?} without the effect" + ); + } } } } From 95fdb96680e8133af05b90c3dd329126e206d858 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 05:19:22 -0800 Subject: [PATCH 36/38] Text wrap: a clipped contour visits only the edges in each line band A clipped contour now looks only at the edges of the outline and its clips that reach a line band, and charges its work budget for those alone. An outline with thousands of points elsewhere no longer exhausts the budget and falls back to the projections' common part. The Text wrap row of docs/typography-parity.md notes that an image over 40 megapixels, or one that can't be decoded, follows the frame's path. --- crates/compose/src/lib.rs | 150 ++++++++++++++++++++---------------- crates/compose/src/tests.rs | 30 ++++++++ docs/typography-parity.md | 2 +- 3 files changed, 115 insertions(+), 67 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 76df87568..4224b793f 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -406,10 +406,6 @@ impl Region<'_> { fn fills(self, winding: i32) -> bool { if self.even_odd { winding % 2 != 0 } else { winding != 0 } } - - fn edges(self) -> usize { - self.rings.iter().map(Vec::len).sum() - } } /// Points a contour outline may have; a larger outline wraps around its bounding box. @@ -545,19 +541,56 @@ fn intersect_spans(a: &[(f64, f64)], b: &[(f64, f64)]) -> Vec<(f64, f64)> { /// one shape that lies inside the others. The spans are the middle slice of all the shapes and /// the parts of each shape's edges in the band that lie inside the others, so a part of the /// outline outside a clip blocks nothing, even where it shares x positions with the clip in the -/// same band. None when that takes more than [`MAX_CLIPPED_WORK`] edge visits. +/// same band. Only the edges that reach the band are visited. None when that takes more than +/// [`MAX_CLIPPED_WORK`] edge visits. fn clipped_band_spans(shapes: &[Region], y0: f64, y1: f64) -> Option> { let middle = y0 * 0.5 + y1 * 0.5; let mut slices = shapes.iter().map(|s| merge_spans(middle_spans(*s, middle))); let first = slices.next()?; let mut spans = slices.fold(first, |a, b| intersect_spans(&a, &b)); + let banded: Vec = shapes.iter().map(|s| BandEdges::of(*s, y0, y1)).collect(); let mut budget = MAX_CLIPPED_WORK; - for k in 0..shapes.len() { - edges_inside(shapes, k, y0, y1, &mut budget, &mut spans)?; + for k in 0..banded.len() { + edges_inside(&banded, k, y0, y1, &mut budget, &mut spans)?; } Some(merge_spans(spans)) } +/// The edges of a shape that reach a line band, with a margin above and below. Every point +/// [`segment_inside`] tests lies within the margin, and an edge that doesn't reach a point's y +/// neither crosses the ray from it nor passes within a hair of it, so these edges give the same +/// inside and on-edge answers as the whole shape for those points. +struct BandEdges { + edges: Vec<(Point, Point)>, + even_odd: bool, +} + +impl BandEdges { + /// Margin added above and below the band; well above the side step in [`segment_inside`]. + const MARGIN: f64 = 1e-3; + + fn of(shape: Region, y0: f64, y1: f64) -> BandEdges { + let (lo, hi) = (y0 - Self::MARGIN, y1 + Self::MARGIN); + let mut edges = Vec::new(); + for ring in shape.rings { + let Some(&last) = ring.last() else { continue }; + let mut a = last; + for &b in ring { + if a.y.max(b.y) >= lo && a.y.min(b.y) <= hi { + edges.push((a, b)); + } + a = b; + } + } + BandEdges { edges, even_odd: shape.even_odd } + } + + /// Is a point with this winding number filled? + fn fills(&self, winding: i32) -> bool { + if self.even_odd { winding % 2 != 0 } else { winding != 0 } + } +} + /// Takes `n` edge visits from `budget`; None when it doesn't hold that many. fn spend(budget: &mut usize, n: usize) -> Option<()> { *budget = budget.checked_sub(n)?; @@ -567,25 +600,19 @@ fn spend(budget: &mut usize, n: usize) -> Option<()> { /// Pushes to `out` the x-ranges of the parts of the edges of `shapes[k]` within the band `y0..y1` /// that bound the part of the plane inside all of `shapes` ([`segment_inside`]). None when /// `budget` runs out. -fn edges_inside(shapes: &[Region], k: usize, y0: f64, y1: f64, budget: &mut usize, out: &mut Vec<(f64, f64)>) -> Option<()> { - let n = shapes.iter().map(|o| o.edges()).sum::().max(1); - let rings = shapes.get(k)?.rings; - for ring in rings { - let Some(&last) = ring.last() else { continue }; - let mut a = last; - for &b in ring { - let dy = b.y - a.y; - if a.y.max(b.y) >= y0 && a.y.min(b.y) <= y1 { - let (t0, t1) = if dy.abs() < 1e-12 { - (0.0, 1.0) - } else { - let (u, v) = ((y0 - a.y) / dy, (y1 - a.y) / dy); - (u.min(v).clamp(0.0, 1.0), u.max(v).clamp(0.0, 1.0)) - }; - let (p, q) = (a + (b - a) * t0, a + (b - a) * t1); - segment_inside(p, q, shapes, k, n, budget, out)?; - } - a = b; +fn edges_inside(shapes: &[BandEdges], k: usize, y0: f64, y1: f64, budget: &mut usize, out: &mut Vec<(f64, f64)>) -> Option<()> { + let n = shapes.iter().map(|o| o.edges.len()).sum::().max(1); + for &(a, b) in &shapes.get(k)?.edges { + let dy = b.y - a.y; + if a.y.max(b.y) >= y0 && a.y.min(b.y) <= y1 { + let (t0, t1) = if dy.abs() < 1e-12 { + (0.0, 1.0) + } else { + let (u, v) = ((y0 - a.y) / dy, (y1 - a.y) / dy); + (u.min(v).clamp(0.0, 1.0), u.max(v).clamp(0.0, 1.0)) + }; + let (p, q) = (a + (b - a) * t0, a + (b - a) * t1); + segment_inside(p, q, shapes, k, n, budget, out)?; } } Some(()) @@ -597,14 +624,14 @@ fn edges_inside(shapes: &[Region], k: usize, y0: f64, y1: f64, budget: &mut usiz /// the points just beside it on one side are inside all the shapes. Where `shapes[k]` has no area /// on either side (an open path), it counts when it is inside the other shapes or on their /// boundary. Each pass over the edges takes `n` from `budget`; None when it runs out. -fn segment_inside(p: Point, q: Point, shapes: &[Region], k: usize, n: usize, budget: &mut usize, out: &mut Vec<(f64, f64)>) -> Option<()> { +fn segment_inside(p: Point, q: Point, shapes: &[BandEdges], k: usize, n: usize, budget: &mut usize, out: &mut Vec<(f64, f64)>) -> Option<()> { let r = q - p; let len2 = r.hypot2(); if !len2.is_finite() { return Some(()); } let at = |t: f64| p + r * t; - let in_others = |pt: Point| shapes.iter().enumerate().all(|(i, s)| i == k || inside_or_on(*s, pt)); + let in_others = |pt: Point| shapes.iter().enumerate().all(|(i, s)| i == k || inside_or_on(s, pt)); spend(budget, n)?; if len2 < 1e-24 { if in_others(p) { @@ -615,7 +642,7 @@ fn segment_inside(p: Point, q: Point, shapes: &[Region], k: usize, n: usize, bud // A step to either side of the segment, well above rounding at page coordinates. let side = r.turn_90() * (1e-6 / len2.sqrt()); let bounds_the_part = |m: Point| { - let own = shapes.get(k).copied(); + let own = shapes.get(k); let in_own = |pt: Point| own.is_some_and(|s| inside_or_on(s, pt)); let (a, b) = (m + side, m - side); match (in_own(a), in_own(b)) { @@ -625,29 +652,24 @@ fn segment_inside(p: Point, q: Point, shapes: &[Region], k: usize, n: usize, bud }; let (ylo, yhi) = (p.y.min(q.y), p.y.max(q.y)); let mut ts = vec![0.0, 1.0]; - for ring in shapes.iter().enumerate().filter(|(i, _)| *i != k).flat_map(|(_, s)| s.rings) { - let Some(&last) = ring.last() else { continue }; - let mut c = last; - for &d in ring { - if c.y.max(d.y) >= ylo - 1e-9 && c.y.min(d.y) <= yhi + 1e-9 { - let e = d - c; - let denom = r.cross(e); - let w = c - p; - if denom.abs() > 1e-12 * (len2 * e.hypot2()).sqrt() { - let (t, u) = (w.cross(e) / denom, w.cross(r) / denom); - if (-1e-9..=1.0 + 1e-9).contains(&u) && (0.0..=1.0).contains(&t) { + for &(c, d) in shapes.iter().enumerate().filter(|(i, _)| *i != k).flat_map(|(_, s)| &s.edges) { + if c.y.max(d.y) >= ylo - 1e-9 && c.y.min(d.y) <= yhi + 1e-9 { + let e = d - c; + let denom = r.cross(e); + let w = c - p; + if denom.abs() > 1e-12 * (len2 * e.hypot2()).sqrt() { + let (t, u) = (w.cross(e) / denom, w.cross(r) / denom); + if (-1e-9..=1.0 + 1e-9).contains(&u) && (0.0..=1.0).contains(&t) { + ts.push(t); + } + } else if w.cross(r).abs() <= 1e-9 * len2.sqrt() { + // Collinear: the edge's ends split the segment. + for t in [w.dot(r) / len2, (d - p).dot(r) / len2] { + if (0.0..=1.0).contains(&t) { ts.push(t); } - } else if w.cross(r).abs() <= 1e-9 * len2.sqrt() { - // Collinear: the edge's ends split the segment. - for t in [w.dot(r) / len2, (d - p).dot(r) / len2] { - if (0.0..=1.0).contains(&t) { - ts.push(t); - } - } } } - c = d; } } ts.sort_by(f64::total_cmp); @@ -663,26 +685,22 @@ fn segment_inside(p: Point, q: Point, shapes: &[Region], k: usize, n: usize, bud Some(()) } -/// Is `pt` inside `shape` or within a hair of one of its edges? -fn inside_or_on(shape: Region, pt: Point) -> bool { +/// Is `pt`, a point within the band of `shape`'s edges, inside the shape or within a hair of one +/// of its edges? +fn inside_or_on(shape: &BandEdges, pt: Point) -> bool { let mut winding = 0i32; - for ring in shape.rings { - let Some(&last) = ring.last() else { continue }; - let mut a = last; - for &b in ring { - let e = b - a; - let len2 = e.hypot2(); - let t = if len2 > 0.0 { ((pt - a).dot(e) / len2).clamp(0.0, 1.0) } else { 0.0 }; - if (a + e * t - pt).hypot2() < 1e-18 { - return true; + for &(a, b) in &shape.edges { + let e = b - a; + let len2 = e.hypot2(); + let t = if len2 > 0.0 { ((pt - a).dot(e) / len2).clamp(0.0, 1.0) } else { 0.0 }; + if (a + e * t - pt).hypot2() < 1e-18 { + return true; + } + if (a.y <= pt.y && pt.y < b.y) || (b.y <= pt.y && pt.y < a.y) { + let x = a.x + (pt.y - a.y) / e.y * e.x; + if x > pt.x { + winding += if e.y > 0.0 { 1 } else { -1 }; } - if (a.y <= pt.y && pt.y < b.y) || (b.y <= pt.y && pt.y < a.y) { - let x = a.x + (pt.y - a.y) / e.y * e.x; - if x > pt.x { - winding += if e.y > 0.0 { 1 } else { -1 }; - } - } - a = b; } } shape.fills(winding) diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index e8fb2aac8..8ea0fe3cc 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -8612,6 +8612,36 @@ fn a_clipped_contour_blocks_only_what_its_frame_shows() { assert_eq!(c.within(&poly(&detailed), id).band_spans(0.0, 10.0), vec![(20.0, 40.0)]); } +/// Edges far above or below a line band cost a clipped contour nothing there: an outline with +/// 7,000 points well below the band still gets the exact spans, not the projections' common part. +#[test] +fn a_clipped_contours_budget_counts_only_edges_in_the_band() { + let poly = |pts: &[(f64, f64)]| { + let mut p = designcraft_geom::BezPath::new(); + p.move_to(pts[0]); + for &q in &pts[1..] { + p.line_to(q); + } + p.close_path(); + p + }; + let id = designcraft_geom::Affine::IDENTITY; + // The L-shaped frame of the test above, with its notch at x 10..40 above y = 6. + let frame = poly(&[(0.0, 0.0), (10.0, 0.0), (10.0, 6.0), (40.0, 6.0), (40.0, 10.0), (0.0, 10.0)]); + // An outline in the notch and to the right of the frame, with 40 teeth along its top in the + // band and a 7,000-point zigzag along its bottom at y = 200. + let mut pts = vec![(20.0, 0.0)]; + for i in 0..40 { + let x = 40.0 + 0.5 * f64::from(i); + pts.extend([(x, 0.0), (x + 0.25, 0.5)]); + } + pts.push((60.0, 0.0)); + pts.extend((0..7000).map(|i| (60.0 - 10.0 * f64::from(i) / 6999.0, if i % 2 == 0 { 200.0 } else { 201.0 }))); + pts.extend([(50.0, 4.0), (20.0, 4.0)]); + let c = Contour::of(&poly(&pts), id, 0.0).unwrap(); + assert_eq!(c.within(&frame, id).band_spans(0.0, 10.0), Vec::<(f64, f64)>::new(), "the frame shows none of it"); +} + /// Rule Above's offset runs from the first line's baseline up to the rule's bottom edge, as in /// InDesign: a rule set clear of the cap height sits between the rows, not across the row above /// (an INDD project list whose rules crossed the previous paragraph's text). diff --git a/docs/typography-parity.md b/docs/typography-parity.md index 0b3c030cf..323e028dc 100644 --- a/docs/typography-parity.md +++ b/docs/typography-parity.md @@ -59,7 +59,7 @@ Fidelity: **unmeasured** unless stated. | Paragraph rules, borders, shading | ✓ | ✓ | ✓ | | | Balance ragged lines | ✓ | ✓ | ✓ | | | Optical margin alignment | per story with a size, Ignore Optical Margin per paragraph and style | ✓ (hang table of our own, scaled by the story's size; Story panel; IDML `StoryPreference`, `IgnoreEdgeAlignment`) | ~ | hang amounts are ours, InDesign's unknown (#947) | -| Text wrap | bounding box, shape, jump object/column, wrap sides (text on both sides of an object), contour options, on page items and anchored objects | ✓ | ~ | Alpha Channel contours follow the outline of the image's visible pixels; Detect Edges and Photoshop Path contours follow the frame's path | +| Text wrap | bounding box, shape, jump object/column, wrap sides (text on both sides of an object), contour options, on page items and anchored objects | ✓ | ~ | Alpha Channel contours follow the outline of the image's visible pixels (an image over 40 megapixels, or one that can't be decoded, follows the frame's path); Detect Edges and Photoshop Path contours follow the frame's path | | Anchored objects | inline, above line, custom | ✓ | ~ | anchored objects in table cells not drawn (#163) | | Footnotes, endnotes | ✓ | ✓ | ~ | footnotes don't split across columns yet | | Type on a path | ✓ | ✓ | ✓ | | From 20319e991e049862f15da16632dffa3ff3fc265e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 08:07:12 -0800 Subject: [PATCH 37/38] Text Frame Options: link the insets only when every selected frame has even sides The dialog turned the inset link on when the first selected frame had the same inset on all four sides. With a second frame whose sides differ, typing one side then set all four sides of every selected frame, so the second frame lost its other insets. The link is now on only when every frame the dialog changes has even sides: the selected items, else the caret's frame, else every frame of the caret's story. --- crates/ui-egui/src/dialogs.rs | 21 +++++++++++- crates/ui-egui/src/dialogs/tests.rs | 51 +++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index a6400054d..1499b37e5 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1005,6 +1005,24 @@ fn selected_frame_options(app: &DesignApp) -> Option bool { + let Some(st) = app.session.active() else { + return true; + }; + let mut ids = st.selection.items.clone(); + if ids.is_empty() + && let Some(t) = st.selection.text + { + ids = match t.frame { + Some(f) => vec![f], + None => st.doc.story(t.story).map(|s| s.frames.clone()).unwrap_or_default(), + }; + } + ids.iter().filter_map(|id| st.doc.item(*id)?.text_frame()).all(|t| t.options.inset.iter().all(|v| (v - t.options.inset[0]).abs() < 1e-9)) +} + /// Object › Text Frame Options shows the selected frame's options. `_shown` keeps what it showed, /// so OK sends only the fields the user changed. Fields given when the dialog opened count as /// changed. @@ -1056,7 +1074,8 @@ fn seed_text_frame_options(app: &DesignApp, d: &mut Dialog) { d.fields.entry(k.clone()).or_insert_with(|| v.clone()); } } - d.fields.entry("insetLinked".to_string()).or_insert_with(|| json!(o.inset.iter().all(|v| (v - o.inset[0]).abs() < 1e-9))); + // Linked only when no selected frame has uneven sides, so a typed side cannot overwrite them. + d.fields.entry("insetLinked".to_string()).or_insert_with(|| json!(selected_frames_have_even_insets(app))); // The frame's width: Fixed Number shows each column's width from it as the fields change. if let Some(w) = selected_frame_width(app) { d.fields.insert("_frameWidth".into(), json!(w)); diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 272165b08..5c2cf749e 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -1485,6 +1485,57 @@ fn text_frame_options_change_only_what_was_edited() { assert_eq!(opts(&app), before); } +#[test] +fn text_frame_options_link_the_insets_only_when_every_selected_frame_has_even_sides() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let frame = |app: &mut DesignApp, rect: [u32; 4], inset: Value| { + let r = app.run("frame.create", json!({"rect": rect, "content": "text", "text": "Hello"})).unwrap(); + let id = r["id"].as_u64().unwrap(); + app.run("object.textFrameOptions", json!({"ids": [id], "inset": inset})).unwrap(); + (designcraft_doc::ItemId(id), r["story"].clone()) + }; + let (even, _) = frame(&mut app, [36, 36, 236, 236], json!(6)); + let (uneven, uneven_story) = frame(&mut app, [300, 36, 500, 236], json!([1, 2, 3, 4])); + let (other_even, other_even_story) = frame(&mut app, [36, 300, 236, 500], json!(9)); + let inset = |app: &DesignApp, id| app.session.doc().unwrap().doc.item(id).unwrap().text_frame().unwrap().options.inset; + let seeded = |app: &DesignApp| { + let mut d = Dialog::new("textFrameOptions", json!({})); + seed_text_frame_options(app, &mut d); + d + }; + // The frame with even sides is selected first: the uneven one still turns the link off. + app.run("selection.set", json!({"ids": [even.0, uneven.0]})).unwrap(); + let mut d = seeded(&app); + assert_eq!(d.s("insetTop"), "0p6"); + assert!(!d.b("insetLinked"), "a selected frame with uneven sides leaves the link off"); + // Top edited while unlinked: only Top changes, and each frame keeps its other sides. + d.fields.insert("insetTop".into(), json!("1p0")); + app.ui.dialog = Some(d); + confirm(&mut app).unwrap(); + assert_eq!(inset(&app, even), [12.0, 6.0, 6.0, 6.0]); + assert_eq!(inset(&app, uneven), [12.0, 2.0, 3.0, 4.0]); + // A frame with even sides alone keeps the link on; selected with the uneven one, the link is off. + app.run("selection.set", json!({"ids": [other_even.0]})).unwrap(); + assert!(seeded(&app).b("insetLinked")); + app.run("selection.set", json!({"ids": [other_even.0, uneven.0]})).unwrap(); + assert!(!seeded(&app).b("insetLinked")); + // A caret: the frames the dialog changes decide. + app.run("text.select", json!({"story": uneven_story, "anchor": 0, "focus": 0})).unwrap(); + assert!(!seeded(&app).b("insetLinked")); + app.run("text.select", json!({"story": other_even_story, "anchor": 0, "focus": 0})).unwrap(); + assert!(seeded(&app).b("insetLinked")); + // A story threaded on into the uneven frame: a caret placed in the first frame changes only + // that frame, and a caret with no frame changes every frame of the story. + let (first, story) = frame(&mut app, [300, 300, 500, 500], json!(6)); + let (last, _) = frame(&mut app, [300, 520, 500, 700], json!([1, 2, 3, 4])); + app.run("text.thread", json!({"from": first.0, "to": last.0})).unwrap(); + app.run("text.placeCaret", json!({"frame": first.0, "point": [310, 310]})).unwrap(); + assert!(seeded(&app).b("insetLinked")); + app.run("text.select", json!({"story": story, "anchor": 0, "focus": 0})).unwrap(); + assert!(!seeded(&app).b("insetLinked")); +} + #[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()); From 49792c9ad5d25d499b5c34f63a64294f3ade8f28 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 09:04:11 -0800 Subject: [PATCH 38/38] Text wrap: look up Alpha Channel outlines by binary search The document keeps its outlines sorted by image and threshold, and the lookup uses a binary search. The data merge test now checks that the merged document has the image's outline, and that a bounding box wrap leaves the same caption overset. --- crates/doc/src/lib.rs | 6 +++-- crates/engine/src/cmd/alpha_wrap.rs | 30 ++++++++++++++++++++++++ crates/engine/src/cmd/datamerge/tests.rs | 24 +++++++++++++++---- 3 files changed, 54 insertions(+), 6 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 5e18186da..f4c8a25b6 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -803,7 +803,8 @@ pub struct Document { pub sections: Vec
, #[serde(default)] pub assets: BTreeMap>, - /// Alpha Channel contour outlines of placed images (see [`AlphaOutline`]). Not saved. + /// Alpha Channel contour outlines of placed images (see [`AlphaOutline`]), sorted by asset + /// and then threshold, at most one for each pair. Not saved. #[serde(skip)] pub alpha_outlines: Vec>, /// Hyperlinks (Window → Interactive → Hyperlinks). @@ -1118,7 +1119,8 @@ impl Document { /// The Alpha Channel outline of `asset` at `threshold`, once the engine has traced it. pub fn alpha_outline(&self, asset: AssetId, threshold: u8) -> Option<&AlphaOutline> { - self.alpha_outlines.iter().map(|o| &**o).find(|o| o.asset == asset && o.threshold == threshold) + let i = self.alpha_outlines.binary_search_by_key(&(asset, threshold), |o| (o.asset, o.threshold)).ok()?; + self.alpha_outlines.get(i).map(|o| &**o) } pub fn layer(&self, id: LayerId) -> Option<&Layer> { diff --git a/crates/engine/src/cmd/alpha_wrap.rs b/crates/engine/src/cmd/alpha_wrap.rs index 86b864a61..ea9801f79 100644 --- a/crates/engine/src/cmd/alpha_wrap.rs +++ b/crates/engine/src/cmd/alpha_wrap.rs @@ -49,6 +49,7 @@ pub(crate) fn sync(doc: &mut Document) { let assets = &doc.assets; doc.alpha_outlines.retain(|o| wanted.contains(&(o.asset, o.threshold)) && assets.get(&o.asset).is_some_and(|a| o.traced_from(&a.data))); let traced: BTreeSet<(AssetId, u8)> = doc.alpha_outlines.iter().map(|o| (o.asset, o.threshold)).collect(); + let kept = doc.alpha_outlines.len(); for (asset, threshold) in wanted { if traced.contains(&(asset, threshold)) { continue; @@ -59,6 +60,10 @@ pub(crate) fn sync(doc: &mut Document) { let o = trace(&a.data, asset, threshold).unwrap_or_else(|| no_outline(&a.data, asset, threshold)); doc.alpha_outlines.push(Arc::new(o)); } + // `Document::alpha_outline` finds an outline by binary search. + if doc.alpha_outlines.len() > kept { + doc.alpha_outlines.sort_by_key(|o| (o.asset, o.threshold)); + } } /// The empty outline standing for an image `trace` found none in: zero-size, which composition @@ -443,6 +448,31 @@ mod tests { assert_eq!((b.x0, b.x1), (24.0, 40.0), "the new image's outline"); } + /// An outline traced after others is still found: a threshold lower than a kept outline's + /// is traced later and must not hide either outline from the lookup. + #[test] + fn outlines_traced_later_are_found_by_image_and_threshold() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let data = png(|x, _| (16..48).contains(&x)); + let image = s + .execute("file.place", &json!({"base64": crate::cmd::file::base64_encode(&data), "name": "cut.png", "x": 100, "y": 100, "width": 128})) + .unwrap()["id"] + .as_u64() + .unwrap(); + s.execute("object.textWrap", &json!({"ids": [image], "content": true, "mode": "contour", "contour": "alphaChannel", "threshold": 200})) + .unwrap(); + s.execute("object.textWrap", &json!({"ids": [image], "mode": "contour", "contour": "alphaChannel"})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.alpha_outlines.len(), 2, "one outline for each threshold"); + let Some(g) = d.item(designcraft_doc::ItemId(image)).and_then(|it| it.graphic()) else { panic!("a graphic") }; + for threshold in [designcraft_doc::DEFAULT_ALPHA_THRESHOLD, 200] { + let o = d.alpha_outline(g.asset, threshold); + assert!(o.is_some_and(|o| o.threshold == threshold), "the outline at {threshold} is found"); + } + assert!(d.alpha_outline(g.asset, 100).is_none(), "no outline at a threshold nothing uses"); + } + /// A wrap with the default threshold saves as it did before thresholds were stored; another /// threshold is saved. #[test] diff --git a/crates/engine/src/cmd/datamerge/tests.rs b/crates/engine/src/cmd/datamerge/tests.rs index 055f7e5d6..963cb4fd7 100644 --- a/crates/engine/src/cmd/datamerge/tests.rs +++ b/crates/engine/src/cmd/datamerge/tests.rs @@ -1339,10 +1339,22 @@ fn a_previewed_image_gets_its_alpha_channel_outline() { } /// The overset count data merge reports is composed with the merged images' Alpha Channel -/// outlines: a caption over the image's transparent part is set, not counted as overset. +/// outlines: a caption over the image's transparent part is set, not counted as overset. With a +/// bounding box wrap the same caption is overset, so the count depends on the outline. #[test] fn a_merge_counts_overset_text_with_the_merged_images_outlines() { - let dir = scratch("alpha-merge"); + let (r, outlines) = merge_with_wrap("alpha-merge", json!({"mode": "contour", "contour": "alphaChannel", "offset": 0})); + assert_eq!(r["oversetStories"], 0, "the caption sits beside the image's visible pixels: {r}"); + assert_eq!(outlines, 1, "the merged document has the image's outline"); + let (r, _) = merge_with_wrap("alpha-merge-box", json!({"mode": "boundingBox", "offset": 0})); + assert_eq!(r["oversetStories"], 1, "the image's frame covers the caption: {r}"); +} + +/// Merges one record whose image is visible only in its left quarter into a graphic placeholder +/// with the text wrap `wrap`, beside a caption over the image's transparent part. Returns the +/// merge's answer and the number of nonempty Alpha Channel outlines in the merged document. +fn merge_with_wrap(name: &str, wrap: Value) -> (Value, usize) { + let dir = scratch(name); // Visible only in the left quarter. let mut pixels = vec![0u8; 64 * 64 * 4]; for y in 0..64 { @@ -1358,11 +1370,15 @@ fn a_merge_counts_overset_text_with_the_merged_images_outlines() { let frame = exec(&mut s, "frame.create", json!({"rect": [100.0, 100.0, 300.0, 300.0], "content": "graphic"})); exec(&mut s, "data.placeholder.add", json!({"field": "Photo", "role": "image", "item": frame["id"]})); exec(&mut s, "data.options", json!({"fitting": "fitContentToFrame"})); - exec(&mut s, "object.textWrap", json!({"ids": [frame["id"]], "mode": "contour", "contour": "alphaChannel", "offset": 0})); + let mut params = wrap; + params["ids"] = json!([frame["id"]]); + exec(&mut s, "object.textWrap", params); text_frame(&mut s, [220.0, 180.0, 290.0, 220.0], "Hi", None); let r = exec(&mut s, "data.merge", json!({})); - assert_eq!(r["oversetStories"], 0, "the caption sits beside the image's visible pixels: {r}"); + let doc = &s.documents()[s.active_index().unwrap()].doc; + let outlines = doc.alpha_outlines.iter().filter(|o| o.width > 0 && !o.outer.subpaths.is_empty()).count(); let _ = std::fs::remove_dir_all(&dir); + (r, outlines) } /// The cell texts of the first table in the active document's first story that has one.