mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-14 22:35:26 +00:00
Merge main into fix/bottom-aligned-text-wrap
This commit is contained in:
commit
cc7f289187
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.
|
||||
@@ -388,6 +388,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
|
||||
|
||||
## Progress log
|
||||
|
||||
- **2026-11-12** · Type shortcuts: Justify All Lines ⇧⌘F, Insert White Space › Em Space ⇧⌘M and En Space ⇧⌘N (shown in the menu, enabled at an insertion point), Paragraph Rules… ⌥⌘J (shown in the Paragraph panel menu). On the Mac a menu-bar insert (Em/En Space, Current Page Number) no longer types into the document while a text field has focus, a dialog or Quick Apply is open, or an input method is composing. An alignment the paragraphs already have records no undo step (#1138). No readiness percentage change.
|
||||
- **2026-11-12** · The status bar's preflight count and the Preflight panel take their issues from a check on a background worker, which checks the newest state of the document and shows the issues last found meanwhile. Moving or resizing a frame and typing no longer wait for a check of the whole document on every drag step and keystroke. No readiness percentage change.
|
||||
- **2026-11-04** · Striped strokes retain out-and-back cubic segments whose second control point is away from the coincident endpoints and first control point. A geometry regression checks the offset extent and both stripe bands. No readiness percentage change.
|
||||
- **2026-11-04** · Esperanto is a text language (Character › Language, `type.char {language: "Esperanto"}`, IDML `$ID/Esperanto`): tag `eo`, English-style quotes; not hyphenated yet (#1201). No readiness percentage change.
|
||||
@@ -462,6 +463,7 @@ Newest first.
|
||||
- **2026-10-24** · A guide pulled out of a ruler keeps the resize cursor across it until it is dropped; the tool's cursor took over as soon as the pointer left the ruler, so the drag looked as if it had stopped. No readiness percentage change.
|
||||
- **2026-10-24** · Anchored text frames, such as marginal notes, compose where their host story places them: they align to the baseline grid at that position, and page-number markers and spine-relative alignment inside them read the host's page. They composed against a grid measured from their stored position and on no page, so a note set to Only Align First Line to Grid could sit a fraction of a line off the body text's grid line. No readiness percentage change.
|
||||
- **2026-10-24** · Tables: a gradient swatch in a cell fill draws as a gradient on the canvas and in PDF, in one gradient space for the whole table, so it runs across cells without restarting. Cells showed the gradient's first stop as a solid colour. No readiness percentage change.
|
||||
- **2026-10-25** · Table › Go to Row… (`table.goToRow {row, section?}`): as in InDesign, a dialog takes a row number and, when the table has header or footer rows, the section is chosen from a menu. The menu lists Body Rows and those of Header Rows and Footer Rows that the table has. DesignCraft counts the row from 1 within its section and selects it, and the view scrolls the row to its middle at the same zoom, on the page in view or another. A row number past the section's last row is refused. No readiness percentage change.
|
||||
- **2026-10-22** · Paragraph Style Options › Paragraph Shading and Paragraph Border are laid out as InDesign's: headed with the page's name; Tint is a spinner with InDesign's presets and has Overprint beside it (shading, border and gap; a new attribute each, kept through IDML; PDF export warns it doesn't overprint yet); Corner Size and Shape is InDesign's two-by-two grid of corner icons, sizes and shape menus showing each shape's glyph, with the link in the middle; offsets and weights have spinners. Found in the Learn Skills Daily course, chapter 41. No readiness percentage change.
|
||||
- **2026-10-24** · New documents start with Hyphenate Capitalized Words, Hyphenate Last Word and Hyphenate Across Column off in [Basic Paragraph], as in InDesign. Opened and imported files keep the values they carry. No readiness percentage change.
|
||||
- **2026-10-24** · The baseline grid's Relative To setting applies: with Top Margin, the grid starts at each page's own top margin in text composition, on the canvas and for snapping. It started at the page top, so grid-aligned text sat on the wrong lines and a page could fit one line fewer. No readiness percentage change.
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
//! wrapping item on the frames' spreads, and the same for every story whose anchored objects wrap
|
||||
//! its text ([`crate::wrap::neighbours`]). An anchored frame's entry also records where its host
|
||||
//! story's composition places it. Entries hold clones of those `Arc`s, so the pointers
|
||||
//! can't be reused by other allocations while the entry lives.
|
||||
//! can't be reused by other allocations while the entry lives. The document's Alpha Channel
|
||||
//! outlines count by serial number, which no other outline shares.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
@@ -91,6 +92,18 @@ fn doc_signature(doc: &Document) -> u64 {
|
||||
// The compositions the indexes are built from also depend on these (see `signature`).
|
||||
let g = &doc.settings.baseline_grid;
|
||||
(doc.font_scope, g.relative_to == designcraft_doc::GridRelative::TopMargin, g.start.to_bits(), g.increment.to_bits()).hash(&mut h);
|
||||
outlines_hash(doc).hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// The Alpha Channel outlines text wraps around: a relink traces a new one while the items and
|
||||
/// stories stay the same.
|
||||
fn outlines_hash(doc: &Document) -> u64 {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
for o in &doc.alpha_outlines {
|
||||
o.serial.hash(&mut h);
|
||||
}
|
||||
h.finish()
|
||||
}
|
||||
|
||||
@@ -146,6 +159,14 @@ impl Cache {
|
||||
c
|
||||
}
|
||||
|
||||
/// Where the host story places anchored text frame `fid`, one of story `sid`'s frames; `None` for
|
||||
/// a frame on a spread.
|
||||
pub fn placement(&self, doc: &Document, sid: StoryId, fid: designcraft_doc::ItemId) -> Option<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> {
|
||||
@@ -443,6 +464,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
@@ -385,17 +385,50 @@ pub struct Contour {
|
||||
/// The frame path of an outline taken from a placed graphic: the frame clips the graphic, so
|
||||
/// the outline counts only within it (empty: no clip).
|
||||
pub clip: Vec<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
|
||||
@@ -407,18 +440,41 @@ impl Contour {
|
||||
}
|
||||
}
|
||||
|
||||
/// This outline counted only inside the graphic's clipping region `clip` (after `to_inner`),
|
||||
/// filled even-odd when `even_odd` is set; unchanged if the region is empty or too detailed.
|
||||
fn within_graphic_clip(self, clip: &designcraft_geom::BezPath, even_odd: bool, to_inner: designcraft_geom::Affine) -> Contour {
|
||||
match flatten_rings(clip, to_inner) {
|
||||
Some(graphic_clip) => Contour { graphic_clip, graphic_clip_even_odd: even_odd, ..self },
|
||||
None => self,
|
||||
}
|
||||
}
|
||||
|
||||
/// The outline mapped by `a`.
|
||||
fn transformed(self, a: designcraft_geom::Affine) -> Contour {
|
||||
let map = |rings: Vec<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)
|
||||
}
|
||||
|
||||
@@ -428,12 +484,301 @@ impl Contour {
|
||||
fn band_extent(&self, y0: f64, y1: f64) -> Option<(f64, f64)> {
|
||||
let (y0, y1) = (y0 - self.offset, y1 + self.offset);
|
||||
let (mut lo, mut hi) = rings_band_extent(&self.rings, y0, y1)?;
|
||||
if !self.clip.is_empty() {
|
||||
let (a, b) = rings_band_extent(&self.clip, y0, y1)?;
|
||||
for c in self.clips() {
|
||||
let (a, b) = rings_band_extent(c.rings, y0, y1)?;
|
||||
(lo, hi) = (lo.max(a), hi.min(b));
|
||||
}
|
||||
(lo <= hi).then_some((lo - self.offset, hi + self.offset))
|
||||
}
|
||||
|
||||
/// Occupied intervals across the whole line band, preserving gaps between parts of a
|
||||
/// concave or compound outline. Project boundary edges and the filled middle slice: any
|
||||
/// area that starts or ends inside the band is bounded by those projected edges.
|
||||
fn band_spans(&self, y0: f64, y1: f64) -> Vec<(f64, f64)> {
|
||||
let (low, high) = (y0 - self.offset, y1 + self.offset);
|
||||
if !low.is_finite() || !high.is_finite() || high < low {
|
||||
return self.band_extent(y0, y1).into_iter().collect();
|
||||
}
|
||||
let mut spans = rings_band_spans(Region::nonzero(&self.rings), low, high);
|
||||
// Extremely fragmented imported paths use the conservative outer extent, bounding the
|
||||
// work of subtracting intervals from every line. A traced 128-column mask stays below it.
|
||||
if spans.len() > 128 {
|
||||
return self.band_extent(y0, y1).into_iter().collect();
|
||||
}
|
||||
let clips: Vec<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.
|
||||
@@ -496,19 +841,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
|
||||
@@ -516,7 +881,7 @@ pub fn graphic_exclusion(it: &Item, xf: Affine, inv: Affine, left_page: bool) ->
|
||||
fn wrap_exclusion(
|
||||
wrap: &designcraft_doc::TextWrap,
|
||||
b: Rect,
|
||||
outline: impl FnOnce() -> Option<(designcraft_geom::BezPath, Option<designcraft_geom::BezPath>)>,
|
||||
outline: impl FnOnce() -> Option<Outline>,
|
||||
xf: Affine,
|
||||
inv: Affine,
|
||||
left_page: bool,
|
||||
@@ -536,35 +901,69 @@ fn wrap_exclusion(
|
||||
let contour = matches!(wrap.mode, WrapMode::BoundingBox | WrapMode::Contour).then_some(custom);
|
||||
return Exclusion { rect, mode: wrap.mode, side, contour, after: None, anchor_baseline: None };
|
||||
}
|
||||
let contour = if wrap.mode == WrapMode::Contour { outline() } else { None }.and_then(|(path, frame)| {
|
||||
let c = Contour::of(&path, inv * xf, o[0])?;
|
||||
Some(match frame {
|
||||
Some(frame) => c.within(&frame, inv * xf),
|
||||
None => c,
|
||||
})
|
||||
let contour = if wrap.mode == WrapMode::Contour { outline() } else { None }.and_then(|src| {
|
||||
let mut c = Contour::of(&src.path, inv * xf, o[0])?;
|
||||
if let Some(frame) = &src.frame {
|
||||
c = c.within(frame, inv * xf);
|
||||
}
|
||||
if let Some((clip, even_odd)) = &src.graphic_clip {
|
||||
c = c.within_graphic_clip(clip, *even_odd, inv * xf);
|
||||
}
|
||||
Some(c)
|
||||
});
|
||||
let rect = contour.as_ref().map_or_else(|| inv.transform_rect_bbox(r), Contour::bounds);
|
||||
Exclusion { rect, mode: wrap.mode, side, contour, after: None, anchor_baseline: None }
|
||||
}
|
||||
|
||||
/// The outline a contour wrap of `kind` around frame `it` follows, in the frame's inner space, and
|
||||
/// for an outline taken from the placed graphic, the frame path that clips it. Bounding Box
|
||||
/// follows the part of the placed graphic the frame shows; Same As Clipping follows the graphic's
|
||||
/// clipping path when it has one; detected edges, alpha channels and image paths need the image,
|
||||
/// so they follow the frame's path (as other frames do).
|
||||
fn contour_path(it: &Item, kind: designcraft_doc::ContourType) -> (designcraft_geom::BezPath, Option<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.
|
||||
@@ -649,20 +1048,32 @@ const MAX_WRAP_DEPTH: usize = 64;
|
||||
/// the spread; `skip` is the text frame being wrapped; `left_page` tells whether a spread x is on
|
||||
/// a left page.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn wrap_obstacles(it: &Item, parent: Affine, inv: Affine, skip: ItemId, left_page: &dyn Fn(f64) -> bool, depth: usize, out: &mut Vec<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);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -679,7 +1090,7 @@ fn frame_wrap_exclusions(doc: &Document, sp: &designcraft_doc::Spread, item: &It
|
||||
if doc.layer(other.layer).is_some_and(|l| !l.visible) {
|
||||
continue;
|
||||
}
|
||||
wrap_obstacles(other, Affine::IDENTITY, inv, item.id, &left, 0, exclusions);
|
||||
wrap_obstacles(doc, other, Affine::IDENTITY, inv, item.id, &left, 0, exclusions);
|
||||
}
|
||||
let a = item.text_area();
|
||||
exclusions.retain(|e| {
|
||||
@@ -4329,39 +4740,40 @@ fn free_slots(f: &FrameSpec, col: Rect, y0: f64, y1: f64, desc: f64, size: f64,
|
||||
let blocked = match ex.mode {
|
||||
WrapMode::JumpToNextColumn if y1 > r.y0 && r.x0 < col.x1 && r.x1 > col.x0 => return vec![],
|
||||
WrapMode::BoundingBox | WrapMode::Contour if overlaps_band => match &ex.contour {
|
||||
Some(c) => c.band_extent(y0, y1),
|
||||
None => Some((r.x0, r.x1)),
|
||||
Some(c) => c.band_spans(y0, y1),
|
||||
None => vec![(r.x0, r.x1)],
|
||||
},
|
||||
_ => None,
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let Some((a, b)) = blocked.filter(|&(a, b)| a < col.x1 && b > col.x0) else { continue };
|
||||
cut = true;
|
||||
// One-sided wraps also block the column on the other side of the object.
|
||||
let text_left = match ex.side {
|
||||
WrapSide::LeftSide => Some(true),
|
||||
WrapSide::RightSide => Some(false),
|
||||
WrapSide::LargestArea => Some(r.x0 - col.x0 >= col.x1 - r.x1),
|
||||
WrapSide::BothSides | WrapSide::TowardsSpine | WrapSide::AwayFromSpine => None,
|
||||
};
|
||||
let (a, b) = match text_left {
|
||||
Some(true) => (a, b.max(col.x1)),
|
||||
Some(false) => (a.min(col.x0), b),
|
||||
None => (a, b),
|
||||
};
|
||||
let mut next = Vec::with_capacity(free.len() + 1);
|
||||
for (fa, fb) in free {
|
||||
if b <= fa || a >= fb {
|
||||
next.push((fa, fb));
|
||||
continue;
|
||||
}
|
||||
if a > fa {
|
||||
next.push((fa, a));
|
||||
}
|
||||
if b < fb {
|
||||
next.push((b, fb));
|
||||
for (a, b) in blocked.into_iter().filter(|&(a, b)| a < col.x1 && b > col.x0) {
|
||||
cut = true;
|
||||
// One-sided wraps also block the column on the other side of the object.
|
||||
let text_left = match ex.side {
|
||||
WrapSide::LeftSide => Some(true),
|
||||
WrapSide::RightSide => Some(false),
|
||||
WrapSide::LargestArea => Some(r.x0 - col.x0 >= col.x1 - r.x1),
|
||||
WrapSide::BothSides | WrapSide::TowardsSpine | WrapSide::AwayFromSpine => None,
|
||||
};
|
||||
let (a, b) = match text_left {
|
||||
Some(true) => (a, b.max(col.x1)),
|
||||
Some(false) => (a.min(col.x0), b),
|
||||
None => (a, b),
|
||||
};
|
||||
let mut next = Vec::with_capacity(free.len() + 1);
|
||||
for (fa, fb) in free {
|
||||
if b <= fa || a >= fb {
|
||||
next.push((fa, fb));
|
||||
continue;
|
||||
}
|
||||
if a > fa {
|
||||
next.push((fa, a));
|
||||
}
|
||||
if b < fb {
|
||||
next.push((b, fb));
|
||||
}
|
||||
}
|
||||
free = next;
|
||||
}
|
||||
free = next;
|
||||
}
|
||||
if cut {
|
||||
free.retain(|(a, b)| b - a >= size * 1.5);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -886,6 +886,62 @@ fn vertical_justify_follows_an_object_anchored_in_a_later_line() {
|
||||
assert!((top - last).abs() < 1e-6, "Justify moved the last line to {last}, not down to the object at {top}: {baselines:?}");
|
||||
}
|
||||
|
||||
/// An Alpha Channel contour counts only where the graphic's clipping path shows the image: here
|
||||
/// the visible pixels are in the right half and the clipping path shows the left half, so the
|
||||
/// image wraps nothing; inverted, the clipping path shows the right half and the image wraps.
|
||||
#[test]
|
||||
fn an_alpha_channel_contour_stops_at_the_clipping_path() {
|
||||
use designcraft_geom::{PathData, Point, SubPath};
|
||||
let rect = |x0: f64, x1: f64| {
|
||||
PathData::single(SubPath::polyline(&[Point::new(x0, 0.0), Point::new(x1, 0.0), Point::new(x1, 200.0), Point::new(x0, 200.0)], true))
|
||||
};
|
||||
let lines_beside = |invert: bool| {
|
||||
let (mut d, sid, _) = doc_with(&[LOREM; 4].join(" "), Rect::new(0.0, 0.0, 300.0, 800.0), ParaAttrs::default());
|
||||
let lid = d.default_layer();
|
||||
let asset = designcraft_doc::AssetId(d.alloc());
|
||||
let mut it = designcraft_doc::Item::new(
|
||||
ItemId(d.alloc()),
|
||||
lid,
|
||||
designcraft_doc::Shape::Rectangle,
|
||||
designcraft_geom::shapes::rectangle(Rect::new(150.0, 100.0, 300.0, 300.0)),
|
||||
);
|
||||
let mut clip = designcraft_doc::ClippingPath::new(designcraft_doc::ClippingKind::DetectEdges, rect(0.0, 75.0));
|
||||
clip.invert = invert;
|
||||
it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic {
|
||||
layer_options: None,
|
||||
asset,
|
||||
size: (150.0, 200.0),
|
||||
xf: designcraft_geom::Affine::translate((150.0, 100.0)),
|
||||
clip: Some(std::sync::Arc::new(clip)),
|
||||
auto_fit: Default::default(),
|
||||
fit_align: 0,
|
||||
crop: [0.0; 4],
|
||||
wrap: Default::default(),
|
||||
fill: Default::default(),
|
||||
});
|
||||
it.wrap.mode = WrapMode::Contour;
|
||||
it.wrap.side = WrapSide::LeftSide;
|
||||
it.wrap.contour = designcraft_doc::ContourType::AlphaChannel;
|
||||
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
|
||||
d.alpha_outlines.push(std::sync::Arc::new(designcraft_doc::AlphaOutline {
|
||||
asset,
|
||||
threshold: designcraft_doc::DEFAULT_ALPHA_THRESHOLD,
|
||||
width: 150,
|
||||
height: 200,
|
||||
outer: rect(75.0, 150.0),
|
||||
holes: PathData::default(),
|
||||
source: std::sync::Weak::new(),
|
||||
serial: designcraft_doc::AlphaOutline::next_serial(),
|
||||
}));
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
cs.frames[0].lines.iter().filter(|l| l.baseline - l.ascent > 100.0 && l.baseline + l.descent < 300.0).map(|l| l.x1).collect::<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]
|
||||
@@ -8173,6 +8229,38 @@ fn anchored_object_wraps_another_storys_text() {
|
||||
assert!(lines_in_band(&moved, 200.0, 260.0).iter().all(|l| l.x0 >= left + 100.0 - 1e-6));
|
||||
}
|
||||
|
||||
/// An anchored frame whose wrap is set only on its placed graphic (Direct Selection) wraps
|
||||
/// another story's text as a wrap set on the frame does.
|
||||
#[test]
|
||||
fn anchored_graphic_wrap_wraps_another_storys_text() {
|
||||
let long = [LOREM; 3].join(" ");
|
||||
let (mut d, a, fa) = doc_with("Anchor here.", Rect::new(0.0, 0.0, 300.0, 400.0), ParaAttrs::default());
|
||||
let lid = d.default_layer();
|
||||
let (_, b) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(320.0, 0.0, 620.0, 400.0), lid, &long, ParaFormat::default()).unwrap();
|
||||
let left = all_lines(&compose_story(&d, b, &ComposeOptions::default()))[0].x0;
|
||||
let area = d.item(fa).unwrap().text_area();
|
||||
anchor_box(&mut d, a, 0, left - area.x0, 100.0 - area.y0, false, WrapMode::None);
|
||||
let wrap = designcraft_doc::TextWrap { mode: WrapMode::BoundingBox, ..Default::default() };
|
||||
let obj = std::sync::Arc::make_mut(&mut d.story_mut(a).unwrap().objects[0]);
|
||||
obj.item.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic {
|
||||
layer_options: None,
|
||||
asset: designcraft_doc::AssetId(1),
|
||||
size: (100.0, 60.0),
|
||||
xf: designcraft_geom::Affine::IDENTITY,
|
||||
clip: None,
|
||||
auto_fit: designcraft_doc::Fitting::None,
|
||||
fit_align: 0,
|
||||
crop: [0.0; 4],
|
||||
wrap,
|
||||
fill: Default::default(),
|
||||
});
|
||||
let cs = compose_story(&d, b, &ComposeOptions::default());
|
||||
let band = lines_in_band(&cs, 100.0, 160.0);
|
||||
assert!(!band.is_empty());
|
||||
assert!(band.iter().all(|l| l.x0 >= left + 100.0 - 1e-6), "{:?}", band.iter().map(|l| l.x0).collect::<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]
|
||||
@@ -9074,6 +9162,123 @@ fn an_object_wraps_the_frame_its_anchor_lands_in_after_an_earlier_object_pushed_
|
||||
assert_eq!(line_layout(&compose_story(&d, sid, &ComposeOptions::default())), line_layout(&cs));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contour_intervals_preserve_the_gap_between_legs() {
|
||||
let mut path = designcraft_geom::BezPath::new();
|
||||
path.move_to((0.0, 0.0));
|
||||
for p in [(40.0, 0.0), (40.0, 100.0), (30.0, 100.0), (30.0, 20.0), (10.0, 20.0), (10.0, 100.0), (0.0, 100.0)] {
|
||||
path.line_to(p);
|
||||
}
|
||||
path.close_path();
|
||||
let c = Contour::of(&path, designcraft_geom::Affine::IDENTITY, 3.0).unwrap();
|
||||
assert_eq!(c.band_spans(50.0, 60.0), vec![(-3.0, 13.0), (27.0, 43.0)]);
|
||||
assert_eq!(c.band_spans(5.0, 15.0), vec![(-3.0, 43.0)]);
|
||||
let frame = designcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 35.0, 100.0)).to_bezpath();
|
||||
assert_eq!(c.within(&frame, designcraft_geom::Affine::IDENTITY).band_spans(50.0, 60.0), vec![(-3.0, 13.0), (27.0, 38.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contour_intervals_cover_the_whole_slanted_line_band() {
|
||||
let mut path = designcraft_geom::BezPath::new();
|
||||
path.move_to((0.0, 0.0));
|
||||
for p in [(2.0, 0.0), (12.0, 10.0), (10.0, 10.0)] {
|
||||
path.line_to(p);
|
||||
}
|
||||
path.close_path();
|
||||
let c = Contour::of(&path, designcraft_geom::Affine::IDENTITY, 0.0).unwrap();
|
||||
assert_eq!(c.band_spans(0.0, 10.0), vec![(0.0, 12.0)]);
|
||||
}
|
||||
|
||||
/// A vertical open path blocks the offset on both sides of it, and splits the line with no
|
||||
/// offset; clipped to a frame that holds it, it still does.
|
||||
#[test]
|
||||
fn a_vertical_open_path_keeps_its_offset() {
|
||||
let mut path = designcraft_geom::BezPath::new();
|
||||
path.move_to((10.0, 0.0));
|
||||
path.line_to((10.0, 100.0));
|
||||
let c = Contour::of(&path, designcraft_geom::Affine::IDENTITY, 3.0).unwrap();
|
||||
assert_eq!(c.band_spans(40.0, 50.0), vec![(7.0, 13.0)]);
|
||||
let frame = designcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 100.0)).to_bezpath();
|
||||
assert_eq!(c.within(&frame, designcraft_geom::Affine::IDENTITY).band_spans(40.0, 50.0), vec![(7.0, 13.0)]);
|
||||
let c = Contour::of(&path, designcraft_geom::Affine::IDENTITY, 0.0).unwrap();
|
||||
assert_eq!(c.band_spans(40.0, 50.0), vec![(10.0, 10.0)]);
|
||||
}
|
||||
|
||||
/// An outline counts only where its frame shows it: a part of it in a notch of the frame blocks
|
||||
/// nothing, even where the frame covers the same x positions lower in the same line band.
|
||||
#[test]
|
||||
fn a_clipped_contour_blocks_only_what_its_frame_shows() {
|
||||
let poly = |pts: &[(f64, f64)]| {
|
||||
let mut p = designcraft_geom::BezPath::new();
|
||||
p.move_to(pts[0]);
|
||||
for &q in &pts[1..] {
|
||||
p.line_to(q);
|
||||
}
|
||||
p.close_path();
|
||||
p
|
||||
};
|
||||
let id = designcraft_geom::Affine::IDENTITY;
|
||||
// An L-shaped frame with a notch at x 10..40 above y = 6.
|
||||
let frame = poly(&[(0.0, 0.0), (10.0, 0.0), (10.0, 6.0), (40.0, 6.0), (40.0, 10.0), (0.0, 10.0)]);
|
||||
// An outline in the notch only.
|
||||
let c = Contour::of(&poly(&[(20.0, 0.0), (40.0, 0.0), (40.0, 4.0), (20.0, 4.0)]), id, 0.0).unwrap();
|
||||
assert_eq!(c.band_spans(0.0, 10.0), vec![(20.0, 40.0)]);
|
||||
assert_eq!(c.within(&frame, id).band_spans(0.0, 10.0), Vec::<(f64, f64)>::new(), "the frame shows none of it");
|
||||
// Reaching below the notch, where the frame shows it from y = 6 to 8.
|
||||
let c = Contour::of(&poly(&[(20.0, 0.0), (40.0, 0.0), (40.0, 8.0), (20.0, 8.0)]), id, 0.0).unwrap();
|
||||
assert_eq!(c.within(&frame, id).band_spans(0.0, 10.0), vec![(20.0, 40.0)]);
|
||||
// A band that crosses both shapes whole keeps their common part, widened by the offset.
|
||||
let c = Contour::of(&poly(&[(5.0, -10.0), (30.0, -10.0), (30.0, 20.0), (5.0, 20.0)]), id, 2.0).unwrap();
|
||||
assert_eq!(c.within(&frame, id).band_spans(7.0, 9.0), vec![(3.0, 32.0)]);
|
||||
// A frame in more than 128 pieces across the band takes the common part of the projections.
|
||||
let mut fragmented = frame.clone();
|
||||
for i in 0..200 {
|
||||
let x = 1000.0 + 2.0 * f64::from(i);
|
||||
fragmented.extend(poly(&[(x, 0.0), (x + 1.0, 0.0), (x + 1.0, 10.0), (x, 10.0)]).elements().iter().copied());
|
||||
}
|
||||
let c = Contour::of(&poly(&[(20.0, 0.0), (40.0, 0.0), (40.0, 4.0), (20.0, 4.0)]), id, 0.0).unwrap();
|
||||
assert_eq!(c.within(&fragmented, id).band_spans(0.0, 10.0), vec![(20.0, 40.0)]);
|
||||
// So does a frame and an outline too detailed to compare within a line's budget: here 2,000
|
||||
// points on the frame's bottom edge and on the outline's top edge.
|
||||
let mut detailed = vec![(0.0, 0.0), (10.0, 0.0), (10.0, 6.0), (40.0, 6.0), (40.0, 10.0)];
|
||||
detailed.extend((1..2000).map(|i| (40.0 - 0.02 * f64::from(i), 10.0)));
|
||||
detailed.push((0.0, 10.0));
|
||||
let mut notch: Vec<(f64, f64)> = (0..2000).map(|i| (20.0 + 0.01 * f64::from(i), 0.0)).collect();
|
||||
notch.extend([(40.0, 0.0), (40.0, 4.0), (20.0, 4.0)]);
|
||||
let c = Contour::of(&poly(¬ch), id, 0.0).unwrap();
|
||||
assert_eq!(c.within(&poly(&detailed), id).band_spans(0.0, 10.0), vec![(20.0, 40.0)]);
|
||||
}
|
||||
|
||||
/// Edges far above or below a line band cost a clipped contour nothing there: an outline with
|
||||
/// 7,000 points well below the band still gets the exact spans, not the projections' common part.
|
||||
#[test]
|
||||
fn a_clipped_contours_budget_counts_only_edges_in_the_band() {
|
||||
let poly = |pts: &[(f64, f64)]| {
|
||||
let mut p = designcraft_geom::BezPath::new();
|
||||
p.move_to(pts[0]);
|
||||
for &q in &pts[1..] {
|
||||
p.line_to(q);
|
||||
}
|
||||
p.close_path();
|
||||
p
|
||||
};
|
||||
let id = designcraft_geom::Affine::IDENTITY;
|
||||
// The L-shaped frame of the test above, with its notch at x 10..40 above y = 6.
|
||||
let frame = poly(&[(0.0, 0.0), (10.0, 0.0), (10.0, 6.0), (40.0, 6.0), (40.0, 10.0), (0.0, 10.0)]);
|
||||
// An outline in the notch and to the right of the frame, with 40 teeth along its top in the
|
||||
// band and a 7,000-point zigzag along its bottom at y = 200.
|
||||
let mut pts = vec![(20.0, 0.0)];
|
||||
for i in 0..40 {
|
||||
let x = 40.0 + 0.5 * f64::from(i);
|
||||
pts.extend([(x, 0.0), (x + 0.25, 0.5)]);
|
||||
}
|
||||
pts.push((60.0, 0.0));
|
||||
pts.extend((0..7000).map(|i| (60.0 - 10.0 * f64::from(i) / 6999.0, if i % 2 == 0 { 200.0 } else { 201.0 })));
|
||||
pts.extend([(50.0, 4.0), (20.0, 4.0)]);
|
||||
let c = Contour::of(&poly(&pts), id, 0.0).unwrap();
|
||||
assert_eq!(c.within(&frame, id).band_spans(0.0, 10.0), Vec::<(f64, f64)>::new(), "the frame shows none of it");
|
||||
}
|
||||
|
||||
/// Rule Above's offset runs from the first line's baseline up to the rule's bottom edge, as in
|
||||
/// InDesign: a rule set clear of the cap height sits between the rows, not across the row above
|
||||
/// (an INDD project list whose rules crossed the previous paragraph's text).
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
//! is composed at most that many more times (at least [`MIN_PASSES`]), and never more than
|
||||
//! [`MAX_PASSES`].
|
||||
|
||||
use designcraft_doc::{AnchorPosition, AnchoredObject, Document, Item, OBJECT_MARK, PageSide, SpreadRef, Story, StoryId, WrapMode};
|
||||
use designcraft_doc::{AnchorPosition, AnchoredObject, Document, Item, OBJECT_MARK, PageSide, SpreadRef, Story, StoryId};
|
||||
use designcraft_geom::Affine;
|
||||
|
||||
use crate::{ComposeOptions, ComposedStory, Exclusion, FrameSpec};
|
||||
@@ -50,8 +50,9 @@ pub(crate) struct Obstacle<'a> {
|
||||
anchor_baseline: Option<f64>,
|
||||
}
|
||||
|
||||
/// The wrap may be set on the frame or on its placed graphic (Direct Selection).
|
||||
fn wraps(o: &AnchoredObject) -> bool {
|
||||
o.item.wrap.mode != WrapMode::None && !o.item.hidden
|
||||
o.item.has_wrap() && !o.item.hidden
|
||||
}
|
||||
|
||||
/// Does `story` have an anchored object whose wrap can push text?
|
||||
@@ -155,7 +156,7 @@ pub(crate) fn exclusions(doc: &Document, specs: &[FrameSpec], obs: &[Obstacle])
|
||||
let a = spec.area;
|
||||
for ob in obs.iter().filter(|o| o.spread == loc.spread) {
|
||||
let mut found = Vec::new();
|
||||
crate::wrap_obstacles(ob.item, ob.xf, inv, spec.id, &left, 0, &mut found);
|
||||
crate::wrap_obstacles(doc, ob.item, ob.xf, inv, spec.id, &left, 0, &mut found);
|
||||
for mut e in found {
|
||||
let r = e.rect;
|
||||
if r.is_finite() && r.x0 < a.x1 && r.x1 > a.x0 && r.y0 < a.y1 && r.y1 > a.y0 {
|
||||
|
||||
@@ -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