diff --git a/ROADMAP.md b/ROADMAP.md index 9e1a512eb..21b35c19c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -69,7 +69,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. @@ -388,6 +388,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-12** · The status bar's preflight count and the Preflight panel take their issues from a check on a background worker, which checks the newest state of the document and shows the issues last found meanwhile. Moving or resizing a frame and typing no longer wait for a check of the whole document on every drag step and keystroke. 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. @@ -462,6 +463,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 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 dd7180915..349a0385b 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}; @@ -91,6 +92,18 @@ fn doc_signature(doc: &Document) -> u64 { // The compositions the indexes are built from also depend on these (see `signature`). let g = &doc.settings.baseline_grid; (doc.font_scope, g.relative_to == designcraft_doc::GridRelative::TopMargin, g.start.to_bits(), g.increment.to_bits()).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() } @@ -146,6 +159,14 @@ impl Cache { c } + /// 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 mut resolved = Resolved::new(); + let placed = crate::placed::placements(doc, sid, &mut |host| self.resolve(doc, host, true, 1, &mut resolved)); + 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, resolved: &mut Resolved) -> Arc { @@ -443,6 +464,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/compose/src/lib.rs b/crates/compose/src/lib.rs index 4891b41a1..b7f7086fe 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -385,17 +385,50 @@ 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 } + } +} + /// 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; + +/// 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 { 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 @@ -407,18 +440,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) } @@ -428,12 +484,301 @@ 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)) } + + /// 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(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(); + } + 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()) + } +} + +/// 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 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. 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..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)?; + Some(()) +} + +/// 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: &[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(()) +} + +/// 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: &[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)); + spend(budget, n)?; + if len2 < 1e-24 { + 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); + 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 &(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); + } + } + } + } + } + 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, 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))); + } + } + Some(()) +} + +/// 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 &(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 }; + } + } + } + shape.fills(winding) +} + +/// 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); + 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(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 { + 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))); + } + a = b; + } + } + merge_spans(spans) +} + +/// 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 shape.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() { + 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 shape.fills(winding) + && let Some(start) = previous + { + spans.push((start, x)); + } + winding += delta; + previous = Some(x); + } + spans } /// The closed polylines of `path` (after `to_inner`), flattened; None if it is empty or too detailed. @@ -496,19 +841,39 @@ 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: &Item, xf: Affine, inv: Affine, left_page: bool) -> Exclusion { +pub fn exclusion(doc: &Document, other: &Item, xf: Affine, inv: Affine, left_page: bool) -> Exclusion { 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(other, other.wrap.contour)), 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 /// the part of the graphic the frame shows. None if there is no graphic or it is not shown. -pub fn graphic_exclusion(it: &Item, xf: Affine, inv: Affine, left_page: bool) -> Option { +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(it, g.wrap.contour)), 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 @@ -516,7 +881,7 @@ pub fn graphic_exclusion(it: &Item, xf: Affine, inv: Affine, left_page: bool) -> 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, @@ -536,35 +901,69 @@ 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, anchor_baseline: 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, anchor_baseline: 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; 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: &Item, kind: designcraft_doc::ContourType) -> (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 + && let Some(g) = it.graphic() + && let Some(o) = doc.alpha_outline(g.asset, wrap.alpha_threshold) + && o.width > 0 + && o.height > 0 + { + let to_graphic = 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 wrap.inside_edges { + path.extend(o.holes.to_bezpath().iter()); + } + // 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. @@ -649,20 +1048,32 @@ const MAX_WRAP_DEPTH: usize = 64; /// the spread; `skip` is the text frame being wrapped; `left_page` tells whether a spread x is on /// a left page. #[allow(clippy::too_many_arguments)] -fn wrap_obstacles(it: &Item, parent: Affine, inv: Affine, skip: ItemId, left_page: &dyn Fn(f64) -> bool, depth: usize, out: &mut Vec) { +fn wrap_obstacles( + doc: &Document, + it: &Item, + parent: Affine, + inv: Affine, + skip: ItemId, + left_page: &dyn Fn(f64) -> bool, + depth: usize, + out: &mut Vec, +) { if it.id == skip || it.hidden || depth > MAX_WRAP_DEPTH { return; } 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(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(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(c, xf, inv, skip, left_page, depth + 1, out); + wrap_obstacles(doc, c, xf, inv, skip, left_page, depth + 1, out); } } _ => {} @@ -679,7 +1090,7 @@ fn frame_wrap_exclusions(doc: &Document, sp: &designcraft_doc::Spread, item: &It if doc.layer(other.layer).is_some_and(|l| !l.visible) { continue; } - wrap_obstacles(other, Affine::IDENTITY, inv, item.id, &left, 0, exclusions); + wrap_obstacles(doc, other, Affine::IDENTITY, inv, item.id, &left, 0, exclusions); } let a = item.text_area(); exclusions.retain(|e| { @@ -4329,39 +4740,40 @@ fn free_slots(f: &FrameSpec, col: Rect, y0: f64, y1: f64, desc: f64, size: f64, 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 { free.retain(|(a, b)| b - a >= size * 1.5); diff --git a/crates/compose/src/table.rs b/crates/compose/src/table.rs index 5811887ae..0190aad05 100644 --- a/crates/compose/src/table.rs +++ b/crates/compose/src/table.rs @@ -53,6 +53,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, @@ -89,6 +92,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; @@ -500,6 +507,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/compose/src/tests.rs b/crates/compose/src/tests.rs index 9e4f92386..ec85be8cd 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -886,6 +886,62 @@ fn vertical_justify_follows_an_object_anchored_in_a_later_line() { assert!((top - last).abs() < 1e-6, "Justify moved the last line to {last}, not down to the object at {top}: {baselines:?}"); } +/// 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] @@ -8173,6 +8229,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] @@ -9074,6 +9162,123 @@ fn an_object_wraps_the_frame_its_anchor_lands_in_after_an_earlier_object_pushed_ assert_eq!(line_layout(&compose_story(&d, sid, &ComposeOptions::default())), line_layout(&cs)); } +#[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)]); +} + +/// 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)]); +} + +/// 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)]); +} + +/// 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/crates/compose/src/wrap.rs b/crates/compose/src/wrap.rs index a8495bd46..d12be94d0 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}; @@ -50,8 +50,9 @@ pub(crate) struct Obstacle<'a> { anchor_baseline: 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? @@ -155,7 +156,7 @@ pub(crate) fn exclusions(doc: &Document, specs: &[FrameSpec], obs: &[Obstacle]) let a = spec.area; for ob in obs.iter().filter(|o| o.spread == loc.spread) { let mut found = Vec::new(); - crate::wrap_obstacles(ob.item, ob.xf, inv, spec.id, &left, 0, &mut found); + crate::wrap_obstacles(doc, ob.item, ob.xf, inv, spec.id, &left, 0, &mut found); for mut e in found { let r = e.rect; if r.is_finite() && r.x0 < a.x1 && r.x1 > a.x0 && r.y0 < a.y1 && r.y1 > a.y0 { diff --git a/crates/doc/src/build.rs b/crates/doc/src/build.rs index f64394e03..3fc1062a7 100644 --- a/crates/doc/src/build.rs +++ b/crates/doc/src/build.rs @@ -165,6 +165,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 d99bfebd4..29b97e15d 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, 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 { diff --git a/crates/doc/src/item/text_frame.rs b/crates/doc/src/item/text_frame.rs index d8f7f29e3..25a0ffb61 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, @@ -284,7 +284,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, @@ -298,6 +298,35 @@ 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. + #[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 { + 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, + } + } } impl TextWrap { diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 4ebcd665b..f4c8a25b6 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -703,6 +703,50 @@ 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)] +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, + /// 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>, + /// 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)) + } +} + +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 { /// A raster image (PNG, JPEG, GIF, WebP, TIFF, BMP, PSD), as opposed to a PDF, EPS or SVG. pub fn is_raster(&self) -> bool { @@ -759,6 +803,10 @@ pub struct Document { pub sections: Vec
, #[serde(default)] pub assets: BTreeMap>, + /// 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). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub hyperlinks: Vec, @@ -1069,6 +1117,12 @@ 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> { + 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> { self.layers.iter().find(|l| l.id == id) } diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index fec9ef5d7..f042c0b8d 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.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/alpha_wrap.rs b/crates/engine/src/cmd/alpha_wrap.rs new file mode 100644 index 000000000..ea9801f79 --- /dev/null +++ b/crates/engine/src/cmd/alpha_wrap.rs @@ -0,0 +1,584 @@ +//! 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::collections::BTreeSet; +use std::sync::Arc; + +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). +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: 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.insert((g.asset, w.alpha_threshold)); + } + } + }; + for sp in doc.spreads.iter().chain(doc.parents.iter()) { + for top in &sp.items { + 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() { + return; + } + // 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(); + let kept = doc.alpha_outlines.len(); + for (asset, threshold) in wanted { + if traced.contains(&(asset, threshold)) { + continue; + } + let Some(a) = doc.assets.get(&asset).filter(|a| a.is_raster()) else { continue }; + // 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)); + } + // `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 +/// 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), + serial: AlphaOutline::next_serial(), + } +} + +/// 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 { + return None; + } + let img = designcraft_images::decode_rgba(data)?; + if img.width() != w || img.height() != h { + 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 = match finer.as_ref().and_then(|(m, fc, fr)| coarsen(m, *fc, *fr, cols, rows)) { + Some(m) => m, + 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. + 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(); + 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) + }; + let source = Arc::downgrade(data); + 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; + } + 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). +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)]; + // 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); + if px[3] > threshold + && let Some(c) = mask.get_mut(base + gx) + { + *c = true; + } + } + } + mask +} + +/// 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 { + 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: 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] + 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(&Arc::new(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'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 + /// 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, "{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))); + // 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))); + // 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(); + 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).unwrap(); + 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 outline is traced once: later edits keep it. + #[test] + fn an_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"); + 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] + fn graphic_wraps_nested_frames_and_relinked_images_get_outlines() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let place = |s: &mut Session, x: u64| { + let data = png(|x, _| (16..48).contains(&x)); + s.execute("file.place", &json!({"base64": crate::cmd::file::base64_encode(&data), "name": "cut.png", "x": x, "y": 100, "width": 128})) + .unwrap()["id"] + .as_u64() + .unwrap() + }; + let image = place(&mut s, 100); + // The wrap on the graphic (Direct Selection), not the frame. + s.execute("object.textWrap", &json!({"ids": [image], "content": true, "mode": "contour", "contour": "alphaChannel"})).unwrap(); + let frame_wrap = s.doc().unwrap().doc.item(designcraft_doc::ItemId(image)).unwrap().wrap.mode; + assert_eq!(frame_wrap, WrapMode::None, "the frame has no wrap of its own"); + assert_eq!(s.doc().unwrap().doc.alpha_outlines.len(), 1, "traced for the graphic's wrap"); + // In a group with another frame: still traced. + let other = s.execute("frame.create", &json!({"rect": [300, 100, 350, 150]})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.group", &json!({"ids": [image, other]})).unwrap(); + let d = &s.doc().unwrap().doc; + assert!(d.find(designcraft_doc::ItemId(image)).unwrap().path.len() > 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 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] + 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] + 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) { + 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/datamerge.rs b/crates/engine/src/cmd/datamerge.rs index c57d507fb..d34558423 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(); @@ -261,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 9180964c2..e631b69ea 100644 --- a/crates/engine/src/cmd/datamerge/tests.rs +++ b/crates/engine/src/cmd/datamerge/tests.rs @@ -1536,6 +1536,76 @@ fn merged_records_give_anchored_objects_their_own_ids() { 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); +} + +/// 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. 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 (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 { + 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"})); + 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!({})); + 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. fn cell_texts(s: &Session) -> Vec { let doc = &s.documents()[s.active_index().unwrap()].doc; diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 08f00a55b..1ed74d39c 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -628,7 +628,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; null or those: each object's own, unchanged), offset?: number|[t,l,b,r] (a null side keeps each object's own), invert?: bool, side?: bothSides|leftSide|rightSide|towardsSpine|awayFromSpine|largestArea, contour?: boundingBox|graphicFrame|sameAsClipping, content?: bool (the placed graphic instead of its frame; default: whether the selection is the frame's content), 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; null or those: each object's own, unchanged), offset?: number|[t,l,b,r] (a null side keeps each object's own), invert?: bool, side?: bothSides|leftSide|rightSide|towardsSpine|awayFromSpine|largestArea, contour?: boundingBox|graphicFrame|sameAsClipping|alphaChannel, insideEdges?: bool (alphaChannel: text may enter transparent holes), content?: bool (the placed graphic instead of its frame; default: whether the selection is the frame's content), threshold?: 0..254 (alphaChannel: pixels more opaque count; default 25), ids?}; offset or contour drops an imported custom wrap boundary", has_selection, text_wrap ), @@ -2276,18 +2276,23 @@ 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. + // Alpha contours use the document's traced image mask; other unsupported contour types are rejected. 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 = + p.get("insideEdges").map(|v| v.as_bool().ok_or_else(|| bad("object.textWrap", "insideEdges must be a boolean"))).transpose()?; + let threshold = match p.get("threshold") { + Some(v) => Some(v.as_u64().filter(|n| *n <= 254).ok_or_else(|| bad("object.textWrap", "threshold must be 0..254"))? as u8), + None => None, + }; let mode: Option = match p.get("mode") { // Null keeps each object's own mode (an edit of the offsets or Invert alone). Some(Value::Null) => None, 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), }; // Per side [top, left, bottom, right]; a null side keeps each object's own offset there, so @@ -2330,6 +2335,12 @@ fn text_wrap(s: &mut Session, p: &Value) -> Result { if let Some(c) = contour { w.contour = c; } + if let Some(v) = inside_edges { + w.inside_edges = v; + } + if let Some(v) = threshold { + w.alpha_threshold = v; + } // 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/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 aca8b2cd4..df8820dad 100644 --- a/crates/engine/src/cmd/table/registry.rs +++ b/crates/engine/src/cmd/table/registry.rs @@ -1,7 +1,7 @@ use super::cell::{get_cell_stroke, set_cell}; use super::content::{drop_cells, place_graphic, sort_rows}; use super::options::{get, options}; -use super::selection::{move_rc, select, step_cell, toggle_selection}; +use super::selection::{go_to_row, move_rc, select, step_cell, toggle_selection}; use super::sizing::{distribute_cols, distribute_rows, resize_border, set_col_width, set_row_height}; use super::structure::{ can_convert_rows, convert_from_text, convert_rows, convert_to_text, delete_cols, delete_rows, delete_table, insert, insert_col, insert_row, @@ -396,6 +396,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, &crate::cmd::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 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 + ), 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!( @@ -407,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. Supplied `story` and `table` values must be nonnegative integer IDs. When a table ID occurs in multiple stories, supply `story` with `table`.", 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. Supplied `story` and `table` values must be nonnegative integer IDs. When a table ID occurs in multiple stories, supply `story` with `table`.", in_table_or_ids, get), ] } diff --git a/crates/engine/src/cmd/table/selection.rs b/crates/engine/src/cmd/table/selection.rs index 656dfdd78..320b21350 100644 --- a/crates/engine/src/cmd/table/selection.rs +++ b/crates/engine/src/cmd/table/selection.rs @@ -24,16 +24,113 @@ 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 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(); 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. +pub(super) 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); + // 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).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.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, 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"], + "table": r["table"], + "range": r["range"], + "page": page, + "spread": spread, + "rowBounds": bounds.map(|b| [b.x0, b.y0, b.x1, b.y1]), + })) +} + +/// 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 /// cell adds a row. pub(crate) fn step_cell(s: &mut Session, forward: bool) -> Result { @@ -138,3 +235,222 @@ pub(super) fn move_rc(s: &mut Session, p: &Value, rows: bool) -> Result { Ok(json!({"from": from, "to": to})) }) } + +#[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_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(); + // 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] + 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); + 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); + } + + #[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(); + // 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 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. + 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/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index d1e0fb0f2..813de0959 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}; @@ -241,17 +241,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"}) + Some(Align::FullyJustified) )), cmd!( "type.bold", @@ -444,3 +446,165 @@ 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 designcraft_doc::{Align, Document}; + use serde_json::{Value, json}; + + use crate::Session; + + 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_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 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/engine/src/lib.rs b/crates/engine/src/lib.rs index 716068c89..14a88bb68 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -146,7 +146,9 @@ fn next_revision() -> u64 { impl DocState { /// Initialize saved/live document identity, revision, epoch, active default layer, empty history with limit 1,000 and the initial path. - 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.working_layer(); let doc = Arc::new(doc); let revision = next_revision(); @@ -830,6 +832,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); // The generated endnote story first: it replaces its text, anchored frames pasted into it // included. doc.sync_endnote_story(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 7cdcd5e02..bd8a49618 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2010,6 +2010,30 @@ fn direct_selection_on_a_selected_frame_deletes_only_its_image() { assert!(st.selection.content && st.selection.items == [designcraft_doc::ItemId(holder)], "the pasted-in items are selected: {:?}", st.selection); } +/// 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}"); +} + /// Issue #1637: with several objects selected, a click on one of them (no drag) makes it the key /// object, and Align to Key Object leaves it in place. A second click on it drops the key. #[test] diff --git a/crates/idml/src/export/item.rs b/crates/idml/src/export/item.rs index ca27a8ca0..e3acfa623 100644 --- a/crates/idml/src/export/item.rs +++ b/crates/idml/src/export/item.rs @@ -397,7 +397,7 @@ fn text_wrap_pref(w: &designcraft_doc::TextWrap) -> El { .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", bool_s(w.inside_edges))) } pub(super) fn text_frame_pref(o: &TextFrameOptions, rule_color: &str) -> El { diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 980c38058..164ffe76e 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -806,6 +806,7 @@ impl<'r> Importer<'r> { modified: self.modified.unwrap_or(0), next_id: self.next_id, font_scope: 0, + alpha_outlines: Vec::new(), }; // A link from a frame anchored in text is kept -- it is in the document and goes back out // to IDML -- but PDF export finds a link's frame among the spreads' items, where an diff --git a/crates/idml/src/import/frame.rs b/crates/idml/src/import/frame.rs index 396168127..ce8bf5ba8 100644 --- a/crates/idml/src/import/frame.rs +++ b/crates/idml/src/import/frame.rs @@ -9,6 +9,7 @@ pub(super) fn text_wrap(w: &El) -> TextWrap { 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") { diff --git a/crates/render/src/blendspace.rs b/crates/render/src/blendspace.rs index 4eafc1d89..6096b3419 100644 --- a/crates/render/src/blendspace.rs +++ b/crates/render/src/blendspace.rs @@ -118,6 +118,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)) @@ -129,6 +130,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.file_info == *file_info && old.settings == *settings diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index f34dbdaae..65cb4a66e 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -16,7 +16,8 @@ //! shape, blurred and clipped) with screen / multiply blending; satin shows where two offset //! copies' coverages differ. //! - 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 std::collections::HashMap; use std::sync::Arc; @@ -50,6 +51,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); @@ -163,88 +175,33 @@ 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() { - 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 { - designcraft_doc::FeatherCorners::Diffused => (w / 4.0, kurbo::Join::Round), - 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. + if basic { 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. - 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| { - c.set_transform(fr.view); - c.push_layer(None, None, None, None, Some(blur(sigma))); - c.set_transform(fr.view * xf); - c.set_paint(peniko::Color::BLACK); - c.fill_path(bp); - 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_stroke(kurbo::Stroke::new(w).with_join(join)); - c.stroke_path(bp); - c.pop_layer(); - c.pop_layer(); - }); + } + self.draw_body(ctx, f, it, bp, xf, page); + if basic { 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; - if df.on && df.widths.iter().any(|w| *w > 0.0) { - let b = it.inner_bounds(); - let area = if xf.determinant().abs() > 1e-12 { xf.inverse().transform_rect_bbox(reach) } else { b }; - let ramps = [ - (df.widths[0], kurbo::Point::new(b.x0, b.y0), kurbo::Point::new(b.x0, b.y0 + df.widths[0])), - (df.widths[1], kurbo::Point::new(b.x0, b.y0), kurbo::Point::new(b.x0 + df.widths[1], b.y0)), - (df.widths[2], kurbo::Point::new(b.x0, b.y1), kurbo::Point::new(b.x0, b.y1 - df.widths[2])), - (df.widths[3], kurbo::Point::new(b.x1, b.y0), kurbo::Point::new(b.x1 - df.widths[3], b.y0)), - ]; - for (w, p0, p1) in ramps { - if w <= 0.0 { - continue; - } - let stops = [ - peniko::ColorStop::from((0.0, peniko::Color::from_rgba8(0, 0, 0, 0))), - peniko::ColorStop::from((1.0, peniko::Color::from_rgba8(0, 0, 0, 255))), - ]; - ctx.set_transform(Affine::IDENTITY); - ctx.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestIn)), None, None, None); - ctx.set_transform(f.view * xf); - ctx.set_paint(peniko::Gradient::new_linear(p0, p1).with_stops(stops.as_slice())); - ctx.fill_rect(&area.inflate(4.0, 4.0)); - ctx.reset_paint_transform(); - ctx.pop_layer(); - } - } - if gf.on { - gradient_feather_mask(ctx, f, it, xf); - } - if gf.on || directional { + 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)); @@ -267,6 +224,7 @@ impl Renderer { 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. @@ -285,7 +243,7 @@ impl Renderer { // With a graphic, the glow fills what the object paints, shrunk by the band: the // object's coverage is drawn once offscreen and eroded by the band there. let band = (bound(g.size) - choke).max(0.5); - self.with_blended_filters(ctx, f, reach, sigma, mix, (bp, xf), |me, c, fr| { + self.with_blended_filters(ctx, f, reach, sigma, mix, (bp, xf), fade, |me, c, fr| { let spread = (sigma * 3.0 + band) / fr.px + 2.0; let Some(o) = mask_offscreen(fr.view.transform_rect_bbox(reach), spread, c.width(), c.height()) else { return }; let mut off = RenderContext::new_with(o.width, o.height, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() }); @@ -321,7 +279,7 @@ impl Renderer { c.pop_layer(); }); } else if g.center { - self.with_blended_filters(ctx, f, reach, sigma, mix, (bp, xf), |_, c, fr| { + 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); @@ -338,7 +296,7 @@ impl Renderer { c.pop_layer(); }); } else { - self.inner_edge(ctx, f, reach, bp, xf, Vec2::ZERO, sigma, choke, paint, mix, painted); + 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. @@ -357,7 +315,7 @@ impl Renderer { // With a graphic, the object's coverage is drawn once offscreen and the two shifted // copies are compared there, pixel by pixel, so a partly transparent pixel that // both copies cover equally gets no satin. - self.with_blended_filters(ctx, f, reach, sigma, Mix::Multiply, (bp, xf), |me, c, fr| { + self.with_blended_filters(ctx, f, reach, sigma, Mix::Multiply, (bp, xf), fade, |me, c, fr| { // The copies' shift on the device. let shift = fr.view * off.to_point() - fr.view * kurbo::Point::ZERO; let spread = (sigma * 3.0) / fr.px + shift.hypot() + 2.0; @@ -395,7 +353,7 @@ impl Renderer { c.pop_layer(); }); } else { - self.with_blended_filters(ctx, f, reach, sigma, Mix::Multiply, (bp, xf), |_, c, fr| { + self.with_blended_filters(ctx, f, reach, sigma, Mix::Multiply, (bp, xf), fade, |_, c, fr| { // One copy of the shape in the satin colour, moved by `by`. let copy = |c: &mut RenderContext, by: Vec2| { c.set_transform(fr.view * Affine::translate(by) * xf); @@ -426,10 +384,12 @@ impl Renderer { 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); + 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) { - 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); + 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); } } } @@ -437,7 +397,8 @@ impl Renderer { /// 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. + /// 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, @@ -452,11 +413,12 @@ impl Renderer { 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), |me, c, fr| { + 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, ... @@ -480,7 +442,7 @@ impl Renderer { }); return; } - self.with_blended_filters(ctx, f, reach, sigma, mix, (bp, xf), |_, c, fr| { + 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. @@ -504,7 +466,8 @@ impl Renderer { /// [`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. + /// 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, @@ -514,18 +477,90 @@ impl Renderer { sigma: f64, mix: Mix, clip: (&BezPath, Affine), + fade: Option<&Item>, draw: impl FnOnce(&mut Self, &mut RenderContext, &Frame), ) { - if mix != Mix::Normal { + 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 mix != Mix::Normal { + 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 { + designcraft_doc::FeatherCorners::Diffused => (w / 4.0, kurbo::Join::Round), + designcraft_doc::FeatherCorners::Rounded => (w / 3.0, kurbo::Join::Round), + designcraft_doc::FeatherCorners::Sharp => (w / 6.0, kurbo::Join::Miter), + }; + // 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| { + c.set_transform(fr.view); + c.push_layer(None, None, None, None, Some(blur(sigma))); + c.set_transform(fr.view * xf); + c.set_paint(peniko::Color::BLACK); + c.fill_path(bp); + 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_stroke(kurbo::Stroke::new(w).with_join(join)); + c.stroke_path(bp); + c.pop_layer(); + c.pop_layer(); + }); + ctx.pop_layer(); + } + // Directional feather: one fading ramp per side, multiplied in. + let df = &e.directional_feather; + if df.on && df.widths.iter().any(|w| *w > 0.0) { + let b = it.inner_bounds(); + let area = if xf.determinant().abs() > 1e-12 { xf.inverse().transform_rect_bbox(reach) } else { b }; + let ramps = [ + (df.widths[0], kurbo::Point::new(b.x0, b.y0), kurbo::Point::new(b.x0, b.y0 + df.widths[0])), + (df.widths[1], kurbo::Point::new(b.x0, b.y0), kurbo::Point::new(b.x0 + df.widths[1], b.y0)), + (df.widths[2], kurbo::Point::new(b.x0, b.y1), kurbo::Point::new(b.x0, b.y1 - df.widths[2])), + (df.widths[3], kurbo::Point::new(b.x1, b.y0), kurbo::Point::new(b.x1 - df.widths[3], b.y0)), + ]; + for (w, p0, p1) in ramps { + if w <= 0.0 { + continue; + } + let stops = [ + peniko::ColorStop::from((0.0, peniko::Color::from_rgba8(0, 0, 0, 0))), + peniko::ColorStop::from((1.0, peniko::Color::from_rgba8(0, 0, 0, 255))), + ]; + ctx.set_transform(Affine::IDENTITY); + ctx.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestIn)), None, None, None); + ctx.set_transform(f.view * xf); + ctx.set_paint(peniko::Gradient::new_linear(p0, p1).with_stops(stops.as_slice())); + ctx.fill_rect(&area.inflate(4.0, 4.0)); + ctx.reset_paint_transform(); + ctx.pop_layer(); + } + } + if e.gradient_feather.on { + gradient_feather_mask(ctx, f, it, xf); + } + } + /// Run `draw`, which pushes filter layers. Single-threaded contexts draw directly. On a /// multithreaded context, or for a blur wider than [`MAX_BLUR_SPREAD`], the layer is drawn into /// an offscreen single-threaded context covering `reach` (spread space) plus the blur's spread, @@ -1365,4 +1400,120 @@ mod tests { assert!(worst <= 1, "off by {worst}"); assert_eq!(kept.kept.drawn, 2); } + + /// 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), + 2 => (e.inner_glow.on, e.inner_glow.color, e.inner_glow.opacity, e.inner_glow.size) = (true, "[Paper]".into(), 1.0, 20.0), + 3 => { + (e.inner_glow.on, e.inner_glow.color, e.inner_glow.opacity, e.inner_glow.size) = (true, "[Black]".into(), 1.0, 20.0); + e.inner_glow.center = true; + } + _ => { + (e.satin.on, e.satin.color, e.satin.opacity) = (true, "[Black]".into(), 1.0); + (e.satin.distance, e.satin.invert) = (0.0, true); + } + } + 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, 3, 4] { + 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" + ); + } + } + // 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" + ); + } + } + } + } + } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 50652ffe6..d09344683 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, page_target, 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, page_target, 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 f00a005f5..a583e1bb7 100644 --- a/crates/ui-egui/src/canvas/view.rs +++ b/crates/ui-egui/src/canvas/view.rs @@ -216,6 +216,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 7e6118a1b..2d5b2d6c6 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)) +} + /// Whether every selected text frame (the selected items, or the frame of the caret) has an /// outline that is not its own axis-aligned rectangle: an oval, a polygon, rounded or other corner /// options. Its text keeps one inset from the outline, as compose sets it; type on a path doesn't. @@ -1084,7 +1102,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)); @@ -1612,6 +1631,60 @@ 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!({"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 { + ("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!({ + "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 + }), + )); +} + +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)); + } +} + /// The document's vertical ruler unit: what a bare number means in a height field. fn doc_vertical_unit(app: &DesignApp) -> Unit { app.session.active().map(|s| s.doc.settings.vertical_units).unwrap_or(Unit::Picas) @@ -2980,6 +3053,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"), "insertPages" => crate::i18n::tr(&app.ui.language, "Insert Pages"), "insertTable" => crate::i18n::tr(&app.ui.language, "Insert Table…").trim_end_matches('…').trim_end(), @@ -3853,6 +3927,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" => insert_table_dialog(app, ui, &mut d), "convertTextToTable" | "convertTableToText" => convert_table_dialog(app, ui, &mut d), @@ -4441,6 +4516,39 @@ fn confirm_part1(app: &mut DesignApp, d: Dialog) -> 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)); + 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 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) => { + let mut d = d.clone(); + d.fields.insert("status".into(), json!(e)); + app.ui.dialog = Some(d); + } + } + r + } "insertPages" => { let n = app.session.active().map_or(1, |st| st.doc.page_count()); // The page the menu shows: a page past the end is the last. diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 9c3bb747b..119d92da0 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -1738,6 +1738,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()); @@ -4047,6 +4098,113 @@ fn create_guides_dialog_covers_the_spread_in_view() { 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"); +} + +/// 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] +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/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index d3986a5c2..a27e18854 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -300,6 +300,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 8b4dae1f1..a60c34ee2 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -679,6 +679,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 b3103f0f9..d3545b0a6 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -305,6 +305,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"), @@ -642,6 +643,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/fr.rs b/crates/ui-egui/src/i18n/fr.rs index ad4f56b0f..685591a96 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -13,7 +13,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Align To", "Aligner sur"), ("Alignment", "Alignement"), ("All Documents", "Tous les documents"), - ("Alpha Channel", "Couche alpha"), ("Alternate layout", "Variante de mise en page"), ("Anchored Object Options", "Options d'objet ancré"), ("Apply Numbering & Section Options", "Appliquer les options de numérotation et de section"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 7ef033154..0ff7e481d 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -810,6 +810,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 cc540e5f9..e6dd25efb 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -488,6 +488,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Generate QR Code", "QR コードを生成"), ("Go To", "移動"), ("Go to Page", "ページへ移動"), + ("Go to Row", "行へ移動"), ("Gutter", "段間"), ("Header Rows", "ヘッダー行"), ("Height", "高さ"), @@ -832,6 +833,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 7ae783e13..24a5d150a 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -729,6 +729,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 6cb1b5ae2..537d66f73 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -689,6 +689,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 2040bd49b..338e65d0c 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -311,6 +311,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 047fc5b3e..7fa0f4606 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -669,6 +669,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/sv.rs b/crates/ui-egui/src/i18n/sv.rs index 23d8f4688..bc826031d 100644 --- a/crates/ui-egui/src/i18n/sv.rs +++ b/crates/ui-egui/src/i18n/sv.rs @@ -103,6 +103,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"), diff --git a/crates/ui-egui/src/i18n/table.rs b/crates/ui-egui/src/i18n/table.rs index e791e02a4..3ae658f79 100644 --- a/crates/ui-egui/src/i18n/table.rs +++ b/crates/ui-egui/src/i18n/table.rs @@ -389,6 +389,11 @@ pub(super) const TABLE: &[(&str, [&str; 5])] = &[ ), ("Add CMYK Swatch", ["CMYK-Farbfeld hinzufügen", "Ajouter une nuance CMJN", "Añadir muestra CMYK", "CMYK スウォッチを追加", "添加 CMYK 色板"]), ("Added swatch", ["Farbfeld hinzugefügt", "Nuance ajoutée", "Muestra añadida", "スウォッチを追加しました", "已添加色板"]), + ( + "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", [ @@ -1254,6 +1259,7 @@ pub(super) 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", "高度"]), @@ -2255,6 +2261,7 @@ pub(super) 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/tests.rs b/crates/ui-egui/src/i18n/tests.rs index f873e5b8a..74e9843e8 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -550,6 +550,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", "Key Object", "Load Styles", "layer", diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 265387c27..bfbd226c9 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -813,6 +813,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/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index b9d35539d..a359b04bb 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -83,6 +83,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"); @@ -146,6 +150,13 @@ 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), + // 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 // 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) { @@ -162,6 +173,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"]; + /// Zoom In/Out from a key: the pointer's position (`at`), so the zoom keeps the point under it /// when it is over the canvas. `None` for other commands or with no pointer in the window. pub(super) fn pointer_zoom_params(ctx: &egui::Context, id: &str) -> Option { 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/run.rs b/crates/ui-egui/src/menus/run.rs index 731efb570..b6310b88e 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -844,6 +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.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 00749603e..e8c142e25 100644 --- a/crates/ui-egui/src/menus/state.rs +++ b/crates/ui-egui/src/menus/state.rs @@ -175,6 +175,15 @@ 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") { + let caret = app.session.active().and_then(|d| d.selection.text).is_some_and(|t| t.is_caret()); + return caret && enabled(app, "text.insert"); + } + // 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; } diff --git a/crates/ui-egui/src/menus/tables.rs b/crates/ui-egui/src/menus/tables.rs index 2001dc2a2..de034752e 100644 --- a/crates/ui-egui/src/menus/tables.rs +++ b/crates/ui-egui/src/menus/tables.rs @@ -249,8 +249,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}", @@ -469,6 +469,8 @@ pub const MENUS: &[(&str, &[&str])] = &[ "-", "cmd:table.distributeRows", "cmd:table.distributeColumns", + "-", + "cmd:table.goToRow", ], ), ( diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index e977c6733..23778f649 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -323,6 +323,17 @@ fn transform_menu_items_open_their_dialogs() { } } +#[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. @@ -2373,6 +2384,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 @@ -2487,6 +2500,127 @@ 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 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. + 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"); + // 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); + 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 (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 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()); diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 0021b4233..88c7cccec 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -139,7 +139,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)", ), ( @@ -190,6 +190,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+="), "{at?: [x, y] window pixels, kept in place when over the canvas}"), ("view.zoomOut", "Zoom Out", Some("Cmd+-"), "{at?: [x, y] window pixels, kept in place when over the canvas}"), diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 33d9bd3c4..650bab97d 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -56,11 +56,12 @@ pub struct SelInfo { /// Uniform corner (shape, size) — the first corner. pub corner: (designcraft_geom::corners::CornerShape, f64), pub wrap_invert: bool, - /// Text wrap side and contour type ids (`bothSides`, `sameAsClipping`, …). - pub wrap_side: String, - pub wrap_contour: String, /// Text wrap offsets: top, left, bottom, right. 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, } @@ -261,6 +262,7 @@ pub fn sel_info(app: &DesignApp) -> Option { wrap_side: serde_json::to_value(tw.side).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default(), wrap_contour: serde_json::to_value(tw.contour).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default(), wrap_offsets: tw.offsets, + wrap_inside_edges: tw.inside_edges, locked: first.locked, }) } diff --git a/crates/ui-egui/src/panels/properties/object_panels.rs b/crates/ui-egui/src/panels/properties/object_panels.rs index 719ca9809..61498965a 100644 --- a/crates/ui-egui/src/panels/properties/object_panels.rs +++ b/crates/ui-egui/src/panels/properties/object_panels.rs @@ -286,11 +286,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})); + } + } } } diff --git a/crates/ui-egui/src/panels/properties/typography.rs b/crates/ui-egui/src/panels/properties/typography.rs index 797a36bf9..93bb4be0a 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/gaps.md b/docs/gaps.md index addb0b679..fb1e93a36 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -154,8 +154,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) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 419b38883..6429f92e4 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -284,7 +284,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. - Paragraph spacing and justification values are checked: `type.para`, `style.paragraph.create` and `style.paragraph.edit` refuse left and right indents, Space Before, Space After and same-style spacing outside 0 to 15552 pt, first and last line indents outside -15552 to 15552 pt, word spacing outside 0% to 1000%, letter spacing outside -100% to 500%, glyph scaling outside 50% to 200%, a Minimum above Desired or a Desired above Maximum, Auto Leading outside 0% to 500%, and a Single Word Justification other than left, center, right or fullyJustified. A style edit or redefinition is also checked over the styles based on it and the paragraphs that use them, and `style.paragraph.apply` refuses a style whose values would put a row out of order with a paragraph's own overrides. `object.textFrameOptions` refuses an inset or gutter outside 0 to 15552 pt, an inset list that isn't four sides, a column count outside 1 to 40, an on/off option that isn't true or false, and a vertical justification, first baseline, auto-size or columns kind that isn't one of its names. IDML import reads these values as before. - 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. diff --git a/docs/typography-parity.md b/docs/typography-parity.md index 1064ef823..7868f7aff 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 (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 | ✓ | ✓ | ✓ | |