mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-14 14:22:39 +00:00
Merge main preserving incremental composition and alpha-wrap caching
This commit is contained in:
commit
2a2d73ead9
57 files changed
+2793
-210
No files matched your search
+3
-1
@@ -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.
|
||||
@@ -389,6 +389,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
|
||||
## Progress log
|
||||
|
||||
- **2026-11-12** · An edit of a long threaded story composes the paragraphs it can change and copies the other lines from the composition before the edit; every typed character composed the whole story. In a 240-paragraph story a typed character composes 3 paragraphs (240 before). An edit that moves lines into other frames composes on until a paragraph starts in the same frame and place as before, or to the story's end, and stories with footnotes, tables, anchored objects, balanced columns or keep options that move lines are composed whole. No readiness percentage change.
|
||||
- **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.
|
||||
@@ -463,6 +464,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.
|
||||
|
||||
@@ -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.
|
||||
//!
|
||||
//! When only the story itself differs from an entry's (an edit of its text or formatting), the
|
||||
//! new composition starts from the entry's and composes the paragraphs the edit can change
|
||||
@@ -115,6 +116,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()
|
||||
}
|
||||
|
||||
@@ -170,6 +183,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<crate::Placement> {
|
||||
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<ComposedStory> {
|
||||
@@ -472,6 +493,7 @@ fn signature(doc: &Document, story: &Arc<Story>) -> (Vec<usize>, Vec<Arc<Item>>)
|
||||
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();
|
||||
|
||||
+476
-64
@@ -383,17 +383,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<Vec<Point>>,
|
||||
/// 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<Vec<Point>>,
|
||||
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<Point>],
|
||||
even_odd: bool,
|
||||
}
|
||||
|
||||
impl Region<'_> {
|
||||
fn nonzero(rings: &[Vec<Point>]) -> 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<Contour> {
|
||||
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
|
||||
@@ -405,18 +438,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<Vec<Point>>| 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<Item = Region<'_>> {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -426,12 +482,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<Region> = self.clips().collect();
|
||||
if !clips.is_empty() {
|
||||
let projected: Vec<Vec<(f64, f64)>> = 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<Vec<(f64, f64)>> {
|
||||
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<BandEdges> = 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::<usize>().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.
|
||||
@@ -494,19 +839,39 @@ fn rings_band_extent(rings: &[Vec<Point>], 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<Exclusion> {
|
||||
pub fn graphic_exclusion(doc: &Document, it: &Item, xf: Affine, inv: Affine, left_page: bool) -> Option<Exclusion> {
|
||||
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
|
||||
@@ -514,7 +879,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<designcraft_geom::BezPath>)>,
|
||||
outline: impl FnOnce() -> Option<Outline>,
|
||||
xf: Affine,
|
||||
inv: Affine,
|
||||
left_page: bool,
|
||||
@@ -534,35 +899,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 };
|
||||
}
|
||||
let contour = if wrap.mode == WrapMode::Contour { outline() } else { None }.and_then(|(path, frame)| {
|
||||
let c = Contour::of(&path, inv * xf, o[0])?;
|
||||
Some(match frame {
|
||||
Some(frame) => c.within(&frame, inv * xf),
|
||||
None => c,
|
||||
})
|
||||
let contour = if wrap.mode == WrapMode::Contour { outline() } else { None }.and_then(|src| {
|
||||
let mut c = Contour::of(&src.path, inv * xf, o[0])?;
|
||||
if let Some(frame) = &src.frame {
|
||||
c = c.within(frame, inv * xf);
|
||||
}
|
||||
if let Some((clip, even_odd)) = &src.graphic_clip {
|
||||
c = c.within_graphic_clip(clip, *even_odd, inv * xf);
|
||||
}
|
||||
Some(c)
|
||||
});
|
||||
let rect = contour.as_ref().map_or_else(|| inv.transform_rect_bbox(r), Contour::bounds);
|
||||
Exclusion { rect, mode: wrap.mode, side, contour, after: None }
|
||||
}
|
||||
|
||||
/// The outline a contour wrap of `kind` around frame `it` follows, in the frame's inner space, and
|
||||
/// for an outline taken from the placed graphic, the frame path that clips it. Bounding Box
|
||||
/// follows the part of the placed graphic the frame shows; Same As Clipping follows the graphic's
|
||||
/// clipping path when it has one; 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<designcraft_geom::BezPath>) {
|
||||
/// 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<designcraft_geom::BezPath>,
|
||||
/// 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.
|
||||
@@ -647,20 +1046,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<Exclusion>) {
|
||||
fn wrap_obstacles(
|
||||
doc: &Document,
|
||||
it: &Item,
|
||||
parent: Affine,
|
||||
inv: Affine,
|
||||
skip: ItemId,
|
||||
left_page: &dyn Fn(f64) -> bool,
|
||||
depth: usize,
|
||||
out: &mut Vec<Exclusion>,
|
||||
) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -677,7 +1088,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| {
|
||||
@@ -4383,39 +4794,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);
|
||||
|
||||
@@ -53,6 +53,9 @@ pub struct TableFrag {
|
||||
pub column: u32,
|
||||
pub rect: Rect,
|
||||
pub cells: Vec<PlacedCell>,
|
||||
/// 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<StrokeSeg>,
|
||||
/// 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,
|
||||
|
||||
@@ -573,6 +573,62 @@ fn wrap_on_both_sides_sets_text_left_then_right_of_the_object() {
|
||||
assert!(!cs.is_overset());
|
||||
}
|
||||
|
||||
/// An Alpha Channel contour counts only where the graphic's clipping path shows the image: here
|
||||
/// the visible pixels are in the right half and the clipping path shows the left half, so the
|
||||
/// image wraps nothing; inverted, the clipping path shows the right half and the image wraps.
|
||||
#[test]
|
||||
fn an_alpha_channel_contour_stops_at_the_clipping_path() {
|
||||
use designcraft_geom::{PathData, Point, SubPath};
|
||||
let rect = |x0: f64, x1: f64| {
|
||||
PathData::single(SubPath::polyline(&[Point::new(x0, 0.0), Point::new(x1, 0.0), Point::new(x1, 200.0), Point::new(x0, 200.0)], true))
|
||||
};
|
||||
let lines_beside = |invert: bool| {
|
||||
let (mut d, sid, _) = doc_with(&[LOREM; 4].join(" "), Rect::new(0.0, 0.0, 300.0, 800.0), ParaAttrs::default());
|
||||
let lid = d.default_layer();
|
||||
let asset = designcraft_doc::AssetId(d.alloc());
|
||||
let mut it = designcraft_doc::Item::new(
|
||||
ItemId(d.alloc()),
|
||||
lid,
|
||||
designcraft_doc::Shape::Rectangle,
|
||||
designcraft_geom::shapes::rectangle(Rect::new(150.0, 100.0, 300.0, 300.0)),
|
||||
);
|
||||
let mut clip = designcraft_doc::ClippingPath::new(designcraft_doc::ClippingKind::DetectEdges, rect(0.0, 75.0));
|
||||
clip.invert = invert;
|
||||
it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic {
|
||||
layer_options: None,
|
||||
asset,
|
||||
size: (150.0, 200.0),
|
||||
xf: designcraft_geom::Affine::translate((150.0, 100.0)),
|
||||
clip: Some(std::sync::Arc::new(clip)),
|
||||
auto_fit: Default::default(),
|
||||
fit_align: 0,
|
||||
crop: [0.0; 4],
|
||||
wrap: Default::default(),
|
||||
fill: Default::default(),
|
||||
});
|
||||
it.wrap.mode = WrapMode::Contour;
|
||||
it.wrap.side = WrapSide::LeftSide;
|
||||
it.wrap.contour = designcraft_doc::ContourType::AlphaChannel;
|
||||
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
|
||||
d.alpha_outlines.push(std::sync::Arc::new(designcraft_doc::AlphaOutline {
|
||||
asset,
|
||||
threshold: designcraft_doc::DEFAULT_ALPHA_THRESHOLD,
|
||||
width: 150,
|
||||
height: 200,
|
||||
outer: rect(75.0, 150.0),
|
||||
holes: PathData::default(),
|
||||
source: std::sync::Weak::new(),
|
||||
serial: designcraft_doc::AlphaOutline::next_serial(),
|
||||
}));
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
cs.frames[0].lines.iter().filter(|l| l.baseline - l.ascent > 100.0 && l.baseline + l.descent < 300.0).map(|l| l.x1).collect::<Vec<_>>()
|
||||
};
|
||||
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]
|
||||
@@ -7797,6 +7853,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::<Vec<_>>());
|
||||
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]
|
||||
@@ -8698,6 +8786,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).
|
||||
|
||||
@@ -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::reuse::{Reuse, Trace};
|
||||
@@ -49,8 +49,9 @@ pub(crate) struct Obstacle<'a> {
|
||||
anchor: Option<usize>,
|
||||
}
|
||||
|
||||
/// 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?
|
||||
@@ -153,7 +154,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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<PathData>,
|
||||
/// 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 {
|
||||
|
||||
@@ -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<Vec<u8>>,
|
||||
/// 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<Vec<u8>>) -> 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<Section>,
|
||||
#[serde(default)]
|
||||
pub assets: BTreeMap<AssetId, Arc<Asset>>,
|
||||
/// 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<Arc<AlphaOutline>>,
|
||||
/// Hyperlinks (Window → Interactive → Hyperlinks).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub hyperlinks: Vec<Hyperlink>,
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Vec<u8>>, 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<Vec<u8>>, asset: AssetId, threshold: u8) -> Option<AlphaOutline> {
|
||||
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<bool>, 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::<usize>() <= 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<Vec<bool>> {
|
||||
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<bool> {
|
||||
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<(i32, i32)>>, Vec<Vec<(i32, i32)>>)> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -210,6 +210,8 @@ fn preview_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
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<Value> = missing.iter().map(|m| json!({"record": m.record, "field": m.field, "path": m.path})).collect();
|
||||
|
||||
@@ -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<String> {
|
||||
let doc = &s.documents()[s.active_index().unwrap()].doc;
|
||||
|
||||
@@ -628,7 +628,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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<Value> {
|
||||
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::<ContourType>(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<WrapMode> = 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<Value> {
|
||||
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() {
|
||||
|
||||
@@ -70,19 +70,23 @@ pub(super) fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> = (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<Value> = (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)
|
||||
}
|
||||
@@ -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<CommandSpec> {
|
||||
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<CommandSpec> {
|
||||
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),
|
||||
]
|
||||
}
|
||||
@@ -24,16 +24,113 @@ pub(super) fn select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => {}
|
||||
}
|
||||
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<Value> {
|
||||
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<designcraft_geom::Rect> =
|
||||
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<Value> {
|
||||
@@ -138,3 +235,222 @@ pub(super) fn move_rc(s: &mut Session, p: &Value, rows: bool) -> Result<Value> {
|
||||
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<f64> = 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<f64> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
+174
-10
@@ -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<CommandSpec> {
|
||||
),
|
||||
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::<Option<Align>>(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<CommandSpec> {
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// 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<Align>) -> Result<Value> {
|
||||
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<Align> {
|
||||
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]);
|
||||
}
|
||||
}
|
||||
@@ -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<String>) -> Self {
|
||||
pub fn new(mut doc: Document, path: Option<String>) -> 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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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<designcraft_doc::Spread>], b: &[std::sync::Arc<designcraft_doc::Spread>]| {
|
||||
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
|
||||
|
||||
+235
-84
@@ -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::<u32>();
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<designcraft_doc::ids::PageId> {
|
||||
doc.spreads.iter().flat_map(|s| s.pages.iter().map(|p| p.id)).collect()
|
||||
|
||||
@@ -1005,6 +1005,24 @@ fn selected_frame_options(app: &DesignApp) -> Option<designcraft_doc::TextFrameO
|
||||
st.doc.item(fid)?.text_frame().map(|t| t.options.clone())
|
||||
}
|
||||
|
||||
/// True when every text frame the dialog changes has the same inset on all four sides: the
|
||||
/// selected items, else the caret's frame, else every frame of the caret's story.
|
||||
fn selected_frames_have_even_insets(app: &DesignApp) -> 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<Value, String> {
|
||||
)
|
||||
}
|
||||
"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::<u64>().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::<designcraft_doc::SpreadRef>(v["spread"].clone()).ok();
|
||||
let bounds = v["rowBounds"].as_array().and_then(|a| {
|
||||
let n: Vec<f64> = 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.
|
||||
|
||||
@@ -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<f64> = 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);
|
||||
|
||||
@@ -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", "الصفحة الأولى"),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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", "グループの透明"),
|
||||
|
||||
@@ -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", "ჯგუფის გამჭვირვალობა"),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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", "Прозрачность группы"),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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", "编组透明度"]),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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", "Прозорість групи"),
|
||||
|
||||
@@ -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<Value> {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -844,6 +844,13 @@ fn run_ui_part4(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value
|
||||
run_ui(app, "window.panel", &json!({"panel": panel})).unwrap_or(Ok(Value::Null))
|
||||
}
|
||||
"app.insertCurrentPageNumber" => 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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
),
|
||||
(
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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}"),
|
||||
|
||||
@@ -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<SelInfo> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 | ✓ | ✓ | ✓ | |
|
||||
|
||||
Reference in new issue
Block a user