Merge remote-tracking branch 'upstream/main' into fix/graphic-styles-list-height-2992

# Conflicts:
#	docs/roadmap.md
#	docs/target-app-parity.md
This commit is contained in:
Don Omondi
2026-11-13 03:20:58 -05:00
163 changed files with 8509 additions and 720 deletions
Generated
+1
View File
@@ -6855,6 +6855,7 @@ dependencies = [
"serde",
"serde_json",
"toml",
"vectorcraft-apischema",
"vectorcraft-brush",
"vectorcraft-cad",
"vectorcraft-color",
+4 -4
View File
@@ -33,8 +33,8 @@ pub use inline::{SYMBOL_HALF, SYMBOL_SIZES};
pub use shared::Shared;
pub use symbol_export_type::{SYMBOL_EXPORT_TYPE, SymbolExportType};
pub use symbol_nine_slice::{
EditedNineSlice, MAX_SLICE_GROWTH, MIN_CENTER_BAND, NineSliceGuide, NineSliceMap, PAINTED_EFFECTS, SYMBOL_NINE_SLICE, SymbolNineSlice,
charge_symbol_art,
EditedNineSlice, MAX_GUIDE_FRACTION, MAX_SLICE_GROWTH, MIN_CENTER_BAND, NineSliceGuide, NineSliceMap, PAINTED_EFFECTS, SYMBOL_NINE_SLICE,
SymbolNineSlice, charge_symbol_art,
};
pub use symbol_overrides::SymbolOverride;
pub use symbol_registration::{SYMBOL_REGISTRATION, SymbolRegistration};
@@ -265,8 +265,8 @@ pub struct Document {
/// File → Document Setup (bleed, transparency grid, paper, type options).
#[serde(default, skip_serializing_if = "skip::is_default")]
pub setup: DocSetup,
/// Layers panel → Paste Remembers Layers: pasted objects go back into the layers (by name)
/// they were copied from instead of the current layer.
/// Layers panel → Paste Remembers Layers: pasted objects go back into the layers and
/// sublayers they were copied from instead of the current layer.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub paste_remembers_layers: bool,
/// File → File Info (its title is [`Document::info_title`]).
+1 -1
View File
@@ -55,7 +55,7 @@ use blend_spec::{blend_color_shift, blend_ramp};
pub use envelope::{
EnvelopeKind, EnvelopeMap, EnvelopeOptions, H_DOWN, H_LEFT, H_RIGHT, H_UP, PreserveShape, TypeInk, envelope_bounds, envelope_content_bounds,
envelope_content_boxes, envelope_expand, envelope_frame, envelope_grid, envelope_mapper, envelope_of, envelope_overlay, envelope_stroke_margin,
set_type_ink, smooth_handles,
set_type_ink, smooth_handles, warp_grid,
};
pub(crate) use expand::max_outset;
pub use expand::{
+250 -8
View File
@@ -525,21 +525,106 @@ pub fn envelope_of(doc: &crate::Document, id: NodeId) -> Option<&Node> {
doc.ancestry(id)?.into_iter().rev().find_map(|a| doc.node(a).filter(|n| matches!(n.kind, NodeKind::Envelope { .. })))
}
/// What a selected envelope shows on the canvas: its mesh lines (a warp's surface on a 4×4 grid,
/// a mesh envelope's own grid) or its top object's outline, and for a mesh envelope the grid to
/// edit (its points and handles).
/// What a selected envelope shows on the canvas: its mesh lines (a warp's surface on its
/// [`warp_grid`], a mesh envelope's own grid) or its top object's outline, and for a warp or mesh
/// envelope the grid to edit (its points and handles; none while its contents are edited).
pub fn envelope_overlay(n: &Node) -> Option<(PathData, Option<GradientMesh>)> {
let NodeKind::Envelope { kind, .. } = &n.kind else { return None };
let NodeKind::Envelope { kind, editing, .. } = &n.kind else { return None };
let points = |grid: GradientMesh| (!*editing).then_some(grid);
match &**kind {
EnvelopeKind::Mesh { rows, cols, points, handles } => {
let grid = envelope_grid(*rows, *cols, points, handles)?;
Some((grid.lines(), Some(grid)))
EnvelopeKind::Mesh { rows, cols, points: at, handles } => {
let grid = envelope_grid(*rows, *cols, at, handles)?;
Some((grid.lines(), points(grid)))
}
EnvelopeKind::Warp { .. } => Some((EnvelopeMap::of(n)?.surface_mesh(4, 4, Color::BLACK).lines(), None)),
EnvelopeKind::Warp { .. } => match warp_grid(n) {
Some(grid) => Some((grid.lines(), points(grid))),
None => Some((EnvelopeMap::of(n)?.surface_mesh(4, 4, Color::BLACK).lines(), None)),
},
EnvelopeKind::TopObject { path, .. } => Some((path.clone(), None)),
}
}
/// The grid sizes [`warp_grid`] tries, in order: patches per row and per column.
const WARP_GRIDS: [u32; 3] = [4, 8, 16];
/// How far a [`warp_grid`] may draw from the warp it replaces, as a share of the diagonal of the
/// warp's bounds.
const WARP_GRID_TOLERANCE: f64 = 1e-3;
/// The grid a warp envelope's points and handles are edited on: its surface sampled on the first
/// of 4×4, 8×8 and 16×16 patches whose curves draw within [`WARP_GRID_TOLERANCE`] of the warp, with
/// handles along it, as a gradient mesh with black points. Editing a point makes the envelope a
/// mesh envelope with this grid. `None` for other nodes, for content with no bounds, and for a warp
/// that no such grid follows closely enough or whose grid isn't finite.
/// Built again only when the envelope's content objects, warp, frame or options change, or what
/// type is measured with ([`set_type_ink`]).
pub fn warp_grid(n: &Node) -> Option<GradientMesh> {
let NodeKind::Envelope { content, kind, frame, options, .. } = &n.kind else { return None };
if !matches!(**kind, EnvelopeKind::Warp { .. }) {
return None;
}
// `None` until a measure is registered, so a grid kept before then is built again after.
let inputs = TYPE_INK.get().map(|(_, inputs)| inputs());
let key: Vec<usize> = content.iter().map(|c| Arc::as_ptr(c) as usize).collect();
let same = |k: &KeptGrid| k.kind == **kind && k.frame == **frame && k.options == *options && k.inputs == inputs;
if let Some(grid) = WARP_GRIDS_KEPT.with_borrow(|kept| kept.get(&key).filter(|k| same(k)).map(|k| k.grid.clone())) {
return grid;
}
let grid = build_warp_grid(n);
WARP_GRIDS_KEPT.with_borrow_mut(|kept| {
if kept.len() >= WARP_GRID_KEPT {
// Entries for content that no longer exists go first, then one entry at a time.
kept.retain(|_, k| k.content.iter().all(|c| c.strong_count() > 0));
if kept.len() >= WARP_GRID_KEPT
&& let Some(any) = kept.keys().next().cloned()
{
kept.remove(&any);
}
}
// The weak pointers keep the content's addresses from being reused while the entry is kept.
let kept_content = content.iter().map(Arc::downgrade).collect();
kept.insert(key, KeptGrid { content: kept_content, kind: (**kind).clone(), frame: **frame, options: *options, inputs, grid: grid.clone() });
});
grid
}
/// A grid [`warp_grid`] built, with what it was built from.
struct KeptGrid {
content: Vec<std::sync::Weak<Node>>,
kind: EnvelopeKind,
frame: Affine,
options: EnvelopeOptions,
inputs: Option<u64>,
grid: Option<GradientMesh>,
}
/// How many grids [`warp_grid`] keeps on one thread.
const WARP_GRID_KEPT: usize = 1024;
std::thread_local! {
/// The grids [`warp_grid`] built on this thread, by the addresses of their envelopes' content.
static WARP_GRIDS_KEPT: std::cell::RefCell<std::collections::HashMap<Vec<usize>, KeptGrid>> = Default::default();
}
/// [`warp_grid`], built from scratch.
fn build_warp_grid(n: &Node) -> Option<GradientMesh> {
let map = EnvelopeMap::of(n)?;
let bounds = map.bounds()?;
let tol = bounds.width().hypot(bounds.height()) * WARP_GRID_TOLERANCE;
if !tol.is_finite() {
return None;
}
WARP_GRIDS.into_iter().find_map(|k| {
let m = map.surface_mesh(k, k, Color::BLACK);
let finite = m.points.iter().all(|q| q.p.is_finite() && q.handles.iter().all(|h| h.is_finite()));
// Each patch is checked at its corners, the middles of its sides and its middle.
let steps = 2 * k as usize;
let at = |i: usize| i as f64 / steps as f64;
let fits = || (0..=steps).all(|j| (0..=steps).all(|i| mesh_at(&m, at(i), at(j)).distance(map.at(at(i), at(j))) <= tol));
(finite && fits()).then_some(m)
})
}
/// The frame an envelope keeps after transform `m` (its frame so far composed with the
/// transform): identity when its axes stay square to the page, unflipped, since evaluating on
/// the page axes gives the same result then.
@@ -760,6 +845,163 @@ mod tests {
assert!(content_box(&nested, false, Affine::IDENTITY).is_some());
}
/// A warp envelope of `style` at `bend` with map `version` around a 200×100 rectangle at
/// (100, 50).
fn warp_node(style: &str, bend: f64, h: f64, version: u32) -> Node {
let rect = vectorcraft_geom::shapes::rectangle(Rect::new(100.0, 50.0, 300.0, 150.0));
let content = Arc::new(Node::path(NodeId(1), rect, crate::Appearance::default_art()));
let kind = EnvelopeKind::Warp { style: style.into(), bend, h, v: 0.0, horizontal: true, version };
Node::new(
NodeId(2),
NodeKind::Envelope {
content: vec![content],
kind: Box::new(kind),
fidelity: 50.0,
editing: false,
options: EnvelopeOptions::NEW,
frame: Box::new(Affine::IDENTITY),
},
)
}
const STYLES: [&str; 15] = [
"arc",
"arcLower",
"arcUpper",
"arch",
"bulge",
"shellLower",
"shellUpper",
"flag",
"wave",
"fish",
"rise",
"fisheye",
"inflate",
"squeeze",
"twist",
];
/// Every warp style's Bézier net has a grid to edit: its points lie on the warp, and the grid
/// draws within a thousandth of the warp's diagonal of it, between the grid lines too. The
/// overlay shows that grid's lines and points.
#[test]
fn every_warp_style_has_a_grid_that_keeps_its_shape() {
for style in STYLES {
for (bend, h) in [(50.0, 0.0), (100.0, 0.0), (-100.0, 60.0)] {
let n = warp_node(style, bend, h, crate::live::WARP_NET);
let grid = warp_grid(&n).unwrap_or_else(|| panic!("{style} {bend} {h}: no grid"));
assert!(WARP_GRIDS.contains(&grid.rows) && grid.rows == grid.cols, "{style}: {}×{}", grid.rows, grid.cols);
let map = EnvelopeMap::of(&n).unwrap();
let b = map.bounds().unwrap();
let tol = b.width().hypot(b.height()) * 1e-3;
let k = grid.cols as usize;
for (i, q) in grid.points.iter().enumerate() {
let want = map.at((i % (k + 1)) as f64 / k as f64, (i / (k + 1)) as f64 / k as f64);
assert!(q.p.distance(want) < 1e-9, "{style}: point {i} at {:?}, the warp at {want:?}", q.p);
}
for j in 0..=20 {
for i in 0..=20 {
let (u, v) = (i as f64 / 20.0, j as f64 / 20.0);
let off = mesh_at(&grid, u, v).distance(map.at(u, v));
assert!(off <= tol, "{style} {bend} {h}: {off} off at ({u}, {v})");
}
}
let (lines, shown) = envelope_overlay(&n).unwrap();
assert_eq!(shown.as_ref(), Some(&grid), "{style}");
assert_eq!(lines, grid.lines(), "{style}");
}
}
}
/// A kept warp grid is built again when the content, the warp, the frame or the options change,
/// and is the grid built from scratch otherwise.
#[test]
fn a_kept_warp_grid_follows_changes() {
let n = warp_node("arc", 50.0, 0.0, crate::live::WARP_NET);
let first = warp_grid(&n);
assert!(first.is_some());
assert_eq!(warp_grid(&n), first);
assert_eq!(build_warp_grid(&n), first);
let changed = |f: &dyn Fn(&mut Node)| {
let mut m = n.clone();
f(&mut m);
let grid = warp_grid(&m);
assert_eq!(grid, build_warp_grid(&m));
assert_ne!(grid, first);
};
changed(&|m| {
if let NodeKind::Envelope { content, .. } = &mut m.kind {
Arc::make_mut(&mut content[0]).transform(Affine::translate((40.0, 0.0)), true);
}
});
changed(&|m| {
if let NodeKind::Envelope { kind, .. } = &mut m.kind
&& let EnvelopeKind::Warp { bend, .. } = &mut **kind
{
*bend = -30.0;
}
});
changed(&|m| {
if let NodeKind::Envelope { frame, .. } = &mut m.kind {
**frame = Affine::rotate(0.3);
}
});
changed(&|m| {
if let NodeKind::Envelope { options, .. } = &mut m.kind {
options.bounds_margin = [0.1; 4];
}
});
}
/// The table of kept warp grids stays bounded: full of envelopes that still exist, it drops one
/// entry at a time instead of emptying, and it drops the entries of envelopes that are gone
/// first.
#[test]
fn the_kept_warp_grids_stay_bounded() {
let nodes: Vec<Node> = (0..WARP_GRID_KEPT + 10).map(|i| warp_node("arc", (i % 100) as f64, 0.0, crate::live::WARP_NET)).collect();
for n in &nodes {
assert!(warp_grid(n).is_some());
}
assert_eq!(WARP_GRIDS_KEPT.with_borrow(|kept| kept.len()), WARP_GRID_KEPT);
// Once the envelopes are gone, their entries are the ones dropped.
drop(nodes);
assert!(warp_grid(&warp_node("arc", 10.0, 0.0, crate::live::WARP_NET)).is_some());
assert_eq!(WARP_GRIDS_KEPT.with_borrow(|kept| kept.len()), 1);
}
/// A warp drawn by the old point map that no grid of up to 16×16 patches follows closely
/// enough, content without bounds and other envelopes have no warp grid; the overlay still
/// draws the old warp's lines. While an envelope's contents are edited, it shows no points.
#[test]
fn a_warp_no_grid_follows_has_no_grid_to_edit() {
let n = warp_node("fisheye", 100.0, 0.0, 0);
assert!(warp_grid(&n).is_none());
let (lines, grid) = envelope_overlay(&n).unwrap();
assert!(grid.is_none());
assert!(lines.anchor_count() > 0);
// The same style on its Bézier net has one.
assert!(warp_grid(&warp_node("fisheye", 100.0, 0.0, crate::live::WARP_NET)).is_some());
let mut empty = warp_node("arc", 50.0, 0.0, crate::live::WARP_NET);
if let NodeKind::Envelope { content, .. } = &mut empty.kind {
content.clear();
}
assert!(warp_grid(&empty).is_none());
let mut mesh = warp_node("arc", 50.0, 0.0, crate::live::WARP_NET);
if let NodeKind::Envelope { kind, .. } = &mut mesh.kind {
**kind = EnvelopeKind::Mesh { rows: 1, cols: 1, points: grid_points(Rect::new(0.0, 0.0, 1.0, 1.0), 1, 1), handles: vec![] };
}
assert!(warp_grid(&mesh).is_none());
// While the contents are edited, a warp's and a mesh envelope's lines show without points.
for mut n in [warp_node("arc", 50.0, 0.0, crate::live::WARP_NET), mesh] {
if let NodeKind::Envelope { editing, .. } = &mut n.kind {
*editing = true;
}
let (lines, grid) = envelope_overlay(&n).unwrap();
assert!(grid.is_none() && lines.anchor_count() > 0);
}
}
/// A table that fills up is emptied instead of growing, and what is looked up after that is
/// measured again.
#[test]
+159 -42
View File
@@ -5,7 +5,7 @@
//! tint (`Paint::Solid.swatch` / `.tint`, `GradientStop.swatch` / `.tint`); a tint swatch is a
//! swatch whose own paint links to its base that way. [`Document::map_solid_paints`] rewrites
//! those links (and colours) wherever art and swatches hold them, copying only the nodes that
//! change.
//! change; [`Document::map_paints`] replaces whole paints of any kind in the same places.
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
@@ -124,6 +124,52 @@ impl Document {
/// ([`Paint::map_links`]). Only nodes holding a changed paint (and the paths to them) are
/// copied. Returns the number of changed paints.
pub fn map_solid_paints(&mut self, f: &mut LinkFn) -> usize {
let mut n = self.map_art_paints(&mut relink(f));
for gs in &mut self.graphic_styles {
for it in &mut gs.appearance.items {
n += usize::from(item_paint_mut(it).map_links(f));
}
}
for w in self.swatches_iter_mut() {
n += usize::from(w.paint.map_links(f));
}
let mut g = relink(f);
for def in &mut self.char_styles {
for key in ["fill", "stroke"] {
n += usize::from(map_style_paint(&mut def.attrs, key, &mut g));
}
}
n
}
/// Visit every paint in the places [`Document::map_solid_paints`] visits (fills, strokes, text
/// runs, image colorize paints, dynamic symbol instances' own paints and graphs' stored series and
/// label paints in the art, opacity masks included, symbol definitions, graph designs, pattern tiles and the tile that pattern
/// editing's Cancel puts back; graphic styles; the swatches; character styles' fill and
/// stroke), whatever its kind. `f(paint)` gives the paint to put in its place, or `None` to keep
/// it. Only nodes holding a changed paint (and the paths to them) are copied. Returns the number
/// of changed paints.
pub fn map_paints(&mut self, f: &mut PaintFn) -> usize {
let mut n = self.map_art_paints(f);
for gs in &mut self.graphic_styles {
for it in &mut gs.appearance.items {
n += usize::from(replace_paint(item_paint_mut(it), f));
}
}
for w in self.swatches_iter_mut() {
n += usize::from(replace_paint(&mut w.paint, f));
}
for def in &mut self.char_styles {
for key in ["fill", "stroke"] {
n += usize::from(map_style_paint(&mut def.attrs, key, f));
}
}
n
}
/// [`Document::map_paints`] limited to the node trees: the art, symbol definitions, graph
/// designs, pattern tiles and the tile that pattern editing's Cancel puts back.
fn map_art_paints(&mut self, f: &mut PaintFn) -> usize {
let mut n = map_trees(&mut self.layers, f);
for s in &mut self.symbols {
n += map_tree(&mut s.art, f);
@@ -134,19 +180,6 @@ impl Document {
for p in self.patterns.iter_mut().chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut())) {
n += map_trees(&mut p.art, f);
}
for gs in &mut self.graphic_styles {
for it in &mut gs.appearance.items {
n += usize::from(item_paint_mut(it).map_links(f));
}
}
for w in self.swatches_iter_mut() {
n += usize::from(w.paint.map_links(f));
}
for def in &mut self.char_styles {
for key in ["fill", "stroke"] {
n += usize::from(map_style_paint(&mut def.attrs, key, f));
}
}
n
}
@@ -156,9 +189,10 @@ impl Document {
let roots: Vec<NodeId> =
ids.iter().copied().filter(|id| !self.ancestry(*id).is_some_and(|a| a[..a.len() - 1].iter().any(|p| ids.contains(p)))).collect();
let mut n = 0;
let mut g = relink(f);
for id in roots {
let Some(node) = self.node(id) else { continue };
if let Some((new, count)) = map_node(node, f)
if let Some((new, count)) = map_node(node, &mut g)
&& let Some(slot) = self.node_mut(id)
{
*slot = new;
@@ -176,7 +210,7 @@ impl Document {
pub fn visit_paints(&self, f: &mut dyn FnMut(&Paint)) {
let mut tree = |n: &Node| {
walk_with_masks(n, &mut |m| {
node_paints(m).chain(override_paints(m)).for_each(&mut *f);
node_paints(m).chain(override_paints(m)).chain(graph_paints(m)).for_each(&mut *f);
if let NodeKind::Mesh(mesh) = &m.kind {
mesh.points.iter().for_each(|p| f(&Paint::solid(p.color)));
}
@@ -258,14 +292,39 @@ impl FreeSwatchNames {
/// true when it changed them.
pub type LinkFn<'a> = dyn FnMut(&mut Color, &mut Option<String>, &mut f32) -> bool + 'a;
/// Apply `f` to the paint a character style's attribute `key` holds, as [`Paint::map_links`]
/// does, and store the result in the attribute. True when it changed.
fn map_style_paint(attrs: &mut serde_json::Map<String, serde_json::Value>, key: &str, f: &mut LinkFn) -> bool {
let Some(value) = attrs.get_mut(key) else { return false };
let Ok(mut paint) = serde_json::from_value::<Paint>(value.clone()) else { return false };
if !paint.map_links(f) {
return false;
/// What [`Document::map_paints`] applies to each paint: the paint to put in its place, or `None`
/// to keep it.
pub type PaintFn<'a> = dyn FnMut(&Paint) -> Option<Paint> + 'a;
/// `f` as a [`PaintFn`]: a solid or gradient paint with `f` applied ([`Paint::map_links`]) when
/// `f` changed it, `None` for every other paint.
fn relink<'a>(f: &'a mut LinkFn<'_>) -> impl FnMut(&Paint) -> Option<Paint> + 'a {
move |p| match p {
Paint::Solid { .. } | Paint::Gradient(_) => {
let mut q = p.clone();
q.map_links(f).then_some(q)
}
_ => None,
}
}
/// Put `f(p)` in place of `p` when it gives a paint. True when it did.
fn replace_paint(p: &mut Paint, f: &mut PaintFn) -> bool {
match f(p) {
Some(q) => {
*p = q;
true
}
None => false,
}
}
/// Apply `f` to the paint a character style's attribute `key` holds and store the result in the
/// attribute. An attribute that is not a paint stays as it is. True when it changed.
fn map_style_paint(attrs: &mut serde_json::Map<String, serde_json::Value>, key: &str, f: &mut PaintFn) -> bool {
let Some(value) = attrs.get_mut(key) else { return false };
let Ok(paint) = serde_json::from_value::<Paint>(value.clone()) else { return false };
let Some(paint) = f(&paint) else { return false };
match serde_json::to_value(&paint) {
Ok(changed) => {
*value = changed;
@@ -323,6 +382,23 @@ pub fn override_paints(n: &Node) -> impl Iterator<Item = &Paint> {
n.extras().symbol_overrides.iter().flat_map(|o| [o.fill.as_ref(), o.stroke.as_ref()]).flatten()
}
/// The paints a graph keeps for its series and its labels ([`crate::graph::GraphSpec`]), which its
/// marks and labels take each time it is drawn again and which [`node_paints`] leaves out.
pub fn graph_paints(n: &Node) -> impl Iterator<Item = &Paint> {
let g = n.extras().graph.as_deref();
let series = g.into_iter().flat_map(|g| &g.series_paints).flat_map(|s| [s.fill.as_ref(), s.stroke.as_ref()]).flatten();
let labels = g.into_iter().flat_map(|g| [&g.label_styles.values, &g.label_styles.categories, &g.label_styles.legend]).flatten();
series.chain(labels.flat_map(|st| [&st.fill, &st.stroke]))
}
/// Mutable [`graph_paints`] of graph `g`, in the same order.
fn graph_paints_mut(g: &mut crate::graph::GraphSpec) -> impl Iterator<Item = &mut Paint> {
let series = g.series_paints.iter_mut().flat_map(|s| [s.fill.as_mut(), s.stroke.as_mut()]).flatten();
let l = &mut g.label_styles;
let labels = [&mut l.values, &mut l.categories, &mut l.legend].into_iter().flatten();
series.chain(labels.flat_map(|st| [&mut st.fill, &mut st.stroke]))
}
/// Mutable [`node_paints`], in the same order.
pub fn node_paints_mut(n: &mut Node) -> impl Iterator<Item = &mut Paint> {
let runs = match &mut n.kind {
@@ -333,20 +409,11 @@ pub fn node_paints_mut(n: &mut Node) -> impl Iterator<Item = &mut Paint> {
n.appearance.items.iter_mut().map(item_paint_mut).chain(runs.iter_mut().flat_map(|r| [&mut r.style.fill, &mut r.style.stroke])).chain(colorize)
}
/// `n` with `f` applied to its subtree's linkable colours, or `None` when nothing changed (then
/// nothing was copied). Also returns the number of changed paints.
fn map_node(n: &Node, f: &mut LinkFn) -> Option<(Node, usize)> {
// Try each own solid or gradient paint on a scratch copy first, so unchanged nodes are never
// cloned.
let mut changes: Vec<(usize, Paint)> = vec![];
for (i, p) in node_paints(n).enumerate() {
if matches!(p, Paint::Solid { .. } | Paint::Gradient(_)) {
let mut q = p.clone();
if q.map_links(f) {
changes.push((i, q));
}
}
}
/// `n` with `f` applied to its subtree's paints, or `None` when nothing changed (then nothing was
/// copied). Also returns the number of changed paints.
fn map_node(n: &Node, f: &mut PaintFn) -> Option<(Node, usize)> {
// `f` gives each own paint's replacement first, so unchanged nodes are never cloned.
let changes: Vec<(usize, Paint)> = node_paints(n).enumerate().filter_map(|(i, p)| f(p).map(|q| (i, q))).collect();
let mut count = changes.len();
let mut out: Option<Node> = None;
if !changes.is_empty() {
@@ -366,15 +433,29 @@ fn map_node(n: &Node, f: &mut LinkFn) -> Option<(Node, usize)> {
let mut overrides = n.extras().symbol_overrides.clone();
let mut k = 0;
for p in overrides.iter_mut().flat_map(|o| [&mut o.fill, &mut o.stroke]).flatten() {
if matches!(p, Paint::Solid { .. } | Paint::Gradient(_)) && p.map_links(f) {
k += 1;
}
k += usize::from(replace_paint(p, f));
}
if k > 0 {
out.get_or_insert_with(|| n.clone()).extras_mut().symbol_overrides = overrides;
count += k;
}
}
// A graph's series and label paints, which its marks and labels take when it is drawn again.
let graph: Vec<(usize, Paint)> = graph_paints(n).enumerate().filter_map(|(i, p)| f(p).map(|q| (i, q))).collect();
if !graph.is_empty() {
count += graph.len();
let m = out.get_or_insert_with(|| n.clone());
if let Some(spec) = m.extras_if_any().and_then(|e| e.graph.as_deref_mut()) {
let mut graph = graph.into_iter().peekable();
for (i, p) in graph_paints_mut(spec).enumerate() {
if graph.peek().is_some_and(|c| c.0 == i)
&& let Some((_, q)) = graph.next()
{
*p = q;
}
}
}
}
if let Some(ch) = n.children() {
for (i, c) in ch.iter().enumerate() {
if let Some((new, k)) = map_node(c, f) {
@@ -415,6 +496,13 @@ pub fn walk_with_masks<'a>(n: &'a Node, f: &mut dyn FnMut(&'a Node)) {
/// brush: `f` applied to the linkable colors of `n` and its subtree (opacity masks included). Only
/// the nodes holding a changed paint are copied. Returns the number of changed paints.
pub fn map_node_solid_paints(n: &mut Node, f: &mut LinkFn) -> usize {
map_node_paints(n, &mut relink(f))
}
/// [`Document::map_paints`] for art a document keeps outside its trees, such as the art of a brush:
/// `f` applied to every paint of `n` and its subtree (opacity masks included). Only the nodes
/// holding a changed paint are copied. Returns the number of changed paints.
pub fn map_node_paints(n: &mut Node, f: &mut PaintFn) -> usize {
match map_node(n, f) {
Some((new, count)) => {
*n = new;
@@ -424,7 +512,7 @@ pub fn map_node_solid_paints(n: &mut Node, f: &mut LinkFn) -> usize {
}
}
fn map_tree(n: &mut Arc<Node>, f: &mut LinkFn) -> usize {
fn map_tree(n: &mut Arc<Node>, f: &mut PaintFn) -> usize {
match map_node(n, f) {
Some((new, count)) => {
*n = Arc::new(new);
@@ -434,7 +522,7 @@ fn map_tree(n: &mut Arc<Node>, f: &mut LinkFn) -> usize {
}
}
fn map_trees(v: &mut [Arc<Node>], f: &mut LinkFn) -> usize {
fn map_trees(v: &mut [Arc<Node>], f: &mut PaintFn) -> usize {
v.iter_mut().map(|n| map_tree(n, f)).sum()
}
@@ -593,6 +681,35 @@ mod tests {
assert_eq!(map_node_solid_paints(&mut loose, &mut to_blue), 0, "nothing left to change");
}
#[test]
fn mapping_reaches_the_series_and_label_paints_a_graph_keeps() {
let (mut d, a, _) = doc();
let red = linked(Color::rgb(1.0, 0.0, 0.0), "Brand");
let spec = crate::graph::GraphSpec {
series_paints: vec![crate::SeriesPaint { fill: Some(red.clone()), stroke: Some(red.clone()), ..Default::default() }],
label_styles: crate::graph::LabelStyles {
legend: Some(crate::CharStyle { fill: red.clone(), stroke: Paint::None, ..Default::default() }),
..Default::default()
},
..Default::default()
};
d.node_mut(a).unwrap().extras_mut().graph = Some(Box::new(spec));
let mut seen = 0;
d.visit_paints(&mut |p| seen += usize::from(*p == red));
assert_eq!(seen, 4, "the fill and the graph's series fill, series stroke and legend fill");
let blue = Color::rgb(0.0, 0.0, 1.0);
let mut to_blue = |c: &mut Color, s: &mut Option<String>, _: &mut f32| s.as_deref() == Some("Brand") && std::mem::replace(c, blue) != blue;
assert_eq!(d.map_solid_paints(&mut to_blue), 4);
let g = d.node(a).unwrap().extras().graph.clone().unwrap();
assert_eq!(g.series_paints[0].fill, Some(linked(blue, "Brand")));
assert_eq!(g.series_paints[0].stroke, Some(linked(blue, "Brand")));
let legend = g.label_styles.legend.unwrap();
assert_eq!((legend.fill, legend.stroke), (linked(blue, "Brand"), Paint::None));
let mut cleared = |p: &Paint| (*p == linked(blue, "Brand")).then_some(Paint::None);
assert_eq!(d.map_paints(&mut cleared), 4, "the fill and the graph's three");
assert!(graph_paints(d.node(a).unwrap()).all(Paint::is_none));
}
#[test]
fn mapping_within_ids_visits_each_paint_once() {
let (mut d, a, b) = doc();
+180 -30
View File
@@ -10,9 +10,19 @@
//! The cells are sized along the symbol's own axes, and the instance's rotation, shear, reflection
//! and position then apply to them.
//!
//! The guides are stored as fractions (0..1) of the symbol's natural size, measured from the left
//! and top edges and from the right and bottom edges, so they survive resizing the art. Stored
//! under [`SYMBOL_NINE_SLICE`] keyed by symbol name; symbols without 9-slice have no entry.
//! A guide may lie outside the art, and the instance still scales, by the slices the art falls in
//! ([the help page](https://help.adobe.com/archive/en/illustrator/cs6/illustrator_reference.pdf),
//! Use 9-slice scaling section). VectorCraft's own rules for that: a guide past an edge of the art
//! cuts the art at that edge, so the outer cell on its side holds no art; with both guides of an
//! axis past opposite edges the art scales as a whole along it; with both past the same edge the
//! art lies in the outer cell at the other edge and keeps its size, from that edge of the instance,
//! or shrinks to the instance's width or height when the instance is smaller than the art.
//!
//! The guides are stored as fractions of the symbol's natural size, measured from the left and top
//! edges and from the right and bottom edges, so they survive resizing the art. A fraction below 0
//! puts a guide past its own edge, and one above 1 past the opposite edge; each lies within
//! ±[`MAX_GUIDE_FRACTION`], and the two guides of an axis keep their order. Stored under
//! [`SYMBOL_NINE_SLICE`] keyed by symbol name; symbols without 9-slice have no entry.
//!
//! [`NineSliceMap`] places such an instance's art for the canvas, bounds, Break Link, Expand and
//! the exporters ([`Document::place_symbol_art`]).
@@ -49,11 +59,16 @@ impl Default for SymbolNineSlice {
}
}
/// The farthest a guide may lie from its edge, as a fraction of the symbol's width or height, in
/// either direction (VectorCraft's own rule).
pub const MAX_GUIDE_FRACTION: f64 = 16.0;
impl SymbolNineSlice {
/// Guides from `[left, top, right, bottom]`; `None` unless each is finite, within 0..1, and the
/// two on each axis leave a gap between them.
/// Guides from `[left, top, right, bottom]`; `None` unless each is finite and within
/// ±[`MAX_GUIDE_FRACTION`], and the two on each axis leave a gap between them
/// (`left + right < 1`, `top + bottom < 1`).
pub fn new(left: f64, top: f64, right: f64, bottom: f64) -> Option<Self> {
let ok = |v: f64| v.is_finite() && (0.0..=1.0).contains(&v);
let ok = |v: f64| v.is_finite() && (-MAX_GUIDE_FRACTION..=MAX_GUIDE_FRACTION).contains(&v);
(ok(left) && ok(top) && ok(right) && ok(bottom) && left + right < 1.0 && top + bottom < 1.0).then_some(Self { left, top, right, bottom })
}
@@ -102,7 +117,8 @@ pub struct EditedNineSlice {
}
impl EditedNineSlice {
/// Guide `g` as a line across the art's box, from one edge to the other, in the document.
/// Guide `g` as a line across the art's box, from one edge to the other, in the document. A
/// guide outside the art spans the art's height or width beside it.
pub fn line(&self, g: NineSliceGuide) -> (Point, Point) {
let (b, s) = (self.art, self.guides);
let (a, c) = match g {
@@ -126,14 +142,17 @@ impl EditedNineSlice {
})
}
/// The guides with guide `g` moved across its axis to document point `p`. The guide stays
/// within the art's box, and [`MIN_CENTER_BAND`] short of the opposite guide, or where it is
/// when the guides are already closer than that. A point that is not finite leaves the guides
/// as they are.
/// The guides with guide `g` moved across its axis to document point `p`. The guide may pass
/// either edge of the art, up to [`MAX_GUIDE_FRACTION`] from its own edge, and stops
/// [`MIN_CENTER_BAND`] short of the opposite guide, or where it is when the guides are already
/// closer than that. A point that is not finite, or that gives a fraction that is not, leaves
/// the guides as they are.
pub fn moved(&self, g: NineSliceGuide, p: Point) -> SymbolNineSlice {
let (b, mut out) = (self.art, self.guides);
let q = self.to_doc.inverse() * p;
let fit = |v: f64, other: f64, now: f64| if v.is_finite() { v.clamp(0.0, (1.0 - other - MIN_CENTER_BAND).max(now)) } else { now };
let fit = |v: f64, other: f64, now: f64| {
if v.is_finite() { v.clamp(-MAX_GUIDE_FRACTION, (1.0 - other - MIN_CENTER_BAND).min(MAX_GUIDE_FRACTION).max(now)) } else { now }
};
match g {
NineSliceGuide::Left => out.left = fit((q.x - b.x0) / b.width(), out.right, out.left),
NineSliceGuide::Right => out.right = fit((b.x1 - q.x) / b.width(), out.left, out.right),
@@ -181,20 +200,27 @@ const MAX_SLICE_DEPTH: u32 = 64;
/// One axis of a 9-slice map: the three cells between the cuts and where each one goes.
#[derive(Clone, Copy, Debug, PartialEq)]
struct AxisMap {
/// The cuts in the art at its natural size: its start, the two guides, its end.
/// The cuts in the art at its natural size: its start, the two guides, its end. A guide
/// outside the art cuts it at the nearer end, so a cell can be empty.
src: [f64; 4],
/// Where the cuts go. The outer cells keep their length unless the scaled length is shorter
/// than both together; then both shrink in proportion and the middle cell is empty.
/// than both together; then both shrink in proportion and the middle cell is empty. An empty
/// cell's start is where the cell before it ends, so the map stays continuous.
dst: [f64; 4],
}
impl AxisMap {
/// The axis from `lo` to `hi` with guides `near` and `far` (fractions of its length in from
/// each end), scaled by `k` (above 0).
/// The axis from `lo` to `hi` (`lo < hi`) with guides `near` and `far` (fractions of its
/// length in from each end, `near + far < 1`; either may lie past an end), scaled by `k`
/// (above 0). With both guides past the far end the art keeps its length from the scaled
/// start, and with both past the near end it keeps its length up to the scaled end; when the
/// scaled length is shorter than the art, the art shrinks to the scaled length.
fn new(lo: f64, hi: f64, near: f64, far: f64, k: f64) -> Self {
let len = hi - lo;
let (a, b) = (near * len, far * len);
let src = [lo, lo + a, hi - b, hi];
let c1 = (lo + near * len).clamp(lo, hi);
let c2 = (hi - far * len).clamp(c1, hi);
let src = [lo, c1, c2, hi];
let (a, b) = (c1 - lo, hi - c2);
let (t0, t3) = (k * lo, k * hi);
let room = t3 - t0;
let (a, b) = if a + b <= room {
@@ -203,9 +229,15 @@ impl AxisMap {
let f = (room / (a + b)).max(0.0);
(a * f, b * f)
};
let t1 = t0 + a;
let t2 = (t3 - b).max(t1);
Self { src, dst: [t0, t1, t2, t3] }
let dst = if c2 > c1 {
let t1 = t0 + a;
[t0, t1, (t3 - b).max(t1), t3]
} else if a > 0.0 {
[t0, t0 + a, t0 + a, t3]
} else {
[t3 - b, t3 - b, t3 - b, t3]
};
Self { src, dst }
}
/// The cell `v` lies in; values outside the art belong to the outer cells.
@@ -279,10 +311,11 @@ pub struct NineSliceMap {
impl NineSliceMap {
/// The map for an instance placed by `xf` that shows art with natural-size transform
/// `natural` and geometric bounds `art` (in symbol space). `None` when the art or the
/// transform is degenerate, or when the instance is at its natural size on both axes (then
/// `xf` places the art as it is).
/// `natural` and geometric bounds `art` (in symbol space). `None` when the guides are not ones
/// [`SymbolNineSlice::new`] accepts, when the art or the transform is degenerate, or when the
/// instance is at its natural size on both axes (then `xf` places the art as it is).
pub fn new(guides: SymbolNineSlice, art: Rect, natural: Affine, xf: Affine) -> Option<Self> {
let guides = SymbolNineSlice::new(guides.left, guides.top, guides.right, guides.bottom)?;
let b = natural.transform_rect_bbox(art);
if ![b.x0, b.y0, b.x1, b.y1].into_iter().all(f64::is_finite) || b.width() <= 1e-9 || b.height() <= 1e-9 {
return None;
@@ -894,6 +927,113 @@ mod tests {
assert!(got.iter().all(|b| finite(*b)), "{got:?}");
}
/// Guides may lie outside the art, up to 16 times its size from their edge, as long as the
/// two on each axis keep their order; values that are not finite are refused.
#[test]
fn guides_outside_the_art_are_accepted_in_order_and_within_the_limit() {
let ok = |l, t, r, b| SymbolNineSlice::new(l, t, r, b).is_some();
assert!(ok(-0.25, 0.25, 0.25, 0.25));
assert!(ok(1.25, 0.25, -0.5, 0.25), "both past the right edge");
assert!(ok(0.25, -16.0, 0.25, 16.0) && ok(-16.0, 0.25, -16.0, 0.25));
assert!(!ok(-16.5, 0.25, 0.25, 0.25) && !ok(0.25, 0.25, 0.25, 17.0));
assert!(!ok(f64::NAN, 0.25, 0.25, 0.25) && !ok(0.25, f64::INFINITY, 0.25, 0.25) && !ok(0.25, 0.25, f64::NEG_INFINITY, 0.25));
assert!(!ok(1.2, 0.25, -0.2, 0.25), "the left guide on the right one");
assert!(!ok(0.25, 0.6, 0.25, 0.6), "crossed");
let g = SymbolNineSlice::from_value(&json!([-0.25, 1.5, 0.5, -1.0])).unwrap();
assert_eq!(g, SymbolNineSlice { left: -0.25, top: 1.5, right: 0.5, bottom: -1.0 });
assert_eq!(SymbolNineSlice::from_value(&g.to_value()), Some(g));
assert!(SymbolNineSlice::from_value(&json!([-0.25, 0.1, 1.5])).is_none());
assert!(SymbolNineSlice::from_value(&json!([-20.0, 0.1, 0.5, 0.1])).is_none());
// Guides built without the check are not used to place art.
let bad = SymbolNineSlice { left: f64::NAN, ..SymbolNineSlice::default() };
assert!(NineSliceMap::new(bad, Rect::new(0.0, 0.0, 100.0, 100.0), Affine::IDENTITY, Affine::scale(2.0)).is_none());
// Art without width or height has no map, whatever the guides.
let out = SymbolNineSlice::new(-0.25, 1.25, 0.25, -0.5).unwrap();
for art in [Rect::new(0.0, 0.0, 0.0, 100.0), Rect::new(0.0, 0.0, 100.0, 0.0)] {
assert!(NineSliceMap::new(out, art, Affine::IDENTITY, Affine::scale(2.0)).is_none(), "{art:?}");
}
}
/// At 200 × 150, a left guide 25 % outside the art leaves the art in the center and right
/// columns: the left column holds no art, the center one (0..75) stretches to 0..175 and the
/// right one keeps its 25. Both guides outside on opposite sides scale the art as a whole on
/// that axis; both past the same side keep the art at its size in the outer cell at the other
/// side, from that side of the instance.
#[test]
fn art_scales_by_the_cells_it_falls_in_when_guides_lie_outside_it() {
let xf = Affine::scale_non_uniform(2.0, 1.5);
let d = markers(SymbolNineSlice::new(-0.25, 0.25, 0.25, 0.25));
let got = boxes(&d, xf);
let k = 175.0 / 75.0;
assert!(near(got[0], Rect::new(0.0, 0.0, 200.0, 150.0)), "{:?}", got[0]);
assert!(near(got[1], Rect::new(0.0, 0.0, 10.0 * k, 10.0)), "the top-left square stretches: {:?}", got[1]);
assert!(near(got[2], Rect::new(190.0, 0.0, 200.0, 10.0)), "the top-right corner keeps its size: {:?}", got[2]);
assert!(near(got[5], Rect::new(45.0 * k, 65.0, 55.0 * k, 85.0)), "{:?}", got[5]);
// Both vertical guides outside, on opposite sides: the art scales as a whole across.
let d = markers(SymbolNineSlice::new(-0.25, 0.25, -1.0, 0.25));
let got = boxes(&d, xf);
assert!(near(got[1], Rect::new(0.0, 0.0, 20.0, 10.0)) && near(got[2], Rect::new(180.0, 0.0, 200.0, 10.0)), "{got:?}");
assert!(near(got[5], Rect::new(90.0, 65.0, 110.0, 85.0)), "{:?}", got[5]);
// Both past the right edge: the art keeps its width from the instance's left edge.
let d = markers(SymbolNineSlice::new(1.2, 0.25, -0.5, 0.25));
let got = boxes(&d, xf);
assert!(near(got[0], Rect::new(0.0, 0.0, 100.0, 150.0)), "{:?}", got[0]);
assert!(near(got[2], Rect::new(90.0, 0.0, 100.0, 10.0)) && near(got[5], Rect::new(45.0, 65.0, 55.0, 85.0)), "{got:?}");
// Both past the left edge: the art keeps its width up to the instance's right edge.
let d = markers(SymbolNineSlice::new(-0.5, 0.25, 1.2, 0.25));
let got = boxes(&d, xf);
assert!(near(got[0], Rect::new(100.0, 0.0, 200.0, 150.0)), "{:?}", got[0]);
assert!(near(got[1], Rect::new(100.0, 0.0, 110.0, 10.0)), "{:?}", got[1]);
// The same rules apply to rows: both horizontal guides above the art keep its height up to
// the instance's bottom edge.
let d = markers(SymbolNineSlice::new(0.25, -0.5, 0.25, 1.2));
let got = boxes(&d, xf);
assert!(near(got[0], Rect::new(0.0, 50.0, 200.0, 150.0)), "{:?}", got[0]);
assert!(near(got[3], Rect::new(0.0, 140.0, 10.0, 150.0)), "{:?}", got[3]);
// Smaller than the art, an art-covered outer cell shrinks in proportion.
let d = markers(SymbolNineSlice::new(1.2, 0.25, -0.5, 0.25));
let got = boxes(&d, Affine::scale_non_uniform(0.5, 1.5));
assert!(near(got[0], Rect::new(0.0, 0.0, 50.0, 150.0)) && near(got[1], Rect::new(0.0, 0.0, 5.0, 10.0)), "{got:?}");
}
/// With guides outside the art, the map stays finite, never runs backward, and does not jump
/// at the art's edges or at the guides; objects keep to the cells that hold art.
#[test]
fn the_map_with_guides_outside_the_art_is_continuous_and_monotone() {
let cases = [(-0.25, 0.25), (-0.25, -1.0), (1.2, -0.5), (-0.5, 1.2), (0.0, -16.0), (-16.0, 16.0), (0.25, 0.25)];
for (l, r) in cases {
let d = markers(SymbolNineSlice::new(l, 0.25, r, 0.25));
for sx in [0.3, 2.0, 1e6] {
let m = d.symbol_slice_map("S", Affine::scale_non_uniform(sx, 1.5)).unwrap();
let at = |x: f64| m.map_point(Point::new(x, 40.0)).x;
let mut last = f64::NEG_INFINITY;
for i in 0..=1200 {
let x = -10.0 + i as f64 * 0.1;
let v = at(x);
assert!(v.is_finite() && v >= last - 1e-12 * last.abs().max(1.0), "{l} {r} {sx}: {x} → {v} after {last}");
last = v;
}
for edge in [0.0, 75.0, 100.0] {
let jump = (at(edge + 1e-9) - at(edge - 1e-9)).abs();
assert!(jump < 1e-6 * sx.max(1.0), "{l} {r} {sx}: a jump of {jump} at {edge}");
}
}
}
// A gradient bar across the art with the left guide outside is drawn in the two columns
// that hold art, not three.
let mut d = markers(SymbolNineSlice::new(-0.25, 0.25, 0.25, 0.25));
let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default())));
let bar = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 40.0, 100.0, 60.0)), Appearance::basic(gradient, Paint::None, 0.0));
let m = d.symbol_slice_map("S", Affine::scale_non_uniform(2.0, 1.5)).unwrap();
assert_eq!(m.place(&bar, &d).children().map(Vec::len), Some(2));
assert_eq!(m.most_placed(&bar, &d), 7);
// With both guides outside on opposite sides, the bar lies in one cell and is moved whole.
let d = markers(SymbolNineSlice::new(-0.25, 0.25, -0.25, 0.25));
let m = d.symbol_slice_map("S", Affine::scale_non_uniform(2.0, 1.5)).unwrap();
assert!(matches!(m.place(&bar, &d).kind, NodeKind::Path { .. }));
assert_eq!(m.most_placed(&bar, &d), 1);
}
/// A negative scale reflects the sized cells; a rotation turns them, the corners keeping
/// their size along the symbol's own axes.
#[test]
@@ -1456,27 +1596,37 @@ mod tests {
assert!(d.edited_nine_slice().is_none());
}
/// Moving a guide changes that guide alone, follows the pointer across its axis only, stays
/// within the art's box and stops short of the opposite guide.
/// Moving a guide changes that guide alone, follows the pointer across its axis only, may pass
/// either edge of the art up to [`MAX_GUIDE_FRACTION`], and stops short of the opposite guide.
#[test]
fn a_moved_guide_stays_in_the_art_and_short_of_the_opposite_guide() {
fn a_moved_guide_passes_the_arts_edges_and_stops_short_of_the_opposite_guide() {
let xf = Affine::translate((10.0, 20.0)) * Affine::scale(2.0);
let e = editing(Some(SymbolNineSlice::default()), xf).edited_nine_slice().unwrap();
let q = SymbolNineSlice { left: 0.25, top: 0.25, right: 0.25, bottom: 0.25 };
assert_eq!(e.moved(NineSliceGuide::Left, Point::new(30.0, 999.0)), SymbolNineSlice { left: 0.1, ..q });
assert_eq!(e.moved(NineSliceGuide::Bottom, Point::new(-50.0, 180.0)), SymbolNineSlice { bottom: 0.2, ..q });
assert_eq!(e.moved(NineSliceGuide::Top, Point::new(0.0, -500.0)), SymbolNineSlice { top: 0.0, ..q });
assert_eq!(e.moved(NineSliceGuide::Right, Point::new(1e6, 0.0)), SymbolNineSlice { right: 0.0, ..q });
// Past the art's near edges: the art spans 10..210 across and 20..220 down.
assert_eq!(e.moved(NineSliceGuide::Left, Point::new(-40.0, 50.0)), SymbolNineSlice { left: -0.25, ..q });
assert_eq!(e.moved(NineSliceGuide::Top, Point::new(0.0, -500.0)), SymbolNineSlice { top: -2.6, ..q });
assert_eq!(e.moved(NineSliceGuide::Right, Point::new(1e6, 0.0)), SymbolNineSlice { right: -MAX_GUIDE_FRACTION, ..q });
assert_eq!(e.moved(NineSliceGuide::Left, Point::new(-1e300, 0.0)), SymbolNineSlice { left: -MAX_GUIDE_FRACTION, ..q });
let crossed = e.moved(NineSliceGuide::Left, Point::new(200.0, 50.0));
assert!((crossed.left - (0.75 - MIN_CENTER_BAND)).abs() < 1e-12, "{crossed:?}");
assert!(SymbolNineSlice::new(crossed.left, crossed.top, crossed.right, crossed.bottom).is_some());
assert_eq!(e.moved(NineSliceGuide::Top, Point::new(f64::NAN, f64::NAN)), q);
assert_eq!(e.moved(NineSliceGuide::Top, Point::new(0.0, f64::INFINITY)), q);
// With the right guide past the art's right edge, the left one may pass that edge too.
let wide = SymbolNineSlice { right: -0.5, ..q };
let e = editing(Some(wide), xf).edited_nine_slice().unwrap();
assert_eq!(e.moved(NineSliceGuide::Left, Point::new(260.0, 50.0)), SymbolNineSlice { left: 1.25, ..wide });
assert!(near_point(e.line(NineSliceGuide::Right).0, Point::new(310.0, 20.0)));
assert!(near_point(e.line(NineSliceGuide::Right).1, Point::new(310.0, 220.0)));
// Saved guides closer than the minimum center band keep their place when a drag does not
// move them closer.
let narrow = SymbolNineSlice::new(0.00005, 0.25, 0.9999, 0.25).unwrap();
let e = editing(Some(narrow), xf).edited_nine_slice().unwrap();
assert_eq!(e.moved(NineSliceGuide::Left, Point::new(200.0, 50.0)), narrow);
assert_eq!(e.moved(NineSliceGuide::Left, Point::new(0.0, 50.0)), SymbolNineSlice { left: 0.0, ..narrow });
assert_eq!(e.moved(NineSliceGuide::Left, Point::new(0.0, 50.0)), SymbolNineSlice { left: -0.05, ..narrow });
// A mirrored placement measures along the symbol's own axes.
let e = editing(Some(SymbolNineSlice::default()), Affine::scale_non_uniform(-1.0, 1.0)).edited_nine_slice().unwrap();
assert!(near_point(e.line(NineSliceGuide::Left).0, Point::new(-25.0, 0.0)));
+15
View File
@@ -508,6 +508,21 @@ pub enum Mojikumi {
/// Consecutive punctuation shares one half-em space (JLREQ 3.1.4), and a closing bracket,
/// comma or full stop ending a line is set half width.
LineEndHalf,
/// YakumonoHankaku: every opening and closing bracket, comma and full stop is set half width,
/// a middle dot loses a quarter em on each side, and a quarter em separates Japanese and Latin
/// letters or digits.
YakumonoHankaku,
/// GyomatsuYakumonoHankaku: punctuation keeps its full width, except that an opening bracket
/// starting a line is set flush with the line's start, a closing bracket, comma or full stop
/// ending a line is set half width and a middle dot ending a line loses the quarter em after
/// it; a quarter em separates Japanese and Latin letters or digits.
GyomatsuYakumonoHankaku,
/// GyomatsuYakumonoZenkaku: punctuation keeps its full width at a line's end too; a quarter em
/// separates Japanese and Latin letters or digits.
GyomatsuYakumonoZenkaku,
/// YakumonoZenkaku: punctuation keeps its full width, with no added space between Japanese and
/// Latin text.
YakumonoZenkaku,
}
impl Mojikumi {
+49
View File
@@ -0,0 +1,49 @@
//! A warp grid kept before type's measure is registered is built again once it is, even when the
//! registered inputs number is 0. One test in its own process, so nothing has registered the
//! measure before it runs.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::sync::Arc;
use vectorcraft_doc::live::{EnvelopeOptions, set_type_ink, warp_grid};
use vectorcraft_doc::{CharStyle, EnvelopeKind, Node, NodeId, NodeKind, TextObject};
use vectorcraft_geom::{Affine, Point, Rect};
/// A measure that gives every type the same box, well away from its laid-out box.
fn fixed_ink(_: &TextObject, _: Affine, _: Option<(vectorcraft_geom::Homography, Affine)>) -> Option<Rect> {
Some(Rect::new(0.0, 0.0, 400.0, 300.0))
}
fn no_inputs() -> u64 {
0
}
/// An arc warp around `t`, its content in a new `Arc`.
fn warp_of(t: &TextObject) -> Node {
let content = Arc::new(Node::new(NodeId(1), NodeKind::Text(Box::new(t.clone()))));
let kind = EnvelopeKind::Warp { style: "arc".into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true, version: 1 };
Node::new(
NodeId(2),
NodeKind::Envelope {
content: vec![content],
kind: Box::new(kind),
fidelity: 50.0,
editing: false,
options: EnvelopeOptions::NEW,
frame: Box::new(Affine::IDENTITY),
},
)
}
#[test]
fn a_warp_grid_kept_before_the_type_measure_is_built_again() {
let t = TextObject::point(Point::new(100.0, 100.0), "Type", CharStyle::default());
let n = warp_of(&t);
let before = warp_grid(&n);
set_type_ink(fixed_ink, no_inputs);
let fresh = warp_grid(&warp_of(&t));
assert!(fresh.is_some());
assert_ne!(fresh, before, "the registered measure moves the grid");
assert_eq!(warp_grid(&n), fresh, "the grid kept from before the registration is built again");
}
+1 -1
View File
@@ -1351,7 +1351,7 @@ mod tests {
group.appearance = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0).into();
for id in [REVOLVE, crate::EXTRUDE] {
assert!(crate::validate_three_d(&group, id, &json!({})).is_ok(), "{id} on the group itself");
let refused = crate::validate_three_d_at(&group, id, &json!({}), Some((0, 0))).unwrap_err();
let refused = crate::validate_three_d_at(&group, id, &json!({}), (Some(0), 0)).unwrap_err();
assert!(refused.contains("on the group itself"), "{id}: {refused}");
}
}
+123 -38
View File
@@ -6,7 +6,7 @@ use serde_json::Value;
use vectorcraft_doc::color::{BlendMode, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, Node, NodeId, NodeKind, OpacityMask, StrokeLayer};
use vectorcraft_geom::{FillRule, PathData, Rect};
use vectorcraft_three_d::{Bevel, BevelShape, Extrude, Facet, Inflate, Mesh, PlanarFace, View};
use vectorcraft_three_d::{Bevel, BevelShape, Extrude, Facet, Inflate, MAX_OBJECT_FACES, Mesh, PlanarFace, View};
#[cfg(test)]
use vectorcraft_doc::color::Color;
@@ -250,36 +250,64 @@ pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> {
/// [`validate_solid`] with the symbols of `doc`: a symbol instance, a blend and a clipping group
/// build from the art they show ([`resolve`]).
pub fn validate_solid_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value) -> Result<(), String> {
validate_solid_for(doc, n, id, p, None)
validate_solid_for(doc, n, id, p, (None, None))
}
/// [`validate_solid_in`] for the effect on the object (`item` `None`) or on its appearance item
/// `item`, whose shadow is cast by that item's solid alone.
fn validate_solid_for(doc: Option<&Document>, n: &Node, id: &str, p: &Value, item: Option<usize>) -> Result<(), String> {
/// [`validate_solid_in`] for the effect at `(item, index)`: index `index` of the effects of the
/// object (`item` `None`) or of its appearance item `item`, whose shadow is cast by that item's
/// solid alone. The index is the effects' length for a new effect. The solids are checked as
/// [`solid_art_in`] draws them: from the shapes after the geometry effects before the 3D effect.
/// On the object (or group) with `index` `None`, that effect is its first visible Extrude & Bevel,
/// Inflate or Rotate, as drawn, or a new one after its other effects when it has none.
fn validate_solid_for(doc: Option<&Document>, n: &Node, id: &str, p: &Value, at: (Option<usize>, Option<usize>)) -> Result<(), String> {
validate_solid_params(id, p)?;
if let Some(doc) = doc {
graphics::validate_symbols(doc, p)?;
}
let label = label_of(id);
let n = &*source::resolve_for(doc, n, label, is_plane(id))?;
let effects = &n.appearance.effects;
let index = match at {
(None, Some(index)) => index,
_ => solid(effects).map_or(effects.len(), |(index, _)| index),
};
let before = effects.get(..index).unwrap_or(effects);
if is_container(n) {
let shapes = members(n, label)?;
let mut shapes = members(n, label)?;
reshape_members(n, before, &mut shapes);
let b = union_bounds(&shapes).ok_or_else(|| format!("{label} requires a group holding a shape with at least two anchors"))?;
shapes.iter().try_for_each(|s| facets(id, &s.path, s.rule, b, p).map(|_| ()))?;
check_solids(id, p, &shapes)?;
return cast_shadow(id, p, &shapes).map(|_| ());
}
let (path, rule) = object_profile(n, label)?;
let b = path.bounds().ok_or_else(|| format!("{label} requires a shape with at least two anchors"))?;
facets(id, &path, rule, b, p)?;
let anchors = || format!("{label} requires a shape with at least two anchors");
path.bounds().ok_or_else(anchors)?;
let ctx = GeomContext::of(n);
// With its shadow on, the shadow of the object's solids (or of the item's) must fit too.
let items = match item.and_then(|i| n.appearance.items.get(i)) {
Some(item) => vec![item.clone()],
None => solid_paints(n),
let (path, items) = match at.0.and_then(|i| Some((n.appearance.items.get(i)?, at.1.unwrap_or(usize::MAX)))) {
Some((item, index)) => {
let path = geometry(&path, effects, &ctx);
(geometry(&path, item.effects().get(..index).unwrap_or(item.effects()), &ctx.item(item)), vec![item.clone()])
}
None => (geometry(&path, before, &ctx), solid_paints(n)),
};
let b = path.bounds().ok_or_else(anchors)?;
facets(id, &path, rule, b, p)?;
let shapes = [Profile { path, rule, items, opacity: 1.0, scopes: vec![] }];
check_solids(id, p, &shapes)?;
cast_shadow(id, p, &shapes).map(|_| ())
}
/// Do the solids 3D effect `id` with parameters `p` builds from the fills and strokes of `shapes`
/// fit, each on its own and all of them together ([`built_solids`])? Rotate's planes are not
/// counted.
fn check_solids(id: &str, p: &Value, shapes: &[Profile]) -> Result<(), String> {
let Some(b) = union_bounds(shapes).filter(|_| !is_plane(id)) else { return Ok(()) };
let (parts, bounds) = solid_parts(shapes, b);
built_solids(id, p, &parts, bounds, MAX_OBJECT_FACES).try_for_each(|r| r.map(|_| ()))
}
/// [`profile`], or the error a command reports for object `n`: type with no outlines to build from
/// (empty, or only spaces) is a shape without anchors, an image is traced first, and any other
/// object is the wrong kind.
@@ -457,26 +485,28 @@ pub fn validate_three_d_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value
if crate::revolve::is_revolve(id) { crate::revolve::validate_in(doc, n, id, p) } else { validate_solid_in(doc, n, id, p) }
}
/// [`validate_three_d`] for an effect at `(item, index)`, index `index` of the effects of
/// appearance item `item` of `n` (the effects' length for a new effect), or on the object itself
/// when `at` is `None`. A Revolve on a fill or stroke is checked in that item's paint, after the
/// item's geometry effects before it, together with the object's other items that carry a Revolve.
/// A group's or layer's fills and strokes take no 3D effect: only the group's own effect draws.
pub fn validate_three_d_at(n: &Node, id: &str, p: &Value, at: Option<(usize, usize)>) -> Result<(), String> {
/// [`validate_three_d`] for an effect at `(item, index)`: index `index` of the effects of
/// appearance item `item` of `n`, or of the object itself when `item` is `None` (the effects'
/// length for a new effect). An Extrude & Bevel, Inflate or Rotate is checked on the shapes after
/// the geometry effects before that index. A Revolve on a fill or stroke is checked in that item's
/// paint, after the item's geometry effects before it, together with the object's other items that
/// carry a Revolve. A group's or layer's fills and strokes take no 3D effect: only the group's own
/// effect draws.
pub fn validate_three_d_at(n: &Node, id: &str, p: &Value, at: (Option<usize>, usize)) -> Result<(), String> {
validate_three_d_at_in(None, n, id, p, at)
}
/// [`validate_three_d_at`] with the symbols of `doc` ([`validate_solid_in`]). A symbol instance, a
/// blend and a clipping group take a 3D effect as groups do: on the object itself.
pub fn validate_three_d_at_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value, at: Option<(usize, usize)>) -> Result<(), String> {
pub fn validate_three_d_at_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value, at: (Option<usize>, usize)) -> Result<(), String> {
graphics::validate(id, p)?;
match at {
Some(_) if is_container(n) || source::resolves(n) => {
(Some(_), _) if is_container(n) || source::resolves(n) => {
Err("A 3D effect on a group or layer goes on the group itself, not on its fill or stroke".into())
}
Some((item, index)) if crate::revolve::is_revolve(id) => crate::revolve::validate_item_of(n, id, p, item, index),
Some((item, _)) => validate_solid_for(doc, n, id, p, Some(item)),
None => validate_three_d_in(doc, n, id, p),
(Some(item), index) if crate::revolve::is_revolve(id) => crate::revolve::validate_item_of(n, id, p, item, index),
(None, _) if crate::revolve::is_revolve(id) => crate::revolve::validate_in(doc, n, id, p),
(item, index) => validate_solid_for(doc, n, id, p, (item, Some(index))),
}
}
@@ -596,6 +626,55 @@ fn solid_parts(shapes: &[Profile], fallback: Rect) -> (Vec<Part<'_>>, Rect) {
(parts, bounds)
}
#[cfg(test)]
thread_local! {
/// How many solids [`built_solids`] has built on this thread, for the tests.
static SOLIDS_BUILT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// The solids Extrude & Bevel (Classic included) or Inflate `id` with parameters `p` builds from
/// `parts` ([`solid_parts`]) about `bounds`, one result for each part in order, each built only as
/// the iterator reaches it. A stroke's solid past its own limits is `None` (left out of the art, its
/// faces not counted); a fill's is an error. The faces of the solids kept count together: once they
/// pass `limit` ([`MAX_OBJECT_FACES`] for the art) the result is an error, and
/// no later part is built. An error ends the iterator.
fn built_solids<'a>(
id: &'a str,
p: &'a Value,
parts: &'a [Part<'a>],
bounds: Rect,
limit: usize,
) -> impl Iterator<Item = Result<Option<Vec<Facet>>, String>> + 'a {
// Inflate's eye is placed for every puff together, as far away as the highest needs.
let mut inflated = (id == INFLATE).then(|| {
let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule, _, _)| (area, *rule)).collect();
vectorcraft_three_d::inflate_each(&shapes, bounds, inflate_options(p), view_options(p))
});
let mut kept = 0usize;
let mut ended = false;
parts.iter().map_while(move |(item, _, area, rule, _, _)| {
if ended {
return None;
}
#[cfg(test)]
SOLIDS_BUILT.with(|n| n.set(n.get() + 1));
let built = match inflated.as_mut() {
Some(each) => each.next().unwrap_or_else(|| Ok(vec![])),
None => facets(id, area, *rule, bounds, p),
};
let result = match built {
Ok(f) => {
kept = kept.saturating_add(f.len());
if kept > limit { Err(vectorcraft_three_d::TOO_COMPLEX_SOLID.to_string()) } else { Ok(Some(f)) }
}
Err(_) if matches!(item, AppearanceItem::Stroke(_)) => Ok(None),
Err(e) => Err(e),
};
ended = result.is_err();
Some(result)
})
}
/// What a face of one of the solids [`art_for`] draws in one order was built from: the facet's
/// depth, whether it is a side face and whether it is turned away from the viewer, the bounds of
/// its polygon, the index of its shape, and whether its solid is that shape's fill or one of its
@@ -983,24 +1062,19 @@ fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile
return Some(if masks { masked(n.id, out) } else { out.into_iter().map(|(_, s)| s).collect() });
}
let (parts, bounds) = solid_parts(shapes, b);
let built = if fx.id == INFLATE {
// One eye for every puff, as far away as the highest needs.
let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule, _, _)| (area, *rule)).collect();
vectorcraft_three_d::inflate_together(&shapes, bounds, inflate_options(&p), view_options(&p))
} else {
parts.iter().map(|(_, _, area, rule, _, _)| facets(&fx.id, area, *rule, bounds, &p)).collect()
};
let mut solids = vec![];
for ((item, opacity, _, _, scopes, member), f) in parts.iter().zip(built) {
for ((item, opacity, _, _, scopes, member), f) in parts.iter().zip(built_solids(&fx.id, &p, &parts, bounds, MAX_OBJECT_FACES)) {
let item: &AppearanceItem = item;
match f {
Ok(f) if !crate::revolve::paint_copies_add(&mut paint_bytes, facet_copies(&f, paint_ref(item), colored), paint_ref(item)) => return None,
Ok(f) => solids.push((item, *opacity, *scopes, f, *member)),
Ok(Some(f)) if !crate::revolve::paint_copies_add(&mut paint_bytes, facet_copies(&f, paint_ref(item), colored), paint_ref(item)) => {
return None;
}
Ok(Some(f)) => solids.push((item, *opacity, *scopes, f, *member)),
// A stroke's outline past the limits is left out; the fill's solid still draws.
Err(_) if matches!(item, AppearanceItem::Stroke(_)) => {}
// A loaded file or a shape edited past the limits keeps drawing its source path
// instead of failing the frame; commands validate the same evaluator and report its
// errors.
Ok(None) => {}
// A loaded file or a shape edited past the limits (its own, or those of the object's
// solids together) keeps drawing its source path instead of failing the frame;
// commands validate the same evaluator and report its errors.
Err(_) => return None,
}
}
@@ -1374,15 +1448,26 @@ fn meshes_for(fx: &Effect, shapes: &[Profile], total: &mut usize, limit: usize)
return Ok(out);
}
let (parts, bounds) = solid_parts(shapes, b);
// The solids the art draws, past the limits of the object's solids together as it is
// ([`built_solids`]), each built and its faces counted as the loop reaches it, then dropped.
let drawn = built_solids(&fx.id, &p, &parts, bounds, MAX_OBJECT_FACES).map(|r| r.map(|f| f.is_some()));
// Inflate lays out every puff at once (they share one eye); each Inflate and Extrude & Bevel
// solid and its mesh are built as the loop reaches them.
let mut inflated = (fx.id == INFLATE).then(|| {
let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule, _, _)| (area, *rule)).collect();
vectorcraft_three_d::inflate_meshes(&shapes, bounds, inflate_options(&p), view)
});
let extrude = extrude_options(&p);
for (item, member, area, rule, _, _) in &parts {
let extrude = if fx.id == CLASSIC_EXTRUDE { classic_extrude_options(&p) } else { extrude_options(&p) };
for ((item, member, area, rule, _, _), drawn) in parts.iter().zip(drawn) {
let item: &AppearanceItem = item;
if !drawn? {
// A stroke the art leaves out is left out of the meshes too; Inflate's next puff
// still belongs to the next part.
if let Some(built) = inflated.as_mut() {
built.next();
}
continue;
}
let mesh = match inflated.as_mut() {
Some(built) => built.next().unwrap_or_else(|| Ok(Mesh::default())),
None => vectorcraft_three_d::extrude_mesh(area, *rule, bounds, extrude, view),
+136
View File
@@ -353,6 +353,142 @@ fn a_stroke_past_the_limits_is_left_out() {
}
}
/// Each solid [`built_solids`] gives for effect `id` on `n` within `limit` faces together, as its
/// face count (`None` for a stroke left out) or the error, every result the iterator gives.
fn solid_faces(id: &str, n: &Node, limit: usize) -> Vec<Result<Option<usize>, String>> {
let shapes = [Profile { path: profile(n).unwrap().0, rule: FillRule::NonZero, items: solid_paints(n), opacity: 1.0, scopes: vec![] }];
let p = crate::merged_params(id, &json!({}));
let (parts, bounds) = solid_parts(&shapes, union_bounds(&shapes).unwrap());
built_solids(id, &p, &parts, bounds, limit).map(|r| r.map(|f| f.map(|f| f.len()))).collect()
}
/// The solids of one object count their faces together: at the limit they all build, one face past
/// it is an error, and the solids after the one that passes it are never built.
#[test]
fn an_objects_solids_count_their_faces_together() {
for id in [EXTRUDE, CLASSIC_EXTRUDE, INFLATE] {
let mut n = square_node();
// A second stroke, so a solid follows the one that passes the limit.
let stroke = n.appearance.items[1].clone();
n.appearance.items.push(stroke);
let counts: Vec<usize> = solid_faces(id, &n, MAX_OBJECT_FACES).into_iter().map(|c| c.unwrap().unwrap()).collect();
assert!(counts.len() == 3 && counts.iter().all(|c| *c > 0), "{id}: {counts:?}");
let total: usize = counts.iter().sum();
assert!(solid_faces(id, &n, total).iter().all(Result::is_ok), "{id}: at the limit");
let past = solid_faces(id, &n, total - 1);
assert_eq!(past.last(), Some(&Err(vectorcraft_three_d::TOO_COMPLEX_SOLID.to_string())), "{id}");
SOLIDS_BUILT.with(|b| b.set(0));
let past = solid_faces(id, &n, counts[0] + counts[1] - 1);
assert!(past.len() == 2 && past[1].is_err(), "{id}: {past:?}");
assert_eq!(SOLIDS_BUILT.with(std::cell::Cell::get), 2, "{id}: the second stroke's solid is never built");
}
}
/// A convex polygon of `corners` corners about (20,000, 20,000), filled red, with no stroke.
fn polygon_node(id: u64, corners: usize) -> Node {
let points: Vec<Point> = (0..corners)
.map(|k| {
let (s, c) = (k as f64 * std::f64::consts::TAU / corners as f64).sin_cos();
Point::new(20_000.0 + 10_000.0 * c, 20_000.0 + 10_000.0 * s)
})
.collect();
let path = PathData::single(vectorcraft_geom::SubPath::polyline(&points, true));
Node::path(NodeId(id), path, Appearance::basic(Paint::solid(RED), Paint::None, 0.0))
}
/// A group whose members' solids each fit, but not all of them together, draws its source art, and
/// the commands that check the effect report the error, as do its mesh and its wireframe; one
/// member fewer, within the limit, draws.
#[test]
fn a_groups_solids_together_past_the_limit_draw_the_source() {
let member = polygon_node(1, 16_384);
let mut alone = member.clone();
alone.appearance.effects.push(fx(EXTRUDE, json!({})));
let [Ok(Some(each))] = solid_faces(EXTRUDE, &alone, MAX_OBJECT_FACES)[..] else { panic!("one solid") };
// Members stacked in one place each build the same solid.
let fits = MAX_OBJECT_FACES / each;
let group = |count: usize| {
let members = (0..count)
.map(|k| {
let mut m = member.clone();
m.id = NodeId(k as u64 + 1);
Arc::new(m)
})
.collect();
let mut g = Node::group(NodeId(9999), members);
g.appearance.effects.push(fx(EXTRUDE, json!({})));
g
};
let within = group(fits);
assert!(validate_solid(&within, EXTRUDE, &json!({})).is_ok(), "{fits} × {each} faces");
assert!(solid_art(&within).is_some());
let past = group(fits + 1);
assert_eq!(validate_solid(&past, EXTRUDE, &json!({})), Err(vectorcraft_three_d::TOO_COMPLEX_SOLID.to_string()));
assert!(solid_art(&past).is_none(), "the source art draws");
assert_eq!(three_d_meshes(&past), Err(vectorcraft_three_d::TOO_COMPLEX_SOLID.to_string()));
assert_eq!(three_d_wireframe_in(None, &past), Err(vectorcraft_three_d::TOO_COMPLEX_SOLID.to_string()));
}
/// A fill and strokes whose solids each fit, but not all of them together, draw the source art,
/// and the strokes after the one past the limit are not left out to make room.
#[test]
fn a_fill_and_strokes_together_past_the_limit_draw_the_source() {
let mut n = polygon_node(1, 16_384);
n.appearance.effects.push(fx(EXTRUDE, json!({})));
let stroke = AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(BLUE), 20.0));
n.appearance.items.push(stroke.clone());
// Strokes alike each build the same solid.
let [Ok(Some(fill)), Ok(Some(each))] = solid_faces(EXTRUDE, &n, MAX_OBJECT_FACES)[..] else { panic!("a fill's solid and a stroke's") };
let fits = (MAX_OBJECT_FACES - fill) / each;
assert!(fits >= 2, "{fill} + {fits} × {each}");
n.appearance.items.extend(std::iter::repeat_n(stroke, fits - 1));
assert!(validate_solid(&n, EXTRUDE, &json!({})).is_ok(), "{fill} + {fits} × {each}");
let art = solid_art(&n).unwrap();
assert!(faces(&art).iter().any(|f| blue(f)));
n.appearance.items.push(AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(BLUE), 20.0)));
assert_eq!(validate_solid(&n, EXTRUDE, &json!({})), Err(vectorcraft_three_d::TOO_COMPLEX_SOLID.to_string()));
assert!(solid_art(&n).is_none(), "the source art draws");
}
/// The check builds the solids from the shapes the art draws them from: after the geometry effects
/// before the 3D effect, on an object, on its fill and on a group. Copies a Transform effect before
/// it makes that take the solids past the limit fail the check, as the art then draws the source.
#[test]
fn the_check_counts_the_faces_after_the_geometry_effects_before() {
let mut alone = polygon_node(1, 16_384);
alone.appearance.effects.push(fx(EXTRUDE, json!({})));
let [Ok(Some(each))] = solid_faces(EXTRUDE, &alone, MAX_OBJECT_FACES)[..] else { panic!("one solid") };
// Side by side, each copy builds the same solid.
let copies = |k: usize| fx("distort.transform", json!({"moveH": 30_000.0, "copies": k}));
for (k, fits) in [(1, true), (MAX_OBJECT_FACES / each, false)] {
let mut n = polygon_node(1, 16_384);
n.appearance.effects = vec![copies(k), fx(EXTRUDE, json!({}))];
assert_eq!(validate_solid(&n, EXTRUDE, &json!({})).is_ok(), fits, "on the object, {k} copies");
assert_eq!(solid_art(&n).is_some(), fits, "on the object, {k} copies");
let mut f = polygon_node(1, 16_384);
f.appearance.items[0].effects_mut().extend([copies(k), fx(EXTRUDE, json!({}))]);
assert_eq!(validate_three_d_at(&f, EXTRUDE, &json!({}), (Some(0), 1)).is_ok(), fits, "on the fill, {k} copies");
assert_eq!(solid_art(&f).is_some(), fits, "on the fill, {k} copies");
let mut g = Node::group(NodeId(9999), vec![Arc::new(polygon_node(1, 16_384))]);
g.appearance.effects = vec![copies(k), fx(EXTRUDE, json!({}))];
assert_eq!(validate_solid(&g, EXTRUDE, &json!({})).is_ok(), fits, "on a group, {k} copies");
assert_eq!(solid_art(&g).is_some(), fits, "on a group, {k} copies");
// A new effect goes after the copies.
let mut new = polygon_node(1, 16_384);
new.appearance.effects = vec![copies(k)];
assert_eq!(validate_three_d_at(&new, EXTRUDE, &json!({}), (None, 1)).is_ok(), fits, "a new effect, {k} copies");
}
// A hidden effect checked at its own index counts only the effects before it: copies after it
// copy the drawn art once it is shown again.
let mut hidden = polygon_node(1, 16_384);
let mut extrude = fx(EXTRUDE, json!({}));
extrude.visible = false;
hidden.appearance.effects = vec![extrude, copies(MAX_OBJECT_FACES / each)];
assert!(validate_three_d_at(&hidden, EXTRUDE, &json!({}), (None, 0)).is_ok());
hidden.appearance.effects[0].visible = true;
assert!(solid_art(&hidden).is_some());
}
/// At no volume Inflate's front is flat: turned, the fill's cells are all drawn before the stroke's,
/// whatever their depths, as the extrusion's caps are. When the fill puffs, a stroke too narrow to
/// puff on the grid stays flat and keeps its depths: the fill's puff is drawn over its far side.
+30
View File
@@ -59,6 +59,20 @@ fn the_meshes_of_one_object_share_one_triangle_budget() {
assert!(meshes_for(effect, &shapes, &mut total, MAX_MESH_TRIANGLES).is_err_and(|e| e.contains("too complex")));
}
/// A mesh past the budget stops the export before the solids after it are built.
#[test]
fn the_solids_after_a_mesh_past_the_budget_are_not_built() {
let mut n = square_node(1, 100.0);
n.appearance.effects.push(fx(EXTRUDE, json!({})));
let first = three_d_meshes(&n).unwrap()[0].mesh.triangles.len();
let shapes = [Profile { path: profile(&n).unwrap().0, rule: FillRule::NonZero, items: solid_paints(&n), opacity: 1.0, scopes: vec![] }];
let Some(effect) = n.appearance.effects.first() else { panic!("no effect") };
let mut total = MAX_MESH_TRIANGLES - first + 1;
SOLIDS_BUILT.with(|b| b.set(0));
assert!(meshes_for(effect, &shapes, &mut total, MAX_MESH_TRIANGLES).is_err_and(|e| e.contains("too complex")));
assert_eq!(SOLIDS_BUILT.with(std::cell::Cell::get), 1, "the stroke's solid is never built");
}
#[test]
fn revolve_stops_at_the_first_mesh_past_the_budget() {
// The fill revolves into a mesh; the stroke's profile, copied 1000 times, is too complex to
@@ -109,6 +123,22 @@ fn an_extruded_mesh_is_as_deep_as_the_effect() {
assert!((hi[2] - lo[2] - 30.0).abs() < 1e-9 && (hi[0] - lo[0] - 100.0).abs() < 1e-9, "{lo:?} {hi:?}");
}
/// Extrude & Bevel (Classic) exports the solid it draws: its bevel as wide as it is high.
#[test]
fn a_classic_extrusion_exports_its_own_bevel() {
let mut n = square_node(1, 100.0);
n.appearance.items.truncate(1);
let params = json!({ "bevel": "classic", "bevelHeight": 10.0 });
n.appearance.effects.push(fx(CLASSIC_EXTRUDE, params.clone()));
let meshes = three_d_meshes(&n).unwrap();
let p = crate::merged_params(CLASSIC_EXTRUDE, &params);
let path = profile(&n).unwrap().0;
let bounds = path.bounds().unwrap();
let own = vectorcraft_three_d::extrude_mesh(&path, FillRule::NonZero, bounds, classic_extrude_options(&p), view_options(&p)).unwrap();
assert_eq!(meshes.len(), 1);
assert_eq!(meshes[0].mesh, own);
}
#[test]
fn revolve_gives_a_mesh_in_the_top_fill() {
let mut b = BezPath::new();
+8 -16
View File
@@ -4,13 +4,13 @@ use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::Arc;
use vectorcraft_doc::color::{Color, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, Node, NodeId};
use vectorcraft_doc::{Appearance, Document, Effect, Node, NodeId};
use vectorcraft_geom::{Anchor, CubicBez, PathData, Point, SubPath};
use vectorcraft_three_d::Facet;
use super::{
INFLATE, Profile, ROTATE, facets, geometry, has_solid, inflate_options, is_container, is_plane, is_three_d, label_of, members, object_profile,
region, reshape_members, resolve, resolve_for, solid, solid_paints, solid_parts, union_bounds, view_options,
Profile, ROTATE, built_solids, facets, geometry, has_solid, is_container, is_plane, is_three_d, label_of, members, object_profile, region,
reshape_members, resolve, resolve_for, solid, solid_paints, solid_parts, union_bounds,
};
use crate::{GeomContext, is_geometry};
@@ -125,7 +125,8 @@ fn solid_face_paths(doc: Option<&Document>, n: &Node) -> Result<Vec<PathData>, S
/// The outlines of the facets effect `fx` builds from `shapes`, as `art_for` builds them: Rotate and
/// Rotate (Classic) turn every fill and stroke; Extrude & Bevel and Inflate build a solid from each shape's
/// top fill and from the outline of each stroke. A stroke whose solid is past the limits is left out,
/// as it is left out of the drawn art.
/// as it is left out of the drawn art; past the limits of the object's solids together, the result
/// is an error, as the canvas draws the source art.
fn facet_paths(fx: &Effect, shapes: &[Profile]) -> Result<Vec<PathData>, String> {
let label = label_of(&fx.id);
let b = union_bounds(shapes).ok_or_else(|| format!("{label} requires a shape with at least two anchors"))?;
@@ -141,18 +142,9 @@ fn facet_paths(fx: &Effect, shapes: &[Profile]) -> Result<Vec<PathData>, String>
return Ok(out);
}
let (parts, bounds) = solid_parts(shapes, b);
let built: Vec<Result<Vec<Facet>, String>> = if fx.id == INFLATE {
let shapes: Vec<(&PathData, vectorcraft_geom::FillRule)> = parts.iter().map(|(_, _, area, rule, ..)| (area, *rule)).collect();
vectorcraft_three_d::inflate_together(&shapes, bounds, inflate_options(&p), view_options(&p))
} else {
parts.iter().map(|(_, _, area, rule, ..)| facets(&fx.id, area, *rule, bounds, &p)).collect()
};
for ((item, ..), f) in parts.iter().zip(built) {
match f {
Ok(f) => out.extend(f.iter().map(Facet::path)),
Err(_) if matches!(item, AppearanceItem::Stroke(_)) => {}
Err(e) => return Err(e),
}
// The solids the art draws, each past the limits of the object's solids together as it is.
for f in built_solids(&fx.id, &p, &parts, bounds, vectorcraft_three_d::MAX_OBJECT_FACES) {
out.extend(f?.iter().flatten().map(Facet::path));
}
Ok(out)
}
+1 -1
View File
@@ -1,6 +1,6 @@
use serde_json::{Value, json};
use vectorcraft_doc::color::{Color, Paint};
use vectorcraft_doc::{Appearance, Node, NodeId};
use vectorcraft_doc::{Appearance, AppearanceItem, Node, NodeId};
use vectorcraft_geom::{BezPath, Rect};
use super::*;
+2 -2
View File
@@ -199,7 +199,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New Symbol…",
[],
None,
"{name?, ids?|id?, registration?: 0..8 (9-point grid, 4 = centre; Symbol Options › Registration), exportType?: movieClip (default)|graphic, nineSlice?: bool, nineSliceGuides?: array, type?: static (default)|dynamic (Symbol Options › Symbol Type)} make a symbol from the selection and replace it with an instance; a frame of threaded text in the selection leaves its thread with the text it shows, which the symbol's art holds; nineSlice is Symbol Options › Enable Guides for 9-Slice Scaling, nineSliceGuides [left, top, right, bottom] as fractions 0..1 of the natural size (default a quarter each); an instance of a symbol with guides keeps its corners' size when scaled, its edges stretch along their length and its center in both directions → {name, id, registration, exportType, nineSlice, type}",
"{name?, ids?|id?, registration?: 0..8 (9-point grid, 4 = centre; Symbol Options › Registration), exportType?: movieClip (default)|graphic, nineSlice?: bool, nineSliceGuides?: array, type?: static (default)|dynamic (Symbol Options › Symbol Type)} make a symbol from the selection and replace it with an instance; a frame of threaded text in the selection leaves its thread with the text it shows, which the symbol's art holds; nineSlice is Symbol Options › Enable Guides for 9-Slice Scaling, nineSliceGuides [left, top, right, bottom] as fractions of the natural size in from each edge, each -16..16 (below 0 or above 1 puts a guide outside the art), with left + right < 1 and top + bottom < 1 (default a quarter each); an instance of a symbol with guides keeps its corners' size when scaled, its edges stretch along their length and its center in both directions → {name, id, registration, exportType, nineSlice, type}",
has_selection,
symbol_new
),
@@ -208,7 +208,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Symbol Options…",
[],
None,
"{name?, newName?, registration?: 0..8, exportType?: movieClip|graphic, nineSlice?: bool, nineSliceGuides?: array, type?: static|dynamic} rename a symbol (its instances and the graphics mapped onto 3D objects follow the new name) and/or set Symbol Options › Registration, Export Type, 9-slice guides (nineSliceGuides [left, top, right, bottom] fractions 0..1) and Symbol Type (a dynamic symbol's instances may change the appearance of objects in its art and stay linked; making it static drops those changes); default name: current → {name, registration, exportType, nineSlice, type}",
"{name?, newName?, registration?: 0..8, exportType?: movieClip|graphic, nineSlice?: bool, nineSliceGuides?: array, type?: static|dynamic} rename a symbol (its instances and the graphics mapped onto 3D objects follow the new name) and/or set Symbol Options › Registration, Export Type, 9-slice guides (nineSliceGuides [left, top, right, bottom] fractions in from each edge, each -16..16, with left + right < 1 and top + bottom < 1) and Symbol Type (a dynamic symbol's instances may change the appearance of objects in its art and stay linked; making it static drops those changes); default name: current → {name, registration, exportType, nineSlice, type}",
has_doc,
symbol_options
),
+9 -10
View File
@@ -47,21 +47,20 @@ fn export_type_param(p: &Value, cmd: &str) -> Result<Option<SymbolExportType>> {
}
/// `nineSlice` (bool) and `nineSliceGuides` ([left, top, right, bottom] fractions of the natural
/// size): `None` leaves the symbol alone, `Some(None)` turns 9-slice off, `Some(Some(g))` turns it
/// on with guides `g` (the symbol's current ones, else a quarter in from each edge, when none are
/// given).
/// size, below 0 or above 1 for a guide outside the art): `None` leaves the symbol alone,
/// `Some(None)` turns 9-slice off, `Some(Some(g))` turns it on with guides `g` (the symbol's
/// current ones, else a quarter in from each edge, when none are given).
fn nine_slice_param(p: &Value, cmd: &str, current: Option<SymbolNineSlice>) -> Result<Option<Option<SymbolNineSlice>>> {
let on = match p.get("nineSlice") {
None | Some(Value::Null) => None,
Some(v) => Some(v.as_bool().ok_or_else(|| bad(cmd, "nineSlice must be true or false"))?),
};
let guides =
match p.get("nineSliceGuides") {
None | Some(Value::Null) => None,
Some(v) => Some(SymbolNineSlice::from_value(v).ok_or_else(|| {
bad(cmd, "nineSliceGuides must be [left, top, right, bottom], each 0..1, with left + right < 1 and top + bottom < 1")
})?),
};
let guides = match p.get("nineSliceGuides") {
None | Some(Value::Null) => None,
Some(v) => Some(SymbolNineSlice::from_value(v).ok_or_else(|| {
bad(cmd, "nineSliceGuides must be [left, top, right, bottom], each -16..16, with left + right < 1 and top + bottom < 1")
})?),
};
Ok(match (on, guides) {
(Some(false), _) => Some(None),
(None, None) => None,
+29 -5
View File
@@ -88,8 +88,9 @@ pub struct Clipboard {
/// The artboard the objects were copied from (none when they lie on no artboard; the first of
/// [`Self::artboards`] when there are any): Paste on All Artboards keeps their offset to it.
pub source_artboard: Option<Rect>,
/// Per object: the name of the layer it was copied from (Paste Remembers Layers).
pub source_layers: Vec<Option<String>>,
/// Per object: the layer or sublayer it was copied from and the layers around it (Paste
/// Remembers Layers); none when that layer has no name.
pub source_layers: Vec<Option<SourceLayer>>,
/// The open document the objects came from ([`DocState::uid`]): pasting back into it uses its
/// own resources as they are now.
pub source_doc: Option<u64>,
@@ -101,6 +102,29 @@ pub struct Clipboard {
pub guides: Vec<vectorcraft_doc::Guide>,
}
/// The layer an object was copied from, as Paste Remembers Layers finds it again: the top-level
/// layer first, down to the layer or sublayer that holds the object.
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
pub struct SourceLayer {
/// The layers' ids in the source document ([`Clipboard::source_doc`]).
pub ids: Vec<NodeId>,
/// The layers' names (an unnamed layer above the one that holds the object is "").
pub names: Vec<String>,
}
impl SourceLayer {
/// The layers around the object `anc` ends with ([`Document::ancestry`]); none when the layer
/// that holds it has no name.
fn of(d: &Document, anc: &[NodeId]) -> Option<Self> {
let layers: Vec<&Node> = anc.iter().rev().skip(1).filter_map(|a| d.node(*a).filter(|l| l.is_layer())).collect();
layers.first()?.name.as_ref()?;
Some(Self {
ids: layers.iter().rev().map(|l| l.id).collect(),
names: layers.iter().rev().map(|l| l.name.clone().unwrap_or_default()).collect(),
})
}
}
/// Indices of segments selected on `sp` (either explicitly or via incident anchors).
pub(crate) fn subpath_selected_segments(
sp: &vectorcraft_geom::SubPath,
@@ -256,7 +280,7 @@ impl Clipboard {
let mut compound_bounds: std::collections::HashMap<NodeId, Option<vectorcraft_geom::Rect>> = std::collections::HashMap::new();
for id in roots {
let (Some(n), Some(anc)) = (d.node(*id), d.ancestry(*id)) else { continue };
let layer_name = anc.iter().rev().skip(1).find_map(|a| d.node(*a).filter(|l| l.is_layer())).and_then(|l| l.name.clone());
let source_layer = SourceLayer::of(d, &anc);
let compound_parent = anc.iter().rev().skip(1).find_map(|a| d.node(*a).filter(|p| matches!(p.kind, NodeKind::Compound { .. })));
// Gradients fitted to the compound's bounds are pinned there, as a split pins them.
let gradient_box = compound_parent
@@ -299,11 +323,11 @@ impl Clipboard {
if direct && let Some(pieces) = extract_direct_selection(n, &st.selection) {
for piece in pieces {
nodes.push(inherit_appearance(piece));
source_layers.push(layer_name.clone());
source_layers.push(source_layer.clone());
}
} else {
nodes.push(inherit_appearance(n.clone()));
source_layers.push(layer_name);
source_layers.push(source_layer);
}
}
// Symbol instances are measured by their art.
+6 -2
View File
@@ -397,7 +397,9 @@ pub(crate) fn commit_new_art_colors(s: &mut Session) -> Result<()> {
Ok(())
}
fn record_new_art_colors(s: &mut Session, before: vectorcraft_tools::PaintDefaults) -> Result<()> {
/// Record new-art paints `before` and the session's current ones with the step just made (or the
/// interaction or undo group in progress), so Undo puts `before` back and Redo the current ones.
pub(super) fn record_new_art_colors(s: &mut Session, before: vectorcraft_tools::PaintDefaults) -> Result<()> {
let after = s.paint.clone();
if before != after || s.batch_stash.is_some() || s.doc()?.undo_group.is_some() {
let st = s.doc_mut()?;
@@ -422,7 +424,9 @@ fn record_new_art_colors(s: &mut Session, before: vectorcraft_tools::PaintDefaul
Ok(())
}
fn new_art_colors_before(s: &Session) -> Result<vectorcraft_tools::PaintDefaults> {
/// The new-art paints an undo step that starts now puts back: those at the start of the undo group
/// or interaction in progress, else the session's current ones.
pub(super) fn new_art_colors_before(s: &Session) -> Result<vectorcraft_tools::PaintDefaults> {
let st = s.doc()?;
Ok(st.undo_group.as_ref().map(|g| &g.paint).or_else(|| st.interaction.as_ref().map(|it| &it.paint)).cloned().unwrap_or_else(|| s.paint.clone()))
}
+114 -15
View File
@@ -1,6 +1,6 @@
//! File (document-level) and Edit commands, plus document queries.
use std::collections::{BTreeMap, BTreeSet};
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::sync::Arc;
use serde_json::{Value, json};
@@ -13,7 +13,7 @@ use super::{
f64_req, finite_numbers, has_clipboard, has_doc, has_selection, has_selection_guides_or_artboard_tool, has_selection_or_guides, index, number_or,
object_param, ok, point_param, str_param, switch_or,
};
use crate::{Clipboard, EngineError, inspect};
use crate::{Clipboard, EngineError, SourceLayer, inspect};
/// Register editing commands, including document-to-document artwork drops and their parameter contracts.
pub fn specs() -> Vec<CommandSpec> {
@@ -179,7 +179,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Paste",
["Edit"],
Some("Cmd+V"),
"{center?: [x, y], artboardsAt?: [x, y], dx?, dy?, swatchConflict?} paste centred on `center` (the app passes the view centre), else offset by dx/dy (default: the Paste Offset preference). Pasting brings the image blobs, symbols, patterns, global and spot swatches (with the tint swatches of the tints used), gradient swatches, graphic styles, character and paragraph styles and brushes the objects use; one of the same name that differs comes in renamed. swatchConflict, for a swatch whose name the document gives another colour (clipboard.conflicts): \"merge\" (default: the objects take the document's swatch) | \"add\" (the pasted swatch comes in renamed) | {name: \"merge\"|\"add\"}. With Paste Remembers Layers on (layer.pasteRemembersLayers), objects go back into the layers they came from (by name; made when missing). Artboards copied with artboard.copy come with their art, together right of the last artboard as they lay, or centered on `artboardsAt` (the Artboard tool's canvas menu passes the right-click point; a value that is not [x, y] is an error) (the other paste commands but Paste on All Artboards: where they were), their art back in the layers it came from, and the Artboard tool, when chosen, selects them. In Draw Inside (view.drawMode) the objects go inside the object drawn into, as new art does, and Paste Remembers Layers doesn't apply; edit.pasteInPlace and edit.pasteOnAllArtboards do the same, and in Draw Behind they go behind the selection as edit.pasteInBack puts them; edit.pasteInFront and edit.pasteInBack ignore the drawing mode → {ids, artboard: the first new artboard's index or null, artboards: the new artboards' indices, added: resources added, merged: conflicts merged, renamed: [{kind, from, to}]}",
"{center?: [x, y], artboardsAt?: [x, y], dx?, dy?, swatchConflict?} paste centred on `center` (the app passes the view centre), else offset by dx/dy (default: the Paste Offset preference). Pasting brings the image blobs, symbols, patterns, global and spot swatches (with the tint swatches of the tints used), gradient swatches, graphic styles, character and paragraph styles and brushes the objects use; one of the same name that differs comes in renamed. swatchConflict, for a swatch whose name the document gives another colour (clipboard.conflicts): \"merge\" (default: the objects take the document's swatch) | \"add\" (the pasted swatch comes in renamed) | {name: \"merge\"|\"add\"}. With Paste Remembers Layers on (layer.pasteRemembersLayers), objects go back into the layers and sublayers they came from (by the names of the layer and the layers around it in another document; a missing sublayer is made inside its layer; a hidden or locked one is an error and nothing is pasted). Artboards copied with artboard.copy come with their art, together right of the last artboard as they lay, or centered on `artboardsAt` (the Artboard tool's canvas menu passes the right-click point; a value that is not [x, y] is an error) (the other paste commands but Paste on All Artboards: where they were), their art back in the layers it came from, and the Artboard tool, when chosen, selects them. In Draw Inside (view.drawMode) the objects go inside the object drawn into, as new art does, and Paste Remembers Layers doesn't apply; edit.pasteInPlace and edit.pasteOnAllArtboards do the same, and in Draw Behind they go behind the selection as edit.pasteInBack puts them; edit.pasteInFront and edit.pasteInBack ignore the drawing mode → {ids, artboard: the first new artboard's index or null, artboards: the new artboards' indices, added: resources added, merged: conflicts merged, renamed: [{kind, from, to}]}",
has_clipboard,
|s, p| paste(s, p, PasteMode::Offset)
),
@@ -1380,6 +1380,9 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
&& inside.is_none()
&& (st.doc.paste_remembers_layers || boards.is_some())
&& parent.is_some_and(|p| st.doc.node(p).is_some_and(Node::is_layer));
// Each object's remembered layer, found before anything is pasted: a hidden or locked one
// refuses the whole paste.
let remembered = if remember { remembered_layers(&st.doc, &clip.source_layers, same_doc)? } else { vec![] };
// Front/back: relative to the selection (top-most / bottom-most selected object); with
// nothing selected, the top / bottom of the current layer. Art other than paths goes beside
// the compound path that holds the selected object, not into it.
@@ -1423,7 +1426,8 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
.flat_map(|(all, dv)| all.iter().map(move |a| (a, dv)))
.map(|(a, dv)| super::panelcmds::push_artboard_copy(d, a, a.rect + dv))
.collect();
let mut layers: BTreeMap<&str, NodeId> = BTreeMap::new();
// The layers made for remembered layers the document lacks, by parent and name.
let mut made = HashMap::new();
// Objects pasted into each parent so far (keeps their order in front and back pastes).
let mut placed: BTreeMap<Option<NodeId>, usize> = BTreeMap::new();
let mut new_ids = vec![];
@@ -1437,8 +1441,9 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
// including the pattern phase, independently of transform-tool settings.
c.transform(xf, sc);
}
let layer = match clip.source_layers.get(k) {
Some(Some(name)) if remember => Some(*layers.entry(name).or_insert_with(|| layer_named(d, name))),
let layer = match remembered.get(k) {
Some(Some(Remembered::Layer(l))) => Some(*l),
Some(Some(Remembered::Make { parent, names })) => make_layers(d, &mut made, *parent, names)?,
_ => None,
};
// In a clip group or a clipping layer the bottom child is the clipping path: art pasted
@@ -1557,17 +1562,111 @@ fn paste_anchor(doc: &Document, id: NodeId, can_enter_compound: bool) -> NodeId
}
}
/// The first unlocked layer or sublayer called `name` (topmost first), or a new top layer.
fn layer_named(d: &mut Document, name: &str) -> NodeId {
let mut found = None;
for l in d.layers.iter().rev() {
l.walk(&mut |n| {
if found.is_none() && n.is_layer() && !n.locked && n.name.as_deref() == Some(name) {
found = Some(n.id);
/// Where Paste Remembers Layers puts an object: a layer or sublayer of the document, or new
/// layers `names`, each inside the one before, the first under `parent` (none: on top).
#[derive(Clone, Debug, PartialEq)]
enum Remembered {
Layer(NodeId),
Make { parent: Option<NodeId>, names: Vec<String> },
}
/// [`remembered_layer`] for each object's source layer, worked out once for each layer.
fn remembered_layers(d: &Document, sources: &[Option<SourceLayer>], same_doc: bool) -> Result<Vec<Option<Remembered>>> {
let mut found: HashMap<&SourceLayer, Remembered> = HashMap::new();
sources
.iter()
.map(|src| {
let Some(src) = src else { return Ok(None) };
if let Some(r) = found.get(src) {
return Ok(Some(r.clone()));
}
});
let r = remembered_layer(d, src, same_doc)?;
found.insert(src, r.clone());
Ok(Some(r))
})
.collect()
}
/// The layer `src` names in `d`. In the document it was copied from, the layer that held the
/// object is kept while it is there with its name, wherever it was moved; else the layers around
/// it still where they were (by id) are kept. Below them, and in another document from the top,
/// each name is matched among the layers inside the one above (the topmost of the same name whose
/// layers match the most names, a shown and unlocked one first). The names left unmatched are
/// made. A matched layer that is hidden or locked, or inside one that is, is an error.
fn remembered_layer(d: &Document, src: &SourceLayer, same_doc: bool) -> Result<Remembered> {
let held = |id: &NodeId| d.node(*id).is_some_and(|n| n.is_layer() && n.name.as_ref() == src.names.last());
let kept = if same_doc && src.ids.last().is_some_and(held) {
src.ids.len()
} else if same_doc {
(1..=src.ids.len())
.rev()
.find(|&j| d.node(src.ids[j - 1]).is_some_and(Node::is_layer) && d.ancestry(src.ids[j - 1]).is_some_and(|a| a == src.ids[..j]))
.unwrap_or(0)
} else {
0
};
let rest = &src.names[kept..];
let (end, matched, _) = named_chain(d, kept.checked_sub(1).map(|j| src.ids[j]), rest);
if let Some(l) = end
&& !d.ancestry(l).is_some_and(|a| a.iter().all(|i| d.node(*i).is_some_and(|n| n.visible && !n.locked)))
{
return Err(EngineError::Other("the target layer is locked or hidden".into()));
}
found.unwrap_or_else(|| d.add_layer(Some(name)))
Ok(match end {
Some(l) if matched == rest.len() => Remembered::Layer(l),
parent => Remembered::Make { parent, names: rest[matched..].to_vec() },
})
}
/// The deepest chain of layers inside `parent` (none: the top level) named `names` one inside the
/// other: its last layer (`parent` when none matches), how many names it matched and whether all
/// its layers are shown and unlocked. Of chains as deep, the topmost shown and unlocked one wins.
fn named_chain(d: &Document, parent: Option<NodeId>, names: &[String]) -> (Option<NodeId>, usize, bool) {
let mut best = (parent, 0, true);
let Some((name, rest)) = names.split_first() else { return best };
let siblings = match parent {
None => Some(&d.layers),
Some(p) => d.node(p).and_then(Node::children),
};
for l in siblings.into_iter().flatten().rev().filter(|l| l.is_layer() && l.name.as_deref() == Some(name.as_str())) {
let (end, n, open) = named_chain(d, Some(l.id), rest);
let chain = (end, n + 1, open && l.visible && !l.locked);
if (chain.1, chain.2) > (best.1, best.2) {
best = chain;
}
}
best
}
/// Layers `names`, each inside the one before, the first under `parent` (none: on top); `made`
/// keeps the ones a paste has made, by parent and name, so they are made once. The last one, or
/// none when `names` is empty.
fn make_layers(
d: &mut Document,
made: &mut HashMap<(Option<NodeId>, String), NodeId>,
parent: Option<NodeId>,
names: &[String],
) -> Result<Option<NodeId>> {
let mut at = parent;
for name in names {
let key = (at, name.clone());
let id = match made.get(&key) {
Some(id) => *id,
None => {
let id = match at {
None => d.add_layer(Some(name)),
Some(p) => {
let layer = super::layer::make_layer(d, Some(name));
d.insert(Some(p), usize::MAX, layer)?
}
};
made.insert(key, id);
id
}
};
at = Some(id);
}
Ok(at.filter(|_| !names.is_empty()))
}
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
+22 -7
View File
@@ -16,7 +16,7 @@ use vectorcraft_render::effects;
use super::appearance::{ItemTarget, aligned, appearance_targets, index_param, item_indices, item_target, item_target_at};
use super::expand::has_seeded;
use super::rasterfx;
use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, cmd, fileio, has_doc, str_param, switch_or};
use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, check_switches, cmd, fileio, has_doc, str_param, switch_or};
/// The name of the `svg.filter` effect drawing `xml`: its `<filter>` id. An effect names the
/// document's filter of that name, so `xml` that differs from the document's filter of the same
@@ -76,7 +76,7 @@ fn effect_specs() -> Vec<CommandSpec> {
"Effect Options",
[],
None,
"{index: int, params?: {…} (merged into the current parameters), warpStyle?: string (an existing Warp style suffix, e.g. arc or flag; changes only an applied Warp in place), threeDType?: plane|extrude|revolve|inflate (the 3D and Materials panel's object type: changes only an applied 3D effect in place into Rotate, Extrude & Bevel, Revolve or Inflate; the settings the new effect also takes keep their values, rotation angles wrapped into -180..180 outside Revolve, and the others start at its defaults; params then merge into the new effect's; the type the effect already has, with nothing else given, changes nothing and records no undo step), visible?: bool, item?: appearance item index|null (as effect.remove), ids?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} → {ids}",
"{index: int, params?: {…} (merged into the current parameters), warpStyle?: string (an existing Warp style suffix, e.g. arc or flag; changes only an applied Warp in place; params are checked against the new style), threeDType?: plane|extrude|revolve|inflate (the 3D and Materials panel's object type: changes only an applied 3D effect in place into Rotate, Extrude & Bevel, Revolve or Inflate; the settings the new effect also takes keep their values, rotation angles wrapped into -180..180 outside Revolve, and the others start at its defaults; params then merge into the new effect's; the type the effect already has, with nothing else given, changes nothing and records no undo step), visible?: bool (any other value but null is refused, when a step plays too), item?: appearance item index|null (as effect.remove), ids?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} → {ids}",
has_doc,
set_params
),
@@ -457,9 +457,10 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
for (target, at) in targets.iter().copied().zip(item.per_target(s, &targets)) {
let d = &s.doc()?.doc;
let n = d.node(target).ok_or(EngineError::NoNode(target))?;
// A new effect goes after the item's others.
// A new effect goes after the item's (or the object's) others.
let on = at.and_then(|at| at.effects_item(&n.appearance, C).ok()).flatten();
let on = on.and_then(|i| Some((i, n.appearance.items.get(i)?.effects().len())));
let on = on.and_then(|i| Some((Some(i), n.appearance.items.get(i)?.effects().len())));
let on = on.unwrap_or((None, n.appearance.effects.len()));
effects::validate_three_d_at_in(Some(d), n, id, &effect.params, on).map_err(|e| bad(C, e))?;
if let Some(name) = effects::missing_symbol(d, &effect.params, None) {
return Err(bad(C, format!("no symbol named `{name}`")));
@@ -649,15 +650,28 @@ pub(crate) fn set_params_effects(s: &Session, p: &Value) -> Vec<String> {
return vec![];
};
let Ok(targets) = effect_targets(s, p, item, index, C) else { return vec![] };
// A 3D effect whose type `threeDType` changes takes the new effect's parameters.
// A 3D effect whose type `threeDType` changes, and a Warp whose style `warpStyle` changes, take
// the new effect's parameters.
let kind = three_d_type(p, C).ok().flatten();
let warp = p.get("warpStyle").and_then(Value::as_str).filter(|style| effects::WarpStyle::from_id(style).is_some());
let effect = |&(id, at, index): &(NodeId, Option<usize>, usize)| {
let id = &st.doc.node(id)?.appearance.effects_at(at)?.get(index)?.id;
Some(kind.filter(|_| effects::is_three_d(id)).map_or_else(|| id.clone(), str::to_string))
Some(edited_effect_id(id, kind, warp))
};
targets.iter().filter_map(effect).collect()
}
/// The id effect `id` has once `effect.setParams` sets its 3D type to `kind` or its Warp style to
/// `warp` (each when given and when `id` is an effect of that family).
fn edited_effect_id(id: &str, kind: Option<&str>, warp: Option<&str>) -> String {
let is_warp = id.strip_prefix("warp.").and_then(effects::WarpStyle::from_id).is_some();
match (kind, warp) {
(Some(to), _) if effects::is_three_d(id) => to.to_string(),
(_, Some(style)) if is_warp => format!("warp.{style}"),
_ => id.to_string(),
}
}
/// The effect `threeDType` in `p` turns a 3D effect into ([`effects::THREE_D_TYPES`]), when given.
fn three_d_type(p: &Value, cmd: &str) -> Result<Option<&'static str>> {
let key = "threeDType";
@@ -718,6 +732,7 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
Ok(format!("warp.{style}"))
})
.transpose()?;
check_switches(p, &["visible"], C)?;
let visible = p.get("visible").and_then(Value::as_bool);
let item = item_target(s, p, C)?;
let edited = set_params_effects(s, p);
@@ -772,7 +787,7 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, format!("no symbol named `{name}`")));
}
if visible != Some(false) {
effects::validate_three_d_at_in(Some(d), n, to, &prospective, target.map(|item| (item, index))).map_err(|e| bad(C, e))?;
effects::validate_three_d_at_in(Some(d), n, to, &prospective, (target, index)).map_err(|e| bad(C, e))?;
} else {
// Hidden, the effect draws nothing, so its object need not fit. The values given are
// checked, and after a change of 3D type so is every value it keeps.
+1 -1
View File
@@ -131,7 +131,7 @@ fn map_graphic(s: &mut Session, p: &Value) -> Result<Value> {
graphic.scale = frame.fitting_scale(art);
}
let params = effects::with_graphic(&effects::merged_params(&effect.id, &effect.params), &graphic);
effects::validate_three_d_at_in(Some(d), n, &effect.id, &params, item.map(|i| (i, index))).map_err(|e| bad(C, e))?;
effects::validate_three_d_at_in(Some(d), n, &effect.id, &params, (item, index)).map_err(|e| bad(C, e))?;
edits.push((id, item, index, graphic));
}
let first = edits.first().map(|(.., g)| g.to_value());
+82
View File
@@ -169,6 +169,44 @@ fn solid_effects_apply_to_a_group_and_its_members_stay_editable() {
assert_eq!(s.doc().unwrap().doc, before, "nothing changed");
}
/// Extrude & Bevel is refused, and nothing changes, on a path whose fill's and strokes' solids each
/// fit but together pass the limit of their faces; with one stroke the effect applies.
#[test]
fn extrude_is_refused_when_its_solids_together_are_too_complex() {
use vectorcraft_doc::color::{Color, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem};
let (mut s, id) = session_with_rect();
let points: Vec<vectorcraft_geom::Point> = (0..16_384)
.map(|k| {
let (sin, cos) = (k as f64 * std::f64::consts::TAU / 16_384.0).sin_cos();
vectorcraft_geom::Point::new(20_000.0 + 10_000.0 * cos, 20_000.0 + 10_000.0 * sin)
})
.collect();
let polygon = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&points, true));
let painted = |strokes: usize| {
let mut items = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0).items;
let stroke = AppearanceItem::Stroke(vectorcraft_doc::StrokeLayer::new(Paint::solid(Color::WHITE), 20.0));
items.extend(std::iter::repeat_n(stroke, strokes));
items
};
let set = |s: &mut Session, strokes: usize| {
let n = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap();
let NodeKind::Path { path, live, .. } = &mut n.kind else { panic!("a path") };
*path = polygon.clone();
*live = None;
n.appearance.items = painted(strokes);
};
// The fill's solid and each stroke's fit on their own; sixty strokes' together do not.
set(&mut s, 60);
let before = s.doc().unwrap().doc.clone();
let err = s.execute("effect.apply", &json!({"effect": "threeD.extrude", "ids": [id.0], "item": null})).unwrap_err().to_string();
assert!(err.contains("The 3D surface is too complex; simplify the shape"), "{err}");
assert_eq!(s.doc().unwrap().doc, before, "nothing changed");
set(&mut s, 1);
s.execute("effect.apply", &json!({"effect": "threeD.extrude", "ids": [id.0], "item": null})).unwrap();
assert!(effects::solid_art(&node(&s, id)).is_some());
}
/// A group of a clipping group (a rectangle clipped by an ellipse) and a rectangle: the session,
/// the clipping group and the group.
fn group_with_a_clipping_group() -> (Session, NodeId, NodeId) {
@@ -626,6 +664,50 @@ fn warp_options_non_warp_target_rejection_is_atomic() {
assert!(!state.is_dirty());
}
/// `effect.setParams` refuses a `visible` that is not true or false, when called and when a step
/// plays, and the effect, the document and the undo history stay as they were. `null` leaves the
/// visibility as it is.
#[test]
fn set_params_refuses_a_visible_that_is_not_true_or_false() {
let (mut s, id) = session_with_rect();
s.execute("effect.apply", &json!({"effect": "distort.roughen", "item": null})).unwrap();
s.doc_mut().unwrap().mark_saved();
let before = s.doc().unwrap().doc.clone();
let undo = s.doc().unwrap().history.undo.len();
for visible in [json!(1), json!(0), json!("false"), json!([])] {
let p = json!({"index": 0, "item": null, "visible": visible, "params": {"size": 9}});
for replay in [false, true] {
let r = if replay { s.replay("effect.setParams", &p) } else { s.execute("effect.setParams", &p) };
let err = r.err().map(|e| e.to_string()).unwrap_or_default();
assert!(err.contains("`visible` must be true or false"), "{visible} (replay {replay}): {err}");
let state = s.doc().unwrap();
assert_eq!(state.doc, before, "{visible} (replay {replay})");
assert_eq!(state.history.undo.len(), undo, "{visible} (replay {replay})");
assert!(!state.is_dirty());
}
}
s.execute("effect.setParams", &json!({"index": 0, "item": null, "visible": null, "params": {"size": 9}})).unwrap();
let n = node(&s, id);
assert!(n.appearance.effects[0].visible);
assert_eq!(n.appearance.effects[0].params["size"], 9);
}
/// The effect `effect.setParams` checks the parameters against is the one it leaves: a Warp's new
/// style and a 3D effect's new type, and the effect as it is otherwise.
#[test]
fn set_params_checks_the_effect_it_leaves() {
use super::edited_effect_id;
assert_eq!(edited_effect_id("warp.arc", None, Some("flag")), "warp.flag");
assert_eq!(edited_effect_id("warp.arc", None, None), "warp.arc");
assert_eq!(edited_effect_id("distort.roughen", None, Some("flag")), "distort.roughen");
assert_eq!(edited_effect_id("threeD.extrude", Some("threeD.revolve"), None), "threeD.revolve");
assert_eq!(edited_effect_id("warp.arc", Some("threeD.revolve"), None), "warp.arc");
let (mut s, _) = session_with_rect();
s.execute("effect.apply", &json!({"effect": "warp.arc", "item": null})).unwrap();
let p = json!({"index": 0, "item": null, "warpStyle": "flag", "params": {"bend": 25}});
assert_eq!(super::set_params_effects(&s, &p), ["warp.flag"]);
}
#[test]
fn list_remove_and_set_params() {
let (mut s, id) = session_with_rect();
+4 -2
View File
@@ -13,10 +13,10 @@
//! per-format options (`file.formatOptions`); [`save_with`] is the one save path of every frontend.
//! - `pdf`: PDF settings and presets for every PDF export, `document.exportPdf`.
//! - `pdfimport`: the PDF pages, box and password `document.open` and Place read, `document.pdfInfo`.
//! - `dxf`: DXF export options, `document.exportDxf`, and the formats that can't be written (DWG, PICT).
//! - `dxf`: DXF export options, `document.exportDxf`, and the formats that can't be written (DWG, PageMaker).
//! - `eps`: EPS export options (flattened transparency, previews, the embedded document), `document.exportEps`.
//! - `dxfimport`: the DXF options `document.open` and Place read, `document.dxfInfo`.
//! - `metafile`: EMF and WMF export, open and place.
//! - `metafile`: EMF and WMF export, open and place; PICT open and place.
//! - `hpgl`: HP-GL export options and plots.
//! - `importer`: files that importer plug-ins open (`document.open {importer}`, and extensions no
//! built-in format reads).
@@ -217,6 +217,8 @@ mod tests_dxfimport;
mod tests_hpgl;
#[cfg(test)]
mod tests_metafile;
#[cfg(test)]
mod tests_pict;
#[cfg(test)]
mod tests_pdfoutput;
File diff suppressed because one or more lines are too long
+1 -7
View File
@@ -1,6 +1,6 @@
//! DXF export for every DXF path (`document.export {format: dxf}`, `document.exportDxf`, Export As
//! → DXF Options): the options parsed from the params, one drawing per artboard with Use
//! Artboards, and the formats that can't be written (DWG, PICT) with what to use instead.
//! Artboards, and the formats that can't be written (DWG, PageMaker) with what to use instead.
use std::sync::Arc;
@@ -106,12 +106,6 @@ pub const UNSUPPORTED: &[Unsupported] = &[
extensions: &["dwg"],
hint: "DWG is not available (no openly licensed writer exists): export DXF instead, which CAD apps open",
},
Unsupported {
id: "pict",
label: "PICT",
extensions: &["pict", "pct", "pic"],
hint: "PICT is a retired format and is not supported: convert the file to PDF or PNG first",
},
Unsupported {
id: "pmd",
label: "PageMaker",
+10 -2
View File
@@ -520,6 +520,8 @@ pub const FORMATS: &[Format] = &[
reader("heif", "HEIC/HEIF", &["heic", "heif"], "image/heic", true),
// jxl-oxide reads a JPEG XL file's first frame, which is stored as PNG. Exports don't write it.
reader("jxl", "JPEG XL", &["jxl"], "image/jxl", true),
// QuickDraw pictures, read by `vectorcraft_metafile::import_pict` into paths, type and images.
reader("pict", "PICT", &["pct", "pic", "pict"], "image/x-pict", false),
];
/// Every extension `document.open` reads (the "All readable files" filter of open dialogs).
@@ -564,6 +566,9 @@ pub const OPEN_EXTS: &[&str] = &[
"dxf",
"emf",
"wmf",
"pct",
"pic",
"pict",
"plt",
"hpgl",
"hgl",
@@ -581,10 +586,10 @@ pub const OPEN_EXTS: &[&str] = &[
/// `.epsf`, `.ps`, Targa, PCX, Pixar, JPEG 2000, HEIC/HEIF or JPEG XL type, nor one for the `.pns`, `.dib` and `.rle` names of
/// PNG and BMP files (File › Open and Place still read them all). On Linux the desktop entry's EPS
/// type (shared-mime-info's) also covers `.epsf`, so file managers offer VectorCraft for it.
/// The packaging declares no type for HP-GL plot files either.
/// The packaging declares no type for HP-GL plot files or PICT pictures either.
pub const UNASSOCIATED_EXTS: &[&str] = &[
"psd", "pdd", "psb", "epsf", "ps", "pns", "dib", "rle", "tga", "vda", "icb", "vst", "pcx", "pxr", "jpf", "jpx", "jp2", "j2k", "j2c", "jpc",
"heic", "heif", "jxl", "plt", "hpgl", "hgl", "hpg",
"heic", "heif", "jxl", "plt", "hpgl", "hgl", "hpg", "pct", "pic", "pict",
];
/// The extension that picks each writable format when exporting (the format's first; PNG-8 shares
@@ -658,6 +663,9 @@ pub const PLACE_EXTS: &[&str] = &[
"dxf",
"emf",
"wmf",
"pct",
"pic",
"pict",
"plt",
"hpgl",
"hgl",
+10
View File
@@ -134,6 +134,12 @@ pub fn detect(name: &str, bytes: &[u8]) -> Option<&'static Format> {
if let Some(f) = image::guess_format(bytes).ok().and_then(image_format) {
return Some(f);
}
// A version 2 PICT is known by its version opcodes after the picture's frame, at byte 522 of a
// file or byte 10 of a bare picture (version 1 by its file's name), so a format whose
// signature starts the file is checked first.
if vectorcraft_metafile::is_pict(bytes) {
return format("pict");
}
if clipboard::looks_like_svg(&String::from_utf8_lossy(&bytes[..bytes.len().min(4096)])) {
return format("svg");
}
@@ -505,6 +511,10 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
let (doc, warnings) = super::metafile::import(bytes)?;
(doc, warnings, false)
}
"pict" => {
let (doc, warnings) = super::metafile::import_pict(bytes)?;
(doc, warnings, false)
}
"hpgl" => {
let i = vectorcraft_cad::hpgl::import(bytes).map_err(err)?;
(i.document, i.warnings, false)
+7
View File
@@ -2,6 +2,7 @@
//! `document.export {format: emf|wmf}` and Export As (one picture of an artboard, or one per
//! artboard with Use Artboards; raster effects as images, rendered at the document's raster
//! effects resolution), `document.open` and File → Place (one artboard, the picture's frame).
//! QuickDraw pictures (PICT) open and place the same way; they are never written.
use serde_json::Value;
use vectorcraft_doc::Document;
@@ -52,3 +53,9 @@ pub(super) fn import(bytes: &[u8]) -> Result<(Document, Vec<String>)> {
let i = vectorcraft_metafile::import(bytes).map_err(super::err)?;
Ok((i.document, i.warnings))
}
/// A QuickDraw picture's document and the import's warnings.
pub(super) fn import_pict(bytes: &[u8]) -> Result<(Document, Vec<String>)> {
let p = vectorcraft_metafile::import_pict(bytes).map_err(super::err)?;
Ok((p.document, p.warnings))
}
+6 -5
View File
@@ -1,5 +1,5 @@
//! DXF export through the commands: options, artboards, selection, images, and the formats that
//! can't be written (DWG, PICT).
//! can't be written (DWG, PageMaker).
use serde_json::{Value, json};
@@ -275,23 +275,24 @@ fn formats_list_dxf_and_what_can_not_be_written() {
}
assert!(r["writable"].as_array().unwrap().contains(&json!("dxf")));
let unsupported: Vec<&str> = r["unsupported"].as_array().unwrap().iter().map(|u| u["id"].as_str().unwrap()).collect();
assert_eq!(unsupported, ["dwg", "pict", "pmd"]);
assert_eq!(unsupported, ["dwg", "pmd"]);
}
#[test]
fn dwg_and_pict_say_what_to_use_instead() {
fn dwg_and_pagemaker_say_what_to_use_instead() {
let mut s = two_boards();
for p in [json!({"format": "dwg"}), json!({"path": "/tmp/plan.DWG"})] {
let e = s.execute("document.export", &p).unwrap_err().to_string();
assert!(e.contains("DWG is not available") && e.contains("DXF"), "{p}: {e}");
}
// PICT opens; it isn't written.
let e = s.execute("document.export", &json!({"format": "pict"})).unwrap_err().to_string();
assert!(e.contains("PICT is a retired format"), "{e}");
assert!(e.contains("PICT files can be opened but not written"), "{e}");
let e = s
.execute("document.open", &json!({"name": "old.pct", "dataBase64": vectorcraft_format::base64_encode(&[0u8; 600])}))
.unwrap_err()
.to_string();
assert!(e.contains("old.pct") && e.contains("PICT"), "{e}");
assert!(e.contains("not a PICT file"), "{e}");
let e = s
.execute("document.open", &json!({"name": "site.dwg", "dataBase64": vectorcraft_format::base64_encode(b"AC1032\0\0")}))
.unwrap_err()
+104
View File
@@ -0,0 +1,104 @@
//! PICT pictures through the commands: `document.open` by extension or by content, File › Place as
//! one group clipped to the picture's frame, the dialogs' filters and the format list, and files
//! that aren't pictures, or a PICT export, refused with a message.
use serde_json::json;
use vectorcraft_doc::NodeKind;
use super::*;
fn b64(bytes: &[u8]) -> String {
vectorcraft_format::base64_encode(bytes)
}
/// A version 2 PICT file of a 200 × 100 pt frame: a red rectangle and a line.
fn picture() -> Vec<u8> {
let words = |v: &[i16]| v.iter().flat_map(|x| x.to_be_bytes()).collect::<Vec<u8>>();
let mut b = vec![0; 512];
b.extend(words(&[0, 0, 0, 100, 200]));
b.extend([0x00, 0x11, 0x02, 0xFF, 0x0C, 0x00]);
b.extend(words(&[-1, 0]));
b.extend([0; 20]);
b.extend(words(&[0x1A, -1, 0, 0, 0x31, 10, 20, 50, 80, 0x20, 70, 10, 70, 190, 0xFF]));
b
}
#[test]
fn document_open_reads_a_picture() {
let mut s = Session::new();
let r = s.execute("document.open", &json!({"name": "logo.pct", "dataBase64": b64(&picture())})).unwrap();
assert_eq!((r["format"].as_str(), r["title"].as_str()), (Some("pict"), Some("logo.pct")), "{r}");
assert_eq!(r["warnings"], json!([]));
let st = s.doc().unwrap();
assert_eq!(st.doc.artboards[0].rect.size(), (200.0, 100.0).into());
let mut paths = vec![];
st.doc.walk(|n| {
if let NodeKind::Path { .. } = n.kind {
paths.push(n.clone());
}
});
assert_eq!(paths.len(), 2);
let b = paths[0].geometric_bounds().unwrap();
assert!((b.x0 - 20.0).abs() < 1e-9 && (b.width() - 60.0).abs() < 1e-9, "{b:?}");
// Opened as artwork: Save asks for a name.
assert!(st.path.is_none());
// A picture opens by its content under a name no format reads, and in each of its names.
for name in ["logo.PICT", "logo.pic", "logo.bin", "logo"] {
let r = Session::new().execute("document.open", &json!({"name": name, "dataBase64": b64(&picture())})).unwrap();
assert_eq!(r["format"].as_str(), Some("pict"), "{name}: {r}");
}
assert_eq!(detect("logo.dat", &picture()).map(|f| f.id), Some("pict"));
assert_eq!(detect("bare.dat", &picture()[512..]).map(|f| f.id), Some("pict"));
}
#[test]
fn file_place_places_a_picture_as_one_clipped_group() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap();
let p = json!({"name": "logo.pct", "dataBase64": b64(&picture()), "at": [150, 150]});
let info = s.execute("file.place.info", &p).unwrap();
assert_eq!(info["format"], "pict", "{info}");
assert!((info["width"].as_f64().unwrap() - 200.0).abs() < 1e-9, "{info}");
let r = s.execute("file.place", &p).unwrap();
assert_eq!((&r["format"], r["ids"].as_array().map(Vec::len)), (&json!("pict"), Some(1)), "{r}");
let doc = &s.doc().unwrap().doc;
let group = doc.node(vectorcraft_doc::NodeId(r["ids"][0].as_u64().unwrap())).unwrap();
assert_eq!(group.name.as_deref(), Some("logo.pct"));
let NodeKind::Group { clip: true, .. } = &group.kind else { panic!("a clipping group, got {:?}", group.kind) };
let b = group.geometric_bounds().unwrap();
assert!((b.width() - 200.0).abs() < 1e-6 && (b.height() - 100.0).abs() < 1e-6, "{b:?}");
}
#[test]
fn files_that_are_not_pictures_are_refused() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 50})).unwrap();
let e = s.execute("document.open", &json!({"name": "old.pct", "dataBase64": b64(&[0; 600])})).expect_err("refused");
assert!(e.to_string().contains("not a PICT file"), "{e}");
assert_eq!(s.doc().unwrap().doc.artboards[0].rect.size(), (100.0, 50.0).into());
assert!(s.execute("file.place", &json!({"name": "old.pct", "dataBase64": b64(&[0; 600])})).is_err());
// Other formats' files aren't taken for pictures by their content.
// A file that starts with a PNG signature and holds a picture's version opcodes at byte 522.
let mut png = b"\x89PNG\r\n\x1a\n".to_vec();
png.extend(&picture()[8..]);
assert!(detect("x.png", &png).is_none_or(|f| f.id != "pict"));
let e = s.execute("document.export", &json!({"format": "pict"})).expect_err("refused").to_string();
assert!(e.contains("PICT files can be opened but not written"), "{e}");
}
#[test]
fn the_dialogs_and_the_format_list_name_pict() {
let exts = ["pct", "pic", "pict"];
for e in exts {
assert!(OPEN_EXTS.contains(&e) && PLACE_EXTS.contains(&e) && UNASSOCIATED_EXTS.contains(&e), "{e}");
assert_eq!(format_for_name(&format!("a.{e}")).map(|f| f.id), Some("pict"), "{e}");
}
let filter = ("PICT", &exts[..]);
assert!(open_filters().any(|f| f == filter) && place_filters().any(|f| f == filter));
let r = Session::new().execute("document.formats", &json!({})).unwrap();
let f = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "pict").unwrap().clone();
assert_eq!((f["read"].as_bool(), f["write"].as_bool(), f["raster"].as_bool()), (Some(true), Some(false), Some(false)), "{f}");
assert_eq!(f["mime"], "image/x-pict");
assert!(writable_format(None, Some("x/logo.pct")).is_err());
}
+1 -1
View File
@@ -200,7 +200,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Paste Remembers Layers",
["Window", "Layers"],
None,
"{on?} (default: toggle) the document option: on, the Paste commands put objects back into the layers they were copied from (by name; a missing one is made on top), off into the current layer; one undo step when it changes (document.inspect → pasteRemembersLayers) → {on}",
"{on?} (default: toggle) the document option: on, the Paste commands put objects back into the layers or sublayers they were copied from (in that document while they are still there, else by the names of the layer and the layers around it, the topmost match first; a missing sublayer is made inside its layer, a missing top-level layer on top; a hidden or locked one is an error and nothing is pasted), off into the current layer; one undo step when it changes (document.inspect → pasteRemembersLayers) → {on}",
has_doc,
paste_remembers_layers
),
+89 -19
View File
@@ -387,6 +387,26 @@ fn keep_eps_preview(low_res_proxy_eps: bool, g: &Group, d: &Document) -> bool {
low_res_proxy_eps && g.document.is_none() && linked_eps(&g.link) && previewing(d, &g.key)
}
/// Is the linked file `f` found a DCS file? Only the start of the file is read, unless a binary EPS
/// header puts its PostScript further on; a DCS file read in full stays in `f`. A file that can't
/// be read is not one.
fn is_dcs_file(f: &mut Found) -> bool {
use super::place::dcs;
if let Some(b) = &f.bytes {
return dcs::is_dcs(b);
}
let Ok(head) = fileio::read_file_head(&f.path, dcs::HEAD_BYTES) else { return false };
if let Some(is) = dcs::head_is_dcs(&head, f.size) {
return is;
}
let Ok(b) = f.read() else { return false };
let is = dcs::is_dcs(&b);
if is {
f.bytes = Some(b);
}
is
}
/// Does group `g` of `d` show something other than its file: an image its preview, a placed
/// document nothing (its file's bytes are gone)?
fn stale(d: &Document, g: &Group) -> bool {
@@ -609,9 +629,13 @@ fn clip_shape((frame, room): Clip) -> PathData {
enum ClipTo {
/// It stays as it is.
Keep,
/// It is clipped to this path: the clipping path of the clip group holding just it changes,
/// else it goes into a new clip group.
/// It is clipped to this path (Placement Options' clip): the clipping path of the clip group
/// holding just it changes, else it goes into a new clip group.
Path(PathData),
/// It is clipped to this path, the clipping path its DCS file draws it through: as [`ClipTo::Path`],
/// except that a plain group holding just it (as [`ClipTo::Off`] leaves one) becomes a clip group
/// again.
File(PathData),
/// It goes into a new clip group of this path, inside the clip group that holds it.
Nest(PathData),
/// The clipping path of the clip group holding just it goes (a DCS file's clipping path that
@@ -673,7 +697,7 @@ fn clip_to(
match img.clip.as_ref().map(|path| path.transformed(new_xf)) {
// A clipping mask made in the document stays, with the file's clip group inside it.
Some(path) if held.is_some() && !file_held => ClipTo::Nest(path),
Some(path) => ClipTo::Path(path),
Some(path) => ClipTo::File(path),
None if file_held => ClipTo::Off,
None => ClipTo::Keep,
}
@@ -722,7 +746,7 @@ impl Nested<'_> {
let mut new = Arc::new(new);
self.update(&mut new, true);
*n = match clip {
ClipTo::Path(path) | ClipTo::Nest(path) => Arc::new(self.clip_group(new, path)),
ClipTo::Path(path) | ClipTo::File(path) | ClipTo::Nest(path) => Arc::new(self.clip_group(new, path)),
ClipTo::Keep | ClipTo::Off => new,
};
}
@@ -738,12 +762,16 @@ impl Nested<'_> {
let mut slots: Vec<(usize, Arc<Node>)> = vec![];
// The object a clip group that loses its clipping path holds alone.
let mut declipped = None;
// The clipping path and object of a plain group holding just the object, which becomes a
// clip group again.
let mut reclipped = None;
if let Some(children) = n.children() {
// A clip group holding just the object: its clipping path changes.
let clip_path = match (&n.kind, &children[..]) {
(NodeKind::Group { clip: true, .. }, [c, _]) if matches!(c.kind, NodeKind::Path { clipping: true, .. }) => Some(c),
_ => None,
};
let plain = matches!((&n.kind, &children[..]), (NodeKind::Group { clip: false, .. }, [_]));
for (i, c) in children.iter().enumerate() {
if !(nested && (self.pick)(c)) {
let mut new = Arc::clone(c);
@@ -762,14 +790,15 @@ impl Nested<'_> {
self.update(&mut new, true);
match (clip, clip_path) {
(ClipTo::Keep, _) => slots.push((i, new)),
(ClipTo::Path(clip), Some(path)) if i == 1 => {
(ClipTo::Path(clip) | ClipTo::File(clip), Some(path)) if i == 1 => {
let mut path = Node::clone(path);
if let NodeKind::Path { path: p, .. } = &mut path.kind {
*p = clip;
}
slots.extend([(0, Arc::new(path)), (1, new)]);
}
(ClipTo::Path(clip) | ClipTo::Nest(clip), _) => {
(ClipTo::File(clip), None) if plain => reclipped = Some((clip, new)),
(ClipTo::Path(clip) | ClipTo::File(clip) | ClipTo::Nest(clip), _) => {
let group = self.clip_group(new, clip);
slots.push((i, Arc::new(group)));
}
@@ -778,13 +807,21 @@ impl Nested<'_> {
}
}
}
if mask.is_none() && slots.is_empty() && declipped.is_none() {
if mask.is_none() && slots.is_empty() && declipped.is_none() && reclipped.is_none() {
return;
}
let reclipped = reclipped.map(|(clip, object)| (Arc::new(self.clipping_path(clip)), object));
let n = Arc::make_mut(n);
if let (Some(art), Some(m)) = (mask, n.extras_if_any().and_then(|e| e.mask.as_mut())) {
m.art = art;
}
// A plain group of just the object that becomes a clip group again.
if let Some((path, object)) = reclipped {
if let NodeKind::Group { children, clip } = &mut n.kind {
(*children, *clip) = (vec![path, object], true);
}
return;
}
// A clip group that stays as a plain group of just the object.
if let Some(object) = declipped {
if let NodeKind::Group { children, clip } = &mut n.kind {
@@ -803,15 +840,18 @@ impl Nested<'_> {
/// A clip group of `object` clipped to `clip`, as [`clip_image_to`] makes one in the layers.
fn clip_group(&mut self, object: Arc<Node>, clip: PathData) -> Node {
let (path_id, group_id) = (NodeId(self.next), NodeId(self.next + 1));
self.next += 2;
let none = vectorcraft_color::Paint::None;
let mut path = Node::path(path_id, clip, Appearance::basic(none.clone(), none, 0.0));
if let NodeKind::Path { clipping, .. } = &mut path.kind {
*clipping = true;
}
let path = self.clipping_path(clip);
let group_id = NodeId(self.next);
self.next += 1;
Node::new(group_id, NodeKind::Group { children: vec![Arc::new(path), object], clip: true })
}
/// A clipping path of `clip`, with the next node id.
fn clipping_path(&mut self, clip: PathData) -> Node {
let id = NodeId(self.next);
self.next += 1;
clipping_path(id, clip)
}
}
/// Placed documents `ids` show `src`, read from the file `link` points at, each placed as its
@@ -944,15 +984,41 @@ fn clip_image_to(d: &mut Document, id: NodeId, path: PathData) -> Result<()> {
new_clip_group(d, id, path)
}
/// Clip object `id` of `d` to `path`, the clipping path its DCS file draws it through: the clipping
/// path of the clip group holding just it changes, a plain group holding just it (as [`unclip`]
/// leaves one) becomes a clip group again, else it goes into a new clip group.
fn clip_file_to(d: &mut Document, id: NodeId, path: PathData) -> Result<()> {
if set_clip_path(d, id, path.clone()) {
return Ok(());
}
let plain = d.parent_of(id).filter(|g| match d.node(*g).map(|n| &n.kind) {
Some(NodeKind::Group { children, clip: false }) => matches!(&children[..], [o] if o.id == id),
_ => false,
});
let Some(g) = plain else { return new_clip_group(d, id, path) };
let clip = clipping_path(d.alloc_id(), path);
d.insert(Some(g), 0, clip)?;
if let Some(Node { kind: NodeKind::Group { clip, .. }, .. }) = d.node_mut(g) {
*clip = true;
}
Ok(())
}
/// A clipping path `id` of `path`, unpainted.
fn clipping_path(id: NodeId, path: PathData) -> Node {
let none = vectorcraft_color::Paint::None;
let mut clip = Node::path(id, path, Appearance::basic(none.clone(), none, 0.0));
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
*clipping = true;
}
clip
}
/// Put object `id` of `d` into a new clip group of `path`, where it is.
fn new_clip_group(d: &mut Document, id: NodeId, path: PathData) -> Result<()> {
let (par, index, _) = d.position(id).ok_or(EngineError::NoNode(id))?;
let image = d.remove(id)?;
let none = vectorcraft_color::Paint::None;
let mut clip = Node::path(d.alloc_id(), path, Appearance::basic(none.clone(), none, 0.0));
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
*clipping = true;
}
let clip = clipping_path(d.alloc_id(), path);
let group = Node::new(d.alloc_id(), NodeKind::Group { children: vec![std::sync::Arc::new(clip), image], clip: true });
d.insert(par, index, group)?;
Ok(())
@@ -1017,6 +1083,7 @@ fn install(d: &mut Document, ids: &[NodeId], (link, img): FileRead, force: Optio
clips.into_iter().try_for_each(|(id, clip)| match clip {
ClipTo::Keep => Ok(()),
ClipTo::Path(path) => clip_image_to(d, id, path),
ClipTo::File(path) => clip_file_to(d, id, path),
ClipTo::Nest(path) => new_clip_group(d, id, path),
ClipTo::Off => unclip(d, id),
})
@@ -1159,6 +1226,9 @@ pub fn resolve(d: &mut Document, base: LinkBase, update: bool, low_res_proxy_eps
}
(Status::Ok, Some(mut f)) => {
let preview = previewing(d, &g.key);
// A DCS file is read in full even with the proxy: its plates print and separate on
// their own inks.
let eps_preview = eps_preview && !is_dcs_file(&mut f);
if let Some((blob, hash)) = read_images.get(&f.path).filter(|_| preview && !eps_preview).cloned() {
store_image(d, &g.key, blob, true);
let link = f.link_hashed(hash, &g.link);
+6 -6
View File
@@ -244,7 +244,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Move Envelope Mesh Point",
[],
None,
"{id, index, x, y, handle?: 0 right|1 left|2 down|3 up} move a point of a mesh envelope (its handles follow), or with `handle` place that handle end at (x, y); object.mesh.movePoint, addLine and deletePoint edit mesh envelopes too",
"{id, index, x, y, handle?: 0 right|1 left|2 down|3 up} move a point of a mesh or warp envelope (its handles follow), or with `handle` place that handle end at (x, y); a warp's points are those of its grid (4×4 patches, or 8×8 or 16×16 where 4×4 draws more than 0.1% of its diagonal away from the warp), and its first edit makes it a mesh envelope on that grid in the same undo step (a warp no such grid follows is refused: Reset with Mesh first); object.mesh.movePoint, movePoints, addLine and deletePoint edit mesh and warp envelopes too",
has_doc,
env_set_mesh_point
),
@@ -263,7 +263,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Set Mesh Point Color",
[],
None,
"{id, index | points: [index…] | items: [{id, index | points}…], color? (#rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), opacity?: 0..1} colour and/or opacity of gradient mesh points (one undo step; an index out of range changes nothing)",
"{id, index | points: [index…] | items: [{id, index | points}…], color? (#rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), opacity?: 0..1} colour and/or opacity of gradient mesh points (one undo step; an index out of range changes nothing, and so does a call that changes no point, with no undo step)",
has_doc,
mesh_set_color
),
@@ -272,7 +272,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Move Mesh Point",
[],
None,
"{id, index, x, y, handle?: 0 right|1 left|2 down|3 up} move a point of a gradient mesh or mesh envelope (its handles follow), or with `handle` place that handle end at (x, y)",
"{id, index, x, y, handle?: 0 right|1 left|2 down|3 up} move a point of a gradient mesh or a mesh or warp envelope (its handles follow; a warp becomes a mesh envelope, see object.envelope.setMeshPoint), or with `handle` place that handle end at (x, y)",
has_doc,
mesh_move_point
),
@@ -281,7 +281,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Move Mesh Points",
[],
None,
"{id, points: [index…] | items: [{id, index | points}…], dx, dy} move points of gradient meshes or mesh envelopes by (dx, dy), their handles with them (one undo step; an index out of range changes nothing)",
"{id, points: [index…] | items: [{id, index | points}…], dx, dy} move points of gradient meshes or mesh or warp envelopes by (dx, dy), their handles with them (warps become mesh envelopes, see object.envelope.setMeshPoint; one undo step; an index out of range changes nothing, and so does a call that moves no point, with no undo step)",
has_doc,
mesh_move_points
),
@@ -290,7 +290,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add Mesh Line",
[],
None,
"{id, x, y, color? (#rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), tolerance?: pt (0)} add a row and a column through (x, y), inside or on the edge, to a gradient mesh (new point in `color`) or a mesh envelope; a point outside within `tolerance` of the edge adds them through the nearest point of the edge; at a point that is already a mesh point it adds no line, and records no undo step unless `color` changes a gradient mesh's point (a mesh envelope's points have no color) → {index}",
"{id, x, y, color? (#rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), tolerance?: pt (0)} add a row and a column through (x, y), inside or on the edge, to a gradient mesh (new point in `color`) or a mesh or warp envelope (a warp becomes a mesh envelope, see object.envelope.setMeshPoint); a point outside within `tolerance` of the edge adds them through the nearest point of the edge; at a point that is already a mesh point it adds no line, and records no undo step unless `color` changes a gradient mesh's point (an envelope's points have no color) → {index}",
has_doc,
mesh_add_line
),
@@ -299,7 +299,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Delete Mesh Point",
[],
None,
"{id, index} delete the mesh lines through a point of a gradient mesh or mesh envelope",
"{id, index} delete the mesh lines through a point of a gradient mesh or a mesh or warp envelope (a warp becomes a mesh envelope, see object.envelope.setMeshPoint)",
has_doc,
mesh_delete_point
),
+15 -3
View File
@@ -1,6 +1,6 @@
use super::super::object::choice;
use super::common::{fix_ids, ids_json, is_envelope, roots_param, selected_of, wrap};
use super::mesh::{handle_param, index_param, whole};
use super::mesh::{envelope_edit_grid, handle_param, index_param, mesh_envelope, warp_point_stays, whole};
use super::{
Affine, Appearance, Arc, Beside, Color, Document, EngineError, EnvelopeKind, EnvelopeMap, EnvelopeOptions, Node, NodeId, NodeKind, Paint,
PathData, Point, PreserveShape, Result, Session, Value, bad, check_numbers, f64_or, f64_req, json, live, object_param, ok, switch_or, text_param,
@@ -79,8 +79,7 @@ fn mesh_kind(map: &EnvelopeMap, maintain: bool, rows: u32, cols: u32) -> Envelop
return EnvelopeKind::Mesh { rows, cols, points: map.grid(rows, cols), handles: vec![] };
}
// The surface's points with handles along it: the grid lines keep the current shape.
let m = map.surface_mesh(rows, cols, Color::BLACK);
EnvelopeKind::Mesh { rows, cols, points: m.points.iter().map(|q| q.p).collect(), handles: m.points.iter().map(|q| q.handles).collect() }
mesh_envelope(&map.surface_mesh(rows, cols, Color::BLACK))
}
pub(super) fn env_make_mesh(s: &mut Session, p: &Value) -> Result<Value> {
@@ -428,7 +427,20 @@ pub(super) fn env_set_mesh_point(s: &mut Session, p: &Value) -> Result<Value> {
let index = index_param(p, "index", C)?;
let q = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?);
let handle = handle_param(p, C)?;
// A warp whose point or handle stays where it is stays a warp.
if warp_point_stays(&s.doc()?.doc, id, index, handle, q, C)? {
return ok();
}
s.edit("Move Envelope Point", |d, _| {
// A warp's first edited point makes it a mesh envelope on its grid, in the same step.
if let Some(n) = d.node(id)
&& matches!(&n.kind, NodeKind::Envelope { kind, .. } if matches!(**kind, EnvelopeKind::Warp { .. }))
{
let grid = envelope_edit_grid(n, C).ok_or_else(|| bad(C, "not a mesh envelope point"))??;
if let Some(NodeKind::Envelope { kind, .. }) = d.node_mut(id).map(|n| &mut n.kind) {
**kind = mesh_envelope(&grid);
}
}
let Some(NodeKind::Envelope { kind, .. }) = d.node_mut(id).map(|n| &mut n.kind) else {
return Err(bad(C, "not a mesh envelope point"));
};
+108 -30
View File
@@ -119,31 +119,90 @@ fn with_mesh<T>(s: &mut Session, p: &Value, cmd: &str, label: &str, envelopes: b
s.edit(label, move |d, _| edit_grid(d, id, cmd, envelopes, f))
}
/// Run `f` on the gradient mesh `id` of `d`; with `envelopes`, a mesh envelope's grid too (its
/// points and handles are written back: editing fills in the handles).
fn edit_grid<T>(d: &mut Document, id: NodeId, cmd: &str, envelopes: bool, f: impl FnOnce(&mut GradientMesh) -> Result<T>) -> Result<T> {
edit_kind(&mut d.node_mut(id).ok_or(EngineError::NoNode(id))?.kind, cmd, envelopes, f)
/// The grid an edit of envelope `n`'s points starts from: a mesh envelope's own grid, or a warp's
/// grid ([`live::warp_grid`]), which the edit turns into a mesh envelope. `None` for other nodes.
pub(super) fn envelope_edit_grid(n: &Node, cmd: &str) -> Option<Result<GradientMesh>> {
let NodeKind::Envelope { kind, .. } = &n.kind else { return None };
match &**kind {
EnvelopeKind::Mesh { rows, cols, points, handles } => {
Some(live::envelope_grid(*rows, *cols, points, handles).ok_or_else(|| bad(cmd, "the envelope's mesh is malformed")))
}
EnvelopeKind::Warp { .. } => {
Some(live::warp_grid(n).ok_or_else(|| bad(cmd, "this warp's points can't be edited: use Reset with Mesh first")))
}
EnvelopeKind::TopObject { .. } => None,
}
}
/// [`edit_grid`] on a node's kind.
fn edit_kind<T>(k: &mut NodeKind, cmd: &str, envelopes: bool, f: impl FnOnce(&mut GradientMesh) -> Result<T>) -> Result<T> {
match k {
NodeKind::Mesh(m) => f(m),
NodeKind::Envelope { kind, .. } if envelopes && matches!(**kind, EnvelopeKind::Mesh { .. }) => {
let EnvelopeKind::Mesh { rows, cols, points, handles } = &mut **kind else {
return Err(bad(cmd, "not a gradient mesh or mesh envelope"));
};
let mut m = live::envelope_grid(*rows, *cols, points, handles).ok_or_else(|| bad(cmd, "the envelope's mesh is malformed"))?;
let r = f(&mut m)?;
(*rows, *cols) = (m.rows, m.cols);
*points = m.points.iter().map(|q| q.p).collect();
*handles = m.points.iter().map(|q| q.handles).collect();
Ok(r)
}
_ => Err(bad(cmd, if envelopes { "not a gradient mesh or mesh envelope" } else { "not a gradient mesh" })),
/// The mesh envelope kind of grid `m`, with its points' handles.
pub(super) fn mesh_envelope(m: &GradientMesh) -> EnvelopeKind {
EnvelopeKind::Mesh {
rows: m.rows,
cols: m.cols,
points: m.points.iter().map(|q| q.p).collect(),
handles: m.points.iter().map(|q| q.handles).collect(),
}
}
/// Does placing point `index` of warp envelope `id` of `d`, or that point's `handle` end, at `q`
/// leave it where it is? Such a placement is no edit. `false` for other objects and for an index
/// out of range, whose edits run as before.
pub(super) fn warp_point_stays(d: &Document, id: NodeId, index: usize, handle: Option<usize>, q: Point, cmd: &str) -> Result<bool> {
let Some(n) = d.node(id).filter(|n| matches!(&n.kind, NodeKind::Envelope { kind, .. } if matches!(**kind, EnvelopeKind::Warp { .. }))) else {
return Ok(false);
};
let grid = envelope_grid_to_edit(n, cmd, true)?;
Ok(grid.points.get(index).is_some_and(|pt| match handle {
// The caller sends a handle's end as point plus handle; `q - p` can round away from it.
Some(h) => pt.handles.get(h).is_some_and(|v| pt.p + *v == q),
None => pt.p == q,
}))
}
/// The grid of envelope `n` that [`edit_grid`] edits (only `envelopes`), or the error it fails with.
fn envelope_grid_to_edit(n: &Node, cmd: &str, envelopes: bool) -> Result<GradientMesh> {
match envelope_edit_grid(n, cmd).filter(|_| envelopes) {
Some(grid) => grid,
None => Err(bad(cmd, if envelopes { "not a gradient mesh or mesh envelope" } else { "not a gradient mesh" })),
}
}
/// The grid of mesh `id` of `d` that [`edit_grid`] edits, as it is now.
fn grid_to_edit(d: &Document, id: NodeId, cmd: &str, envelopes: bool) -> Result<GradientMesh> {
match d.node(id) {
Some(Node { kind: NodeKind::Mesh(m), .. }) => Ok(m.clone()),
Some(n) => envelope_grid_to_edit(n, cmd, envelopes),
None => Err(EngineError::NoNode(id)),
}
}
/// Run `f` on the gradient mesh `id` of `d`; with `envelopes`, a warp or mesh envelope's grid too
/// (see [`edit_node`]).
fn edit_grid<T>(d: &mut Document, id: NodeId, cmd: &str, envelopes: bool, f: impl FnOnce(&mut GradientMesh) -> Result<T>) -> Result<T> {
edit_node(d.node_mut(id).ok_or(EngineError::NoNode(id))?, cmd, envelopes, f)
}
/// [`edit_grid`] on a node: with `envelopes`, a warp or mesh envelope's grid
/// ([`envelope_edit_grid`]) is edited too, and its points and handles are written back as a mesh
/// envelope (editing fills in the handles, and makes a warp a mesh envelope).
fn edit_node<T>(n: &mut Node, cmd: &str, envelopes: bool, f: impl FnOnce(&mut GradientMesh) -> Result<T>) -> Result<T> {
if let NodeKind::Mesh(m) = &mut n.kind {
return f(m);
}
let start = envelope_grid_to_edit(n, cmd, envelopes)?;
let mut m = start.clone();
let r = f(&mut m)?;
// A warp stays a warp when the edit changed nothing.
let warp = matches!(&n.kind, NodeKind::Envelope { kind, .. } if matches!(**kind, EnvelopeKind::Warp { .. }));
if warp && m == start {
return Ok(r);
}
if let NodeKind::Envelope { kind, .. } = &mut n.kind {
**kind = mesh_envelope(&m);
}
Ok(r)
}
/// Most mesh points one command names, over all its meshes.
const MAX_POINTS: usize = 1 << 20;
@@ -178,7 +237,8 @@ fn point_items(p: &Value, cmd: &str) -> Result<Vec<(NodeId, BTreeSet<usize>)>> {
}
/// Run `f` on each mesh point `items` names, as one undo step `label` (an index out of range
/// fails it, changing nothing); with `envelopes`, on mesh envelopes' points too.
/// fails it, changing nothing); with `envelopes`, on warp and mesh envelopes' points too. When `f`
/// changes no point, nothing changes and no undo step is recorded.
fn with_points(
s: &mut Session,
items: &[(NodeId, BTreeSet<usize>)],
@@ -187,6 +247,20 @@ fn with_points(
envelopes: bool,
mut f: impl FnMut(&mut live::MeshPoint) -> Result<()>,
) -> Result<()> {
let doc = &s.doc()?.doc;
let mut changes = false;
for (id, points) in items {
let mut m = grid_to_edit(doc, *id, cmd, envelopes)?;
for i in points {
let q = m.points.get_mut(*i).ok_or_else(|| bad(cmd, "index out of range"))?;
let before = q.clone();
f(q)?;
changes |= *q != before;
}
}
if !changes {
return Ok(());
}
s.edit(label, |d, _| {
for (id, points) in items {
edit_grid(d, *id, cmd, envelopes, |m| {
@@ -255,6 +329,10 @@ pub(super) fn mesh_move_point(s: &mut Session, p: &Value) -> Result<Value> {
let index = index_param(p, "index", C)?;
let q = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?);
let handle = handle_param(p, C)?;
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?;
if warp_point_stays(&s.doc()?.doc, id, index, handle, q, C)? {
return ok();
}
with_mesh(s, p, C, "Move Mesh Point", true, |m| {
let pt = m.points.get_mut(index).ok_or_else(|| bad(C, "index out of range"))?;
match handle {
@@ -298,12 +376,12 @@ pub(super) fn mesh_add_line(s: &mut Session, p: &Value) -> Result<Value> {
}
// The lines are added to a copy of the object. A point that is already a mesh point adds none:
// when its color stays too, nothing changes and no undo step is recorded. The grid is compared,
// not the node: a mesh envelope stored without handles takes them in any edit, and keeps no
// colors, so only its points and handles count.
let orig = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?.kind.clone();
let colored = matches!(orig, NodeKind::Mesh(_));
let mut kind = orig.clone();
let (i, same) = edit_kind(&mut kind, C, true, |m| {
// not the node: a mesh envelope stored without handles takes them in any edit, a warp becomes
// a mesh envelope, and neither keeps colors, so only their points and handles count.
let orig = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?.clone();
let colored = matches!(orig.kind, NodeKind::Mesh(_));
let mut node = orig.clone();
let (i, same) = edit_node(&mut node, C, true, |m| {
let before = m.clone();
let i = add(m)?;
let shape = |g: &GradientMesh| (g.rows, g.cols, g.points.iter().map(|q| (q.p, q.handles)).collect::<Vec<_>>());
@@ -315,10 +393,10 @@ pub(super) fn mesh_add_line(s: &mut Session, p: &Value) -> Result<Value> {
let index = s.edit("Add Mesh Line", move |d, _| {
let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?;
// Settling typed values before the edit may have changed the object: add the lines again.
if n.kind != orig {
return edit_kind(&mut n.kind, C, true, add);
if n.kind != orig.kind {
return edit_node(n, C, true, add);
}
n.kind = kind;
n.kind = node.kind;
Ok(i)
})?;
Ok(json!({ "index": index }))
File diff suppressed because one or more lines are too long
+64 -19
View File
@@ -15,8 +15,8 @@
//! commands a command runs, the steps of a `command.batch` or an `action.batch` included: a
//! misspelled `widht` no longer runs the command with its default. So are the `params` of an
//! effect (#1592): `effect.apply` and
//! `effect.setParams` answer a key the effect doesn't take, or a value of another type than its
//! default, with a parameter error ([`check_effect_params`]).
//! `effect.setParams` answer a key the effect doesn't take, or a value of another type than the
//! effect reads, with a parameter error ([`check_effect_params`]).
//!
//! A command with a parameter schema (`CommandSpec::schema`) is also checked against the schema
//! before it runs ([`crate::schemas::check`]): the schema refuses a value of the wrong type or out
@@ -263,15 +263,15 @@ pub(crate) fn unknown(id: &str, key: &str, keys: &[&str]) -> crate::EngineError
}
/// `params` of built-in effect `effect`, as command `id` was given them (#1592): only keys the
/// effect takes (those of its dialog defaults and of its params description), each of its
/// default's type, a `color` the effect can draw ([`check_effect_color`]) and a Levels or Curves
/// `channel` the effect has ([`check_effect_channel`]). Numbers out of range pass: the effect
/// clamps them when it draws. An effect plug-in resolves its own against its manifest, and an
/// unknown effect is the command's to report.
/// effect takes (those of its dialog defaults and of its params description), each of the type
/// the effect reads ([`check_effect_types`]), a `color` the effect can draw ([`check_effect_color`])
/// and a Levels or Curves `channel` the effect has ([`check_effect_channel`]). Numbers out of range
/// pass: the effect clamps them when it draws. An installed effect plug-in's are checked against its
/// manifest, and an unknown effect is the command's to report.
pub fn check_effect_params(id: &str, effect: &str, params: &Value) -> Result<()> {
let Value::Object(given) = params else { return Ok(()) };
if effect.starts_with(vectorcraft_plugins::effect::PREFIX) {
return Ok(());
return check_effect_types(id, effect, params);
}
let Some(info) = vectorcraft_render::effects::effect_info(effect) else { return Ok(()) };
let defaults = info.defaults.as_object();
@@ -282,18 +282,10 @@ pub fn check_effect_params(id: &str, effect: &str, params: &Value) -> Result<()>
keys.push(key);
}
}
for (key, v) in given {
if !keys.contains(&key.as_str()) {
return Err(unknown(id, key, &keys));
}
match defaults.and_then(|d| d.get(key)) {
Some(Value::Number(_)) if !v.is_number() => return Err(bad(id, format!("`{key}` must be a number"))),
Some(Value::Bool(_)) if !v.is_boolean() => return Err(bad(id, format!("`{key}` must be true or false"))),
// (A colour is also taken as `[r, g, b]` or as a colour object.)
Some(Value::String(_)) if v.is_number() || v.is_boolean() => return Err(bad(id, format!("`{key}` must be a string"))),
_ => {}
}
if let Some(key) = given.keys().find(|k| !keys.contains(&k.as_str())) {
return Err(unknown(id, key, &keys));
}
check_effect_types(id, effect, params)?;
// An SVG filter's XML is one drawable <filter> element.
if effect == vectorcraft_render::effects::SVG_FILTER
&& let Some(xml) = given.get("xml")
@@ -329,6 +321,59 @@ pub fn check_effect_params(id: &str, effect: &str, params: &Value) -> Result<()>
check_effect_channel(id, effect, params)
}
/// The type of the values in `params` of effect `effect`, as command `id` was given them: a key
/// the effect has a dialog default for takes a value of its default's type, a key only the
/// effect's params description names takes the type the effect reads ([`undefaulted_type`]), and
/// an installed effect plug-in's take the types its manifest gives them. The `style` of Crop Marks
/// is `roman` or `japanese`. A number given as text (`"10 pt"`), `true` for a number, `1` for a
/// switch and `null` for either are refused.
fn check_effect_types(id: &str, effect: &str, params: &Value) -> Result<()> {
let Value::Object(given) = params else { return Ok(()) };
if effect.starts_with(vectorcraft_plugins::effect::PREFIX) {
return match vectorcraft_plugins::effect::installed(effect) {
Some(plugin) => plugin.manifest().resolve_params(params).map(drop).map_err(|why| bad(id, why.to_string())),
None => Ok(()),
};
}
let Some(info) = vectorcraft_render::effects::effect_info(effect) else { return Ok(()) };
for (key, v) in given {
let want = info.defaults.get(key).cloned().or_else(|| undefaulted_type(effect, key));
match want {
Some(Value::Number(_)) if !v.is_number() => return Err(bad(id, format!("`{key}` must be a number"))),
Some(Value::Bool(_)) if !v.is_boolean() => return Err(bad(id, format!("`{key}` must be true or false"))),
// (A colour is also taken as `[r, g, b]` or as a colour object.)
Some(Value::String(_)) if v.is_number() || v.is_boolean() => return Err(bad(id, format!("`{key}` must be a string"))),
_ => {}
}
}
// Crop Marks draw one of two styles.
if effect == vectorcraft_render::effects::CROP_MARKS
&& let Some(style) = given.get("style")
&& style.as_str().and_then(vectorcraft_doc::marks::MarkStyle::parse).is_none()
{
return Err(bad(id, "`style` must be roman or japanese"));
}
Ok(())
}
/// A value of the type effect `effect` reads for `key`, a parameter its params description names
/// without a dialog default: the earlier 3D Perspective and light (`perspective`, `lightAzimuth`,
/// `lightElevation`) and Scribble `variation` are numbers, an Outline Stroke's `width` is a number
/// and a Gaussian Blur's `linear` is true or false. A Glass or Texturizer `textureImage`, an SVG
/// filter's `name` and `xml`, a Warp's `version` and the `style` of Crop Marks have checks of their
/// own; they aren't listed.
pub(crate) fn undefaulted_type(effect: &str, key: &str) -> Option<Value> {
use vectorcraft_render::effects;
let number = || Some(Value::from(0.0));
match key {
"perspective" | "lightAzimuth" | "lightElevation" if effects::is_three_d(effect) => number(),
"variation" if effect == "stylize.scribble" => number(),
"width" if effect == "path.outlineStroke" => number(),
"linear" if effect == "blur.gaussian" => Some(Value::Bool(false)),
_ => None,
}
}
/// Can an effect read `v` as its `color`: a string [`Color::from_hex`] reads (`#rrggbb`, `#rgb`,
/// `#rrggbbaa`, black, white, red, green, blue), `[r, g, b]` whose first three entries are numbers
/// (read on a 0..1 scale) or a color object (`{"model": "rgb", "r": …}`)? The effect draws its
+94 -61
View File
@@ -14,6 +14,7 @@ use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::Paint;
use vectorcraft_doc::pattern::{self, Overlap, PATTERN_EDIT_LAYER, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType, pattern_paint};
use vectorcraft_doc::swatches::PaintFn;
use vectorcraft_doc::{Document, Node, NodeId, NodeKind, Selection};
use vectorcraft_geom::{Point, Rect};
@@ -54,7 +55,15 @@ pub fn specs() -> Vec<CommandSpec> {
in_pattern_edit,
pattern_done
),
cmd!("object.pattern.cancel", "Cancel", [], None, "{} leave pattern editing mode, discarding changes", in_pattern_edit, pattern_cancel),
cmd!(
"object.pattern.cancel",
"Cancel",
[],
None,
"{} leave pattern editing mode, discarding changes; a pattern made for this edit is removed with its swatches, and every paint of it, new art's fill and stroke included, becomes none",
in_pattern_edit,
pattern_cancel
),
cmd!(
"object.pattern.saveCopy",
"Save a Copy",
@@ -78,7 +87,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Delete Pattern",
[],
None,
"{name} delete a pattern swatch (objects painted with it lose that paint)",
"{name} delete a pattern and its swatches; every paint of it, new art's fill and stroke included, becomes none → {unpainted: the number of document paints cleared}",
has_doc,
pattern_delete
),
@@ -403,26 +412,96 @@ pub(crate) fn finish_edit(d: &mut Document, sel: &mut Selection) -> Result<Strin
Ok(pe.pattern)
}
/// Leave pattern editing mode, discarding the changes (`object.pattern.cancel`). An existing
/// pattern gets its definition back, its name included when Pattern Options renamed it while it was
/// edited and no other pattern, swatch or color group has taken that name since. A new pattern is
/// removed with its swatches, and every paint of it left in the document and in the fill, stroke
/// and appearance new art takes becomes no paint ([`forget_pattern`]).
fn pattern_cancel(s: &mut Session, _: &Value) -> Result<Value> {
s.edit("Cancel Pattern Editing", |d, sel| {
let changed = s.edit("Cancel Pattern Editing", |d, sel| {
let (pe, _) = leave_edit(d, sel)?;
match pe.original {
Some(orig) => {
Some(mut orig) => {
if orig.name != pe.pattern && (d.pattern(&orig.name).is_some() || swatch_name_clash(d, &pe.pattern, &orig.name)) {
orig.name = pe.pattern.clone();
}
let back = (orig.name != pe.pattern).then(|| orig.name.clone());
if let Some(def) = d.pattern_mut(&pe.pattern) {
*def = orig;
}
if let Some(old) = &back {
rename_pattern_uses(d, &pe.pattern, old);
}
Ok(back.map(|old| (pe.pattern, Some(old))))
}
None => {
d.patterns.retain(|p| p.name != pe.pattern);
d.swatches.retain(|sw| !matches!(&sw.paint, Paint::Pattern { pattern, .. } if *pattern == pe.pattern));
forget_pattern(d, &pe.pattern);
Ok(Some((pe.pattern, None)))
}
}
Ok(())
})?;
after_edit_mode(s, None)?;
if let Some((name, to)) = changed {
map_new_art_paints(s, &mut pattern_paints(&name, to.as_deref()))?;
}
ok()
}
/// A [`PaintFn`] that puts a paint of pattern `to` in place of each paint of pattern `name`, with
/// its placement kept, or no paint for `to` `None`.
fn pattern_paints<'a>(name: &'a str, to: Option<&'a str>) -> impl FnMut(&Paint) -> Option<Paint> + 'a {
move |p| match p {
Paint::Pattern { pattern, xf } if pattern == name => Some(match to {
Some(n) => Paint::Pattern { pattern: n.to_string(), xf: *xf },
None => Paint::None,
}),
_ => None,
}
}
/// Remove pattern `name` from `d`: its definition and every swatch, grouped or not, whose paint is
/// the pattern. Every paint of the pattern left anywhere in the document (the art, opacity masks,
/// type, symbol definitions, graph designs, graphs' stored series and label paints, other patterns'
/// tiles, graphic and character styles and the art of brushes) becomes no paint. Returns the number of such paints.
fn forget_pattern(d: &mut Document, name: &str) -> usize {
d.patterns.retain(|p| p.name != name);
let is_it = |sw: &vectorcraft_color::Swatch| matches!(&sw.paint, Paint::Pattern { pattern, .. } if pattern == name);
d.swatches.retain(|sw| !is_it(sw));
for g in &mut d.swatch_groups {
g.swatches.retain(|sw| !is_it(sw));
}
super::swatch::map_all_paints(d, &mut pattern_paints(name, None))
}
/// Apply `f` to the fill and stroke new art takes and to the paints of its template appearance. A
/// change is recorded with the step just made, so Undo and Redo put these paints back with the
/// document.
fn map_new_art_paints(s: &mut Session, f: &mut PaintFn) -> Result<()> {
let start = super::colormgmt::new_art_colors_before(s)?;
let old = s.paint.clone();
let pd = &mut s.paint;
let template = pd.appearance.iter_mut().flat_map(|a| a.items.iter_mut()).map(|it| match it {
vectorcraft_doc::AppearanceItem::Fill(f) => &mut f.paint,
vectorcraft_doc::AppearanceItem::Stroke(s) => &mut s.paint,
});
for p in [&mut pd.fill, &mut pd.stroke].into_iter().chain(template) {
if let Some(q) = f(p) {
*p = q;
}
}
if s.paint != old {
super::colormgmt::record_new_art_colors(s, start)?;
// The step takes the paints the command finally leaves, after the selection's appearance
// is inherited.
let st = s.doc()?;
if st.interaction.is_none() {
let uid = st.uid;
s.batch_paint_docs.push(uid);
}
}
Ok(())
}
fn pattern_save_copy(s: &mut Session, p: &Value) -> Result<Value> {
let wanted = text_param(p, "name", "object.pattern.saveCopy")?.map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
let name = s.edit("Save a Copy", |d, _| {
@@ -524,6 +603,9 @@ fn pattern_options(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok(())
})?;
if let Some(nn) = &new_name {
map_new_art_paints(s, &mut pattern_paints(&name, Some(nn)))?;
}
Ok(json!({ "name": out_name }))
}
@@ -552,47 +634,19 @@ fn swatch_name_clash(d: &Document, old: &str, new: &str) -> bool {
}
/// Rename a pattern in swatches, paints and the edit record. Of the swatches named `old`, only the
/// pattern's own take the new name.
/// pattern's own take the new name. The paints are renamed wherever [`forget_pattern`] clears them.
fn rename_pattern_uses(d: &mut Document, old: &str, new: &str) {
let fix = |p: &mut Paint| {
if let Paint::Pattern { pattern, .. } = p
&& pattern == old
{
*pattern = new.to_string();
}
};
for sw in d.swatches.iter_mut().chain(d.swatch_groups.iter_mut().flat_map(|g| g.swatches.iter_mut())) {
for sw in d.swatches_iter_mut() {
if sw.name == old && pattern_name(&sw.paint).is_some_and(|p| p == old) {
sw.name = new.to_string();
}
fix(&mut sw.paint);
}
if let Some(pe) = &mut d.pattern_edit
&& pe.pattern == old
{
pe.pattern = new.to_string();
}
let ids = nodes_using(d, Some(old));
for id in ids {
if let Some(n) = d.node_mut(id) {
for it in n.appearance.items.iter_mut() {
match it {
vectorcraft_doc::AppearanceItem::Fill(f) => fix(&mut f.paint),
vectorcraft_doc::AppearanceItem::Stroke(s) => fix(&mut s.paint),
}
}
}
}
}
fn nodes_using(d: &Document, name: Option<&str>) -> Vec<NodeId> {
let mut ids = vec![];
d.walk(|n| {
if pattern::uses_pattern(n, name) {
ids.push(n.id);
}
});
ids
super::swatch::map_all_paints(d, &mut pattern_paints(old, Some(new)));
}
fn pattern_delete(s: &mut Session, p: &Value) -> Result<Value> {
@@ -604,29 +658,8 @@ fn pattern_delete(s: &mut Session, p: &Value) -> Result<Value> {
if editing(s).is_some_and(|e| e.pattern == name) {
return Err(bad(C, "finish editing the pattern first"));
}
let n = s.edit("Delete Pattern", |d, _| {
d.patterns.retain(|p| p.name != name);
let is_it = |sw: &vectorcraft_color::Swatch| matches!(&sw.paint, Paint::Pattern { pattern, .. } if *pattern == name);
d.swatches.retain(|sw| !is_it(sw));
for g in &mut d.swatch_groups {
g.swatches.retain(|sw| !is_it(sw));
}
let ids = nodes_using(d, Some(&name));
for id in &ids {
if let Some(n) = d.node_mut(*id) {
for it in n.appearance.items.iter_mut() {
let p = match it {
vectorcraft_doc::AppearanceItem::Fill(f) => &mut f.paint,
vectorcraft_doc::AppearanceItem::Stroke(s) => &mut s.paint,
};
if matches!(p, Paint::Pattern { pattern, .. } if *pattern == name) {
*p = Paint::None;
}
}
}
}
Ok(ids.len())
})?;
let n = s.edit("Delete Pattern", |d, _| Ok(forget_pattern(d, &name)))?;
map_new_art_paints(s, &mut pattern_paints(&name, None))?;
Ok(json!({ "unpainted": n }))
}
+3 -3
View File
@@ -6,8 +6,8 @@
//! `link` ([`super::links`]).
//! - An SVG becomes one group of its art; so does a DXF drawing, read with the DXF options
//! (`dxf`, [`fileio::dxfimport`]), and an HP-GL plot file (`vectorcraft_cad::hpgl`).
//! - An EMF or WMF picture becomes one group, clipped to its frame (`crop: "crop"`) or bounded by
//! its art (`crop: "bounding"`).
//! - An EMF, WMF or PICT picture becomes one group, clipped to its frame (`crop: "crop"`) or
//! bounded by its art (`crop: "bounding"`).
//! - An EPS file (or a PostScript .ai) becomes one group, clipped to its bounding box or bounded by
//! its art: the document an EPS file of ours carries, else what its PostScript draws, else its
//! preview ([`fileio::load`]).
@@ -72,7 +72,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Place…",
["File"],
Some("Cmd+Shift+P"),
"{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect; refused while an object of a symbol instance's art is selected), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings, image?: the id of the image of a DCS file placed in a clip group, which the Links commands take}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, HP-GL (.plt, .hpgl, .hgl, .hpg), PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns, swatches and brushes they use. A DCS file (an EPS file with one program per printing plate, DCS 1.0 or 2.0) is placed only from its path with link (else an error, as are document.open and links.embed): one linked image of its plates mixed into CMYK, clipped by the clipping path its composite is drawn through, its spot plates added as spot swatches in the process equivalents the file gives (a swatch of the same name is kept, with a warning); print separations and Separations Preview show each plate on its own ink, and plate files of their own are read from its folder. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed",
"{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect; refused while an object of a symbol instance's art is selected), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings, image?: the id of the image of a DCS file placed in a clip group, which the Links commands take}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, HP-GL (.plt, .hpgl, .hgl, .hpg), PDF/.ai, EMF/WMF and PICT (.pct, .pic, .pict; clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns, swatches and brushes they use. A DCS file (an EPS file with one program per printing plate, DCS 1.0 or 2.0) is placed only from its path with link (else an error, as are document.open and links.embed): one linked image of its plates mixed into CMYK, clipped by the clipping path its composite is drawn through, its spot plates added as spot swatches in the process equivalents the file gives (a swatch of the same name is kept, with a warning); print separations and Separations Preview show each plate on its own ink, and plate files of their own are read from its folder. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed",
has_doc,
place
),
+49
View File
@@ -39,6 +39,55 @@ pub(crate) fn is_dcs(bytes: &[u8]) -> bool {
vectorcraft_eps::dcs::read(bytes).is_some()
}
/// The most bytes read from the start of a file to look at its DCS header.
pub(crate) const HEAD_BYTES: usize = 1 << 20;
/// Does the header of the PostScript in file `head` (the first bytes of a file `size` bytes long,
/// at most [`HEAD_BYTES`]) name DCS plates? `None` when `head` can't tell: that PostScript doesn't
/// start within it (a binary EPS header that puts it further on), or its header goes on past it. A
/// `head` that holds the whole file is read as [`is_dcs`] reads it, and a binary EPS header whose
/// PostScript or TIFF preview ends past `size` names none, as [`is_dcs`] finds for the whole file.
pub(crate) fn head_is_dcs(head: &[u8], size: u64) -> Option<bool> {
const DOS_MAGIC: [u8; 4] = [0xC5, 0xD0, 0xD3, 0xC6];
if head.len() < HEAD_BYTES || head.len() as u64 >= size {
return Some(is_dcs(head));
}
let (ps, whole) = if head.starts_with(&DOS_MAGIC) {
let word = |at: usize| head.get(at..at + 4).and_then(|b| <[u8; 4]>::try_from(b).ok()).map(|b| u32::from_le_bytes(b) as usize);
let fits = |at: usize| Some((word(at)? as u64).checked_add(word(at + 4)? as u64).is_some_and(|end| end <= size));
if !fits(4)? || (word(24)? != 0 && !fits(20)?) {
return Some(false);
}
let (start, len) = (word(4)?, word(8)?);
let ps = head.get(start..).filter(|ps| !ps.is_empty())?;
(ps.get(..len).unwrap_or(ps), start.checked_add(len).is_some_and(|end| end <= head.len()))
} else {
(head, false)
};
if is_dcs(ps) {
return Some(true);
}
(whole || header_ended(ps)).then_some(false)
}
/// Does `ps`, the start of some PostScript, hold all of the header [`vectorcraft_eps::dcs::read`]
/// looks at (up to `%%EndComments`, a line that isn't a comment, or its 10,000th line)? Its last
/// line may be cut off, so it doesn't count.
fn header_ended(ps: &[u8]) -> bool {
let mut lines = ps.split(|b| matches!(b, b'\n' | b'\r')).filter(|l| !l.is_empty()).peekable();
let mut n = 0;
while let Some(line) = lines.next() {
if lines.peek().is_none() {
return false;
}
n += 1;
if line.starts_with(b"%%EndComments") || !line.starts_with(b"%") || n >= 10_000 {
return true;
}
}
false
}
/// DCS file `bytes`, read from `path` (when there is one: plate files of their own are found in its
/// folder), with every plate.
pub(crate) fn read(bytes: &[u8], path: Option<&str>) -> Result<DcsArt> {
+1
View File
@@ -212,6 +212,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[
p!("fontNamesInEnglish", "Type", "Options", "Show Font Names in English", bool),
p!("autoSizeAreaType", "Type", "Options", "Auto Size New Area Type", bool),
p!("fontPreview", "Type", "Options", "Enable in-menu font previews", bool),
p!("fontSamplesOnWhite", "Type", "Options", "Show samples on white", bool),
p!("fontPreviewSize", "Type", "Options", "Font Preview Size", choice(&[("small", "Small"), ("medium", "Medium"), ("large", "Large")])),
p!("recentFontsCount", "Type", "Options", "Number of Recent Fonts", int(1, 15)),
p!("missingGlyphProtection", "Type", "Options", "Enable Missing Glyph Protection", bool),
+45 -25
View File
@@ -5,7 +5,7 @@ use std::collections::{HashMap, HashSet, VecDeque};
use serde_json::{Value, json};
use vectorcraft_color::{Color, Gradient, GradientKind, GradientMethod, GradientStop, Paint, Swatch, SwatchGroup, tint_percent};
use vectorcraft_doc::pattern::pattern_paint;
use vectorcraft_doc::swatches::{LinkFn, color_name, map_node_solid_paints, node_colors};
use vectorcraft_doc::swatches::{LinkFn, PaintFn, color_name, map_node_paints, map_node_solid_paints, node_colors};
use vectorcraft_doc::{Document, NodeId};
use super::paint::paint_from;
@@ -681,32 +681,16 @@ pub(super) fn map_default_paints(s: &mut Session, f: &mut LinkFn) {
/// paint changed is merged into its entry ([`merge_stored`]), so fields the brush definition does
/// not read are kept at every level.
pub(super) fn map_swatch_links(d: &mut Document, f: &mut LinkFn) -> usize {
use vectorcraft_brush::{Brush, BrushKind, Colorization};
let mut n = d.map_solid_paints(f);
// Each stored entry, its brush when it parses, the brush as written before the edit (only
// when its paint changed), and whether its paint changed.
let mut lib: Vec<(Value, Option<Brush>, Value, bool)> = match d.unknown.get(vectorcraft_brush::DOC_KEY) {
Some(v) => v.as_array().into_iter().flatten().map(|e| (e.clone(), Brush::from_json(e.clone()).ok(), Value::Null, false)).collect(),
None => {
vectorcraft_brush::library(d).into_iter().map(|b| (serde_json::to_value(&b).unwrap_or_default(), Some(b), Value::Null, false)).collect()
}
};
let mut in_brushes = 0;
for (_, b, base, changed) in lib.iter_mut() {
let Some(b) = b else { continue };
let before_edit = serde_json::to_value(&*b).unwrap_or_default();
use vectorcraft_brush::{BrushKind, Colorization};
let n = d.map_solid_paints(f);
n + edit_brush_library(d, &mut |b| {
let key = match b.kind.colorization() {
Colorization::HueShift { key } => Some(key),
_ => None,
};
let before = key.map(|_| art_paint_colors(b.art()));
let in_brush = b.art_mut().into_iter().map(|art| map_node_solid_paints(art, f)).sum::<usize>();
*changed = in_brush > 0;
if *changed {
*base = before_edit;
}
in_brushes += in_brush;
let (Some(key), Some(before)) = (key, before) else { continue };
let in_brush = b.art_mut().into_iter().map(|art| map_node_solid_paints(art, &mut *f)).sum::<usize>();
let (Some(key), Some(before)) = (key, before) else { return in_brush };
// Colors are compared by hex value, as `art_colors` (Brush Options' key colors) lists them.
let same = |a: &Color, b: &Color| a.to_hex() == b.to_hex();
let after = art_paint_colors(b.art());
@@ -719,10 +703,47 @@ pub(super) fn map_swatch_links(d: &mut Document, f: &mut LinkFn) -> usize {
BrushKind::Art(a) => &mut a.colorization,
BrushKind::Scatter(s) => &mut s.colorization,
BrushKind::Pattern(p) => &mut p.colorization,
BrushKind::Calligraphic(_) | BrushKind::Bristle(_) => continue,
BrushKind::Calligraphic(_) | BrushKind::Bristle(_) => return in_brush,
};
*c = Colorization::HueShift { key };
}
in_brush
})
}
/// Apply a paint rewrite ([`Document::map_paints`]) to the document and to the art of the brushes
/// in its library ([`edit_brush_library`]). Returns the number of changed paints.
pub(super) fn map_all_paints(d: &mut Document, f: &mut PaintFn) -> usize {
let n = d.map_paints(f);
n + edit_brush_library(d, &mut |b| b.art_mut().into_iter().map(|art| map_node_paints(art, &mut *f)).sum())
}
/// Apply `edit` to each brush of the document's library, the stored entries or, with none stored,
/// the built-in library; `edit` returns how many paints of the brush's art it changed. The library
/// is stored again only when a brush's paint changed. Stored entries are stored again as they were
/// read, except that a brush whose paint changed is merged into its entry ([`merge_stored`]), so
/// fields the brush definition does not read are kept at every level. Returns the number of
/// changed paints.
fn edit_brush_library(d: &mut Document, edit: &mut dyn FnMut(&mut vectorcraft_brush::Brush) -> usize) -> usize {
use vectorcraft_brush::Brush;
// Each stored entry, its brush when it parses, the brush as written before the edit (only
// when its paint changed), and whether its paint changed.
let mut lib: Vec<(Value, Option<Brush>, Value, bool)> = match d.unknown.get(vectorcraft_brush::DOC_KEY) {
Some(v) => v.as_array().into_iter().flatten().map(|e| (e.clone(), Brush::from_json(e.clone()).ok(), Value::Null, false)).collect(),
None => {
vectorcraft_brush::library(d).into_iter().map(|b| (serde_json::to_value(&b).unwrap_or_default(), Some(b), Value::Null, false)).collect()
}
};
let mut in_brushes = 0;
for (_, b, base, changed) in lib.iter_mut() {
let Some(b) = b else { continue };
let before_edit = serde_json::to_value(&*b).unwrap_or_default();
let in_brush = edit(b);
*changed = in_brush > 0;
if *changed {
*base = before_edit;
}
in_brushes += in_brush;
}
if in_brushes > 0 {
let lib = lib
@@ -733,9 +754,8 @@ pub(super) fn map_swatch_links(d: &mut Document, f: &mut LinkFn) -> usize {
})
.collect();
d.unknown.insert(vectorcraft_brush::DOC_KEY.into(), Value::Array(lib));
n += in_brushes;
}
n
in_brushes
}
/// `new` (a definition as written after an edit) with the parts of `raw` (its stored entry) that
@@ -2108,6 +2108,60 @@ fn new_type_is_composed_with_line_end_half_width_punctuation() {
assert!(serde_json::to_value(&old).unwrap().get("mojikumi").is_none());
}
/// `text.setFormat {mojikumi}` takes each predefined Mojikumi set as one undo step, refuses any
/// other value without changing the type, and each set survives a native save and reopen and a
/// paragraph style that captures it.
#[test]
fn every_predefined_mojikumi_set_is_set_saved_and_carried_by_styles() {
use vectorcraft_doc::Mojikumi;
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "「雅楽」。"})).unwrap()["id"].as_u64().unwrap();
let para = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
NodeKind::Text(t) => t.para.clone(),
_ => panic!("text"),
};
s.execute("select.set", &json!({"ids": [id]})).unwrap();
let sets = [
("yakumonoHankaku", Mojikumi::YakumonoHankaku),
("gyomatsuYakumonoHankaku", Mojikumi::GyomatsuYakumonoHankaku),
("gyomatsuYakumonoZenkaku", Mojikumi::GyomatsuYakumonoZenkaku),
("yakumonoZenkaku", Mojikumi::YakumonoZenkaku),
("none", Mojikumi::None),
("lineEndHalf", Mojikumi::LineEndHalf),
];
for (key, set) in sets {
s.execute("text.setFormat", &json!({"mojikumi": key})).unwrap();
assert_eq!(para(&s).mojikumi, set, "{key}");
let doc = s.doc().unwrap().doc.clone();
let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap();
match &back.node(NodeId(id)).unwrap().kind {
NodeKind::Text(t) => assert_eq!(t.para.mojikumi, set, "{key} saved"),
_ => panic!("text"),
}
let style = format!("Spacing {key}");
s.execute("paraStyle.new", &json!({"name": style, "attrs": {"mojikumi": key}})).unwrap();
s.execute("text.setFormat", &json!({"mojikumi": if set == Mojikumi::None { "lineEndHalf" } else { "none" }})).unwrap();
s.execute("paraStyle.apply", &json!({"name": style, "id": id})).unwrap();
assert_eq!(para(&s).mojikumi, set, "{key} from a paragraph style");
}
// One undo step back to the set before.
s.execute("text.setFormat", &json!({"mojikumi": "yakumonoHankaku"})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(para(&s).mojikumi, Mojikumi::LineEndHalf);
// A value that is no set, or not a string, is refused and leaves the type as it was.
s.execute("text.setFormat", &json!({"mojikumi": "gyomatsuYakumonoZenkaku"})).unwrap();
for bad in [json!("YakumonoHankaku"), json!("lineEnd"), json!(""), json!(1)] {
assert!(s.execute("text.setFormat", &json!({"mojikumi": bad, "leftIndent": 12})).is_err(), "{bad}");
let p = para(&s);
assert_eq!((p.mojikumi, p.left_indent), (Mojikumi::GyomatsuYakumonoZenkaku, 0.0), "{bad}");
}
// A null value leaves the set as it is.
s.execute("text.setFormat", &json!({"mojikumi": null, "leftIndent": 12})).unwrap();
let p = para(&s);
assert_eq!((p.mojikumi, p.left_indent), (Mojikumi::GyomatsuYakumonoZenkaku, 12.0));
}
#[test]
fn vertical_point_type_keeps_its_anchor_on_the_column_centre_line() {
let mut s = Session::new();
+26 -10
View File
@@ -4,12 +4,12 @@ use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::Paint;
use vectorcraft_doc::{Node, NodeKind, Scaling};
use vectorcraft_doc::{Node, NodeKind, OrientedBox, Scaling};
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
use super::edit::{outermost, selected_roots};
use super::{
CommandSpec, NodeId, Result, Session, always, bad, bool_or, check_numbers, check_switches, checked_ids_param, cmd, color_value, f64_or,
CommandSpec, NodeId, Result, Session, always, bad, bool_or, check_numbers, check_switches, checked_ids_param, cmd, color_value, f64_or, f64_req,
find_command, finite_numbers, has_doc, index,
};
use crate::EngineError;
@@ -21,7 +21,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Free Distort",
[],
None,
"{corners: [[x,y]×4] (TL, TR, BR, BL), from?: [x0,y0,x1,y1] (default: selection bounds), ids?} projective warp of anchors and handles; a linked opacity mask's art is warped with its object, an unlinked one stays",
"{corners: [[x,y]×4] (TL, TR, BR, BL), from?: [x0,y0,x1,y1] (default: selection bounds), angle?: degrees (counter-clockwise; `from`, or the selection bounds, is then a box in the frame turned by it, as a rotated bounding box is; default 0), ids?} projective warp of anchors and handles; a linked opacity mask's art is warped with its object, an unlinked one stays",
has_doc,
distort
),
@@ -62,11 +62,14 @@ pub fn specs() -> Vec<CommandSpec> {
pub struct Projective {
m: [f64; 8],
src: Rect,
/// Page → the frame `src` is measured in.
pre: Affine,
}
impl Projective {
/// Map `src`'s corners (TL, TR, BR, BL) onto `q`.
pub fn from_rect(src: Rect, q: [Point; 4]) -> Self {
/// Map the corners (TL, TR, BR, BL) of `src`, a box that may be turned, onto the page points `q`.
pub fn from_box(bx: OrientedBox, q: [Point; 4]) -> Self {
let src = bx.rect;
let [p0, p1, p2, p3] = q;
let (dx1, dx2, dx3) = (p1.x - p2.x, p3.x - p2.x, p0.x - p1.x + p2.x - p3.x);
let (dy1, dy2, dy3) = (p1.y - p2.y, p3.y - p2.y, p0.y - p1.y + p2.y - p3.y);
@@ -80,11 +83,12 @@ impl Projective {
let b = p3.x - p0.x + h * p3.x;
let d = p1.y - p0.y + g * p1.y;
let e = p3.y - p0.y + h * p3.y;
Self { m: [a, b, p0.x, d, e, p0.y, g, h], src }
Self { m: [a, b, p0.x, d, e, p0.y, g, h], src, pre: bx.to_doc().inverse() }
}
pub fn apply(&self, p: Point) -> Point {
let [a, b, c, d, e, f, g, h] = self.m;
let p = self.pre * p;
let u = if self.src.width().abs() > 1e-12 { (p.x - self.src.x0) / self.src.width() } else { 0.0 };
let v = if self.src.height().abs() > 1e-12 { (p.y - self.src.y0) / self.src.height() } else { 0.0 };
let w = g * u + h * v + 1.0;
@@ -152,17 +156,29 @@ fn distort(s: &mut Session, p: &Value) -> Result<Value> {
if ids.is_empty() {
return Err(bad(C, "nothing to distort"));
}
let angle = p.get("angle").filter(|v| !v.is_null()).map(|_| f64_req(p, "angle", C)).transpose()?.map_or(0.0, vectorcraft_geom::normalize_deg);
let src = match p.get("from").filter(|v| !v.is_null()) {
None => s.doc()?.doc.bounds_of(&ids, false).ok_or_else(|| bad(C, "nothing to distort"))?,
None if angle == 0.0 => OrientedBox::aligned(s.doc()?.doc.bounds_of(&ids, false).ok_or_else(|| bad(C, "nothing to distort"))?),
None => {
// The selection's bounds square to the turned frame.
let doc = &s.doc()?.doc;
let (symbols, to_local) = (doc.symbol_boxes(), OrientedBox { angle, rect: Rect::ZERO }.to_doc().inverse());
let rect = ids
.iter()
.filter_map(|id| doc.node(*id))
.fold(None, |acc, n| vectorcraft_geom::union_opt(acc, n.bounds_in(&symbols, to_local, false)));
OrientedBox { angle, rect: rect.ok_or_else(|| bad(C, "nothing to distort"))? }
}
Some(v) => {
let [x0, y0, x1, y1] = finite_numbers(v).ok_or_else(|| bad(C, "from must be [x0, y0, x1, y1]"))?;
Rect::new(x0, y0, x1, y1)
OrientedBox { angle, rect: Rect::new(x0, y0, x1, y1) }
}
};
if !src.width().is_finite() || !src.height().is_finite() || src.width().abs() < 1e-9 || src.height().abs() < 1e-9 {
let r = src.rect;
if !r.width().is_finite() || !r.height().is_finite() || r.width().abs() < 1e-9 || r.height().abs() < 1e-9 {
return Err(bad(C, "cannot distort a zero-size or non-finite bounding box"));
}
let pr = Projective::from_rect(src, [tl, tr, br, bl]);
let pr = Projective::from_box(src, [tl, tr, br, bl]);
s.edit("Free Distort", |d, _| {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
+2 -1
View File
@@ -27,7 +27,8 @@ pub struct HistoryEntry {
pub(crate) named: Option<NamedStep>,
/// A number no other step takes ([`History::step`]).
serial: u64,
/// Original and converted new-art colors, only for steps that change document color mode.
/// Original and changed new-art paints, only for steps that change them: a document color mode
/// change, and a pattern Cancel, Delete or rename that clears or renames a pattern paint.
pub(crate) color_mode_paints: Option<(vectorcraft_tools::PaintDefaults, vectorcraft_tools::PaintDefaults)>,
/// Owned new-art snapshots included in both directional estimates.
color_mode_bytes: usize,
+5 -4
View File
@@ -26,7 +26,7 @@ use vectorcraft_doc::{Document, NodeId, NodeKind, Selection};
use vectorcraft_geom::Affine;
use vectorcraft_tools::{PaintDefaults, Tool};
pub use cmd::clipboard::Clipboard;
pub use cmd::clipboard::{Clipboard, SourceLayer};
pub use cmd::create::constrain_click;
pub use cmd::distortcmds::perspective_click;
pub use cmd::params::{check_keys, check_params};
@@ -217,9 +217,10 @@ pub struct Session {
/// groups: for each step, the documents ([`DocState::uid`]) whose undo groups it belongs to, and
/// the index of the document it left active when it changed the active document.
pub(crate) batch_steps: Option<Vec<(Vec<u64>, Option<usize>)>>,
/// After `command.batch` kept a color-mode step's new-art colors, until the top-level command
/// has inherited the selection's appearance: the documents ([`DocState::uid`]) whose newest
/// history step takes the colors the batch finally left.
/// After `command.batch` kept a color-mode step's new-art colors, or a pattern command recorded
/// the new-art paints it changed, until the top-level command has inherited the selection's
/// appearance: the documents ([`DocState::uid`]) whose newest history step takes the colors the
/// command finally left.
pub(crate) batch_paint_docs: Vec<u64>,
/// The composite fonts (lowercase names) that the open documents defined when the font
/// database was last brought in step with them ([`cmd::compositefonts::sync`]).
+4
View File
@@ -104,6 +104,9 @@ pub struct Prefs {
pub font_names_in_english: bool,
pub auto_size_area_type: bool,
pub font_preview: bool,
/// Type › Show samples on white: the font menus draw each family's sample in dark type on a
/// white area in every brightness (off by default).
pub font_samples_on_white: bool,
pub font_preview_size: String,
pub recent_fonts_count: u32,
pub missing_glyph_protection: bool,
@@ -427,6 +430,7 @@ impl Default for Prefs {
font_names_in_english: true,
auto_size_area_type: false,
font_preview: true,
font_samples_on_white: false,
font_preview_size: s("medium"),
recent_fonts_count: 10,
missing_glyph_protection: true,
+8 -2
View File
@@ -290,7 +290,10 @@ impl Session {
st.revision += 1;
}
st.doc = it.doc.clone();
let restore_paint = cmd == "command.batch" || it.preview.as_ref().is_some_and(|(id, _)| id == "command.batch");
// A batch, or a preview that changed the new-art paints (a pattern rename), started from
// the paints the interaction began with.
let restore_paint =
cmd == "command.batch" || it.preview.as_ref().is_some_and(|(id, _)| id == "command.batch") || it.color_mode_paints.is_some();
let paint = it.paint.clone();
it.preview_paint = None;
it.color_mode_paints = None;
@@ -423,7 +426,7 @@ impl Session {
if let Some(paint) = self
.active()
.and_then(|st| st.interaction.as_ref())
.filter(|it| it.preview.as_ref().is_some_and(|(id, _)| id == "command.batch"))
.filter(|it| it.preview.as_ref().is_some_and(|(id, _)| id == "command.batch") || it.color_mode_paints.is_some())
.map(|it| it.paint.clone())
{
self.paint = paint;
@@ -433,6 +436,9 @@ impl Session {
st.undo_interaction();
// Guides locked and slices locked or hidden during the interaction stay deselected.
deselect_hidden(&menu, &st.doc, &mut st.selection);
// With New Art Has Basic Appearance off, new art takes the appearance of the selection as
// the document has it again, not as a preview left it.
self.inherit_new_art();
Ok(())
}
+86
View File
@@ -104,6 +104,92 @@ fn handles_turn_with_the_object_and_scale_along_its_axes() {
assert!(near(selection_box(&s).rect.width(), 100.0));
}
/// Presses, drags and lets go with the current tool along `pts`, holding `mods`.
fn drag_with(s: &mut Session, pts: &[Point], mods: Mods) {
let v = ViewInfo::default();
let kinds = std::iter::once(PointerKind::Down).chain(std::iter::repeat_n(PointerKind::Drag, pts.len().saturating_sub(2)));
for (kind, p) in kinds.chain([PointerKind::Up]).zip(pts) {
s.pointer(&PointerEvent::new(kind, p.x, p.y).with_mods(mods), v).unwrap();
}
}
/// The anchors of path `id`, on the page.
fn anchors(s: &Session, id: NodeId) -> Vec<Point> {
s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().anchors().map(|(_, _, an)| an.p).collect()
}
fn has_point(pts: &[Point], p: Point) -> bool {
pts.iter().any(|q| q.distance(p) < 1e-6)
}
#[test]
fn free_transform_handles_turn_with_the_object_and_scale_along_its_axes() {
let mut s = session();
let a = turned_rect(&mut s);
let b = selection_box(&s);
// The Right handle of the turned box, dragged 50 pt further out along the object's own x axis.
let right = b.to_doc() * Handle::Right.pos(b.rect);
let to = b.to_doc() * (Handle::Right.pos(b.rect) + vectorcraft_geom::Vec2::new(50.0, 0.0));
let n = undo_len(&s);
s.select_tool("freeTransform", ViewInfo::default()).unwrap();
drag_with(&mut s, &[right, to, to], Mods::default());
assert_eq!(undo_len(&s), n + 1);
assert_eq!(s.doc().unwrap().history.undo.last().map(|e| e.label.as_str()), Some("Scale"));
// 150 × 50 along its own axes, still at exactly 45°, the left side where it was.
assert_eq!(angle(&s, a), b.angle);
let nb = selection_box(&s);
assert!(near(nb.rect.width(), 150.0) && near(nb.rect.height(), 50.0), "{nb:?}");
let left = |b: &OrientedBox| b.to_doc() * Handle::Left.pos(b.rect);
assert!(left(&nb).distance(left(&b)) < 1e-6);
s.execute("edit.undo", &json!({})).unwrap();
assert!(near(selection_box(&s).rect.width(), 100.0));
}
#[test]
fn free_transform_shears_along_the_turned_side() {
let mut s = session();
let a = turned_rect(&mut s);
let b = selection_box(&s);
let [tl, tr, br, bl] = b.corners();
// Dragging the Top handle 20 pt along the object's own top side with Cmd held once the drag
// started (a Cmd press is the temporary Selection tool) slides that side along itself; the
// bottom side stays.
let top = b.to_doc() * Handle::Top.pos(b.rect);
let along = b.to_doc() * (Handle::Top.pos(b.rect) + vectorcraft_geom::Vec2::new(20.0, 0.0));
let shift = along - top;
let v = ViewInfo::default();
s.select_tool("freeTransform", v).unwrap();
let cmd = Mods { cmd: true, ..Default::default() };
for (kind, p, m) in [(PointerKind::Down, top, Mods::default()), (PointerKind::Drag, along, cmd), (PointerKind::Up, along, cmd)] {
s.pointer(&PointerEvent::new(kind, p.x, p.y).with_mods(m), v).unwrap();
}
assert_eq!(s.doc().unwrap().history.undo.last().map(|e| e.label.as_str()), Some("Shear"));
let pts = anchors(&s, a);
for p in [tl + shift, tr + shift, br, bl] {
assert!(has_point(&pts, p), "{p:?} not in {pts:?}");
}
}
#[test]
fn free_transform_distort_starts_from_the_turned_box() {
let mut s = session();
let a = turned_rect(&mut s);
let b = selection_box(&s);
let [tl, tr, br, bl] = b.corners();
s.select_tool("freeTransform", ViewInfo::default()).unwrap();
s.set_tool_option("mode", &json!("distort"));
let to = br + vectorcraft_geom::Vec2::new(10.0, 20.0);
let n = undo_len(&s);
drag_with(&mut s, &[br, to, to], Mods::default());
assert_eq!(undo_len(&s), n + 1);
assert_eq!(s.journal.last().map(|j| j.0.as_str()), Some("object.distort"));
// The dragged corner of the turned box lands under the pointer; the other three stay.
let pts = anchors(&s, a);
for p in [tl, tr, to, bl] {
assert!(has_point(&pts, p), "{p:?} not in {pts:?}");
}
}
#[test]
fn transform_panel_sizes_follow_the_turned_box() {
let mut s = session();
+153 -1
View File
@@ -1094,7 +1094,8 @@ fn paste_remembers_layers_restores_the_source_layers() {
let art = id_of(&s.execute("layer.new", &json!({"name": "Art"})).unwrap());
let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0);
copy(&mut s, &[a, b]);
assert_eq!(s.clipboard.source_layers, [Some("Layer 1".to_string()), Some("Art".to_string())]);
let names: Vec<_> = s.clipboard.source_layers.iter().map(|l| l.as_ref().map(|l| l.names.clone())).collect();
assert_eq!(names, [Some(vec!["Layer 1".to_string()]), Some(vec!["Art".to_string()])]);
let other = id_of(&s.execute("layer.new", &json!({"name": "Other"})).unwrap());
assert_eq!(s.execute("layer.pasteRemembersLayers", &json!({})).unwrap()["on"], true);
assert!(doc(&s).paste_remembers_layers);
@@ -1121,6 +1122,157 @@ fn paste_remembers_layers_restores_the_source_layers() {
assert!(ids.iter().all(|id| doc(&s).parent_of(*id) == Some(other)));
}
/// A sublayer `name` made inside `parent`; it becomes the current layer.
fn sublayer(s: &mut Session, parent: NodeId, name: &str) -> NodeId {
id_of(&s.execute("layer.newSublayer", &json!({"parent": parent.0, "name": name})).unwrap())
}
/// The names of the layers around `id`, the top-level layer first.
fn layer_path(s: &Session, id: NodeId) -> Vec<String> {
let d = doc(s);
d.ancestry(id).unwrap().iter().filter_map(|a| d.node(*a).filter(|n| n.is_layer())).map(|l| l.name.clone().unwrap()).collect()
}
/// Art from two sublayers of the same name in different layers pastes back into each one; in
/// another document a missing sublayer is made inside its layer, and a missing layer on top with
/// the sublayer inside, in the paste's one undo step.
#[test]
fn paste_remembers_layers_keeps_same_named_sublayers_apart() {
let mut s = session();
let first = doc(&s).layers[0].id;
let sub1 = sublayer(&mut s, first, "Sub");
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
let second = id_of(&s.execute("layer.new", &json!({"name": "Layer 2", "top": true})).unwrap());
let sub2 = sublayer(&mut s, second, "Sub");
let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0);
copy(&mut s, &[a, b]);
s.execute("layer.pasteRemembersLayers", &json!({"on": true})).unwrap();
s.execute("layer.setCurrent", &json!({"id": first.0})).unwrap();
let ids = ids_of(&s.execute("edit.paste", &json!({})).unwrap());
let parents: Vec<_> = ids.iter().map(|id| doc(&s).parent_of(*id)).collect();
assert_eq!(parents, [Some(sub1), Some(sub2)]);
new_doc(&mut s);
s.execute("layer.pasteRemembersLayers", &json!({"on": true})).unwrap();
let before = doc(&s).clone();
let ids = ids_of(&s.execute("edit.paste", &json!({})).unwrap());
let paths: Vec<_> = ids.iter().map(|id| layer_path(&s, *id)).collect();
assert_eq!(paths, [["Layer 1", "Sub"], ["Layer 2", "Sub"]]);
assert_eq!(doc(&s).layers.len(), 2, "Layer 1 is kept; Layer 2 is new");
assert_eq!(doc(&s).layers[0].children().unwrap().len(), 1, "Sub is made inside Layer 1");
let after = doc(&s).clone();
// Pasting again finds the layers made.
let again = ids_of(&s.execute("edit.paste", &json!({})).unwrap());
let parents: Vec<_> = again.iter().map(|id| doc(&s).parent_of(*id)).collect();
assert_eq!(parents, ids.iter().map(|id| doc(&s).parent_of(*id)).collect::<Vec<_>>());
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(*doc(&s), after);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(*doc(&s), before, "one undo step takes the art and the layers made for it");
}
/// Art whose sublayer was deleted after the copy pastes into a new sublayer of that name inside
/// the same layer, not into a sublayer of that name in another layer.
#[test]
fn paste_remembers_layers_makes_a_deleted_sublayer_again_inside_its_layer() {
let mut s = session();
let first = doc(&s).layers[0].id;
let sub = sublayer(&mut s, first, "Sub");
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
let second = id_of(&s.execute("layer.new", &json!({"name": "Layer 2", "top": true})).unwrap());
let elsewhere = sublayer(&mut s, second, "Sub");
copy(&mut s, &[a]);
s.execute("layer.delete", &json!({"id": sub.0})).unwrap();
s.execute("layer.pasteRemembersLayers", &json!({"on": true})).unwrap();
let ids = ids_of(&s.execute("edit.paste", &json!({})).unwrap());
let made = doc(&s).parent_of(ids[0]).unwrap();
assert_ne!(made, elsewhere);
assert_eq!(doc(&s).parent_of(made), Some(first));
assert_eq!(layer_path(&s, ids[0]), ["Layer 1", "Sub"]);
}
/// Art whose sublayer was moved into another layer after the copy pastes into that sublayer where
/// it is now; no sublayer is made where it was.
#[test]
fn paste_remembers_layers_follows_a_moved_sublayer() {
let mut s = session();
let first = doc(&s).layers[0].id;
let sub = sublayer(&mut s, first, "Sub");
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
let second = id_of(&s.execute("layer.new", &json!({"name": "Layer 2", "top": true})).unwrap());
copy(&mut s, &[a]);
s.execute("layer.move", &json!({"id": sub.0, "target": second.0})).unwrap();
s.execute("layer.pasteRemembersLayers", &json!({"on": true})).unwrap();
let ids = ids_of(&s.execute("edit.paste", &json!({})).unwrap());
assert_eq!(doc(&s).parent_of(ids[0]), Some(sub));
assert_eq!(layer_path(&s, ids[0]), ["Layer 2", "Sub"]);
assert!(node(&s, first).children().unwrap().is_empty(), "no Sub is made in Layer 1");
}
/// In another document with layers of the same names, art goes into the topmost chain of layers
/// that matches the most names, a shown and unlocked one before a hidden or locked one.
#[test]
fn paste_remembers_layers_takes_the_topmost_open_match_of_repeated_names() {
let mut s = session();
let first = doc(&s).layers[0].id;
sublayer(&mut s, first, "Sub");
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
copy(&mut s, &[a]);
new_doc(&mut s);
s.execute("layer.pasteRemembersLayers", &json!({"on": true})).unwrap();
let lower = doc(&s).layers[0].id;
let lower_sub = sublayer(&mut s, lower, "Sub");
s.execute("layer.new", &json!({"name": "Sub", "top": true})).unwrap();
let upper = id_of(&s.execute("layer.new", &json!({"name": "Layer 1", "top": true})).unwrap());
// The upper Layer 1 has no Sub, and a top-level Sub is not inside a Layer 1: the lower chain
// matches both names.
let ids = ids_of(&s.execute("edit.paste", &json!({})).unwrap());
assert_eq!(doc(&s).parent_of(ids[0]), Some(lower_sub));
let upper_sub = sublayer(&mut s, upper, "Sub");
let ids = ids_of(&s.execute("edit.paste", &json!({})).unwrap());
assert_eq!(doc(&s).parent_of(ids[0]), Some(upper_sub), "the topmost of two full matches");
s.execute("layer.setProps", &json!({"id": upper_sub.0, "locked": true})).unwrap();
let ids = ids_of(&s.execute("edit.paste", &json!({})).unwrap());
assert_eq!(doc(&s).parent_of(ids[0]), Some(lower_sub), "an unlocked match before a locked one");
}
/// A remembered layer or sublayer that is hidden or locked, or inside a layer that is, refuses the
/// whole paste: the document, the selection and the undo history stay as they were.
#[test]
fn paste_remembers_layers_refuses_a_hidden_or_locked_layer() {
let (locked, hidden) = (json!({"locked": true}), json!({"visible": false}));
for (on_sub, props) in [(true, &locked), (true, &hidden), (false, &locked), (false, &hidden)] {
let mut s = session();
let first = doc(&s).layers[0].id;
let sub = sublayer(&mut s, first, "Sub");
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
let other = id_of(&s.execute("layer.new", &json!({"name": "Other", "top": true})).unwrap());
let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0);
copy(&mut s, &[b, a]);
s.execute("layer.pasteRemembersLayers", &json!({"on": true})).unwrap();
let mut p = props.clone();
p["id"] = json!(if on_sub { sub.0 } else { first.0 });
s.execute("layer.setProps", &p).unwrap();
s.execute("layer.setCurrent", &json!({"id": other.0})).unwrap();
let before = s.doc().unwrap().clone();
assert!(s.execute("edit.paste", &json!({})).is_err(), "{p}");
assert_eq!(s.doc().unwrap().doc, before.doc, "{p}");
assert_eq!(s.doc().unwrap().selection, before.selection, "{p}");
assert_eq!(s.doc().unwrap().history.undo.len(), before.history.undo.len(), "{p}");
}
// In another document, a locked layer of the copied layer's name is refused too.
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
copy(&mut s, &[a]);
new_doc(&mut s);
s.execute("layer.pasteRemembersLayers", &json!({"on": true})).unwrap();
let locked = doc(&s).layers[0].id;
id_of(&s.execute("layer.new", &json!({"name": "Flyer"})).unwrap());
s.execute("layer.setProps", &json!({"id": locked.0, "locked": true})).unwrap();
let before = doc(&s).clone();
assert!(s.execute("edit.paste", &json!({})).is_err());
assert_eq!(*doc(&s), before, "no second Layer 1 is made");
}
#[test]
fn paste_remembers_layers_survives_save_and_old_files_load_without_it() {
let mut s = session();
+116
View File
@@ -357,6 +357,42 @@ fn a_file_that_lost_its_clipping_path_unclips_the_image() {
);
}
#[test]
fn a_file_that_gains_its_clipping_path_again_clips_the_group_that_stayed() {
let dir = temp_dir("update-regained-clip");
let path = write(&dir, "pic.eps", &two_plates());
let mut s = session();
let r = s.execute("file.place", &json!({ "path": path, "at": [100, 100] })).unwrap();
let placed = NodeId(r["ids"][0].as_u64().unwrap());
let id = image_of(&s, &r["ids"][0]).id;
let other = s.execute("file.place", &json!({ "path": path, "at": [200, 100] })).unwrap();
s.execute("symbol.new", &json!({ "name": "Pic", "ids": [other["ids"][0]] })).unwrap();
let unclipped = String::from_utf8(two_plates()).unwrap().replacen("closepath clip newpath", "closepath newpath ", 1);
// The symbol's art: how deep the image is.
let depth = |s: &Session| {
fn find(n: &Node, at: usize) -> Option<usize> {
if matches!(n.kind, NodeKind::Image(_)) {
return Some(at);
}
n.children().and_then(|ch| ch.iter().find_map(|c| find(c, at + 1)))
}
find(&s.doc().unwrap().doc.symbols.iter().find(|w| w.name == "Pic").expect("the symbol").art, 0)
};
let clipped_depth = depth(&s);
for _ in 0..2 {
std::fs::write(&path, unclipped.as_bytes()).unwrap();
s.execute("links.update", &json!({ "ids": [id.0] })).unwrap();
std::fs::write(&path, two_plates()).unwrap();
s.execute("links.update", &json!({ "ids": [id.0] })).unwrap();
let doc = &s.doc().unwrap().doc;
assert_eq!(doc.parent_of(id), Some(placed), "the placed group holds the image itself");
let NodeKind::Group { children, clip: true } = &doc.node(placed).unwrap().kind else { panic!("a clip group again") };
assert!(matches!(&children[..], [c, im] if matches!(c.kind, NodeKind::Path { clipping: true, .. }) && im.id == id));
assert_eq!(depth(&s), clipped_depth, "the symbol's image is not nested deeper");
assert!(symbol_clip_width(&s, "Pic").is_some(), "the symbol's image is clipped again");
}
}
/// The kind of group `g` is, as (clip, the kinds of its children are just one image).
fn plain_group_of_image(g: &Node) -> Option<(bool, bool)> {
match &g.kind {
@@ -632,6 +668,86 @@ fn a_saved_document_reads_the_plates_again_and_prints_the_spot_ink() {
assert_eq!(inks, ["Cyan", "Magenta", "Yellow", "Black", "PANTONE 185 C"]);
}
#[test]
fn a_saved_document_reads_the_plates_again_with_the_low_resolution_eps_proxy() {
let dir = temp_dir("saved-proxy");
// Plates wider than a preview, so the saved document keeps a preview of the image.
let wide = |gray: u8| {
let hex = format!("{gray:02x}").repeat(300);
format!(
"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 20 10\n%%EndComments\n20 10 scale\n300 1 8 [300 0 0 -1 0 1] <{hex}> image\nshowpage\n%%Trailer\n%%EOF\n"
)
};
let file = single_file(&[("Cyan", wide(0)), ("PANTONE 185 C", wide(128))], "%%CMYKCustomColor: 0 .91 .76 0 (PANTONE 185 C)\n");
let path = write(&dir, "pic.eps", &file);
let saved = dir.join("doc.vectorcraft").to_string_lossy().into_owned();
let mut s = session();
s.execute("file.place", &json!({ "path": path })).unwrap();
s.execute("document.save", &json!({ "path": saved })).unwrap();
let mut s = Session::new();
s.execute("prefs.set", &json!({"key": "lowResProxyEps", "value": true})).unwrap();
s.execute("document.open", &json!({ "path": saved })).unwrap();
let list = s.execute("links.list", &json!({})).unwrap();
assert_eq!(list["links"][0]["format"], "DCS", "{list}");
let id = NodeId(list["links"][0]["id"].as_u64().unwrap());
let NodeKind::Image(im) = &s.doc().unwrap().doc.node(id).unwrap().kind else { not_an_image() };
assert!(s.doc().unwrap().doc.images[&im.key].separated().is_some(), "the plates, read from the file again");
}
#[test]
fn a_dcs_header_is_found_at_the_start_of_a_file_or_behind_a_binary_eps_header() {
use crate::cmd::place::dcs::{HEAD_BYTES, head_is_dcs, is_dcs};
// A whole file, as `is_dcs` reads it.
let whole = |b: &[u8]| {
let is = head_is_dcs(b, b.len() as u64);
assert_eq!(is, Some(is_dcs(b)));
is
};
let ps = two_plates();
assert_eq!(whole(&ps), Some(true));
assert_eq!(whole(b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 20 10\n%%EndComments\n"), Some(false));
let binary = |start: u32| {
let mut b = vec![0xC5, 0xD0, 0xD3, 0xC6];
b.extend(start.to_le_bytes());
b.extend((ps.len() as u32).to_le_bytes());
b.resize(start as usize, 0);
b.extend(&ps);
b
};
assert_eq!(whole(&binary(30)), Some(true));
// A binary header whose PostScript or TIFF preview ends past the end of the file names none.
let mut cut = binary(30);
cut[8..12].copy_from_slice(&(64u32 << 20).to_le_bytes());
assert_eq!(whole(&cut), Some(false));
let mut tiff = binary(30);
tiff[20..24].copy_from_slice(&30u32.to_le_bytes());
tiff[24..28].copy_from_slice(&(64u32 << 20).to_le_bytes());
assert_eq!(whole(&tiff), Some(false));
// A file longer than the bytes read, its PostScript running past them: its header is read from
// the part there is, unless the header puts the PostScript or the preview past the file's end.
let mut big = binary(30);
big.resize(HEAD_BYTES, b' ');
big[8..12].copy_from_slice(&(64u32 << 20).to_le_bytes());
assert_eq!(head_is_dcs(&big, 30 + (64 << 20)), Some(true));
assert_eq!(head_is_dcs(&big, 30 + (64 << 20) - 1), Some(false));
big[20..24].copy_from_slice(&30u32.to_le_bytes());
big[24..28].copy_from_slice(&(65u32 << 20).to_le_bytes());
assert_eq!(head_is_dcs(&big, 30 + (64 << 20)), Some(false));
// PostScript that starts past the bytes read: the whole file is needed.
let far = binary(HEAD_BYTES as u32 + 10);
assert_eq!(head_is_dcs(&far[..HEAD_BYTES], far.len() as u64), None);
// A header that goes on past the bytes read may name its plates further on.
let mut long = b"%!PS-Adobe-3.0 EPSF-3.0\n".to_vec();
while long.len() < HEAD_BYTES {
long.extend(format!("%%Title: {}\n", "x".repeat(190)).as_bytes());
}
long.truncate(HEAD_BYTES);
assert_eq!(head_is_dcs(&long, HEAD_BYTES as u64 + 1), None);
// One that ended within them names none.
long[30..45].copy_from_slice(b"\n%%EndComments\n");
assert_eq!(head_is_dcs(&long, HEAD_BYTES as u64 + 1), Some(false));
}
#[test]
fn dcs_transparency_marks_transparent_and_overlapped_dcs_images() {
let dir = temp_dir("transparency");
+233
View File
@@ -151,6 +151,239 @@ fn mesh_handles_turn_with_the_envelope_and_round_trip() {
assert!(matches!(n.kind, NodeKind::Envelope { kind, .. } if matches!(&*kind, EnvelopeKind::Mesh { handles, .. } if handles.is_empty())));
}
/// The kind of envelope `e`.
fn kind(s: &Session, e: NodeId) -> EnvelopeKind {
match node(s, e).kind {
NodeKind::Envelope { kind, .. } => *kind,
k => panic!("{k:?}"),
}
}
fn undo_len(s: &Session) -> usize {
s.doc().unwrap().history.undo.len()
}
/// Moving a point of a warp envelope makes it a mesh envelope on the warp's grid with that point
/// moved, as one undo step: the other points and every handle stay where the warp had them, and
/// the shape away from the moved point stays the warp's. Undo gives back the warp, Redo the edit.
#[test]
fn moving_a_warp_point_makes_it_a_mesh_envelope_in_one_step() {
let mut s = session();
let (_, e) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({"style": "arc", "bend": 50}));
let warp = node(&s, e);
let grid = live::warp_grid(&warp).unwrap();
assert_eq!((grid.rows, grid.cols), (4, 4));
let before = EnvelopeMap::of(&warp).unwrap();
let n = undo_len(&s);
// An interior point.
s.execute("object.mesh.movePoint", &json!({"id": e.0, "index": 6, "x": 160, "y": 120})).unwrap();
assert_eq!(undo_len(&s), n + 1, "one undo step");
let (rows, cols, points, handles) = mesh(&s, e);
assert_eq!((rows, cols, points.len(), handles.len()), (4, 4, 25, 25));
for (i, q) in grid.points.iter().enumerate() {
let want = if i == 6 { Point::new(160.0, 120.0) } else { q.p };
assert!(points[i].distance(want) < 1e-9, "point {i}: {:?} vs {want:?}", points[i]);
assert_eq!(handles[i], q.handles, "handles of point {i}");
}
let moved = node(&s, e);
let after = EnvelopeMap::of(&moved).unwrap();
let b = before.src();
let tol = b.width().hypot(b.height()) * 1e-3;
for (u, v) in [(0.9, 0.9), (0.6, 0.875), (1.0, 0.5), (0.0, 1.0)] {
assert!(after.at(u, v).distance(before.at(u, v)) <= tol, "({u}, {v}): {:?} vs {:?}", after.at(u, v), before.at(u, v));
}
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(node(&s, e), warp, "Undo gives back the warp, style and bend included");
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(mesh(&s, e).2[6], Point::new(160.0, 120.0));
// A corner of a warp, and a handle with object.envelope.setMeshPoint.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("object.mesh.movePoints", &json!({"id": e.0, "points": [0], "dx": -10, "dy": -5})).unwrap();
assert!(mesh(&s, e).2[0].distance(grid.points[0].p + Vec2::new(-10.0, -5.0)) < 1e-9);
s.execute("edit.undo", &json!({})).unwrap();
let end = grid.points[12].p + Vec2::new(30.0, -30.0);
s.execute("object.envelope.setMeshPoint", &json!({"id": e.0, "index": 12, "x": end.x, "y": end.y, "handle": 0})).unwrap();
let (_, _, points, handles) = mesh(&s, e);
assert!((points[12] + handles[12][live::H_RIGHT]).distance(end) < 1e-9);
assert_eq!(handles[12][live::H_LEFT], grid.points[12].handles[live::H_LEFT]);
assert_eq!(undo_len(&s), n + 1);
}
/// After a warp's point is edited, the envelope keeps its content, options and fidelity; it saves
/// and opens again as it is; Reset with Mesh with Maintain Shape keeps the edited shape and Reset
/// with Warp gives the chosen style; Release gives back the edited mesh as its shape and Expand
/// keeps the edited distortion.
#[test]
fn an_edited_warp_saves_resets_releases_and_expands_as_edited() {
let mut s = session();
let (_, e) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({"style": "flag", "bend": 40}));
s.execute("object.envelope.options", &json!({"fidelity": 80, "distortAppearance": false})).unwrap();
let warp = node(&s, e);
// The middle of the bottom edge, 30 down.
s.execute("object.mesh.movePoints", &json!({"id": e.0, "points": [22], "dx": 0, "dy": 30})).unwrap();
let edited = node(&s, e);
let (NodeKind::Envelope { content: c0, options: o0, fidelity: f0, .. }, NodeKind::Envelope { content: c1, options: o1, fidelity: f1, .. }) =
(&warp.kind, &edited.kind)
else {
panic!("envelopes")
};
assert_eq!((c0, o0, f0), (c1, o1, f1), "content, options and fidelity stay");
let d = s.doc().unwrap().doc.clone();
let back = vectorcraft_format::load(&vectorcraft_format::save(&d, false)).unwrap();
assert_eq!(back.node(e), d.node(e), "a save opens again as edited");
// Reset with Mesh, keeping the shape: the edited points.
let (_, _, points, _) = mesh(&s, e);
s.execute("object.envelope.resetWithMesh", &json!({"maintainShape": true})).unwrap();
let (rows, cols, kept, _) = mesh(&s, e);
assert_eq!((rows, cols), (4, 4));
for (p, q) in points.iter().zip(&kept) {
assert!(p.distance(*q) < 1e-6, "{p:?} vs {q:?}");
}
// Reset with Warp: the chosen style replaces the edit.
s.execute("object.envelope.resetWithWarp", &json!({"style": "bulge", "bend": 30})).unwrap();
assert!(matches!(kind(&s, e), EnvelopeKind::Warp { style, bend, .. } if style == "bulge" && bend == 30.0));
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(node(&s, e), edited);
// Release: the content and the edited mesh as the envelope's shape.
s.execute("select.set", &json!({"ids": [e.0]})).unwrap();
let ids = s.execute("object.envelope.release", &json!({})).unwrap();
let ids: Vec<NodeId> = ids["ids"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect();
let shape = ids.iter().find_map(|id| match node(&s, *id).kind {
NodeKind::Mesh(m) => Some(m),
_ => None,
});
let shape = shape.expect("the envelope's shape");
assert_eq!(shape.points.iter().map(|q| q.p).collect::<Vec<_>>(), points);
s.execute("edit.undo", &json!({})).unwrap();
// Expand: the distorted content follows the moved point, unlike the warp's.
let expanded = |s: &mut Session| {
s.execute("select.set", &json!({"ids": [e.0]})).unwrap();
s.execute("object.envelope.expand", &json!({})).unwrap();
let b = node(s, e).geometric_bounds().unwrap();
s.execute("edit.undo", &json!({})).unwrap();
b
};
let edited_bounds = expanded(&mut s);
s.execute("edit.undo", &json!({})).unwrap();
let warp_bounds = expanded(&mut s);
assert!(edited_bounds.y1 > warp_bounds.y1 + 5.0, "{edited_bounds:?} vs {warp_bounds:?}");
}
/// An edit of a warp's points that fails (an index out of range, here on the second of two
/// warps) changes neither warp and adds no undo step; a warp that no grid follows closely enough
/// shows no points and is refused, and so is a top object envelope.
#[test]
fn a_warp_point_edit_that_fails_changes_nothing() {
let mut s = session();
let (_, e) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({"style": "arc", "bend": 50}));
let (_, f) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({"style": "wave", "bend": 50}));
let (we, wf, n) = (node(&s, e), node(&s, f), undo_len(&s));
let r = s.execute("object.mesh.movePoints", &json!({"items": [{"id": e.0, "points": [0]}, {"id": f.0, "points": [3, 99999]}], "dx": 5, "dy": 5}));
assert!(r.is_err(), "{r:?}");
let r = s.execute("object.envelope.setMeshPoint", &json!({"id": e.0, "index": 25, "x": 0, "y": 0}));
assert!(r.is_err(), "{r:?}");
assert_eq!((node(&s, e), node(&s, f), undo_len(&s)), (we, wf, n), "nothing changed");
// The old point map's fisheye: no grid of up to 16×16 patches follows it.
let (_, g) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({"style": "fisheye", "bend": 100, "version": 0}));
let old = node(&s, g);
assert!(live::warp_grid(&old).is_none());
assert!(live::envelope_overlay(&old).unwrap().1.is_none(), "no points show");
let n = undo_len(&s);
for (cmd, p) in [
("object.mesh.movePoint", json!({"id": g.0, "index": 0, "x": 0, "y": 0})),
("object.envelope.setMeshPoint", json!({"id": g.0, "index": 0, "x": 0, "y": 0})),
("object.mesh.addLine", json!({"id": g.0, "x": 200, "y": 150})),
] {
let r = s.execute(cmd, &p);
assert!(
matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg == "this warp's points can't be edited: use Reset with Mesh first"),
"{cmd}: {r:?}"
);
}
assert_eq!((node(&s, g), undo_len(&s)), (old, n));
}
/// An edit that moves no point of a warp (no points named, a move by nothing, a point placed where
/// it is, a line added through one of its points) leaves it a warp and records no undo step.
#[test]
fn a_warp_edit_that_moves_nothing_leaves_the_warp() {
let mut s = session();
let (_, e) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({"style": "arc", "bend": 50}));
let (warp, n) = (node(&s, e), undo_len(&s));
let grid = live::warp_grid(&warp).unwrap();
s.execute("object.mesh.movePoints", &json!({"id": e.0, "points": [], "dx": 10, "dy": 0})).unwrap();
s.execute("object.mesh.movePoints", &json!({"id": e.0, "points": [3, 6], "dx": 0, "dy": 0})).unwrap();
assert_eq!((node(&s, e), undo_len(&s)), (warp.clone(), n));
let q = grid.points[6].clone();
s.execute("object.mesh.movePoint", &json!({"id": e.0, "index": 6, "x": q.p.x, "y": q.p.y})).unwrap();
s.execute("object.envelope.setMeshPoint", &json!({"id": e.0, "index": 6, "x": q.p.x, "y": q.p.y})).unwrap();
// A line added through a point of the warp's grid adds none.
let r = s.execute("object.mesh.addLine", &json!({"id": e.0, "x": q.p.x, "y": q.p.y})).unwrap();
assert_eq!(r["index"], json!(6));
assert_eq!((node(&s, e), undo_len(&s)), (warp, n));
// An unknown mesh still fails.
assert!(s.execute("object.mesh.movePoints", &json!({"id": 99999, "points": [], "dx": 1, "dy": 0})).is_err());
}
/// A handle placed at its own end, sent as point plus handle, leaves a warp a warp, even where
/// `(p + v) - p` rounds to a value other than `v`.
#[test]
fn a_warp_handle_placed_at_its_end_leaves_the_warp() {
let mut s = session();
let (_, e) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({"style": "arc", "bend": 50}));
let (warp, n) = (node(&s, e), undo_len(&s));
let grid = live::warp_grid(&warp).unwrap();
let mut rounds = 0;
for (index, pt) in grid.points.iter().enumerate() {
for (h, v) in pt.handles.iter().enumerate() {
let end = pt.p + *v;
if end - pt.p != *v {
rounds += 1;
}
for cmd in ["object.mesh.movePoint", "object.envelope.setMeshPoint"] {
s.execute(cmd, &json!({"id": e.0, "index": index, "x": end.x, "y": end.y, "handle": h})).unwrap();
assert_eq!((node(&s, e), undo_len(&s)), (warp.clone(), n), "{cmd}: point {index}, handle {h}");
}
}
}
assert!(rounds > 0, "the grid has a handle whose end does not give the handle back");
}
/// With Direct Selection, a selected warp's point drags as `object.mesh.movePoint`: the preview
/// makes it a mesh envelope, Escape gives back the warp with no undo step, and a finished drag is
/// one undo step. While its contents are edited, its points don't take the press.
#[test]
fn direct_selection_drags_a_warps_point_and_escape_gives_it_back() {
let mut s = session();
let (_, e) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({"style": "arc", "bend": 50}));
let warp = node(&s, e);
let corner = live::warp_grid(&warp).unwrap().points[24].p;
s.execute("select.set", &json!({"ids": [e.0]})).unwrap();
let v = ViewInfo::default();
s.select_tool("directSelection", v).unwrap();
let n = undo_len(&s);
s.pointer(&PointerEvent::new(PointerKind::Down, corner.x, corner.y), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, corner.x + 20.0, corner.y + 10.0), v).unwrap();
assert!(mesh(&s, e).2[24].distance(corner + Vec2::new(20.0, 10.0)) < 1e-9, "the preview moves the point");
s.tool_key(vectorcraft_tools::ToolKey::Escape, vectorcraft_tools::Mods::default(), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, corner.x + 20.0, corner.y + 10.0), v).unwrap();
assert_eq!((node(&s, e), undo_len(&s)), (warp.clone(), n), "Escape gives back the warp");
s.pointer(&PointerEvent::new(PointerKind::Down, corner.x, corner.y), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, corner.x + 20.0, corner.y + 10.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, corner.x + 20.0, corner.y + 10.0), v).unwrap();
assert_eq!(undo_len(&s), n + 1);
assert!(mesh(&s, e).2[24].distance(corner + Vec2::new(20.0, 10.0)) < 1e-9);
// Edit Contents: the press doesn't take the envelope's point.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("object.envelope.editContents", &json!({"editing": true})).unwrap();
s.execute("select.set", &json!({"ids": [e.0]})).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Down, corner.x, corner.y), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, corner.x + 20.0, corner.y + 10.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, corner.x + 20.0, corner.y + 10.0), v).unwrap();
assert!(matches!(kind(&s, e), EnvelopeKind::Warp { .. }), "{:?}", kind(&s, e));
}
#[test]
fn reset_with_mesh_keeps_the_warp_shape_along_the_grid() {
let mut s = session();
+36
View File
@@ -576,6 +576,42 @@ fn update_links_reads_files_again_inside_symbols_patterns_and_masks() {
assert!(images(&s).iter().all(|(_, _, _, size)| *size == (20, 10)), "{:?}", images(&s));
}
/// An image that Placement Options clip, alone in a group the user made, goes into a new clip group
/// inside that group when its file grows, in the layers and in a symbol's art; the user's group
/// stays a plain group.
#[test]
fn placement_options_clip_an_image_alone_in_a_group_inside_that_group() {
let dir = Folder::new("clip-in-group");
let photo = dir.file("photo.png");
write(&photo, &png(20, 10, RED));
let mut s = session();
let mut groups = vec![];
for _ in 0..2 {
let id = place(&mut s, &photo);
s.execute("links.placementOptions", &json!({"ids": [id.0], "preserve": "fill", "clip": true})).unwrap();
let g = NodeId(s.execute("object.group", &json!({"ids": [id.0]})).unwrap()["id"].as_u64().unwrap());
groups.push((g, id));
}
let (in_symbol, _) = groups[1];
s.execute("symbol.new", &json!({"name": "Mark", "ids": [in_symbol.0]})).unwrap();
write(&photo, &png(60, 20, BLUE));
s.execute("links.update", &json!({})).unwrap();
// A plain group holding just a clip group of a clipping path and an image.
let plain_of_clip = |n: &vectorcraft_doc::Node| match &n.kind {
NodeKind::Group { children, clip: false } => matches!(&children[..], [c] if matches!(&c.kind,
NodeKind::Group { children, clip: true } if matches!(&children[..], [p, im]
if matches!(p.kind, NodeKind::Path { clipping: true, .. }) && matches!(im.kind, NodeKind::Image(_))))),
_ => false,
};
let d = &s.doc().unwrap().doc;
let (g, id) = groups[0];
assert!(plain_of_clip(d.node(g).unwrap()), "{:?}", d.node(g));
assert_ne!(d.parent_of(id), Some(g), "the image is in a new clip group");
let mut found = false;
d.symbols[0].art.walk(&mut |n| found |= plain_of_clip(n));
assert!(found, "{:?}", d.symbols[0].art);
}
#[test]
fn a_missing_link_renders_its_preview_and_relinks() {
let dir = Folder::new("missing");
+6 -1
View File
@@ -1136,7 +1136,12 @@ fn mesh_points_move_and_colour_together() {
assert!(s.execute("object.mesh.movePoints", &p).is_err(), "{p}");
}
assert!(s.execute("object.mesh.setPointColor", &json!({"id": a.0, "points": [0, 9], "color": "#000000"})).is_err());
assert_eq!(((mesh_of(&s, a), mesh_of(&s, b)), undo_len(&s)), (before, n), "nothing changed");
assert_eq!(((mesh_of(&s, a), mesh_of(&s, b)), undo_len(&s)), (before.clone(), n), "nothing changed");
// A call that changes no point records no undo step.
s.execute("object.mesh.movePoints", &json!({"id": a.0, "points": [], "dx": 1, "dy": 1})).unwrap();
s.execute("object.mesh.movePoints", &json!({"id": a.0, "points": [0, 1], "dx": 0, "dy": 0})).unwrap();
s.execute("object.mesh.setPointColor", &json!({"id": a.0, "points": [2, 6], "color": "#0000ff", "opacity": 0.25})).unwrap();
assert_eq!(((mesh_of(&s, a), mesh_of(&s, b)), undo_len(&s)), (before, n), "no change, no undo step");
}
#[test]
+99
View File
@@ -600,6 +600,105 @@ fn effect_params_are_the_effects_own() {
assert!(s.check_params("effect.setParams", &json!({"index": 7, "params": {"blurr": 3}})).is_ok());
}
/// An effect parameter that only the effect's params description names, with no dialog default,
/// is held to the type the effect reads: the earlier 3D Perspective and light, Scribble's
/// `variation` and an Outline Stroke's `width` are numbers, and a Gaussian Blur's `linear` is true
/// or false. The `style` of Crop Marks is `roman` or `japanese`. Every such key of every effect is
/// listed, or has a check of its own. A key left out keeps its default.
#[test]
fn an_effect_parameter_without_a_default_is_held_to_its_type() {
use crate::cmd::params::undefaulted_type;
use vectorcraft_render::effects::effect_catalog;
// Read by checks of their own: a stored texture or a texture to load, an SVG filter, a Warp's
// map and the style of Crop Marks.
let own_check = |effect: &str, key: &str| match key {
"textureImage" | "name" | "xml" => true,
"version" => effect.starts_with("warp."),
"style" => effect == "cropMarks",
_ => false,
};
let mut unlisted = vec![];
for e in effect_catalog() {
for key in doc_keys(e.params).unwrap().keys {
if e.defaults.get(key).is_none() && undefaulted_type(e.id, key).is_none() && !own_check(e.id, key) {
unlisted.push(format!("{}.{key}", e.id));
}
}
}
assert!(unlisted.is_empty(), "parameters without a default or a listed type: {unlisted:?}");
let apply = |effect: &str, params| json!({"effect": effect, "params": params});
for (effect, params, why) in [
("threeD.revolve", json!({"perspective": true}), "`perspective` must be a number"),
("threeD.extrude", json!({"lightAzimuth": "30"}), "`lightAzimuth` must be a number"),
("threeD.rotate", json!({"lightElevation": null}), "`lightElevation` must be a number"),
("threeD.inflate", json!({"perspective": "50%"}), "`perspective` must be a number"),
("path.outlineStroke", json!({"width": "10 pt"}), "`width` must be a number"),
("stylize.scribble", json!({"variation": false}), "`variation` must be a number"),
("blur.gaussian", json!({"radius": 2, "linear": 1}), "`linear` must be true or false"),
("cropMarks", json!({"style": "bogus"}), "`style` must be roman or japanese"),
("cropMarks", json!({"style": 1}), "`style` must be roman or japanese"),
("cropMarks", json!({"style": null}), "`style` must be roman or japanese"),
] {
assert_eq!(refusal("effect.apply", apply(effect, params.clone())), why, "{effect} {params}");
assert_eq!(refusal("appearance.addEffect", apply(effect, params.clone())), why, "{effect} {params}");
}
for (effect, params) in [
("threeD.revolve", json!({"perspective": 40, "lightAzimuth": 30.5, "lightElevation": 0})),
("path.outlineStroke", json!({"width": 10})),
("stylize.scribble", json!({"variation": 2})),
("blur.gaussian", json!({"linear": true})),
("blur.gaussian", json!({})),
("cropMarks", json!({"style": "japanese"})),
("cropMarks", json!({})),
] {
assert!(check_params("effect.apply", &apply(effect, params.clone())).is_ok(), "{effect} {params}");
}
}
/// `effect.apply`, `appearance.addEffect` and `effect.setParams` refuse such a parameter of the
/// wrong type when they run, and so does a step of a `command.batch` whose effect a step before it
/// added, with the document and its undo history as they were. A saved step still plays with it,
/// as with a key the effect no longer takes.
#[test]
fn effect_commands_refuse_an_undefaulted_parameter_of_the_wrong_type() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap();
let state = |s: &Session| {
let st = s.doc().unwrap();
(st.history.undo.len(), st.doc.clone())
};
let before = state(&s);
let apply = |effect: &str, params| json!({"effect": effect, "params": params});
for (id, params, why) in [
("effect.apply", apply("path.outlineStroke", json!({"width": "10 pt"})), "`width` must be a number"),
("appearance.addEffect", apply("blur.gaussian", json!({"linear": 1})), "`linear` must be true or false"),
("effect.apply", apply("threeD.revolve", json!({"perspective": "50"})), "`perspective` must be a number"),
] {
let e = s.execute(id, &params).unwrap_err().to_string();
assert!(e.contains(why), "{id} {params}: {e}");
assert_eq!(state(&s), before, "{id} {params}");
}
let batch = json!({"commands": [
{"command": "effect.apply", "params": apply("path.outlineStroke", json!({"width": 3}))},
{"command": "effect.setParams", "params": {"index": 0, "params": {"width": "10 pt"}}},
]});
let e = s.execute("command.batch", &batch).unwrap_err().to_string();
assert!(e.contains("`width` must be a number"), "{e}");
assert_eq!(state(&s), before);
s.execute("effect.apply", &apply("path.outlineStroke", json!({"width": 3}))).unwrap();
let applied = state(&s);
for params in [json!({"width": "10 pt"}), json!({"width": null}), json!({"width": true})] {
let e = s.execute("effect.setParams", &json!({"index": 0, "params": params.clone()})).unwrap_err().to_string();
assert!(e.contains("`width` must be a number"), "{params}: {e}");
assert_eq!(state(&s), applied, "{params}");
}
s.execute("effect.setParams", &json!({"index": 0, "params": {"width": 4}})).unwrap();
s.replay("effect.setParams", &json!({"index": 0, "params": {"retired": "x", "width": "10 pt"}})).unwrap();
let st = s.doc().unwrap();
assert_eq!(st.doc.layers[0].children().unwrap()[0].appearance.effects[0].params["width"], json!("10 pt"));
}
/// The Transform effect (`distort.transform`) is held to its params description: `effect.apply`
/// and `effect.setParams` refuse each of its on/off options given as text, such as `reflectX:
/// "false"`, and each of its numbers given as text, such as `copies: "0"`, and leave the effect
+362 -1
View File
@@ -2,7 +2,7 @@
use serde_json::{Value, json};
use vectorcraft_color::Paint;
use vectorcraft_doc::pattern::{PATTERN_EDIT_LAYER, RepeatKind, TileType};
use vectorcraft_doc::pattern::{PATTERN_EDIT_LAYER, RepeatKind, TileType, pattern_paint};
use vectorcraft_doc::{Node, NodeKind};
use vectorcraft_geom::{Point, Rect};
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
@@ -212,6 +212,186 @@ fn cancel_new_pattern_removes_it() {
assert_eq!(doc(&s).layers.len(), 1);
}
fn doc_mut(s: &mut Session) -> &mut vectorcraft_doc::Document {
Arc::make_mut(&mut s.doc_mut().unwrap().doc)
}
fn is_pattern(p: &Paint, name: &str) -> bool {
matches!(p, Paint::Pattern { pattern, .. } if pattern == name)
}
/// How many paints of pattern `name` the document holds: in the art and the definitions
/// ([`vectorcraft_doc::Document::visit_paints`]), the swatches, the character styles and the
/// stored brush library.
fn pattern_uses(d: &vectorcraft_doc::Document, name: &str) -> usize {
let mut n = 0;
d.visit_paints(&mut |p| n += usize::from(is_pattern(p, name)));
n += d.swatches_iter().filter(|w| is_pattern(&w.paint, name)).count();
let styled = d.char_styles.iter().flat_map(|c| ["fill", "stroke"].map(|k| c.attrs.get(k).cloned())).flatten();
n += styled.filter(|v| serde_json::from_value::<Paint>(v.clone()).is_ok_and(|p| is_pattern(&p, name))).count();
let needle = format!("\"pattern\":\"{name}\"");
n + d.unknown.get(vectorcraft_brush::DOC_KEY).map_or(0, |v| v.to_string().matches(&needle).count())
}
/// Canceling a new pattern removes every swatch of it, a color group's included, and the fills and
/// strokes of it on art outside the editing layer become no paint. The object's other paints, and
/// a swatch that only shares the pattern's name, stay.
#[test]
fn cancel_new_pattern_forgets_group_swatch_and_outside_fills_and_strokes() {
let mut s = session();
let other = rect(&mut s, 100.0, 100.0, 50.0, 50.0);
s.execute("paint.setStroke", &json!({"ids": [other.0], "color": "#00ff00"})).unwrap();
let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
sel(&mut s, &[r]);
s.execute("object.pattern.make", &json!({"name": "Tiles"})).unwrap();
s.execute("paint.setFill", &json!({"ids": [other.0], "swatch": "Tiles"})).unwrap();
let d = doc_mut(&mut s);
let solid = Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 1.0));
d.swatch_groups.push(vectorcraft_color::SwatchGroup {
name: "Mine".into(),
swatches: vec![
vectorcraft_color::Swatch { name: "Grouped".into(), paint: pattern_paint("Tiles"), global: false, spot: false },
vectorcraft_color::Swatch { name: "Tiles".into(), paint: solid.clone(), global: false, spot: false },
],
});
assert!(pattern_uses(doc(&s), "Tiles") >= 3);
s.execute("object.pattern.cancel", &json!({})).unwrap();
let d = doc(&s);
assert!(d.pattern("Tiles").is_none());
assert_eq!(pattern_uses(d, "Tiles"), 0);
assert!(d.swatch("Grouped").is_none(), "the group's pattern swatch goes");
assert_eq!(d.swatch("Tiles").map(|w| &w.paint), Some(&solid), "a color swatch named like the pattern stays");
let n = node(&s, other);
assert!(n.appearance.fill_paint().is_none());
assert!(matches!(n.appearance.stroke_paint(), Paint::Solid { .. }), "the stroke stays");
}
/// Save a Copy keeps the copy as its own pattern when the new pattern is canceled, and the copy's
/// tile art loses its paints of the canceled pattern.
#[test]
fn cancel_new_pattern_forgets_its_paints_in_a_saved_copy() {
let mut s = session();
let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
sel(&mut s, &[r]);
s.execute("object.pattern.make", &json!({"name": "Tiles"})).unwrap();
let layer = doc(&s).pattern_edit.as_ref().unwrap().layer;
let tile = doc(&s).node(layer).unwrap().children().unwrap()[0].id;
s.execute("paint.setFill", &json!({"ids": [tile.0], "swatch": "Tiles"})).unwrap();
assert_eq!(s.execute("object.pattern.saveCopy", &json!({})).unwrap()["name"], "Tiles copy");
s.execute("object.pattern.cancel", &json!({})).unwrap();
let d = doc(&s);
let copy = d.pattern("Tiles copy").expect("the copy stays");
assert_eq!(copy.art.len(), 1);
assert!(copy.art[0].appearance.fill_paint().is_none());
assert_eq!(pattern_uses(d, "Tiles"), 0);
}
/// Canceling a new pattern also clears its paints in type, opacity mask art, symbol definitions,
/// graph designs, graphic and character styles and brush art, and in the fill, stroke and
/// appearance new art takes, so new art does not take a paint of a pattern that is gone.
#[test]
fn cancel_new_pattern_forgets_hidden_paints_and_new_art_defaults() {
let mut s = session();
let masked = rect(&mut s, 100.0, 100.0, 50.0, 50.0);
let m = rect(&mut s, 100.0, 100.0, 50.0, 50.0);
s.execute("transparency.makeOpacityMask", &json!({"ids": [masked.0, m.0]})).unwrap();
let t = id_of(&s.execute("text.create", &json!({"x": 20, "y": 300, "text": "Hi"})).unwrap());
let band = rect(&mut s, 200.0, 200.0, 40.0, 10.0);
let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
sel(&mut s, &[r]);
s.execute("object.pattern.make", &json!({"name": "Tiles"})).unwrap();
s.execute("paint.setFill", &json!({"ids": [band.0], "swatch": "Tiles"})).unwrap();
s.execute("brush.new", &json!({"type": "art", "name": "Band", "ids": [band.0]})).unwrap();
let p = pattern_paint("Tiles");
let d = doc_mut(&mut s);
let art = d.node(band).unwrap().clone();
d.symbols.push(vectorcraft_doc::Symbol { name: "Sym".into(), art: Arc::new(art.clone()), dynamic: false });
d.graph_designs.push(vectorcraft_doc::GraphDesign { name: "Bar".into(), art: Arc::new(art.clone()) });
d.graphic_styles.push(vectorcraft_doc::GraphicStyle {
name: "Style".into(),
appearance: (*art.appearance).clone(),
id: 0,
opacity: 1.0,
blend: Default::default(),
isolate: false,
knockout: Default::default(),
unit_box: true,
});
let attrs = json!({"fill": p, "stroke": p, "size": 12});
d.char_styles.push(vectorcraft_doc::TextStyleDef { name: "Char".into(), attrs: attrs.as_object().unwrap().clone() });
if let Some(NodeKind::Text(text)) = d.node_mut(t).map(|n| &mut n.kind) {
text.runs[0].style.fill = p.clone();
}
let mask = d.node_mut(masked).and_then(|n| n.extras_if_any()).and_then(|e| e.mask.as_deref_mut()).expect("the mask");
Arc::make_mut(&mut mask.art).appearance = vectorcraft_doc::Appearance::basic(p.clone(), Paint::None, 0.0).into();
s.paint.fill = p.clone();
s.paint.appearance = Some(vectorcraft_doc::Appearance::basic(Paint::None, p.clone(), 1.0));
assert!(pattern_uses(doc(&s), "Tiles") >= 10, "{}", pattern_uses(doc(&s), "Tiles"));
s.execute("object.pattern.cancel", &json!({})).unwrap();
assert_eq!(pattern_uses(doc(&s), "Tiles"), 0);
assert!(doc(&s).char_styles[0].attrs["size"] == json!(12), "the style's other attributes stay");
assert!(s.paint.fill.is_none());
assert!(s.paint.appearance.as_ref().unwrap().items.iter().all(|it| match it {
vectorcraft_doc::AppearanceItem::Fill(f) => !is_pattern(&f.paint, "Tiles"),
vectorcraft_doc::AppearanceItem::Stroke(k) => !is_pattern(&k.paint, "Tiles"),
}));
let fresh = rect(&mut s, 300.0, 300.0, 20.0, 20.0);
let n = node(&s, fresh);
assert!(!is_pattern(&n.appearance.fill_paint(), "Tiles") && !is_pattern(&n.appearance.stroke_paint(), "Tiles"));
}
/// Cancel is one undo step: Undo gives the pattern, its paints and the fill new art takes back, and
/// Redo clears them again.
#[test]
fn cancel_new_pattern_is_one_undo_step() {
let mut s = session();
let other = rect(&mut s, 100.0, 100.0, 50.0, 50.0);
let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
sel(&mut s, &[r]);
s.execute("object.pattern.make", &json!({"name": "Tiles"})).unwrap();
s.execute("paint.setFill", &json!({"ids": [other.0], "swatch": "Tiles"})).unwrap();
s.paint.fill = pattern_paint("Tiles");
let steps = s.doc().unwrap().history.undo.len();
s.execute("object.pattern.cancel", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
assert!(s.paint.fill.is_none() && doc(&s).pattern("Tiles").is_none());
s.execute("edit.undo", &json!({})).unwrap();
assert!(is_pattern(&s.paint.fill, "Tiles"));
assert!(doc(&s).pattern("Tiles").is_some() && doc(&s).pattern_edit.is_some());
assert!(is_pattern(&node(&s, other).appearance.fill_paint(), "Tiles"));
s.execute("edit.redo", &json!({})).unwrap();
assert!(s.paint.fill.is_none() && doc(&s).pattern("Tiles").is_none());
assert_eq!(pattern_uses(doc(&s), "Tiles"), 0);
}
/// Canceling the edit of a pattern that Pattern Options renamed while it was edited gives the
/// pattern its old name back, with its swatch and the paints of it, so nothing is left painted
/// with a name no pattern has.
#[test]
fn cancel_after_renaming_an_edited_pattern_restores_its_name_and_paints() {
let mut s = session();
make_dots(&mut s);
let big = rect(&mut s, 100.0, 100.0, 50.0, 50.0);
s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap();
s.execute("object.pattern.edit", &json!({"name": "Dots"})).unwrap();
s.execute("pattern.options", &json!({"newName": "Spots"})).unwrap();
assert!(is_pattern(&node(&s, big).appearance.fill_paint(), "Spots"));
s.execute("object.pattern.cancel", &json!({})).unwrap();
let d = doc(&s);
assert!(d.pattern("Dots").is_some() && d.pattern("Spots").is_none());
assert!(d.swatch("Dots").is_some_and(|w| is_pattern(&w.paint, "Dots")));
assert!(is_pattern(&node(&s, big).appearance.fill_paint(), "Dots"));
assert_eq!(pattern_uses(d, "Spots"), 0);
// When Save a Copy has taken the old name since, the pattern keeps its new name and paints.
s.execute("object.pattern.edit", &json!({"name": "Dots"})).unwrap();
s.execute("pattern.options", &json!({"newName": "Spots"})).unwrap();
s.execute("object.pattern.saveCopy", &json!({"name": "Dots"})).unwrap();
s.execute("object.pattern.cancel", &json!({})).unwrap();
let d = doc(&s);
assert!(d.pattern("Dots").is_some() && d.pattern("Spots").is_some());
assert!(is_pattern(&node(&s, big).appearance.fill_paint(), "Spots"));
}
#[test]
fn edit_then_cancel_restores_definition() {
let mut s = session();
@@ -381,6 +561,187 @@ fn delete_pattern_unpaints_objects() {
assert!(s.execute("pattern.delete", &json!({"name": "Dots"})).is_err());
}
/// Puts paints of pattern `name` where the layers do not reach: a symbol definition, another
/// pattern's tile, a graphic style, a character style, a type run, a color group's swatch and the
/// fill new art takes.
fn paint_outside_the_layers(s: &mut Session, name: &str) {
let t = id_of(&s.execute("text.create", &json!({"x": 20, "y": 300, "text": "Hi"})).unwrap());
let p = pattern_paint(name);
let d = doc_mut(s);
let shape = vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 5.0, 5.0));
let art = Node::path(d.alloc_id(), shape, vectorcraft_doc::Appearance::basic(p.clone(), Paint::None, 0.0));
d.symbols.push(vectorcraft_doc::Symbol { name: "Sym".into(), art: Arc::new(art.clone()), dynamic: false });
d.patterns.push(vectorcraft_doc::pattern::PatternDef::new("Other", vec![Arc::new(art.clone())]));
d.graphic_styles.push(vectorcraft_doc::GraphicStyle {
name: "Style".into(),
appearance: (*art.appearance).clone(),
id: 0,
opacity: 1.0,
blend: Default::default(),
isolate: false,
knockout: Default::default(),
unit_box: true,
});
let attrs = json!({"fill": p});
d.char_styles.push(vectorcraft_doc::TextStyleDef { name: "Char".into(), attrs: attrs.as_object().unwrap().clone() });
if let Some(NodeKind::Text(text)) = d.node_mut(t).map(|n| &mut n.kind) {
text.runs[0].style.fill = p.clone();
}
d.swatch_groups.push(vectorcraft_color::SwatchGroup {
name: "Mine".into(),
swatches: vec![vectorcraft_color::Swatch { name: "Grouped".into(), paint: p.clone(), global: false, spot: false }],
});
s.paint.fill = p;
}
/// Delete Pattern clears the pattern's paints outside the layers too, and in the fill new art takes.
#[test]
fn delete_pattern_forgets_paints_outside_the_layers() {
let mut s = session();
make_dots(&mut s);
paint_outside_the_layers(&mut s, "Dots");
assert_eq!(pattern_uses(doc(&s), "Dots"), 7);
let v = s.execute("pattern.delete", &json!({"name": "Dots"})).unwrap();
assert_eq!(v["unpainted"], 5, "the symbol, the other tile, the graphic style, the character style and the type");
assert_eq!(pattern_uses(doc(&s), "Dots"), 0);
assert!(doc(&s).swatch("Grouped").is_none() && doc(&s).pattern("Other").is_some());
assert!(s.paint.fill.is_none());
}
/// A Pattern Options rename renames the pattern's paints outside the layers too, and in the fill
/// new art takes.
#[test]
fn rename_pattern_reaches_paints_outside_the_layers() {
let mut s = session();
make_dots(&mut s);
paint_outside_the_layers(&mut s, "Dots");
s.execute("pattern.options", &json!({"name": "Dots", "newName": "Spots"})).unwrap();
assert_eq!(pattern_uses(doc(&s), "Dots"), 0);
assert_eq!(pattern_uses(doc(&s), "Spots"), 7);
assert!(is_pattern(&s.paint.fill, "Spots"));
s.execute("edit.undo", &json!({})).unwrap();
assert!(is_pattern(&s.paint.fill, "Dots"), "Undo gives the fill its old name back");
}
/// A new graph whose stored series fill and stroke and value label fill and stroke are pattern
/// `name`, as a later redraw would give them to its marks and labels.
fn graph_painted_with(s: &mut Session, name: &str) -> NodeId {
let v = s.execute("graph.create", &json!({"type": "column", "x": 200, "y": 200, "width": 60, "height": 40, "csv": ",2024\nQ1,3\nQ2,5"}));
let id = id_of(&v.unwrap());
let p = pattern_paint(name);
let mut extras = doc_mut(s).node_mut(id).unwrap().extras_mut();
let spec = extras.graph.as_mut().unwrap();
spec.series_paints = vec![vectorcraft_doc::SeriesPaint { fill: Some(p.clone()), stroke: Some(p.clone()), ..Default::default() }];
spec.label_styles.values = Some(vectorcraft_doc::CharStyle { fill: p.clone(), stroke: p, ..Default::default() });
id
}
/// The series and label paints a graph stores, in order: series fill and stroke, then the value
/// labels' fill and stroke.
fn stored_graph_paints(s: &Session, id: NodeId) -> Vec<Paint> {
let n = node(s, id);
let g = n.extras().graph.as_deref().unwrap();
let series = g.series_paints.iter().flat_map(|p| [p.fill.clone(), p.stroke.clone()]).flatten();
series.chain(g.label_styles.values.iter().flat_map(|st| [st.fill.clone(), st.stroke.clone()])).collect()
}
/// Delete Pattern clears the series and label paints a graph stores, and a rename renames them, so
/// a later `graph.setData` draws no paint of a missing pattern.
#[test]
fn delete_and_rename_reach_a_graphs_stored_paints() {
let mut s = session();
make_dots(&mut s);
let g = graph_painted_with(&mut s, "Dots");
s.execute("pattern.options", &json!({"name": "Dots", "newName": "Spots"})).unwrap();
assert!(stored_graph_paints(&s, g).iter().all(|p| is_pattern(p, "Spots")), "the rename reaches the graph");
s.execute("edit.undo", &json!({})).unwrap();
assert!(stored_graph_paints(&s, g).iter().all(|p| is_pattern(p, "Dots")));
let v = s.execute("pattern.delete", &json!({"name": "Dots"})).unwrap();
assert_eq!(v["unpainted"], 4, "the series fill and stroke and the value labels' fill and stroke");
assert!(stored_graph_paints(&s, g).iter().all(Paint::is_none));
s.execute("graph.setData", &json!({"id": g.0, "rows": [[2], [4]]})).unwrap();
assert_eq!(pattern_uses(doc(&s), "Dots"), 0, "the redrawn graph holds no paint of the deleted pattern");
}
/// Canceling a new pattern clears the series and label paints a graph stores.
#[test]
fn cancel_new_pattern_reaches_a_graphs_stored_paints() {
let mut s = session();
// The graph is made outside the editing layer, and its paints take the new pattern's name.
let g = graph_painted_with(&mut s, "Tiles");
let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
sel(&mut s, &[r]);
s.execute("object.pattern.make", &json!({"name": "Tiles"})).unwrap();
s.execute("object.pattern.cancel", &json!({})).unwrap();
assert_eq!(stored_graph_paints(&s, g).len(), 4);
assert!(stored_graph_paints(&s, g).iter().all(Paint::is_none));
assert_eq!(pattern_uses(doc(&s), "Tiles"), 0);
}
/// With New Art Has Basic Appearance off, Redo of Delete Pattern gives new art the paints the
/// command left, the selected object's appearance taken after the pattern's fill was cleared.
#[test]
fn redo_of_delete_pattern_keeps_the_inherited_new_art_paints() {
let mut s = session();
let r = make_dots(&mut s);
s.prefs.new_art_basic = false;
sel(&mut s, &[r]);
s.paint.fill = pattern_paint("Dots");
s.execute("pattern.delete", &json!({"name": "Dots"})).unwrap();
let after = s.paint.clone();
assert!(!is_pattern(&after.fill, "Dots"));
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(s.paint, after);
}
/// A Pattern Options rename tried as a preview changes the fill new art takes only while the
/// preview stands: another preview and Cancel start from the fill the interaction began with.
/// Keeping the preview keeps the rename, and Undo takes it back.
#[test]
fn rename_previews_restore_the_fill_new_art_takes() {
let mut s = session();
make_dots(&mut s);
s.paint.fill = pattern_paint("Dots");
s.begin_interaction("Pattern Options").unwrap();
s.preview("pattern.options", &json!({"name": "Dots", "newName": "Spots"})).unwrap();
assert!(is_pattern(&s.paint.fill, "Spots"));
s.preview("pattern.options", &json!({"name": "Dots", "newName": "Tiles"})).unwrap();
assert!(is_pattern(&s.paint.fill, "Tiles"));
s.cancel_interaction().unwrap();
assert!(is_pattern(&s.paint.fill, "Dots") && doc(&s).pattern("Dots").is_some());
s.begin_interaction("Pattern Options").unwrap();
s.preview("pattern.options", &json!({"name": "Dots", "newName": "Spots"})).unwrap();
s.commit_interaction().unwrap();
assert!(is_pattern(&s.paint.fill, "Spots") && doc(&s).pattern("Spots").is_some());
s.execute("edit.undo", &json!({})).unwrap();
assert!(is_pattern(&s.paint.fill, "Dots") && doc(&s).pattern("Dots").is_some());
}
/// With New Art Has Basic Appearance off, a canceled rename preview leaves new art with the
/// appearance of the selected object as the document has it again, so no paint of the previewed
/// name is left.
#[test]
fn canceled_rename_preview_leaves_no_inherited_paint_of_the_new_name() {
let mut s = session();
make_dots(&mut s);
let big = rect(&mut s, 100.0, 100.0, 50.0, 50.0);
s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap();
s.prefs.new_art_basic = false;
sel(&mut s, &[big]);
s.paint = vectorcraft_tools::PaintDefaults::default();
s.begin_interaction("Pattern Options").unwrap();
s.preview("pattern.options", &json!({"name": "Dots", "newName": "Spots"})).unwrap();
s.cancel_interaction().unwrap();
assert!(doc(&s).pattern("Dots").is_some() && doc(&s).pattern("Spots").is_none());
let items = s.paint.appearance.iter().flat_map(|a| a.items.iter()).map(|it| match it {
vectorcraft_doc::AppearanceItem::Fill(f) => f.paint.clone(),
vectorcraft_doc::AppearanceItem::Stroke(k) => k.paint.clone(),
});
let paints: Vec<Paint> = [s.paint.fill.clone(), s.paint.stroke.clone()].into_iter().chain(items).collect();
assert!(paints.iter().all(|p| !is_pattern(p, "Spots")), "{paints:?}");
}
#[test]
fn transform_patterns_only_moves_placement() {
let mut s = session();
+8
View File
@@ -235,8 +235,16 @@ fn live_effect_plugins_reshape_editable_art_and_expand() {
run(&mut s, "select.all", json!({}));
run(&mut s, "object.move", json!({"dx": 50, "dy": 0}));
assert_eq!(effected(&s), *node(&s, a).path_data().unwrap());
// A parameter of another type than the manifest gives it is refused.
let before = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len());
let wrong = json!({"ids": [a.0], "effect": "plugin.test.engine.tri", "params": {"size": "4 pt"}});
let e = s.execute("effect.apply", &wrong).unwrap_err().to_string();
assert!(e.contains("`size` must be a number"), "{e}");
assert_eq!((s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len()), before);
run(&mut s, "effect.apply", json!({"ids": [a.0], "effect": "plugin.test.engine.tri", "params": {"size": 4}}));
assert_eq!(effected(&s), triangle());
let e = s.execute("effect.setParams", &json!({"ids": [a.0], "index": 1, "params": {"size": true}})).unwrap_err().to_string();
assert!(e.contains("`size` must be a number"), "{e}");
assert_eq!(node(&s, a).appearance.effects[1].params, json!({"size": 4}));
// Uninstalled: the geometry is drawn as it is.
run(&mut s, "plugin.remove", json!({"id": "test.engine.tri"}));
@@ -250,3 +250,103 @@ fn a_guide_dragged_on_the_canvas_changes_how_a_scaled_instance_draws() {
let left = s.doc().unwrap().doc.symbol_nine_slice("Panel").unwrap().left;
assert!((left - 0.05).abs() < 1e-9, "{left}");
}
/// The 200 × 150 [`panel`] instance with its left guide 25 % outside the art: the left column
/// holds no art, so the corner square is in the center column, which runs 0..75 in the art and
/// 0..175 in the instance, and the center square moves with it. The rows are as before.
fn assert_left_outside(boxes: &[Rect]) {
let k = 175.0 / 75.0;
let want = [
Rect::new(50.0, 50.0, 250.0, 200.0),
Rect::new(50.0, 50.0, 50.0 + 10.0 * k, 60.0),
Rect::new(50.0 + 45.0 * k, 115.0, 50.0 + 55.0 * k, 135.0),
];
assert_eq!(boxes.len(), want.len(), "{boxes:?}");
for (b, w) in boxes.iter().zip(want) {
assert!(close(*b, w), "{b:?} vs {w:?} in {boxes:?}");
}
}
/// `symbol.options` takes guides outside the art, within -16..16 and in order on each axis, as
/// one undo step; a scaled instance then draws by the cells its art falls in, and Break Link to
/// Symbol gives that art. Values out of range or out of order are refused without an edit.
#[test]
fn guides_outside_the_art_are_set_by_command_and_scale_by_the_cells_that_hold_art() {
let (mut s, id) = panel();
s.execute("symbol.options", &json!({"name": "Panel", "nineSliceGuides": [-0.25, 0.25, 0.25, 0.25]})).unwrap();
assert_left_outside(&placed_boxes(&s, id));
let list = s.execute("symbol.list", &json!({})).unwrap();
assert_eq!(list["symbols"][0]["nineSlice"], json!([-0.25, 0.25, 0.25, 0.25]));
for bad in [json!([-16.5, 0.25, 0.25, 0.25]), json!([0.25, 0.25, 0.25, 17]), json!([1.2, 0.25, -0.2, 0.25]), json!([0.25, 0.6, 0.25, 0.6])] {
let e = s.execute("symbol.options", &json!({"name": "Panel", "nineSliceGuides": bad})).unwrap_err().to_string();
assert!(e.contains("each -16..16"), "{e}");
}
assert_eq!(s.doc().unwrap().doc.symbol_nine_slice("Panel").unwrap().left, -0.25, "no edit");
s.execute("symbol.options", &json!({"name": "Panel", "nineSliceGuides": [1.25, 0.25, -0.5, 0.25]})).unwrap();
// Both vertical guides past the art's right edge: the art keeps its width from the left.
let boxes = placed_boxes(&s, id);
assert!(close(boxes[0], Rect::new(50.0, 50.0, 150.0, 200.0)) && close(boxes[1], Rect::new(50.0, 50.0, 60.0, 60.0)), "{boxes:?}");
s.execute("edit.undo", &json!({})).unwrap();
assert_left_outside(&placed_boxes(&s, id));
let r = s.execute("symbol.breakLink", &json!({})).unwrap();
let art = NodeId(r["ids"][0].as_u64().unwrap());
assert_left_outside(&path_boxes(s.doc().unwrap().doc.node(art).unwrap()));
}
/// Dragging the left guide past the art's left edge on the canvas puts it outside the art, as one
/// undo step, and the scaled instance draws by the cells that hold art.
#[test]
fn a_guide_dragged_past_the_arts_edge_lies_outside_it() {
let (mut s, id) = panel();
s.execute("symbol.edit", &json!({"name": "Panel"})).unwrap();
let v = ViewInfo::default();
s.select_tool("selection", v).unwrap();
let e = s.doc().unwrap().doc.edited_nine_slice().unwrap();
let ((a, b), (l, r)) = (e.line(NineSliceGuide::Left), (e.line(NineSliceGuide::Left).0.x, e.line(NineSliceGuide::Right).0.x));
let y = (a.y + b.y) / 2.0;
// The guides lie a quarter in from each edge, so the art's left edge lies half the guides'
// distance to the left of the left guide, and 25 % outside it lies as far again.
let to = l - (r - l);
s.pointer(&PointerEvent::new(PointerKind::Down, l + 1.0, y), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, to, y), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, to, y), v).unwrap();
let g = s.doc().unwrap().doc.symbol_nine_slice("Panel").unwrap();
assert!((g.left + 0.25).abs() < 1e-9, "{g:?}");
let e = s.doc().unwrap().doc.edited_nine_slice().unwrap();
assert!((e.line(NineSliceGuide::Left).0.x - to).abs() < 1e-9, "the guide is drawn where it was dropped");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.symbol_nine_slice("Panel").unwrap().left, 0.25, "one undo step");
s.execute("edit.redo", &json!({})).unwrap();
s.execute("symbol.exitEditing", &json!({})).unwrap();
assert_left_outside(&placed_boxes(&s, id));
}
/// A native file keeps guides outside the art exactly, and an instance opened from it draws as
/// before saving; a file whose saved guides are out of range or out of order opens with 9-slice
/// off for that symbol.
#[test]
fn guides_outside_the_art_survive_saving_and_reopening_a_native_file() {
let (mut s, id) = panel();
let guides = [-0.123456789, 1.5, 0.3, -2.75];
s.execute("symbol.options", &json!({"name": "Panel", "nineSliceGuides": guides})).unwrap();
s.execute("symbol.options", &json!({"name": "Panel", "nineSliceGuides": [-0.25, 0.25, 0.25, 0.25]})).unwrap();
s.execute("symbol.options", &json!({"name": "Panel", "nineSliceGuides": guides})).unwrap();
let doc = s.doc().unwrap().doc.clone();
let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap();
let g = back.symbol_nine_slice("Panel").unwrap();
assert_eq!([g.left, g.top, g.right, g.bottom], guides);
let placed = |d: &vectorcraft_doc::Document| {
let NodeKind::SymbolInstance { symbol, xf } = &d.node(id).unwrap().kind else { panic!("an instance") };
let art = d.symbols.iter().find(|s| &s.name == symbol).unwrap().art.clone();
path_boxes(&d.place_symbol_art(symbol, &art, *xf).unwrap())
};
assert_eq!(placed(&back), placed(&doc));
for bad in [json!([-20.0, 0.25, 0.25, 0.25]), json!([0.8, 0.25, 0.5, 0.25]), json!([0.25, 0.25, 0.25]), json!("x")] {
let mut d = (*doc).clone();
d.unknown.insert(vectorcraft_doc::SYMBOL_NINE_SLICE.into(), json!({ "Panel": bad }));
let back = vectorcraft_format::load(&vectorcraft_format::save(&d, false)).unwrap();
assert!(back.symbol_nine_slice("Panel").is_none(), "{bad}");
let NodeKind::SymbolInstance { symbol, xf } = &back.node(id).unwrap().kind else { panic!("an instance") };
assert!(back.symbol_slice_map(symbol, *xf).is_none(), "{bad}: scaled as a whole");
}
}
+26
View File
@@ -1190,6 +1190,32 @@ fn distort_rejects_malformed_coordinates_and_ids_without_editing_art() {
assert_eq!(undo_len(&s), history + 1);
}
/// `object.distort` with `angle` reads `from` as a box in the frame turned by it, as the Free
/// Transform tool sends a turned bounding box: corners where that box already stands leave the art.
#[test]
fn distort_reads_from_in_a_turned_frame_and_rejects_a_bad_angle() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
let original = bounds(&s, a);
let bx = vectorcraft_doc::OrientedBox { angle: 90.0, rect: Rect::new(-100.0, 0.0, 0.0, 100.0) };
let corners: Vec<serde_json::Value> = bx.corners().iter().map(|c| json!([c.x, c.y])).collect();
let history = undo_len(&s);
for p in [
json!({"corners": corners, "from": [-100, 0, 0, 100], "angle": "90", "ids": [a.0]}),
json!({"corners": corners, "from": [-100, 0, 0, 100], "angle": [90], "ids": [a.0]}),
] {
assert!(s.execute("object.distort", &p).is_err(), "invalid input: {p}");
assert!(close(bounds(&s, a), original));
assert_eq!(undo_len(&s), history);
}
s.execute("object.distort", &json!({"corners": corners, "from": [-100, 0, 0, 100], "angle": 90, "ids": [a.0]})).unwrap();
assert!(close(bounds(&s, a), original), "{:?}", bounds(&s, a));
assert_eq!(undo_len(&s), history + 1);
// Without `from`, the box is the objects' bounds in the turned frame: the same box here.
s.execute("object.distort", &json!({"corners": corners, "angle": 90, "ids": [a.0]})).unwrap();
assert!(close(bounds(&s, a), original), "{:?}", bounds(&s, a));
}
/// `artboard.move` and `artboard.delete` take several artboards at once (#1139): one undo step
/// each, the art on each moving with it, and one artboard always left.
#[test]
+102 -1
View File
@@ -3,7 +3,8 @@
//! (`.vcflattener`) and export preset (`.vcexportpresets`) libraries, shortcut sets and action sets other apps save as text (`.kys`, `.aia`), and native files (compressed, damaged, saved for older versions),
//! must load as an error or as a document that then renders and exports, without a panic; nor may
//! bitmaps, PDF and text pasted from other apps, nor EMF and WMF pictures (damaged files, records
//! of every kind with random contents) opened, placed or pasted, nor EPS and PostScript files
//! of every kind with random contents) opened, placed or pasted, nor PICT pictures (damaged files,
//! opcodes with random contents) opened or placed, nor EPS and PostScript files
//! (damaged ones, hostile programs) read by the PostScript interpreter, nor the editing data of
//! Illustrator EPS and `.ai` files (the layers they carry, damaged or hostile), nor Affinity documents
//! (mutated object streams, archives and indexed PNG previews, hostile image dimensions), nor the
@@ -2938,6 +2939,106 @@ proptest! {
}
}
// ---------- PICT pictures: opened and placed ----------
fn be16(v: &[i16]) -> Vec<u8> {
v.iter().flat_map(|x| x.to_be_bytes()).collect()
}
/// A version 2 PICT file of a 200 × 100 pt frame holding `ops`, each padded to an even length.
fn pict_of(ops: &[(u16, Vec<u8>)]) -> Vec<u8> {
let mut b = vec![0; 512];
b.extend(be16(&[0, 0, 0, 100, 200]));
b.extend([0x00, 0x11, 0x02, 0xFF, 0x0C, 0x00]);
b.extend(be16(&[-1, 0]));
b.extend([0; 20]);
for (op, data) in ops {
b.extend(op.to_be_bytes());
b.extend(data);
if b.len() % 2 == 1 {
b.push(0);
}
}
b.extend([0x00, 0xFF]);
b
}
/// A picture with shapes, a region, text, a pattern and a packed pixel map.
fn pict_sample() -> Vec<u8> {
let region = [be16(&[36, 0, 0, 20, 20]), be16(&[0, 0, 20, 0x7FFF, 10, 10, 20, 0x7FFF, 20, 0, 10, 0x7FFF, 0x7FFF])].concat();
let table = be16(&[0, 0, 0, 1, 0, -1, 0, 0, 1, 0, 0, -1]);
let pixmap = [
be16(&[0x8008u16 as i16, 0, 0, 2, 8]),
be16(&[0, 0, 0, 0, 72, 0, 72, 0, 0, 8, 1, 8, 0, 0, 0, 0, 0, 0]),
table,
be16(&[0, 0, 2, 8, 10, 20, 14, 36, 0]),
vec![2, 0xF9, 0, 9, 7, 1, 0, 1, 0, 1, 0, 1, 0],
]
.concat();
pict_of(&[
(0x1A, be16(&[-1, 0, 0])),
(0x07, be16(&[2, 2])),
(0x31, be16(&[10, 20, 50, 80])),
(0x30, be16(&[10, 20, 50, 80])),
(0x0B, be16(&[8, 8])),
(0x41, be16(&[0, 100, 40, 160])),
(0x51, be16(&[50, 100, 90, 160])),
(0x61, be16(&[0, 0, 60, 60, 30, 120])),
(0x71, be16(&[22, 0, 0, 40, 40, 0, 0, 40, 40, 0, 40])),
(0x81, region.clone()),
(0x01, region),
(0x20, be16(&[70, 10, 70, 190])),
(0x21, be16(&[90, 100])),
(0x0A, vec![0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55]),
(0x34, be16(&[0, 0, 10, 10])),
(0x2C, [be16(&[9, 3]), vec![6], b"Geneva".to_vec()].concat()),
(0x0D, be16(&[12])),
(0x28, [be16(&[80, 10]), vec![3], b"abc".to_vec()].concat()),
(0x98, pixmap),
])
}
/// The sample the mutations start from opens with art and an image.
#[test]
fn the_pict_sample_opens() {
let l = vectorcraft_engine::cmd::fileio::load("x.pct", &pict_sample()).unwrap();
assert_eq!(l.format.id, "pict");
let (mut paths, mut images, mut texts) = (0, 0, 0);
l.doc.walk(|n| match n.kind {
vectorcraft_doc::NodeKind::Path { .. } => paths += 1,
vectorcraft_doc::NodeKind::Image(_) => images += 1,
vectorcraft_doc::NodeKind::Text(_) => texts += 1,
_ => {}
});
assert!(paths > 8 && images == 1 && texts == 1, "{paths} paths, {images} images, {texts} texts, warnings {:?}", l.warnings);
}
/// PICT opcodes VectorCraft reads, and reserved ones it skips.
const PICT_OPS: [u16; 40] = [
0x01, 0x03, 0x04, 0x07, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x12, 0x13, 0x14, 0x1A, 0x1B, 0x20, 0x21, 0x22, 0x23, 0x28, 0x29, 0x2A, 0x2B, 0x2C, 0x30,
0x31, 0x38, 0x41, 0x51, 0x61, 0x71, 0x81, 0x90, 0x91, 0x98, 0x99, 0x9A, 0x9B, 0xA1, 0x8200, 0x0024,
];
proptest! {
#![proptest_config(config())]
#[test]
fn mutated_pict_pictures_never_panic(cut in 0usize..4096, edits in prop::collection::vec((0usize..4096, any::<u8>()), 0..16)) {
let mut bytes = pict_sample();
let n = bytes.len();
for &(at, b) in &edits {
bytes[512 + at % (n - 512)] = b;
}
bytes.truncate(cut.max(512));
survive_metafile("mutated PICT", "x.pct", &bytes)?;
}
#[test]
fn random_pict_opcodes_never_panic(ops in prop::collection::vec((prop::sample::select(PICT_OPS.to_vec()), arb_body()), 0..32)) {
survive_metafile("PICT opcodes", "x.pct", &pict_of(&ops))?;
}
}
// ---------- HP-GL plot files: opened and placed ----------
/// A plot file with every kind of instruction the reader plays, printer escapes around it.
+64
View File
@@ -980,6 +980,34 @@ fn a_scaled_nine_slice_symbol_instance_keeps_its_corner_size() {
assert!(sizes.contains(&(200, 150)), "the frame is scaled: {sizes:?}");
}
/// A scaled instance of a symbol whose left 9-slice guide lies outside its art is written as the
/// canvas draws it: the corner square is in the center column, which runs 0..75 in the art and
/// 0..175 in the instance, so it is about 23 wide. The editing copy read alone has no guides.
#[test]
fn a_nine_slice_instance_with_a_guide_outside_its_art_is_written_as_drawn() {
let mut doc = Document::new(400.0, 400.0);
let layer = doc.layers[0].id;
let square = |doc: &mut Document, r: Rect| {
Node::path(doc.alloc_id(), shapes::rectangle(r), Appearance::basic(solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0))
};
let frame = Arc::new(square(&mut doc, Rect::new(0.0, 0.0, 100.0, 100.0)));
let corner = Arc::new(square(&mut doc, Rect::new(0.0, 0.0, 10.0, 10.0)));
let art = Node::group(doc.alloc_id(), vec![frame, corner]);
doc.symbols.push(Symbol { dynamic: false, name: "S".into(), art: Arc::new(art) });
doc.set_symbol_nine_slice("S", vectorcraft_doc::SymbolNineSlice::new(-0.25, 0.25, 0.25, 0.25));
let xf = Affine::scale_non_uniform(2.0, 1.5);
let instance = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "S".into(), xf });
doc.insert(Some(layer), usize::MAX, instance).unwrap();
let back = read_back(&doc);
let mut all = vec![];
paths(back.layers[0].children().unwrap(), &mut all);
let sizes: Vec<(i64, i64)> =
all.iter().filter_map(|p| p.geometric_bounds()).map(|b| (b.width().round() as i64, b.height().round() as i64)).collect();
assert!(sizes.contains(&(23, 10)), "the corner stretches with the center column: {sizes:?}");
assert!(sizes.contains(&(200, 150)), "the frame is scaled: {sizes:?}");
assert!(back.symbol_nine_slice("S").is_none());
}
/// Nested symbols whose instances multiply, written as their art (on a large canvas), stop at
/// [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects of symbol art in all, with a warning.
#[test]
@@ -3943,6 +3971,42 @@ fn korean_line_break_anywhere_is_reported_only_for_korean_paragraphs() {
}
}
/// The text document holds no Mojikumi set: a paragraph with any of the predefined sets other
/// than None is reported as left out, and reads back without the set; None and Line-end
/// Punctuation Half Width are not reported.
#[test]
fn predefined_mojikumi_sets_are_reported_as_left_out() {
use vectorcraft_doc::{Mojikumi, TextObject};
for (set, reported) in [
(Mojikumi::None, false),
(Mojikumi::LineEndHalf, false),
(Mojikumi::YakumonoHankaku, true),
(Mojikumi::GyomatsuYakumonoHankaku, true),
(Mojikumi::GyomatsuYakumonoZenkaku, true),
(Mojikumi::YakumonoZenkaku, true),
] {
let mut t = TextObject::point(vectorcraft_geom::Point::new(20.0, 40.0), "「かな」。", style(14.0, Color::BLACK));
t.para.mojikumi = set;
let mut doc = Document::new(200.0, 200.0);
let layer = doc.layers[0].id;
let n = text_node(&mut doc, t);
doc.insert(Some(layer), usize::MAX, n).unwrap();
let out = export_ai_editing(&doc, &opts()).unwrap();
assert_eq!(!out.warnings.is_empty(), reported, "{set:?}: {:?}", out.warnings);
let mut all = vec![];
paths(&read_back(&doc).layers, &mut all);
let sets: Vec<Mojikumi> = all
.iter()
.filter_map(|n| match &n.kind {
NodeKind::Text(t) => Some(t.para_at(0).mojikumi),
_ => None,
})
.collect();
assert_eq!(sets.len(), 1, "{set:?}");
assert!(matches!(sets[0], Mojikumi::None | Mojikumi::LineEndHalf), "{set:?} reads back as {:?}", sets[0]);
}
}
/// The text document has no Bunri-Kinshi: a paragraph with it on and an inseparable pair in it is
/// reported as left out, and comes back off; with it off, or with no pair, nothing is reported.
#[test]
+12
View File
@@ -731,6 +731,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"",
"{key, value} | {values: {key: value…}}, tool?: id (default: the active tool) → the tool's options (`{}` reads them). The options a tool keeps (Liquify brush and tool options, Mirror & Cut, Puppet Warp, drawing tools…) last across tool switches and are saved with the preferences; another tool's are stored for when it is chosen",
),
(
"tool.toggleTouchMode",
"Switch Selection and Drawing Modes",
"",
"{} from the Selection, Direct Selection, Group Selection, Magic Wand, Lasso or Perspective Selection tool, choose the drawing tool chosen last in this window (none yet: the Pencil tool); from any other tool, choose the Selection tool, remembering the tool left as the drawing tool unless it is the Hand, Rotate View, Zoom or Print Tiling tool → {tool}. A two-finger tap on the canvas runs it in the Touch workspace (Enable Touch Gestures on)",
),
(
"effect.dialog",
"Effect…",
@@ -945,6 +951,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"",
"{ids?|id?} open Layer Options for Layers panel rows (default: the highlighted rows, else the current layer), filled in from the first: dialog `layerOptions`, fields name, color (a preset name such as \"Light Blue\" or #rrggbb), template, locked, visible, printable, preview, dimImages (bool), dimPercent (0–100); an object's row has name, visible and locked. OK runs layer.setProps (one undo step); for several rows it sends only the fields set in the dialog",
),
(
"ui.layerDelete",
"Delete Selection",
"",
"{ids?|id?, doc?, confirmed?: bool} Delete Selection as the Layers panel runs it (its trash button, rows dropped on the trash, its menu, Delete and Backspace), on the rows layer.delete takes (default: the highlighted rows, else the current layer). When a layer or sublayer among them holds art (an object other than a guide; hidden, locked and nested objects count) it asks first: dialog `confirm` with `message`; OK deletes those rows by id with layer.delete (one undo step), Cancel keeps them. OK runs ui.layerDelete again with the rows, `doc` (the document's uid) and `confirmed: true`: with another document active then, nothing is deleted. Other rows (an empty layer, groups and objects alone) and rows layer.delete refuses go to layer.delete at once → layer.delete's {deleted}, or null while the question is open",
),
(
"ui.rename",
"Rename",
+1 -1
View File
@@ -3,7 +3,7 @@ name = "vectorcraft-metafile"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "VectorCraft Windows metafiles: hand-written EMF and WMF writers and readers"
description = "VectorCraft metafiles: hand-written EMF and WMF writers and readers, and a QuickDraw picture (PICT) reader"
[lints]
workspace = true
+3 -3
View File
@@ -11,8 +11,8 @@ const MAX_SIDE: u32 = 8192;
/// Most pixels an image the writers store holds.
const MAX_PIXELS: u64 = 1 << 24;
/// Most pixels along a side, and in all, of a bitmap the reader decodes.
const MAX_READ_SIDE: u32 = 1 << 15;
const MAX_READ_PIXELS: u64 = 1 << 26;
pub(crate) const MAX_READ_SIDE: u32 = 1 << 15;
pub(crate) const MAX_READ_PIXELS: u64 = 1 << 26;
/// Most pixels the bitmaps of one file may decode to together (four of the largest one), so a small
/// file of many image records can't cost minutes of decoding (#3600).
pub(crate) const MAX_FILE_PIXELS: u64 = 1 << 28;
@@ -164,7 +164,7 @@ fn png_without_metadata(bytes: &[u8]) -> std::borrow::Cow<'_, [u8]> {
}
/// Take a bitmap of `w` × `h` from what is `left` of the file's budget, before it is decoded.
fn charge(w: u32, h: u32, left: &mut u64) -> Result<(), String> {
pub(crate) fn charge(w: u32, h: u32, left: &mut u64) -> Result<(), String> {
let n = u64::from(w) * u64::from(h);
*left = left.checked_sub(n).ok_or(OVER_BUDGET)?;
Ok(())
+20 -8
View File
@@ -1,6 +1,7 @@
//! The metafile reader: the format parsers (`emf`, `wmf`) play their records on a [`Player`], a
//! small model of the device context they draw into (objects, transforms, the current position,
//! the path bracket, the clip), which turns what they draw into document objects.
//! The metafile reader: the format parsers (`emf`, `wmf`, and `pict` for QuickDraw pictures) play
//! their records on a [`Player`], a small model of the device context they draw into (objects,
//! transforms, the current position, the path bracket, the clip), which turns what they draw into
//! document objects.
//!
//! Fills and strokes become paths with a fill and a stroke; lines drawn one after another with the
//! same pen join into one path. Clips become clipping groups around what they clip. Images keep
@@ -8,6 +9,7 @@
//! doesn't know are counted and skipped, with one warning.
mod emf;
pub(crate) mod pict;
mod wmf;
use std::sync::Arc;
@@ -787,15 +789,25 @@ impl Player {
if img.width == 0 || img.height == 0 {
return;
}
let (w, h) = (f64::from(img.width), f64::from(img.height));
self.image_xf(img, Affine::translate((dest.x0, dest.y0)) * Affine::scale_non_uniform(dest.width() / w, dest.height() / h), opacity);
}
/// An image placed by `xf` (its pixels, from the top-left, → logical), at `opacity`. An image
/// that `xf` flattens or sends past finite numbers is left out, and adds nothing to the
/// document's images.
pub(crate) fn image_xf(&mut self, img: Rgba, xf: Affine, opacity: f64) {
if img.width == 0 || img.height == 0 {
return;
}
let xf = self.xf() * xf;
if !xf.as_coeffs().iter().all(|v| v.is_finite()) || xf.determinant().abs() < 1e-12 {
return;
}
let Ok(png) = img.to_png() else { return self.warn(BAD_IMAGE) };
let blob = ImageBlob::new("image/png", png);
let key = blob.content_key();
self.doc.images.entry(key.clone()).or_insert(blob);
let (w, h) = (f64::from(img.width), f64::from(img.height));
let xf = self.xf() * Affine::translate((dest.x0, dest.y0)) * Affine::scale_non_uniform(dest.width() / w, dest.height() / h);
if !xf.as_coeffs().iter().all(|v| v.is_finite()) || xf.determinant().abs() < 1e-12 {
return;
}
let im = ImageObject { key, width: img.width, height: img.height, xf, link: None, placement: Default::default(), pixels: None };
let mut n = Node::new(vectorcraft_doc::NodeId(0), NodeKind::Image(Box::new(im)));
n.opacity = opacity.clamp(0.0, 1.0) as f32;
File diff suppressed because it is too large Load Diff
+26 -2
View File
@@ -15,13 +15,14 @@
//!
//! [`import`] reads either format back into a document of one artboard (the picture's frame) and
//! one layer: paths with fills and strokes, clipping, images and text. Records it doesn't read are
//! skipped, with one warning.
//! skipped, with one warning. [`import_pict`] reads QuickDraw pictures (PICT, versions 1 and 2)
//! into the same kind of document.
//!
//! - `bytes`: little-endian writing and bounds-checked reading.
//! - `dib`: device-independent bitmaps, both ways.
//! - `scene`: the document walk both writers share.
//! - `emf`, `wmf`: the writers.
//! - `import`: the reader.
//! - `import`: the readers.
mod bytes;
mod dib;
@@ -107,5 +108,28 @@ pub fn import(bytes: &[u8]) -> Result<Imported, String> {
Ok(i)
}
/// A QuickDraw picture read into a document.
#[derive(Clone, Debug)]
pub struct Picture {
pub document: Document,
/// What was approximated or skipped.
pub warnings: Vec<String>,
}
/// Do `bytes` hold a version 2 QuickDraw picture, after a PICT file's 512 bytes of application
/// data or on their own? A version 1 picture is known by its file's name.
pub fn is_pict(bytes: &[u8]) -> bool {
import::pict::is_pict(bytes)
}
/// Read a QuickDraw picture (version 1 or 2; a PICT file, or a picture on its own) into a document
/// whose one artboard is the picture's frame.
pub fn import_pict(bytes: &[u8]) -> Result<Picture, String> {
let i = import::pict::play(bytes)?;
let mut p = Picture { document: i.document, warnings: i.warnings };
p.document.limit_depth_noting(&mut p.warnings);
Ok(p)
}
#[cfg(test)]
mod tests;
+1
View File
@@ -12,6 +12,7 @@ use vectorcraft_geom::{Affine, PathData, Point, Rect, shapes};
use crate::*;
mod import;
mod pict;
/// Add `n` (with a fresh id) on top of `d`'s first layer.
pub(crate) fn add(d: &mut Document, mut n: Node) {
+466
View File
@@ -0,0 +1,466 @@
//! QuickDraw pictures read from hand-built opcodes: versions 1 and 2, with and without the file's
//! application data; shapes, lines, regions, patterns, text, bitmaps, pixel maps and QuickTime
//! JPEG images; and pictures that are cut short, damaged or never end.
use vectorcraft_doc::{AppearanceItem, Document, Node, NodeKind};
use vectorcraft_geom::{Point, Rect};
use crate::import_pict;
/// Big-endian words.
fn be(v: &[i16]) -> Vec<u8> {
v.iter().flat_map(|x| x.to_be_bytes()).collect()
}
/// A QuickDraw rectangle: top, left, bottom, right.
fn rect(top: i16, left: i16, bottom: i16, right: i16) -> Vec<u8> {
be(&[top, left, bottom, right])
}
/// A version 2 PICT file: 512 bytes of application data, then a picture of `frame` (top, left,
/// bottom, right) holding `ops`, each padded to an even length, and the end opcode.
fn v2(frame: [i16; 4], ops: &[(u16, Vec<u8>)]) -> Vec<u8> {
let mut b = vec![0xA5; 512];
b.extend([0, 0]);
b.extend(be(&frame));
b.extend([0x00, 0x11, 0x02, 0xFF, 0x0C, 0x00]);
b.extend(be(&[-1, 0]));
b.extend([0; 20]);
for (op, data) in ops {
b.extend(op.to_be_bytes());
b.extend(data);
if b.len() % 2 == 1 {
b.push(0);
}
}
b.extend([0x00, 0xFF]);
b
}
/// An RGB foreground color.
fn fg(r: u16, g: u16, b: u16) -> (u16, Vec<u8>) {
(0x1A, [r, g, b].iter().flat_map(|c| c.to_be_bytes()).collect())
}
/// Every object of the imported document, depth first.
fn all(d: &Document) -> Vec<Node> {
let mut v = vec![];
for l in &d.layers {
for c in l.children().into_iter().flatten() {
c.walk(&mut |n| v.push(n.clone()));
}
}
v
}
fn paths(d: &Document) -> Vec<Node> {
all(d).into_iter().filter(|n| matches!(n.kind, NodeKind::Path { clipping: false, .. })).collect()
}
fn near(a: Rect, b: Rect, tol: f64) -> bool {
[(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(x, y)| (x - y).abs() <= tol)
}
fn fill_rgb(n: &Node) -> Option<[u8; 3]> {
n.appearance.items.iter().find_map(|i| match i {
AppearanceItem::Fill(f) => f.paint.color().map(|c| {
let [r, g, b, _] = c.to_rgba8(1.0);
[r, g, b]
}),
_ => None,
})
}
fn stroke(n: &Node) -> Option<([u8; 3], f64)> {
n.appearance.items.iter().find_map(|i| match i {
AppearanceItem::Stroke(s) => s.paint.color().map(|c| {
let [r, g, b, _] = c.to_rgba8(1.0);
([r, g, b], s.width)
}),
_ => None,
})
}
/// The images of `d` with their pixels.
fn images(d: &Document) -> Vec<(Node, image::RgbaImage)> {
all(d)
.into_iter()
.filter_map(|n| match &n.kind {
NodeKind::Image(im) => {
let png = image::load_from_memory(&d.images[&im.key].bytes).unwrap().to_rgba8();
Some((n.clone(), png))
}
_ => None,
})
.collect()
}
#[test]
fn a_version_2_picture_opens_as_shapes_in_its_frame() {
let bytes = v2(
[10, 20, 110, 220],
&[
fg(0xFFFF, 0, 0),
// paintRect
(0x31, rect(20, 30, 60, 80)),
(0x07, be(&[4, 4])),
fg(0, 0, 0xFFFF),
// frameRect: stroked inside the rectangle.
(0x30, rect(20, 120, 80, 200)),
// A line, and one from its end: one path.
(0x20, be(&[90, 40, 90, 140])),
(0x21, be(&[100, 140])),
],
);
assert!(crate::is_pict(&bytes));
let p = import_pict(&bytes).unwrap();
assert!(p.warnings.is_empty(), "{:?}", p.warnings);
let a = p.document.artboards[0].rect;
assert!(near(a, Rect::new(0.0, 0.0, 200.0, 100.0), 1e-9), "{a:?}");
let v = paths(&p.document);
assert_eq!(v.len(), 3, "{v:?}");
assert_eq!(fill_rgb(&v[0]), Some([255, 0, 0]));
assert!(near(v[0].geometric_bounds().unwrap(), Rect::new(10.0, 10.0, 60.0, 50.0), 1e-6));
assert!(v[0].appearance.stroke().is_none());
// The pen's outer edge is the rectangle.
assert_eq!(stroke(&v[1]), Some(([0, 0, 255], 4.0)));
assert!(near(v[1].geometric_bounds().unwrap(), Rect::new(102.0, 12.0, 178.0, 68.0), 1e-6));
assert!(fill_rgb(&v[1]).is_none());
// The pen hangs below and to the right of the line.
assert_eq!(stroke(&v[2]), Some(([0, 0, 255], 4.0)));
assert!(near(v[2].geometric_bounds().unwrap(), Rect::new(22.0, 82.0, 122.0, 92.0), 1e-6));
}
#[test]
fn a_bare_picture_and_a_version_1_picture_open() {
// A version 2 picture without the file's application data.
let file = v2([0, 0, 50, 50], &[(0x31, rect(10, 10, 20, 20))]);
let bare = &file[512..];
assert!(crate::is_pict(bare));
assert_eq!(paths(&import_pict(bare).unwrap().document).len(), 1);
// Version 1: byte opcodes, known by name only.
let mut v1 = vec![0, 0];
v1.extend(rect(0, 0, 40, 60));
v1.extend([0x11, 0x01, 0x31]);
v1.extend(rect(5, 5, 15, 25));
v1.extend([0xA0, 0x00, 0x05, 0x50]);
v1.extend(rect(20, 20, 30, 30));
v1.push(0xFF);
assert!(!crate::is_pict(&v1));
let p = import_pict(&v1).unwrap();
assert!(p.warnings.is_empty(), "{:?}", p.warnings);
assert_eq!(p.document.artboards[0].rect.size(), (60.0, 40.0).into());
let v = paths(&p.document);
assert_eq!(v.len(), 2);
assert!(near(v[0].geometric_bounds().unwrap(), Rect::new(5.0, 5.0, 25.0, 15.0), 1e-6));
// The default pen: one point, black.
assert_eq!(stroke(&v[1]), Some(([0, 0, 0], 1.0)));
// Inside a file's application data too.
let mut file = vec![0; 512];
file.extend(&v1);
assert_eq!(paths(&import_pict(&file).unwrap().document).len(), 2);
}
#[test]
fn files_that_are_not_pictures_are_refused() {
assert_eq!(import_pict(&[0; 600]).unwrap_err(), "not a PICT file");
assert_eq!(import_pict(b"").unwrap_err(), "not a PICT file");
// An empty frame.
let mut b = vec![0, 0];
b.extend(rect(10, 10, 10, 50));
b.extend([0x11, 0x01, 0xFF]);
assert!(import_pict(&b).is_err());
}
#[test]
fn rounded_rectangles_ovals_arcs_and_same_shapes() {
let bytes = v2(
[0, 0, 100, 100],
&[
(0x0B, be(&[20, 20])),
(0x41, rect(0, 0, 50, 50)),
(0x51, rect(50, 0, 100, 50)),
// A wedge from 12 to 3 o'clock.
(0x61, [rect(0, 50, 100, 150), be(&[0, 90])].concat()),
fg(0, 0xFFFF, 0),
// paintSameRect after paintRect: the same rectangle.
(0x31, rect(60, 60, 70, 80)),
(0x39, vec![]),
],
);
let p = import_pict(&bytes).unwrap();
let v = paths(&p.document);
assert_eq!(v.len(), 5, "{v:?}");
assert!(near(v[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 50.0, 50.0), 1e-6));
assert!(near(v[1].geometric_bounds().unwrap(), Rect::new(0.0, 50.0, 50.0, 100.0), 1e-3));
assert!(near(v[2].geometric_bounds().unwrap(), Rect::new(100.0, 0.0, 150.0, 50.0), 1e-3), "{:?}", v[2].geometric_bounds());
assert_eq!(v[3].geometric_bounds(), v[4].geometric_bounds());
assert_eq!(fill_rgb(&v[4]), Some([0, 255, 0]));
}
#[test]
fn polygons_and_regions_fill_and_clip() {
// An L: the top band 20 wide, the bottom band 10.
let region = [be(&[36]), rect(0, 0, 20, 20), be(&[0, 0, 20, 0x7FFF, 10, 10, 20, 0x7FFF, 20, 0, 10, 0x7FFF, 0x7FFF])].concat();
let poly = [be(&[22]), rect(0, 0, 40, 40), be(&[0, 0, 40, 40, 0, 40])].concat();
let bytes = v2([0, 0, 100, 100], &[(0x81, region.clone()), (0x71, poly), (0x01, region), (0x31, rect(0, 0, 100, 100))]);
let p = import_pict(&bytes).unwrap();
assert!(p.warnings.is_empty(), "{:?}", p.warnings);
let v = paths(&p.document);
assert_eq!(v.len(), 3);
let NodeKind::Path { path, .. } = &v[0].kind else { panic!("a path") };
assert_eq!(path.subpaths.len(), 2, "two bands");
assert!(near(v[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 20.0, 20.0), 1e-9));
assert!(near(v[1].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 40.0, 40.0), 1e-9));
// The rectangle after the clip region is clipped to it.
let layer = p.document.layers[0].children().unwrap();
let NodeKind::Group { children, clip: true } = &layer[2].kind else { panic!("a clipping group: {:?}", layer[2].kind) };
assert!(near(children[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 20.0, 20.0), 1e-9));
}
#[test]
fn patterns_fill_with_their_average_color() {
let gray = vec![0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55];
let bytes = v2(
[0, 0, 10, 10],
&[(0x0A, gray), (0x34, rect(0, 0, 10, 10)), (0x12, [be(&[2]), vec![0; 8], be(&[0, -1, 0])].concat()), (0x32, rect(0, 0, 5, 5))],
);
let p = import_pict(&bytes).unwrap();
let v = paths(&p.document);
assert_eq!(fill_rgb(&v[0]), Some([128, 128, 128]));
// eraseRect with an RGB background pattern.
assert_eq!(fill_rgb(&v[1]), Some([0, 255, 0]));
assert!(p.warnings.iter().any(|w| w.contains("average of their colors")), "{:?}", p.warnings);
}
#[test]
fn text_comes_in_as_point_type_in_the_named_font() {
let name = [be(&[9, 300]), vec![6], b"Geneva".to_vec()].concat();
let bytes = v2(
[0, 0, 100, 200],
&[(0x2C, name), (0x03, be(&[300])), (0x0D, be(&[18])), (0x04, vec![1]), (0x28, [be(&[50, 10]), vec![4], b"Caf\x8E".to_vec()].concat())],
);
let p = import_pict(&bytes).unwrap();
let texts: Vec<_> = all(&p.document).into_iter().filter(|n| matches!(n.kind, NodeKind::Text(_))).collect();
let NodeKind::Text(t) = &texts[0].kind else { panic!("text") };
assert_eq!(t.plain_text(), "Café");
let st = &t.runs[0].style;
assert_eq!((st.font_family.as_str(), st.font_style.as_str(), st.size), ("Geneva", "Bold", 18.0));
// The text location is the baseline's start.
assert_eq!(t.xf.translation(), Point::new(10.0, 50.0).to_vec2());
}
/// A pixel map's fields after `rowBytes` and its bounds: version, packing, resolution, depth and
/// components.
fn pixmap_fields(pack: i16, size: i16, cmps: i16) -> Vec<u8> {
be(&[0, pack, 0, 0, 72, 0, 72, 0, 0, size, cmps, 8, 0, 0, 0, 0, 0, 0])
}
#[test]
fn a_packed_indexed_pixel_map_becomes_an_image() {
let table = be(&[0, 0, 0, 1, 0, -1, 0, 0, 1, 0, 0, -1]);
// An 8 × 2 map: a run of red, then blue and red by turns.
let rows = [vec![2, 0xF9, 0], vec![9, 7, 1, 0, 1, 0, 1, 0, 1, 0]].concat();
let data =
[be(&[0x8008u16 as i16]), rect(0, 0, 2, 8), pixmap_fields(0, 8, 1), table, rect(0, 0, 2, 8), rect(10, 20, 14, 36), be(&[0]), rows].concat();
let p = import_pict(&v2([0, 0, 50, 50], &[(0x98, data)])).unwrap();
assert!(p.warnings.is_empty(), "{:?}", p.warnings);
let imgs = images(&p.document);
assert_eq!(imgs.len(), 1);
let (n, px) = &imgs[0];
assert_eq!(px.dimensions(), (8, 2));
assert_eq!(px.get_pixel(0, 0).0, [255, 0, 0, 255]);
assert_eq!(px.get_pixel(0, 1).0, [0, 0, 255, 255]);
assert_eq!(px.get_pixel(1, 1).0, [255, 0, 0, 255]);
// Drawn into its destination, twice its size.
assert!(near(n.geometric_bounds().unwrap(), Rect::new(20.0, 10.0, 36.0, 14.0), 1e-9));
}
#[test]
fn direct_pixel_maps_and_masked_bitmaps() {
// 32-bit, packed by component planes: red, then green.
let row = vec![7, 5, 255, 0, 0, 255, 0, 0];
let direct =
[be(&[0, 0xFF, 0x8008u16 as i16]), rect(0, 0, 1, 2), pixmap_fields(4, 32, 3), rect(0, 0, 1, 2), rect(0, 0, 1, 2), be(&[0]), row].concat();
// A one-bit bitmap ORed in (its clear bits left out), masked by a region.
let mask = [be(&[10]), rect(0, 0, 1, 1)].concat();
let bitmap = [be(&[2]), rect(0, 0, 1, 2), rect(0, 0, 1, 2), rect(5, 0, 6, 2), be(&[1]), mask, vec![0x80, 0]].concat();
let p = import_pict(&v2([0, 0, 10, 10], &[(0x9A, direct), (0x91, bitmap)])).unwrap();
assert!(p.warnings.is_empty(), "{:?}", p.warnings);
let imgs = images(&p.document);
assert_eq!(imgs.len(), 2);
assert_eq!(imgs[0].1.get_pixel(0, 0).0, [255, 0, 0, 255]);
assert_eq!(imgs[0].1.get_pixel(1, 0).0, [0, 255, 0, 255]);
assert_eq!(imgs[1].1.get_pixel(0, 0).0, [0, 0, 0, 255]);
assert_eq!(imgs[1].1.get_pixel(1, 0).0[3], 0, "the clear bit is left out");
let layer = p.document.layers[0].children().unwrap();
assert!(matches!(layer[1].kind, NodeKind::Group { clip: true, .. }), "the mask region clips the bitmap");
}
/// A pixel map's rows may be only a little longer than its pixels need: a map one pixel wide
/// with rows of 16,383 bytes is left out, and a pixel pattern like it fills with its bitmap.
#[test]
fn rows_far_longer_than_their_pixels_are_refused() {
let table = be(&[0, 0, 0, 1, 0, -1, 0, 0, 1, -1, 0, 0]);
// A row of 16,383 ones: 127 runs of 128 and one of 127, after its byte count.
let mut row = be(&[256]);
(0..127).for_each(|_| row.extend([0x81, 1]));
row.extend([0x82, 1]);
let rows = row.repeat(4);
let map = [be(&[0xBFFFu16 as i16]), rect(0, 0, 4, 1), pixmap_fields(0, 8, 1), table.clone()].concat();
let data = [map, rect(0, 0, 4, 1), rect(0, 0, 4, 1), be(&[0]), rows].concat();
let p = import_pict(&v2([0, 0, 10, 10], &[(0x98, data)])).unwrap();
assert!(images(&p.document).is_empty());
assert!(p.warnings.iter().any(|w| w.contains("couldn't be read")), "{:?}", p.warnings);
let pattern = [be(&[1]), vec![0xFF; 8], be(&[0xBFFFu16 as i16]), rect(0, 0, 1, 1), pixmap_fields(0, 8, 1), table, row].concat();
let p = import_pict(&v2([0, 0, 10, 10], &[(0x12, pattern), (0x32, rect(0, 0, 5, 5))])).unwrap();
assert_eq!(fill_rgb(&paths(&p.document)[0]), Some([0, 0, 0]), "the pattern's bitmap in the foreground color");
}
/// BitsRect rows are stored unpacked, also when a row is 8 bytes or longer.
#[test]
fn bitmap_rows_of_eight_bytes_or_more_are_read_unpacked() {
let row = vec![0x80, 0, 0, 0, 0, 0, 0, 0];
let bitmap = [be(&[8]), rect(0, 0, 1, 16), rect(0, 0, 1, 16), rect(0, 0, 1, 16), be(&[0]), row].concat();
let p = import_pict(&v2([0, 0, 10, 20], &[(0x90, bitmap), (0x31, rect(2, 2, 4, 4))])).unwrap();
assert!(p.warnings.is_empty(), "{:?}", p.warnings);
let imgs = images(&p.document);
assert_eq!(imgs.len(), 1);
assert_eq!(imgs[0].1.dimensions(), (16, 1));
assert_eq!(imgs[0].1.get_pixel(0, 0).0, [0, 0, 0, 255]);
assert_eq!(imgs[0].1.get_pixel(1, 0).0, [255, 255, 255, 255]);
assert_eq!(paths(&p.document).len(), 1, "the rectangle after it");
}
/// An empty clip region clips away what follows it.
#[test]
fn an_empty_clip_region_hides_what_follows() {
let empty = [be(&[10]), rect(0, 0, 0, 0)].concat();
let p = import_pict(&v2([0, 0, 50, 50], &[(0x01, empty), (0x31, rect(0, 0, 10, 10))])).unwrap();
let layer = p.document.layers[0].children().unwrap();
let NodeKind::Group { children, clip: true } = &layer[0].kind else { panic!("a clipping group: {:?}", layer[0].kind) };
let b = children[0].geometric_bounds().unwrap();
assert_eq!((b.width(), b.height()), (0.0, 0.0), "{b:?}");
}
/// A QuickTime image of `ctype` with `matrix` (a, b, c, d, tx, ty in points) and source rectangle
/// `w` × `h`, holding `data`.
fn quicktime(ctype: &[u8; 4], matrix: [f64; 6], w: i16, h: i16, data: &[u8]) -> Vec<u8> {
let fixed = |v: f64| ((v * 65536.0) as i32).to_be_bytes();
let [a, b, c, d, tx, ty] = matrix;
let mut q = be(&[0]);
for v in [a, b, 0.0, c, d, 0.0, tx, ty] {
q.extend(fixed(v));
}
q.extend((1i32 << 30).to_be_bytes());
q.extend([0; 4]);
q.extend([0; 8]);
q.extend(be(&[0]));
q.extend(rect(0, 0, h, w));
q.extend([0; 4]);
q.extend([0; 4]);
let mut desc = vec![];
desc.extend(86u32.to_be_bytes());
desc.extend(ctype);
desc.extend([0; 24]);
desc.extend(be(&[w, h]));
desc.extend([0; 8]);
desc.extend((data.len() as u32).to_be_bytes());
desc.resize(86, 0);
q.extend(desc);
q.extend(data);
[(q.len() as u32).to_be_bytes().to_vec(), q].concat()
}
fn red_jpeg() -> Vec<u8> {
let img = image::RgbImage::from_pixel(4, 2, image::Rgb([255, 0, 0]));
let mut out = std::io::Cursor::new(vec![]);
img.write_to(&mut out, image::ImageFormat::Jpeg).unwrap();
out.into_inner()
}
#[test]
fn quicktime_jpeg_images_are_placed_by_their_matrix() {
let jpeg = red_jpeg();
let ops = [
(0x8200, quicktime(b"jpeg", [2.0, 0.0, 0.0, 2.0, 10.0, 5.0], 4, 2, &jpeg)),
(0x8200, quicktime(b"rle ", [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], 4, 2, &[1, 2, 3])),
];
let p = import_pict(&v2([0, 0, 50, 50], &ops)).unwrap();
let imgs = images(&p.document);
assert_eq!(imgs.len(), 1);
let (n, px) = &imgs[0];
assert_eq!(px.dimensions(), (4, 2));
let [r, g, b, _] = px.get_pixel(1, 1).0;
assert!(r > 240 && g < 16 && b < 16, "{r} {g} {b}");
assert!(near(n.geometric_bounds().unwrap(), Rect::new(10.0, 5.0, 18.0, 9.0), 1e-9));
assert!(p.warnings.iter().any(|w| w.contains("other than JPEG")), "{:?}", p.warnings);
}
/// An image whose matrix flattens it is left out, and adds nothing to the document's images.
#[test]
fn a_flattened_image_adds_no_image_data() {
let p = import_pict(&v2([0, 0, 50, 50], &[(0x8200, quicktime(b"jpeg", [0.0; 6], 4, 2, &red_jpeg()))])).unwrap();
assert!(images(&p.document).is_empty());
assert!(p.document.images.is_empty(), "{:?}", p.document.images.keys().collect::<Vec<_>>());
}
#[test]
fn pictures_cut_short_keep_what_came_before() {
let full = v2([0, 0, 50, 50], &[(0x31, rect(0, 0, 10, 10)), (0x31, rect(20, 20, 30, 30))]);
// Cut inside the second rectangle.
let cut = &full[..full.len() - 6];
let p = import_pict(cut).unwrap();
assert_eq!(paths(&p.document).len(), 1);
assert!(p.warnings.iter().any(|w| w.contains("cut short")), "{:?}", p.warnings);
// A long comment longer than the file.
let p = import_pict(&v2([0, 0, 50, 50], &[(0x31, rect(0, 0, 10, 10)), (0xA1, be(&[100, 5000]))])).unwrap();
assert_eq!(paths(&p.document).len(), 1);
assert!(p.warnings.iter().any(|w| w.contains("cut short")), "{:?}", p.warnings);
// Nothing drawn.
let p = import_pict(&v2([0, 0, 50, 50], &[(0xA0, be(&[100]))])).unwrap();
assert!(p.warnings.iter().any(|w| w.contains("draws nothing")), "{:?}", p.warnings);
}
#[test]
fn reserved_opcodes_are_skipped_by_their_lengths() {
let ops = [
// A 16-bit length, a fixed length for Apple's reserved words, and a 32-bit length.
(0x0024, be(&[4, 1, 2])),
(0x0200, be(&[1, 2])),
(0x00D0, [8u32.to_be_bytes().to_vec(), vec![9; 8]].concat()),
(0x8201, [2u32.to_be_bytes().to_vec(), vec![9; 2]].concat()),
(0x31, rect(0, 0, 10, 10)),
];
let p = import_pict(&v2([0, 0, 50, 50], &ops)).unwrap();
assert_eq!(paths(&p.document).len(), 1, "{:?}", p.warnings);
}
/// A picture of nothing but no-ops stops at the opcode limit.
#[test]
fn a_picture_that_never_ends_stops_at_the_opcode_limit() {
let mut b = v2([0, 0, 50, 50], &[(0x31, rect(0, 0, 10, 10))]);
b.truncate(b.len() - 2);
b.resize(b.len() + 2 * ((1 << 22) + 1), 0);
let p = import_pict(&b).unwrap();
assert_eq!(paths(&p.document).len(), 1);
assert!(p.warnings.iter().any(|w| w.contains("more opcodes")), "{:?}", p.warnings);
}
/// Regions share one budget of inversion points per picture: past it, a region is left out.
#[test]
fn regions_past_the_picture_budget_are_left_out() {
// One scanline of 16,000 inversion points: 8,000 rectangles.
let mut words = vec![0];
words.extend(0..16_000i16);
words.extend([0x7FFF, 1, 0x7FFF, 0x7FFF]);
let body = [rect(0, 0, 1, 16_000), be(&words)].concat();
let region = [be(&[(body.len() + 2) as i16]), body].concat();
let p = import_pict(&v2([0, 0, 300, 300], &[(0x81, region.clone()), (0x81, region.clone()), (0x81, region)])).unwrap();
assert!(p.warnings.iter().any(|w| w.contains("regions larger")), "{:?}", p.warnings);
let v = paths(&p.document);
assert_eq!(v.len(), 2);
let NodeKind::Path { path, .. } = &v[0].kind else { panic!("a path") };
assert_eq!(path.subpaths.len(), 8_000);
}
+13 -1
View File
@@ -11,6 +11,9 @@ pub(super) struct ActualText {
depth: usize,
owner: Option<usize>,
pending: Option<String>,
/// The owner's replacement is empty: its content has no text, so its glyphs stay art
/// ([`Self::textless`], #6971).
empty: bool,
}
impl ActualText {
@@ -22,6 +25,7 @@ impl ActualText {
if self.owner == Some(self.depth) {
self.owner = None;
self.pending = None;
self.empty = false;
}
self.depth = self.depth.saturating_sub(1);
}
@@ -30,6 +34,12 @@ impl ActualText {
self.owner.is_some()
}
/// Inside a span whose replacement text is empty: the glyphs there are no text, but they
/// still draw, so they come in as outlines rather than as type with no characters (#6971).
pub(super) fn textless(&self) -> bool {
self.owner.is_some() && self.empty
}
pub(super) fn set(&mut self, bytes: &[u8]) -> bool {
if self.active() {
return true;
@@ -39,8 +49,10 @@ impl ActualText {
if self.depth == 0 || self.depth > 128 || bytes.len() > 64 * 1024 {
return false;
}
let text = crate::import_scan::text_string(bytes);
self.owner = Some(self.depth);
self.pending = Some(crate::import_scan::text_string(bytes));
self.empty = text.is_empty();
self.pending = Some(text);
true
}
}
+3 -2
View File
@@ -800,7 +800,8 @@ impl<'p> Builder<'p> {
}
/// Glyph `o` as type: added to the line being gathered (or starting one). `false`: it keeps
/// its outline (no Unicode text, mirrored, or not type mode). No `paint`: invisible text,
/// its outline (no Unicode text, mirrored, not type mode, or in a span whose ActualText is
/// empty). No `paint`: invisible text,
/// type with neither fill nor stroke.
pub(super) fn type_glyph(
&mut self,
@@ -811,7 +812,7 @@ impl<'p> Builder<'p> {
stroke: Option<&StrokeProps>,
overprint: bool,
) -> bool {
if self.text_as != TextAs::Text || self.nested > 0 {
if self.text_as != TextAs::Text || self.nested > 0 || self.actual.textless() {
return false;
}
let authored = self.actual.active();
+25
View File
@@ -121,6 +121,31 @@ fn actual_text_empty_and_nested_owners() {
}
}
/// Glyphs in a span whose ActualText is empty have no text, but they still draw: they stay on the
/// page as outlines in their paint, filled and stroked as the text rendering mode says (#6971).
#[test]
fn empty_actual_text_keeps_the_glyphs_art() {
let outlines = |report: &ImportReport| {
let mut found = vec![];
report.document.walk(|node| {
if matches!(node.kind, NodeKind::Path { .. }) {
found.push((node.appearance.fill().is_some(), node.appearance.stroke().is_some(), node.geometric_bounds()));
}
});
found
};
for (mode, fill, stroke) in [("0", true, false), ("1", false, true), ("2", true, true)] {
let content = format!("/Span << /ActualText () >> BDC 1 0 0 rg 0 0 1 RG 3 w {mode} Tr (Halo) Tj 0 Tr EMC 0 0 0 rg (C) Tj");
let report = open(&content);
assert_eq!(plain(&report), "C", "mode {mode}: the span adds no text");
let found = outlines(&report);
assert!(found.iter().any(|(f, _, _)| *f == fill) && found.iter().any(|(_, s, _)| *s == stroke), "mode {mode}: the glyphs' art: {found:?}");
let ink = found.iter().filter_map(|(_, _, b)| *b).fold(None, |acc: Option<kurbo::Rect>, b| Some(acc.map_or(b, |a| a.union(b))));
let ink = ink.unwrap_or_else(|| panic!("mode {mode}: no outlines"));
assert!(ink.x0 >= 15.0 && ink.x1 > 40.0 && ink.y0 > 50.0 && ink.y1 < 140.0, "mode {mode}: where the word is set: {ink:?}");
}
}
#[test]
fn actual_text_decodes_pdf_strings_and_preserves_authored_order() {
for (encoded, expected) in [
+11
View File
@@ -90,6 +90,17 @@ fn japanese_type_with_mojikumi_is_laid_out_again() {
assert_eq!(layouts_again(&none, true), 0);
}
/// A middle dot is a character the Mojikumi sets space: type holding only middle dots is laid out
/// again with each set that spaces them.
#[test]
fn middle_dots_with_mojikumi_are_laid_out_again() {
for set in [Mojikumi::YakumonoHankaku, Mojikumi::GyomatsuYakumonoHankaku] {
let mut t = point("・・");
t.para.mojikumi = set;
assert_eq!(layouts_again(&t, true), 1, "{set:?}");
}
}
#[test]
fn a_japanese_run_or_paragraph_lays_the_whole_object_out_again() {
let mut runs = point("Latin ");
+35
View File
@@ -115,6 +115,41 @@ fn a_scaled_nine_slice_instance_is_written_as_the_canvas_draws_it() {
assert_similar(&canvas, &resvg_render(&svg, 300, 200), 24.0, 0.002);
}
/// A scaled instance of a symbol whose left 9-slice guide lies outside its art is written as the
/// canvas draws it: the corner square is in the stretched center column. Reading the file back
/// gives that art and no 9-slice guides, which SVG has no place for.
#[test]
fn a_nine_slice_instance_with_a_guide_outside_its_art_is_written_as_drawn() {
let mut d = Document::new(300.0, 200.0);
let fill = |c: Color| Appearance::basic(Paint::solid(c), Paint::None, 0.0);
let rects = [(Rect::new(0.0, 0.0, 100.0, 100.0), Color::rgb8(220, 220, 220)), (Rect::new(0.0, 0.0, 10.0, 10.0), Color::BLACK)];
let art: Vec<Arc<Node>> = rects.iter().map(|(r, c)| Arc::new(Node::path(d.alloc_id(), shapes::rectangle(*r), fill(*c)))).collect();
let group = Node::group(d.alloc_id(), art);
d.symbols.push(Symbol { dynamic: false, name: "Panel".into(), art: Arc::new(group) });
d.set_symbol_nine_slice("Panel", vectorcraft_doc::SymbolNineSlice::new(-0.25, 0.25, 0.25, 0.25));
instance(&mut d, "Panel", Affine::translate((20.0, 20.0)) * Affine::scale_non_uniform(2.0, 1.5));
let svg = export(&d, &ExportOptions::default());
assert_eq!(svg.matches("<use ").count(), 0, "{svg}");
let canvas = render_artboard(&d);
let dark = |im: &Image, x: u32, y: u32| im.rgba[((y * im.width + x) * 4) as usize] < 60;
// The corner square covers 20..43.3 across: the center column runs 0..75 in the art and
// 0..175 in the instance. With the guide inside the art it would end at x = 30.
assert!(dark(&canvas, 40, 25) && !dark(&canvas, 46, 25) && !dark(&canvas, 40, 32));
assert_similar(&canvas, &resvg_render(&svg, 300, 200), 24.0, 0.002);
let back = import(&svg).unwrap();
assert!(back.unknown.get(vectorcraft_doc::SYMBOL_NINE_SLICE).is_none());
let mut boxes = vec![];
back.walk(|n| {
if let NodeKind::Path { path, .. } = &n.kind {
boxes.extend(path.bounds());
}
});
assert!(
boxes.iter().any(|b| (b.x0 - 20.0).abs() < 1e-3 && (b.x1 - 20.0 - 70.0 / 3.0).abs() < 1e-3 && (b.height() - 10.0).abs() < 1e-3),
"{boxes:?}"
);
}
/// A scaled instance of a symbol with 9-slice guides whose art placed through the guides could
/// hold more than `MAX_SYMBOL_NODES` objects is left out, as the canvas leaves it out: here a
/// gradient square in all nine cells, each copy with a disabled opacity mask of 2,251,501 objects.
+2
View File
@@ -167,6 +167,8 @@ pub(crate) fn apply_desired(sg: &mut [SGlyph], j: &JustificationOptions, scale_g
g.sx *= g_des;
g.dx *= g_des;
g.kern_shift = (g.kern_shift - g.aki_shift) * g_des + g.aki_shift;
// The space Mojikumi took off before the glyph scales with it.
g.lead *= g_des;
}
f.width = g.adv - aki;
f.scale = g_des;
+40 -23
View File
@@ -4,11 +4,13 @@ use super::breaking::{
roman_share, set_width, squeeze_em, tab_advance, word_width,
};
use super::region::Region;
use super::vertical::{EM_CENTER, align_shift, compress_punctuation, glyph_em, punct_half, space_japanese_and_latin};
use super::vertical::{
EM_CENTER, MojikumiRules, align_shift, compress_punctuation, dot_quarter, glyph_em, halve_punctuation, punct_half, space_japanese_and_latin,
};
use super::{
Affine, BidiInfo, Burasagari, CharStyle, Composer, Ctx, EPS, Flex, Justify, Kinsoku, KinsokuShoriType, LeadingModel, LineFit, LineInfo,
ListMarker, Mojikumi, ParaStyle, Params, Point, Punct, Range, SGlyph, SingleWordJustify, TextObject, apply_desired, cap_x_heights, compose,
fit_line, hyphen_glyph, is_cjk, is_word_space, kashida, mark_word_ends, no_line_end, no_line_start, style_metrics,
ListMarker, ParaStyle, Params, Point, Punct, Range, SGlyph, SingleWordJustify, TextObject, apply_desired, cap_x_heights, compose, fit_line,
hyphen_glyph, is_cjk, is_word_space, kashida, mark_word_ends, no_line_end, no_line_start, style_metrics,
};
use crate::justify::is_format_control;
use crate::shape::{keep_inseparable_pairs, keep_korean_words};
@@ -76,7 +78,7 @@ fn compose_para(
// The same rule as the line loop below: an opening bracket starting a wrapped line gives up
// the space before it.
let start_credit = |i: usize| {
let flush = !rtl && para.mojikumi == Mojikumi::LineEndHalf;
let flush = !rtl && MojikumiRules::of(para.mojikumi).flush_start;
sg.get(i).filter(|g| flush && g.lead <= 0.0).and_then(|g| punct_half(g, Punct::Opening)).unwrap_or(0.0)
};
let mut sim = pen.clone();
@@ -195,7 +197,8 @@ struct ParagraphData<'a, 'text> {
wakan: Vec<f64>,
/// How much narrower a line ending with each glyph is set ([`line_end_trim`]), for measuring
/// where lines break; empty when the paragraph's lines are measured at full width (Mojikumi
/// None, or right-to-left text).
/// None or YakumonoZenkaku, or right-to-left text with a set that adds no space between
/// Japanese and Latin text).
line_end: Vec<f64>,
flex: Vec<Flex>,
marker: usize,
@@ -234,14 +237,26 @@ fn prepare_paragraph<'a, 'text>(
if para.bunri_kinshi && para.kinsoku != Kinsoku::None {
keep_inseparable_pairs(text, &mut sg);
}
// Japanese composition: consecutive punctuation shares one half-em space.
let mut compressed = if para.mojikumi == Mojikumi::LineEndHalf { compress_punctuation(&mut sg) } else { vec![] };
// Japanese composition (the paragraph's Mojikumi set): every punctuation mark set half width,
// or consecutive punctuation sharing one half-em space…
let rules = MojikumiRules::of(para.mojikumi);
let mut compressed = if rules.half {
halve_punctuation(&mut sg)
} else if rules.share {
compress_punctuation(&mut sg)
} else {
vec![]
};
// …and a quarter em between Japanese and Latin text.
let mut wakan = if para.mojikumi == Mojikumi::LineEndHalf { space_japanese_and_latin(&mut sg) } else { vec![] };
let mut wakan = if rules.wakan { space_japanese_and_latin(&mut sg) } else { vec![] };
// Justification…: the glyphs at Desired word and letter spacing and glyph scaling, and how
// far a justified line may move each one from it (Dynamic Text's Block on area type sets the
// Desired word spacing to its word gap).
let mut flex = apply_desired(&mut sg, &t.layout_justification(para), !cx.vertical);
// The Japanese–Latin space is part of the advance Desired glyph scaling scaled.
for (w, f) in wakan.iter_mut().zip(&flex) {
*w *= f.scale;
}
// Threaded text: a first paragraph that continues from the frame before has its first
// line, first-line indent and list marker there.
let continued = pi == 0 && t.first_para_continues;
@@ -277,11 +292,9 @@ fn prepare_paragraph<'a, 'text>(
pen.pending += para.space_before;
}
// A line fits when it fits as it is set: with its last character narrowed by Mojikumi.
let line_end = if para.mojikumi == Mojikumi::LineEndHalf && !rtl {
(0..sg.len()).map(|k| line_end_trim(&sg, &compressed, &wakan, k)).collect()
} else {
vec![]
};
let end_rules = rules.at_line_end(rtl);
let line_end =
if end_rules.trims_line_end() { (0..sg.len()).map(|k| line_end_trim(&sg, &compressed, &wakan, end_rules, k)).collect() } else { vec![] };
let composed = compose_para(cx, &sg, &flex, &line_end, para, pen, rtl, continued);
// Hyphenation options: the Hyphenation Zone applies with the Single-line Composer, and the
// Hyphen Limit counts the lines in a row that end with a hyphen.
@@ -369,7 +382,7 @@ fn place_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, context: &mut PlacementC
// space before it goes), and the line has that much more room. At a paragraph's start
// that is the first-line indent: JIS X 4051's principle (JLREQ 3.1.5, Figure 71 ①).
if !*rtl
&& para.mojikumi == Mojikumi::LineEndHalf
&& MojikumiRules::of(para.mojikumi).flush_start
&& let Some(glyph) = sg.get_mut(*i)
&& glyph.lead <= 0.0
&& let Some(half) = punct_half(glyph, Punct::Opening)
@@ -536,14 +549,17 @@ fn run_paragraph_lines(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, t: &TextObject, mut
true
}
/// Mojikumi (Line-end Punctuation Half Width): how much narrower a line ending with glyph `k` of
/// `sg` is set. A closing bracket, comma or full stop ending the line is set half width, unless it
/// already lost that half to the punctuation after it (`compressed`), and no Japanese–Latin space
/// (`wakan`) is left after the line's last character. Never more than the glyph's advance, and 0
/// for an advance or a space that is not finite.
pub(super) fn line_end_trim(sg: &[SGlyph], compressed: &[bool], wakan: &[f64], k: usize) -> f64 {
/// Mojikumi: how much narrower a line ending with glyph `k` of `sg` is set by the paragraph's set
/// `rules`. With [`MojikumiRules::half_end`], a closing bracket, comma or full stop ending the line
/// is set half width, and with [`MojikumiRules::dot_end`] a middle dot loses the quarter em after
/// it, unless the glyph already lost that space (`compressed`); no Japanese–Latin space (`wakan`)
/// is left after the line's last character. Never more than the glyph's advance, and 0 for an
/// advance or a space that is not finite.
pub(super) fn line_end_trim(sg: &[SGlyph], compressed: &[bool], wakan: &[f64], rules: MojikumiRules, k: usize) -> f64 {
let Some(g) = sg.get(k) else { return 0.0 };
let half = if compressed.get(k).copied().unwrap_or(false) { None } else { punct_half(g, Punct::Closing) };
let lost = compressed.get(k).copied().unwrap_or(false);
let half = (!lost && rules.half_end).then(|| punct_half(g, Punct::Closing)).flatten();
let half = half.or_else(|| (!lost && rules.dot_end).then(|| dot_quarter(g)).flatten());
let trim = half.unwrap_or(0.0) + wakan.get(k).copied().unwrap_or(0.0);
if trim.is_finite() && g.adv.is_finite() { trim.min(g.adv).max(0.0) } else { 0.0 }
}
@@ -563,10 +579,11 @@ fn prepare_line_geometry(cx: &Ctx<'_>, t: &TextObject, paragraph: &ParagraphData
trimmed -= 1;
}
let hyphen = (position.hyph && position.end > index).then(|| hyphen_glyph(&sg[position.end - 1]));
let rules = MojikumiRules::of(para.mojikumi).at_line_end(paragraph.rtl);
let end_trim = trimmed
.checked_sub(1)
.filter(|&k| k >= index && para.mojikumi == Mojikumi::LineEndHalf)
.map_or(0.0, |k| line_end_trim(sg, &paragraph.compressed, &paragraph.wakan, k));
.filter(|&k| k >= index && rules.trims_line_end())
.map_or(0.0, |k| line_end_trim(sg, &paragraph.compressed, &paragraph.wakan, rules, k));
let measured_width = set_width(sg, index, trimmed, &para.tabs, position.ind_l) + hyphen.as_ref().map_or(0.0, |g| g.adv) - end_trim;
let line_start = sg.get(index).map_or(paragraph.pr.start, |g| g.byte);
let line_end = sg.get(position.end).map_or(paragraph.pr.end, |g| g.byte);
+85 -7
View File
@@ -1,6 +1,6 @@
use super::flow::flow;
use super::region::Region;
use super::{Affine, Ctx, Punct, Range, Rect, SGlyph, Tcy, TextLayout, TextObject, VerticalAlign, continues_cluster, is_cjk, punct};
use super::{Affine, Ctx, Mojikumi, Punct, Range, Rect, SGlyph, Tcy, TextLayout, TextObject, VerticalAlign, continues_cluster, is_cjk, punct};
/// System Layout on or Fractional Widths off: each character that takes space advances by a whole
/// number of points, at least one, so small type does not collapse onto itself. A zero advance (a
@@ -126,6 +126,84 @@ pub(super) fn punct_half(g: &SGlyph, kind: Punct) -> Option<f64> {
((g.face.advance(g.gid) - em).abs() <= em * 0.1).then_some(em * 0.5 * g.sx)
}
/// A quarter em of `g`'s size when it is a full-width Japanese middle dot (・, its advance an em,
/// give or take a tenth): the space on each side of the dot that a Mojikumi set can take off.
pub(super) fn dot_quarter(g: &SGlyph) -> Option<f64> {
if g.ch != '・' || g.tcy.is_some() || g.proportional {
return None;
}
let em = g.face.units_per_em();
((g.face.advance(g.gid) - em).abs() <= em * 0.1).then_some(em * 0.25 * g.sx)
}
/// What a paragraph's Mojikumi set does to its characters. The five predefined sets other than
/// Line-end Punctuation Half Width follow VectorCraft's own spacing rules, built on the help
/// page's one-line description of each set
/// (<https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/use-mojikumi-for-japanese-text-composition.html>).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(super) struct MojikumiRules {
/// Consecutive punctuation shares one half-em space ([`compress_punctuation`]).
pub share: bool,
/// Every opening and closing bracket, comma and full stop is set half width, and a middle dot
/// loses the quarter em on each side of it ([`halve_punctuation`]).
pub half: bool,
/// An opening bracket starting a line is set flush with the line's start.
pub flush_start: bool,
/// A closing bracket, comma or full stop ending a line is set half width.
pub half_end: bool,
/// A middle dot ending a line loses the quarter em after it.
pub dot_end: bool,
/// A quarter em between Japanese and Latin letters or digits ([`space_japanese_and_latin`]),
/// taken off again at a line's end.
pub wakan: bool,
}
impl MojikumiRules {
pub(super) fn of(set: Mojikumi) -> Self {
let none = Self::default();
match set {
Mojikumi::None | Mojikumi::YakumonoZenkaku => none,
Mojikumi::LineEndHalf => Self { share: true, flush_start: true, half_end: true, wakan: true, ..none },
Mojikumi::YakumonoHankaku => Self { half: true, wakan: true, ..none },
Mojikumi::GyomatsuYakumonoHankaku => Self { flush_start: true, half_end: true, dot_end: true, wakan: true, ..none },
Mojikumi::GyomatsuYakumonoZenkaku => Self { wakan: true, ..none },
}
}
/// The rules a line's end follows: a right-to-left line keeps its punctuation's width there
/// and only loses the space between Japanese and Latin text.
pub(super) fn at_line_end(self, rtl: bool) -> Self {
if rtl { Self { half_end: false, dot_end: false, ..self } } else { self }
}
/// Whether a line ending at some character can be set narrower than its characters' advances.
pub(super) fn trims_line_end(&self) -> bool {
self.half_end || self.dot_end || self.wakan
}
}
/// YakumonoHankaku: every full-width opening bracket loses the half-em space before it, every
/// closing bracket, comma and full stop the half-em space after it, and every middle dot a quarter
/// em on each side. Returns which glyphs lost the space after them (a line ending in one takes
/// nothing more off).
pub(super) fn halve_punctuation(sg: &mut [SGlyph]) -> Vec<bool> {
let mut lost_after = vec![false; sg.len()];
for (g, lost) in sg.iter_mut().zip(lost_after.iter_mut()) {
if let Some(h) = punct_half(g, Punct::Closing) {
g.adv -= h;
*lost = true;
} else if let Some(h) = punct_half(g, Punct::Opening) {
g.adv -= h;
g.lead += h;
} else if let Some(q) = dot_quarter(g) {
g.adv -= 2.0 * q;
g.lead += q;
*lost = true;
}
}
lost_after
}
/// Mojikumi (JLREQ 3.1.4): consecutive punctuation shares one half-em space. A closing bracket,
/// comma or full stop followed by punctuation loses the space after it; an opening bracket after
/// another loses the space before it. Returns which glyphs lost the space after them (a line
@@ -154,7 +232,7 @@ pub(super) fn compress_punctuation(sg: &mut [SGlyph]) -> Vec<bool> {
}
/// The space between Japanese and Latin letters or digits (JLREQ 3.2.2: a quarter em of the
/// Japanese characters' size), with Line-end Punctuation Half Width.
/// Japanese characters' size), with the Mojikumi sets that add it ([`MojikumiRules::wakan`]).
const WAKAN_AKI: f64 = 0.25;
/// A Japanese character for the space next to Latin text: kana and kanji (and full-width
@@ -174,12 +252,12 @@ fn is_japanese_letter(c: char) -> bool {
}
impl TextLayout {
/// Whether Line-end Punctuation Half Width (Mojikumi) can set type laid out as this layout
/// differently from Mojikumi None: `text`, the type's plain text, or one of the layout's list
/// markers holds Japanese punctuation or a Japanese letter, the only characters it spaces.
/// When it holds neither, the type lays out the same with either setting.
/// Whether a Mojikumi set can set type laid out as this layout differently from Mojikumi
/// None: `text`, the type's plain text, or one of the layout's list markers holds Japanese
/// punctuation, a middle dot or a Japanese letter, the only characters the sets space. When it
/// holds none of them, the type lays out the same with any set.
pub fn mojikumi_applies(&self, text: &str) -> bool {
let spaced = |c: char| punct(c).is_some() || is_japanese_letter(c);
let spaced = |c: char| punct(c).is_some() || c == '・' || is_japanese_letter(c);
text.chars().any(spaced) || self.markers.iter().any(|m| m.text.chars().any(spaced))
}
}
+1 -1
View File
@@ -453,7 +453,7 @@ pub fn vertical_punctuation_font(alternates: bool) -> Option<Vec<u8>> {
pub const FULL_WIDTH_FAMILY: &str = "Fullwidthtest Sans";
/// The ideographs and Japanese punctuation [`full_width_font`] sets one em wide.
pub const FULL_WIDTH_CHARS: &str = "一二三四五六「」、。";
pub const FULL_WIDTH_CHARS: &str = "一二三四五六「」、。・";
/// A test-only cmap over bundled Source Sans 3: [`FULL_WIDTH_CHARS`] use the one-em-wide glyph of
/// U+2318, so Japanese composition is tested without OS fonts; Latin letters and the space keep
+243
View File
@@ -1145,6 +1145,249 @@ fn mojikumi_line_end_half_width_counts_when_a_line_breaks() {
}
}
/// The predefined Mojikumi sets space punctuation by their own rules, horizontal and vertical:
/// YakumonoHankaku sets every bracket, comma and full stop half width and a middle dot a half em
/// (a quarter off each side, drawn a quarter em earlier); GyomatsuYakumonoHankaku keeps them full
/// width inside the line, sets an opening bracket starting the line flush and a closing mark ending
/// it half width, and takes the quarter em after a middle dot ending the line off;
/// GyomatsuYakumonoZenkaku and YakumonoZenkaku keep every mark full width. All but YakumonoZenkaku
/// put a quarter em between Japanese and Latin letters. Uses a test font whose ideographs and
/// punctuation are one em.
#[test]
fn mojikumi_predefined_sets_space_punctuation_by_their_rules() {
use crate::test_fonts::{FULL_WIDTH_FAMILY, full_width_font};
use vectorcraft_doc::Mojikumi;
let db = FontDb::with_font_dirs(vec![]);
db.add_font(full_width_font().expect("test font"));
let lay = |text: &str, m: Mojikumi, vertical_type: bool| {
let style = CharStyle { size: 20.0, font_family: FULL_WIDTH_FAMILY.into(), font_style: "Regular".into(), ..CharStyle::default() };
let mut t = TextObject::point(Point::ZERO, text, style);
t.xf = Affine::IDENTITY;
t.vertical = vertical_type;
t.para.mojikumi = m;
layout(&db, &t)
};
let round = |v: Vec<f64>| v.into_iter().map(|x| (x * 100.0).round() / 100.0).collect::<Vec<_>>();
for vertical_type in [false, true] {
let adv = |text: &str, m| round(lay(text, m, vertical_type).glyphs.iter().map(|g| g.advance).collect());
let at = |m: Mojikumi| format!("vertical {vertical_type} {m:?}");
let middle = "一「二」三・四、五。";
let cases = [
(Mojikumi::None, [20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0]),
(Mojikumi::YakumonoHankaku, [20.0, 10.0, 20.0, 10.0, 20.0, 10.0, 20.0, 10.0, 20.0, 10.0]),
(Mojikumi::GyomatsuYakumonoHankaku, [20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 10.0]),
(Mojikumi::GyomatsuYakumonoZenkaku, [20.0; 10]),
(Mojikumi::YakumonoZenkaku, [20.0; 10]),
];
for (m, want) in cases {
assert_eq!(adv(middle, m), want, "{}", at(m));
}
// Consecutive punctuation: YakumonoHankaku halves both marks, GyomatsuYakumonoHankaku
// neither (Line-end Punctuation Half Width only the first).
assert_eq!(adv("一」「二", Mojikumi::YakumonoHankaku), [20.0, 10.0, 10.0, 20.0], "vertical {vertical_type}");
assert_eq!(adv("一」「二", Mojikumi::GyomatsuYakumonoHankaku), [20.0; 4], "vertical {vertical_type}");
assert_eq!(adv("一」「二", Mojikumi::LineEndHalf), [20.0, 10.0, 20.0, 20.0], "vertical {vertical_type}");
// A line's start and end: the opening bracket flush and the middle dot's outer quarter off.
assert_eq!(adv("「一・", Mojikumi::GyomatsuYakumonoHankaku), [10.0, 20.0, 15.0], "vertical {vertical_type}");
assert_eq!(adv("「一・", Mojikumi::YakumonoHankaku), [10.0, 20.0, 10.0], "vertical {vertical_type}");
assert_eq!(adv("「一・", Mojikumi::GyomatsuYakumonoZenkaku), [20.0; 3], "vertical {vertical_type}");
// YakumonoHankaku draws an opening bracket half an em and a middle dot a quarter em
// earlier than its pen.
let along = |r: Rect| if vertical_type { r.y0 } else { r.x0 };
let (on, off) = (lay("一「二", Mojikumi::YakumonoHankaku, vertical_type), lay("一「二", Mojikumi::None, vertical_type));
let moved = along(off.glyphs[1].outline.bounding_box()) - along(on.glyphs[1].outline.bounding_box());
assert!((moved - 10.0).abs() < 0.01, "vertical {vertical_type}: 「 drawn half an em earlier ({moved})");
let (on, off) = (lay("一・二", Mojikumi::YakumonoHankaku, vertical_type), lay("一・二", Mojikumi::None, vertical_type));
let moved = along(off.glyphs[1].outline.bounding_box()) - along(on.glyphs[1].outline.bounding_box());
assert!((moved - 5.0).abs() < 0.01, "vertical {vertical_type}: ・ drawn a quarter em earlier ({moved})");
// The space between Japanese and Latin letters.
let none = adv("一A二", Mojikumi::None);
for (m, extra) in [
(Mojikumi::YakumonoHankaku, 5.0),
(Mojikumi::GyomatsuYakumonoHankaku, 5.0),
(Mojikumi::GyomatsuYakumonoZenkaku, 5.0),
(Mojikumi::YakumonoZenkaku, 0.0),
] {
let got = adv("一A二", m);
assert!((got[0] - none[0] - extra).abs() < 0.01 && (got[1] - none[1] - extra).abs() < 0.01, "{}: {got:?} {none:?}", at(m));
}
}
}
/// The predefined Mojikumi sets measure a line as they set it, with both composers, horizontal
/// and vertical: in a measure of three and a half ems, a closing bracket that fits only half width
/// ends the line with YakumonoHankaku and GyomatsuYakumonoHankaku, and goes to the next line with
/// the character before it (kinsoku) with GyomatsuYakumonoZenkaku and YakumonoZenkaku. A middle dot
/// that fits only without the quarter em after it ends the line with GyomatsuYakumonoHankaku.
#[test]
fn mojikumi_predefined_sets_count_when_a_line_breaks() {
use crate::test_fonts::{FULL_WIDTH_FAMILY, full_width_font};
use vectorcraft_doc::{Burasagari, Composer, Kinsoku, KinsokuShoriType, Mojikumi};
let db = FontDb::with_font_dirs(vec![]);
db.add_font(full_width_font().expect("test font"));
let lay = |text: &str, m: Mojikumi, composer: Composer, vertical_type: bool, measure: f64| {
let style = CharStyle { size: 20.0, font_family: FULL_WIDTH_FAMILY.into(), font_style: "Regular".into(), ..CharStyle::default() };
let mut t = TextObject::point(Point::ZERO, text, style);
t.xf = Affine::IDENTITY;
t.vertical = vertical_type;
t.para.mojikumi = m;
t.para.composer = composer;
t.para.burasagari = Burasagari::None;
t.para.kinsoku = Kinsoku::Hard;
t.para.kinsoku_type = KinsokuShoriType::PushOutOnly;
let frame = if vertical_type { Rect::new(0.0, 0.0, 200.0, measure) } else { Rect::new(0.0, 0.0, measure, 200.0) };
t.kind = TextKind::Area { frame: PathData::from_bezpath(&frame.to_path(0.1)) };
layout(&db, &t)
};
let first = |l: &TextLayout, text: &str| l.lines.first().map(|line| text[line.start..line.end].to_string()).unwrap_or_default();
let widest = |l: &TextLayout| {
l.lines.iter().map(|line| l.glyphs[line.glyph_start..line.glyph_end].iter().map(|g| g.advance).sum::<f64>()).fold(0.0, f64::max)
};
for vertical_type in [false, true] {
for composer in [Composer::SingleLine, Composer::EveryLine] {
let text = "一二三」四五六";
for (m, line) in [
(Mojikumi::YakumonoHankaku, "一二三」"),
(Mojikumi::GyomatsuYakumonoHankaku, "一二三」"),
(Mojikumi::GyomatsuYakumonoZenkaku, "一二"),
(Mojikumi::YakumonoZenkaku, "一二"),
] {
let l = lay(text, m, composer, vertical_type, 70.0);
assert_eq!(first(&l, text), line, "vertical {vertical_type} {composer:?} {m:?}");
assert!(widest(&l) <= 70.0 + 1e-9, "vertical {vertical_type} {composer:?} {m:?}: {}", widest(&l));
}
let text = "一二三・四五六";
for (m, line) in [(Mojikumi::GyomatsuYakumonoHankaku, "一二三・"), (Mojikumi::GyomatsuYakumonoZenkaku, "一二"), (Mojikumi::None, "一二")]
{
let l = lay(text, m, composer, vertical_type, 76.0);
assert_eq!(first(&l, text), line, "vertical {vertical_type} {composer:?} {m:?}");
assert!(widest(&l) <= 76.0 + 1e-9, "vertical {vertical_type} {composer:?} {m:?}: {}", widest(&l));
}
}
}
}
/// A right-to-left paragraph measures its lines at full width with every Mojikumi set, and draws
/// them as it measures them: a closing bracket ending a line keeps its full em, with both composers.
#[test]
fn mojikumi_right_to_left_lines_are_drawn_as_measured() {
use crate::test_fonts::{FULL_WIDTH_FAMILY, full_width_font};
use vectorcraft_doc::{Burasagari, Composer, Kinsoku, KinsokuShoriType, Mojikumi, ParaDirection};
let db = FontDb::with_font_dirs(vec![]);
db.add_font(full_width_font().expect("test font"));
let lay = |text: &str, m: Mojikumi, composer: Composer, measure: f64| {
let style = CharStyle { size: 20.0, font_family: FULL_WIDTH_FAMILY.into(), font_style: "Regular".into(), ..CharStyle::default() };
let mut t = TextObject::point(Point::ZERO, text, style);
t.xf = Affine::IDENTITY;
t.para.direction = Some(ParaDirection::RightToLeft);
t.para.mojikumi = m;
t.para.composer = composer;
t.para.burasagari = Burasagari::None;
t.para.kinsoku = Kinsoku::Hard;
t.para.kinsoku_type = KinsokuShoriType::PushOutOnly;
t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, measure, 200.0).to_path(0.1)) };
layout(&db, &t)
};
let text = "一二三」四五六";
for composer in [Composer::SingleLine, Composer::EveryLine] {
for m in [Mojikumi::LineEndHalf, Mojikumi::GyomatsuYakumonoHankaku] {
let at = format!("{composer:?} {m:?}");
// Three and a half ems: the bracket does not fit at full width, so kinsoku takes 三 on.
let narrow = lay(text, m, composer, 70.0);
let first = narrow.lines.first().map(|line| &text[line.start..line.end]);
assert_eq!(first, Some("一二"), "{at}");
// Four and a quarter ems: the line is measured four ems wide and drawn so.
let l = lay(text, m, composer, 85.0);
let first = l.lines.first().map(|line| &text[line.start..line.end]);
assert_eq!(first, Some("一二三」"), "{at}");
let bracket = text.find('」').and_then(|byte| l.glyphs.iter().find(|g| g.byte == byte)).map(|g| g.advance);
assert_eq!(bracket.map(|a| (a * 100.0).round() / 100.0), Some(20.0), "{at}");
}
}
}
/// A right-to-left line ending between a Japanese and a Latin character does not keep the quarter
/// em the set adds there: in three ems, 一二三 fits before Latin text, measured and drawn three ems
/// wide, with both composers and every set that adds the space.
#[test]
fn mojikumi_right_to_left_lines_end_without_the_japanese_latin_space() {
use crate::test_fonts::{FULL_WIDTH_FAMILY, full_width_font};
use vectorcraft_doc::{Composer, Mojikumi, ParaDirection};
let db = FontDb::with_font_dirs(vec![]);
db.add_font(full_width_font().expect("test font"));
let text = "一二三AB";
for composer in [Composer::SingleLine, Composer::EveryLine] {
for m in [Mojikumi::LineEndHalf, Mojikumi::YakumonoHankaku, Mojikumi::GyomatsuYakumonoHankaku, Mojikumi::GyomatsuYakumonoZenkaku] {
let style = CharStyle { size: 20.0, font_family: FULL_WIDTH_FAMILY.into(), font_style: "Regular".into(), ..CharStyle::default() };
let mut t = TextObject::point(Point::ZERO, text, style);
t.xf = Affine::IDENTITY;
t.para.direction = Some(ParaDirection::RightToLeft);
t.para.mojikumi = m;
t.para.composer = composer;
t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, 60.0, 200.0).to_path(0.1)) };
let l = layout(&db, &t);
let line = l.lines.first();
assert_eq!(line.map(|line| &text[line.start..line.end]), Some("一二三"), "{composer:?} {m:?}");
let drawn = line.map(|line| l.glyphs[line.glyph_start..line.glyph_end].iter().map(|g| g.advance).sum::<f64>());
assert_eq!(drawn.map(|w| (w * 100.0).round() / 100.0), Some(60.0), "{composer:?} {m:?}");
}
}
}
/// With a Desired glyph scaling other than 100%, the space a Mojikumi set takes off before an
/// opening bracket or a middle dot scales with the glyph: the glyph is drawn that much earlier,
/// with YakumonoHankaku and with Line-end Punctuation Half Width's consecutive brackets.
#[test]
fn mojikumi_space_taken_off_scales_with_desired_glyph_scaling() {
use crate::test_fonts::{FULL_WIDTH_FAMILY, full_width_font};
use vectorcraft_doc::Mojikumi;
let db = FontDb::with_font_dirs(vec![]);
db.add_font(full_width_font().expect("test font"));
let lay = |text: &str, m: Mojikumi| {
let style = CharStyle { size: 20.0, font_family: FULL_WIDTH_FAMILY.into(), font_style: "Regular".into(), ..CharStyle::default() };
let mut t = TextObject::point(Point::ZERO, text, style);
t.xf = Affine::IDENTITY;
t.para.mojikumi = m;
t.para.justification.glyph_scaling = [50.0, 50.0, 100.0];
layout(&db, &t)
};
for (text, m, k, want) in
[("一「二", Mojikumi::YakumonoHankaku, 1, 5.0), ("一・二", Mojikumi::YakumonoHankaku, 1, 2.5), ("一「「二", Mojikumi::LineEndHalf, 2, 5.0)]
{
let (on, off) = (lay(text, m), lay(text, Mojikumi::None));
let pen = |l: &TextLayout| l.glyphs[..k].iter().map(|g| g.advance).sum::<f64>();
let drawn = |l: &TextLayout| l.glyphs[k].outline.bounding_box().x0 - pen(l);
let moved = drawn(&off) - drawn(&on);
assert!((moved - want).abs() < 0.01, "{text} {m:?}: drawn {moved} earlier, want {want}");
}
}
/// With a Desired glyph scaling other than 100%, a line ending before Latin text is measured
/// without the scaled quarter em the set added there, not an unscaled one: at 50%, 一二三 is 30 pt
/// wide and does not fit 29 pt, with both composers and every set that adds the space.
#[test]
fn mojikumi_line_end_japanese_latin_space_scales_with_desired_glyph_scaling() {
use crate::test_fonts::{FULL_WIDTH_FAMILY, full_width_font};
use vectorcraft_doc::{Composer, Mojikumi};
let db = FontDb::with_font_dirs(vec![]);
db.add_font(full_width_font().expect("test font"));
let text = "一二三AB";
for composer in [Composer::SingleLine, Composer::EveryLine] {
for m in [Mojikumi::LineEndHalf, Mojikumi::YakumonoHankaku, Mojikumi::GyomatsuYakumonoHankaku, Mojikumi::GyomatsuYakumonoZenkaku] {
let style = CharStyle { size: 20.0, font_family: FULL_WIDTH_FAMILY.into(), font_style: "Regular".into(), ..CharStyle::default() };
let mut t = TextObject::point(Point::ZERO, text, style);
t.xf = Affine::IDENTITY;
t.para.mojikumi = m;
t.para.composer = composer;
t.para.justification.glyph_scaling = [50.0, 50.0, 100.0];
t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, 29.0, 200.0).to_path(0.1)) };
let l = layout(&db, &t);
let first = l.lines.first().map(|line| &text[line.start..line.end]);
assert_eq!(first, Some("一二"), "{composer:?} {m:?}");
}
}
}
/// Mojikumi: in a frame that is narrow at the top, and in a frame a wrap object narrows at the top,
/// a line's first word is measured as the line sets it. A word ending with a closing bracket that
/// fits the narrow lines only with the bracket half width, and an opening bracket that fits them
+5 -2
View File
@@ -9,8 +9,8 @@
mod solid;
mod visibility;
pub use solid::{
Bevel, BevelShape, Extrude, FaceMap, Facet, Inflate, PlanarFace, View, extrude, extrude_face_map, inflate, inflate_together, plane,
plane_face_map,
Bevel, BevelShape, Extrude, FaceMap, Facet, Inflate, PlanarFace, TOO_COMPLEX_SOLID, View, extrude, extrude_face_map, inflate, inflate_each,
inflate_together, plane, plane_face_map,
};
pub use visibility::{VisibleFace, visible_faces};
mod shading;
@@ -32,6 +32,9 @@ const MAX_FACES: usize = 32768;
const MAX_REVOLVE_FACES: usize = 163_840;
/// Most faces Extrude & Bevel and Inflate build for one solid, as many as Revolve's.
const MAX_SOLID_FACES: usize = MAX_REVOLVE_FACES;
/// Most faces the Extrude & Bevel or Inflate solids of one effect on one object keep together: the
/// solid of its fill and those of its strokes, a group's members' all counted, as many as one solid's.
pub const MAX_OBJECT_FACES: usize = MAX_SOLID_FACES;
/// Samples a curved profile segment takes, and the fewest it takes on a surface over its budget.
const CURVE_SAMPLES: usize = 16;
const MIN_CURVE_SAMPLES: usize = 4;
+2 -1
View File
@@ -30,9 +30,10 @@ use inflate::GRID_WORK;
#[cfg(test)]
pub(crate) use inflate::INFLATED;
pub(crate) use inflate::inflate_faces;
pub use inflate::{inflate, inflate_together};
pub use inflate::{inflate, inflate_each, inflate_together};
#[cfg(test)]
use membrane::{Edges, GRID_BUDGET, MAX_EDGE_ENTRIES, MAX_GRID_WORK, Membrane, cell_triangles};
pub use rings::TOO_COMPLEX_SURFACE as TOO_COMPLEX_SOLID;
use rings::signed_area;
#[cfg(test)]
use rings::{MAX_SOLID_POINTS, rings};
+12 -1
View File
@@ -62,7 +62,18 @@ pub fn inflate(path: &PathData, rule: FillRule, bounds: Rect, options: Inflate,
/// on its own over the grid of `bounds`, and one eye sees them all, as far away as the highest
/// puff needs with Perspective. One result for each shape, in order.
pub fn inflate_together(shapes: &[(&PathData, FillRule)], bounds: Rect, options: Inflate, view: View) -> Vec<Result<Vec<Facet>, String>> {
inflate_faces(shapes, bounds, options, view, false).map(|r| r.map(|(facets, ..)| facets)).collect()
inflate_each(shapes, bounds, options, view).collect()
}
/// [`inflate_together`], each solid built only as the iterator reaches it: a caller that stops early
/// builds none of the solids after.
pub fn inflate_each(
shapes: &[(&PathData, FillRule)],
bounds: Rect,
options: Inflate,
view: View,
) -> impl Iterator<Item = Result<Vec<Facet>, String>> + use<> {
inflate_faces(shapes, bounds, options, view, false).map(|r| r.map(|(facets, ..)| facets))
}
/// [`inflate_together`], or with `mesh` each solid's faces before projection instead of its facets.
+3 -1
View File
@@ -17,7 +17,9 @@ pub(super) const CURVE_PIECES: u32 = 32;
pub(super) const MIN_CURVE_PIECES: u32 = 4;
const TOO_COMPLEX_SHAPE: &str = "The shape is too complex for a 3D effect";
pub(super) const TOO_COMPLEX_SURFACE: &str = "The 3D surface is too complex; simplify the shape";
/// The error a solid past its limits returns, and a 3D effect whose solids together pass
/// [`crate::MAX_OBJECT_FACES`].
pub const TOO_COMPLEX_SURFACE: &str = "The 3D surface is too complex; simplify the shape";
/// How many straight pieces follow `c` within `tolerance` (Wang's bound on a cubic's distance from
/// its chords), 1 to [`CURVE_PIECES`].
+7 -4
View File
@@ -573,12 +573,15 @@ impl Tool for SmartPenTool {
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
let mut o = vec![];
if cx.pen_rubber_band
&& self.press.is_none()
// The rubber band preference hides only the preview segment; Measurement Labels decides the readout.
if self.press.is_none()
&& let (Some((id, _, sp)), Some(h)) = (self.active(cx), self.hover)
&& let Some(path) = self.rubber_band(sp, h)
{
o.push(Overlay::Path { path, color: cx.doc.layer_color(id), width: 1.0, dashed: false });
if cx.pen_rubber_band
&& let Some(path) = self.rubber_band(sp, h)
{
o.push(Overlay::Path { path, color: cx.doc.layer_color(id), width: 1.0, dashed: false });
}
if let Some(last) = sp.anchors.last().map(|a| a.p)
&& cx.measurement_labels
&& last.distance(h) > 1e-9
@@ -315,6 +315,35 @@ fn the_rubber_band_shows_the_next_segment_and_ending_the_path() {
assert!(t.overlays(&cx).is_empty());
}
/// With Enable Rubber Band for the Pen off, the preview segment is hidden but Measurement Labels
/// still shows the readout from the last anchor to the pointer.
#[test]
fn the_measurement_label_shows_with_the_rubber_band_off() {
let (d, _, s) = doc(line());
let p = paint();
let on = plain(&d, &s, &p);
let off = ToolContext { pen_rubber_band: false, ..plain(&d, &s, &p) };
let (from, to) = (Point::new(200.0, 100.0), Point::new(300.0, 160.0));
let measure =
|o: &[Overlay]| o.iter().filter_map(|o| if let Overlay::Measure { text, .. } = o { Some(text.clone()) } else { None }).collect::<Vec<_>>();
let band = |o: &[Overlay]| o.iter().any(|o| matches!(o, Overlay::Path { .. }));
let mut t = drawing(&off, from);
t.pointer(&off, &ev(PointerKind::Move, to.x, to.y, NO));
let o = t.overlays(&off);
assert_eq!(measure(&o), vec![off.segment_measure_label(from, to)], "{o:?}");
assert!(!band(&o), "{o:?}");
let o = t.overlays(&on);
assert_eq!(measure(&o), vec![on.segment_measure_label(from, to)], "{o:?}");
assert!(band(&o), "{o:?}");
for rubber in [true, false] {
let quiet = ToolContext { pen_rubber_band: rubber, measurement_labels: false, ..plain(&d, &s, &p) };
assert!(measure(&t.overlays(&quiet)).is_empty());
}
// Hovering exactly on the last anchor measures no segment.
t.pointer(&off, &ev(PointerKind::Move, from.x, from.y, NO));
assert!(measure(&t.overlays(&off)).is_empty());
}
#[test]
fn an_alt_drag_away_from_paths_selects_the_points_inside_a_marquee() {
let (d, id, s) = doc(SubPath::polyline(&[Point::new(100.0, 100.0), Point::new(200.0, 100.0), Point::new(300.0, 100.0)], false));
+1 -1
View File
@@ -28,7 +28,7 @@
//! Direct Selection also edits a blend's spine: drag its points (Shift-click picks several, which
//! drag together; a key object on a point moves with it) and, once a point is clicked, its
//! handles; and the points of selected gradient meshes
//! and mesh envelopes and their handles ([`MeshEdit`]): Shift-clicks and the marquee pick several,
//! and of mesh and warp envelopes and their handles ([`MeshEdit`]): Shift-clicks and the marquee pick several,
//! a click on a gradient mesh's line picks its two ends and one inside a patch the four points
//! around it, a drag of picked points moves them all, the arrow keys nudge them, and a solid fill
//! colour or the Transparency panel's opacity goes to a gradient mesh's picked points. Dragging a
+23 -17
View File
@@ -718,38 +718,44 @@ impl Tool for CurvatureTool {
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
let mut o = self.snap.guides().to_vec();
let band = cx.curvature_rubber_band && self.drag.is_none();
// The rubber band preference hides only the preview curve; Measurement Labels decides the readout.
let idle = self.drag.is_none();
let band = cx.curvature_rubber_band;
let mut bp = BezPath::new();
if self.active(cx).is_none() {
// The curve on from the current end of a selected path: its last segment and the next.
if band
if idle
&& let (Some(e), Some(h)) = (current_end(cx), self.hover)
&& let Some(sp) = subpath(cx, e.id, e.si)
{
let n = sp.anchors.len();
let near = if e.start {
sp.anchors.iter().take(2).rev().map(Anchor::reversed).collect()
} else {
sp.anchors.iter().skip(n.saturating_sub(2)).copied().collect()
};
let mut tail = SubPath::new(near, false);
curvature_extend(&mut tail, false, h, self.how(&e), false);
tail.to_bezpath_into(&mut bp);
o.push(Overlay::Path { path: bp, color: FEEDBACK, width: 1.0, dashed: false });
if band {
let n = sp.anchors.len();
let near = if e.start {
sp.anchors.iter().take(2).rev().map(Anchor::reversed).collect()
} else {
sp.anchors.iter().skip(n.saturating_sub(2)).copied().collect()
};
let mut tail = SubPath::new(near, false);
curvature_extend(&mut tail, false, h, self.how(&e), false);
tail.to_bezpath_into(&mut bp);
o.push(Overlay::Path { path: bp, color: FEEDBACK, width: 1.0, dashed: false });
}
if cx.measurement_labels && e.p.distance(h) > 1e-9 {
o.push(Overlay::Measure { p: h + Vec2::new(cx.tol(12.0), cx.tol(12.0)), text: cx.segment_measure_label(e.p, h) });
}
}
return o;
}
if band
if idle
&& !self.closed
&& let Some(h) = self.hover
{
let mut pts = self.pts.clone();
pts.push((h, false));
curvature_spline(&pts, false).to_bezpath_into(&mut bp);
o.push(Overlay::Path { path: bp, color: FEEDBACK, width: 1.0, dashed: false });
if band {
let mut pts = self.pts.clone();
pts.push((h, false));
curvature_spline(&pts, false).to_bezpath_into(&mut bp);
o.push(Overlay::Path { path: bp, color: FEEDBACK, width: 1.0, dashed: false });
}
if let Some((from, _)) = self.pts.last()
&& cx.measurement_labels
&& from.distance(h) > 1e-9
+48
View File
@@ -539,3 +539,51 @@ fn clicks_go_on_from_a_current_end_and_the_other_end_closes() {
let a = press(&mut t, &quiet(&d, &s, &p), -50.0, 150.0, false);
assert!(matches!(edit_op(&a[0]), Some(("extend", v)) if v["end"] == "start" && v["from"] == "keep"), "{a:?}");
}
/// With Enable Rubber Band for the Curvature tool off, the preview curve is hidden but
/// Measurement Labels still shows the readout, both while drawing a path and going on from the
/// current end of a selected one.
#[test]
fn the_measurement_label_shows_with_the_rubber_band_off() {
let p = paint();
let measure =
|o: &[Overlay]| o.iter().filter_map(|o| if let Overlay::Measure { text, .. } = o { Some(text.clone()) } else { None }).collect::<Vec<_>>();
let band = |o: &[Overlay]| o.iter().any(|o| matches!(o, Overlay::Path { .. }));
let to = pt(650.0, 60.0);
// Going on from the current end of a selected path.
let (d, id) = doc_with(wave());
let s = selected(id, Some(4));
let on = quiet(&d, &s, &p);
let off = ToolContext { curvature_rubber_band: false, ..quiet(&d, &s, &p) };
let mut t = CurvatureTool::default();
t.pointer(&off, &PointerEvent::new(PointerKind::Move, to.x, to.y));
let from = pt(450.0, 60.0);
let o = t.overlays(&off);
assert_eq!(measure(&o), vec![off.segment_measure_label(from, to)], "{o:?}");
assert!(!band(&o), "{o:?}");
let o = t.overlays(&on);
assert_eq!(measure(&o), vec![on.segment_measure_label(from, to)], "{o:?}");
assert!(band(&o), "{o:?}");
// A path being drawn.
let (d, id) = doc_with(SubPath::polyline(&[pt(100.0, 100.0), pt(200.0, 150.0)], false));
let s = selected(id, None);
let on = quiet(&d, &s, &p);
let off = ToolContext { curvature_rubber_band: false, ..quiet(&d, &s, &p) };
let mut t = CurvatureTool { id: Some(id), pts: vec![(pt(100.0, 100.0), false), (pt(200.0, 150.0), false)], ..CurvatureTool::default() };
assert!(t.active(&off).is_some());
t.pointer(&off, &PointerEvent::new(PointerKind::Move, to.x, to.y));
let from = pt(200.0, 150.0);
let o = t.overlays(&off);
assert_eq!(measure(&o), vec![off.segment_measure_label(from, to)], "{o:?}");
assert!(!band(&o), "{o:?}");
let o = t.overlays(&on);
assert_eq!(measure(&o), vec![on.segment_measure_label(from, to)], "{o:?}");
assert!(band(&o), "{o:?}");
for rubber in [true, false] {
let unlabeled = ToolContext { curvature_rubber_band: rubber, measurement_labels: false, ..quiet(&d, &s, &p) };
assert!(measure(&t.overlays(&unlabeled)).is_empty());
}
// Hovering exactly on the last point measures no segment.
t.pointer(&off, &PointerEvent::new(PointerKind::Move, from.x, from.y));
assert!(measure(&t.overlays(&off)).is_empty());
}
+5 -5
View File
@@ -21,7 +21,7 @@ use vectorcraft_doc::{NodeId, NodeKind};
use vectorcraft_geom::Point;
use crate::blendwidget::{self, BlendWidgetDrag};
use crate::meshedit::{MeshEdit, grid_of};
use crate::meshedit::{MeshEdit, grid_of, may_have_grid};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
const FEEDBACK: [u8; 3] = [0x4f, 0x9d, 0xff];
@@ -246,13 +246,13 @@ pub struct MeshTool {
}
impl MeshTool {
/// Meshes (gradient meshes and mesh envelopes) to consider for point hits: selected ones
/// first, then any in the document.
/// Meshes (gradient meshes, and mesh and warp envelopes) to consider for point hits: selected
/// ones first, then any in the document.
fn meshes(cx: &ToolContext) -> Vec<NodeId> {
let mut v: Vec<NodeId> = cx.selection.objects.iter().copied().filter(|id| cx.doc.node(*id).and_then(grid_of).is_some()).collect();
let mut v: Vec<NodeId> = cx.selection.objects.iter().copied().filter(|id| cx.doc.node(*id).is_some_and(may_have_grid)).collect();
let mut rest = vec![];
cx.doc.walk(|n| {
if grid_of(n).is_some() {
if may_have_grid(n) {
rest.push(n.id);
}
});
+65 -5
View File
@@ -1,5 +1,5 @@
//! Picking and dragging mesh points and their handles on the canvas, shared by the Mesh tool and
//! Direct Selection: gradient meshes and mesh envelopes alike. A click on a point picks it alone
//! Direct Selection: gradient meshes, mesh envelopes and warp envelopes alike. A click on a point picks it alone
//! and focuses it: its handles show and can be dragged too. Shift-clicks add points to the picked
//! ones or take them away, and Direct Selection's marquee and its clicks on a gradient mesh's line
//! or patch pick points too ([`part_points`]). The picked points show filled; a drag of one of
@@ -11,7 +11,7 @@ use std::borrow::Cow;
use std::collections::BTreeSet;
use serde_json::{Value, json};
use vectorcraft_doc::live::{GradientMesh, envelope_grid};
use vectorcraft_doc::live::{GradientMesh, envelope_grid, warp_grid};
use vectorcraft_doc::{EnvelopeKind, Node, NodeId, NodeKind};
use vectorcraft_geom::{Point, Vec2};
@@ -19,19 +19,30 @@ use crate::{Action, Mods, Overlay, ToolContext, ToolKey};
const FEEDBACK: [u8; 3] = [0x4f, 0x9d, 0xff];
/// The editable grid of `n`: a gradient mesh, or a mesh envelope's grid (not while its contents
/// are being edited).
/// The editable grid of `n`: a gradient mesh, a mesh envelope's grid or a warp envelope's
/// ([`warp_grid`], which its first edit makes a mesh envelope of); not while an envelope's contents
/// are being edited.
pub fn grid_of(n: &Node) -> Option<Cow<'_, GradientMesh>> {
match &n.kind {
NodeKind::Mesh(m) => Some(Cow::Borrowed(m)),
NodeKind::Envelope { kind, editing: false, .. } => match &**kind {
EnvelopeKind::Mesh { rows, cols, points, handles } => envelope_grid(*rows, *cols, points, handles).map(Cow::Owned),
_ => None,
EnvelopeKind::Warp { .. } => warp_grid(n).map(Cow::Owned),
EnvelopeKind::TopObject { .. } => None,
},
_ => None,
}
}
/// Can `n` have an editable grid ([`grid_of`])? Its kind alone answers, without building the grid.
pub fn may_have_grid(n: &Node) -> bool {
match &n.kind {
NodeKind::Mesh(_) => true,
NodeKind::Envelope { kind, editing: false, .. } => matches!(**kind, EnvelopeKind::Mesh { .. } | EnvelopeKind::Warp { .. }),
_ => false,
}
}
/// The points of gradient mesh `m` a Direct Selection click at `p` picks: the point within
/// `point_tol`, else the two ends of the mesh line within `line_tol`, else the four points around
/// the patch under `p`.
@@ -433,6 +444,55 @@ mod tests {
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 90.0, 210.0)), vec![Action::Commit]);
}
/// The rectangle of [`doc_with_rect`] in an arc warp envelope.
fn doc_with_warp() -> (Document, NodeId) {
let (mut d, e) = doc_with_envelope();
if let Some(NodeKind::Envelope { kind, .. }) = d.node_mut(e).map(|n| &mut n.kind) {
**kind =
EnvelopeKind::Warp { style: "arc".into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true, version: vectorcraft_doc::live::WARP_NET };
}
(d, e)
}
/// The Mesh tool and Direct Selection drag a warp envelope's points (on its grid) and the
/// clicked point's handles, as they do a mesh envelope's; while its contents are edited, its
/// points take no press.
#[test]
fn the_mesh_tool_and_direct_selection_drag_warp_envelope_points() {
let (mut d, e) = doc_with_warp();
let grid = grid_of(d.node(e).unwrap()).unwrap().into_owned();
assert_eq!(Some(&grid), warp_grid(d.node(e).unwrap()).as_ref());
let mut s = Selection::default();
s.set([e]);
let p = paint();
let c = cx(&d, &s, &p);
let tools: [(&str, Box<dyn Tool>); 2] =
[("mesh", crate::meshblend::create("mesh").unwrap()), ("directSelection", Box::new(crate::direct::DirectSelectionTool::new(false)))];
let (i, q) = (6, grid.points[6].clone());
for (what, mut t) in tools {
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, q.p.x, q.p.y)), vec![Action::Begin("Move Mesh Point".into())], "{what}");
assert_eq!(
t.pointer(&c, &PointerEvent::new(PointerKind::Drag, q.p.x + 10.0, q.p.y - 10.0)),
vec![Action::Preview("object.mesh.movePoint".into(), json!({"id": e.0, "index": i, "x": q.p.x + 10.0, "y": q.p.y - 10.0}))],
"{what}"
);
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, q.p.x + 10.0, q.p.y - 10.0)), vec![Action::Commit], "{what}");
// The clicked point's handle.
let end = q.p + q.handles[0];
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, end.x, end.y)), vec![Action::Begin("Move Mesh Point".into())], "{what}");
let drag = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, end.x + 5.0, end.y));
assert!(matches!(&drag[..], [Action::Preview(id, p)] if id == "object.mesh.movePoint" && p["handle"] == json!(0)), "{what}: {drag:?}");
t.pointer(&c, &PointerEvent::new(PointerKind::Up, end.x + 5.0, end.y));
}
if let Some(NodeKind::Envelope { editing, .. }) = d.node_mut(e).map(|n| &mut n.kind) {
*editing = true;
}
assert!(grid_of(d.node(e).unwrap()).is_none(), "Edit Contents: no envelope points");
let c = cx(&d, &s, &p);
let mut t = crate::meshblend::create("mesh").unwrap();
assert_ne!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, q.p.x, q.p.y)), vec![Action::Begin("Move Mesh Point".into())]);
}
#[test]
fn mesh_drag_honors_measurement_labels() {
let (d, _e) = doc_with_envelope();
+39 -4
View File
@@ -5,10 +5,15 @@
//! takes the Move cursor, and dragging the guide moves it (`symbol.options`'s
//! `nineSliceGuides`), in one undo step.
//!
//! VectorCraft's own rules: a guide stays within the art's box and stops short of the opposite
//! guide; the drag does not snap and its modifiers change nothing; a guide within reach takes the
//! press before the art and the bounding box, and of two guides within reach the nearer one, else
//! the first of left, top, right and bottom; Escape during the drag puts the guide back.
//! A guide may be dragged past the art's edges, and the instance still scales by the slices its
//! art falls in.
//!
//! VectorCraft's own rules: a guide stays within 16 times the art's width or height of its edge
//! and stops short of the opposite guide; a guide outside the art is drawn and taken along the
//! art's height or width beside it; the drag does not snap and its modifiers change nothing; a
//! guide within reach takes the press before the art and the bounding box, and of two guides
//! within reach the nearer one, else the first of left, top, right and bottom; Escape during the
//! drag puts the guide back.
use serde_json::json;
use vectorcraft_doc::{EditedNineSlice, NineSliceGuide};
@@ -203,6 +208,36 @@ mod tests {
assert_eq!(preview(&a[1]).1["nineSliceGuides"], json!([0.25, 0.4, 0.25, 0.25]));
}
/// A guide outside the art is taken beside the art, along its height, and a drag may take a
/// guide past the art's edge and back; Escape puts it back where it was.
#[test]
fn a_guide_outside_the_art_is_taken_and_dragged_past_the_arts_edge() {
let d = editing(SymbolNineSlice::new(-0.25, 0.25, 0.25, 0.25));
let (s, p) = (Selection::default(), paint());
let c = cx(&d, &s, &p);
let at = |x: f64, y: f64| guide_at(&c, Point::new(x, y)).map(|(_, g)| g);
assert_eq!(at(75.0, 150.0), Some(NineSliceGuide::Left), "25 % left of the art at x = 100");
assert_eq!(at(77.0, 110.0), Some(NineSliceGuide::Left));
assert_eq!(at(75.0, 95.0), None, "the guide ends with the art's height");
let mut t = SelectionTool::default();
assert_eq!(t.cursor(&c, Point::new(75.0, 150.0), Mods::default()), Cursor::Move);
t.pointer(&c, &ev(PointerKind::Down, 75.0, 150.0));
let a = t.pointer(&c, &ev(PointerKind::Drag, 50.0, 150.0));
assert_eq!(a[0], Action::Begin("Symbol Options".into()));
assert_eq!(preview(&a[1]).1["nineSliceGuides"], json!([-0.5, 0.25, 0.25, 0.25]));
let (_, v) = preview(&t.pointer(&c, &ev(PointerKind::Drag, 110.0, 150.0))[0]);
assert_eq!(v["nineSliceGuides"], json!([0.1, 0.25, 0.25, 0.25]), "back inside the art");
assert_eq!(t.key(&c, ToolKey::Escape, Mods::default()), vec![Action::Cancel]);
// From inside the art, the default bottom guide passes the art's bottom edge at y = 200.
let d = editing(Some(SymbolNineSlice::default()));
let c = cx(&d, &s, &p);
let mut t = SelectionTool::default();
t.pointer(&c, &ev(PointerKind::Down, 150.0, 175.0));
let a = t.pointer(&c, &ev(PointerKind::Drag, 150.0, 250.0));
assert_eq!(preview(&a[1]).1["nineSliceGuides"], json!([0.25, 0.25, 0.25, -0.5]));
assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 150.0, 250.0)), vec![Action::Commit]);
}
/// Escape during a guide drag cancels it and is the tool's; the release after it commits
/// nothing. A double-click on a guide neither edits the art nor leaves the mode.
#[test]
+11 -9
View File
@@ -495,19 +495,21 @@ impl Tool for PenTool {
return t.overlays(cx);
}
let mut o = vec![];
if cx.pen_rubber_band
&& self.drag.is_none()
// The rubber band preference hides only the preview segment; Measurement Labels decides the readout.
if self.drag.is_none()
&& self.handle.is_none()
&& let (Some((id, _, _, last, out)), Some(h)) = (self.active(cx), self.hover)
{
let mut bp = BezPath::new();
bp.move_to(last);
if out.distance(last) > 1e-9 {
bp.quad_to(out, h);
} else {
bp.line_to(h);
if cx.pen_rubber_band {
let mut bp = BezPath::new();
bp.move_to(last);
if out.distance(last) > 1e-9 {
bp.quad_to(out, h);
} else {
bp.line_to(h);
}
o.push(Overlay::Path { path: bp, color: cx.doc.layer_color(id), width: 1.0, dashed: false });
}
o.push(Overlay::Path { path: bp, color: cx.doc.layer_color(id), width: 1.0, dashed: false });
if cx.measurement_labels && last.distance(h) > 1e-9 {
o.push(Overlay::Measure { p: h + Vec2::new(cx.tol(12.0), cx.tol(12.0)), text: cx.segment_measure_label(last, h) });
}
+42
View File
@@ -773,6 +773,48 @@ fn anchors_snap_to_smart_guides() {
assert_eq!(a[1], Action::Preview("path.create".into(), json!({"anchors": [{"x": 100.0, "y": 100.0}]})));
}
/// With Enable Rubber Band off, the preview segment is hidden but Measurement Labels still shows
/// the D / Angle / dX / dY readout at the snapped point before the next click.
#[test]
fn measurement_label_shows_with_the_rubber_band_off() {
let (d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[Point::new(10.0, 300.0), Point::new(60.0, 300.0)], false));
let p = paint();
let on = cx(&d, &s, &p);
let off = ToolContext { pen_rubber_band: false, ..cx(&d, &s, &p) };
let from = Point::new(60.0, 300.0);
let to = Point::new(150.0, 318.0);
let measure =
|o: &[Overlay]| o.iter().filter_map(|o| if let Overlay::Measure { text, .. } = o { Some(text.clone()) } else { None }).collect::<Vec<_>>();
let preview = |o: &[Overlay]| {
o.iter().any(|o| matches!(o, Overlay::Path { path, .. } if path.elements().first().and_then(|e| e.end_point()) == Some(from)))
};
let mut t = PenTool { drawing: true, ..PenTool::default() };
t.pointer(&off, &PointerEvent::new(PointerKind::Move, 152.0, 318.0));
let o = t.overlays(&off);
assert_eq!(measure(&o), vec![off.segment_measure_label(from, to)], "{o:?}");
assert!(!preview(&o), "{o:?}");
// The rubber band on still shows both.
let o = t.overlays(&on);
assert_eq!(measure(&o), vec![on.segment_measure_label(from, to)], "{o:?}");
assert!(preview(&o), "{o:?}");
// Measurement Labels off shows no readout either way.
for band in [true, false] {
let quiet = ToolContext { pen_rubber_band: band, measurement_labels: false, ..cx(&d, &s, &p) };
assert!(measure(&t.overlays(&quiet)).is_empty());
}
// The click places the same anchor as with the rubber band on.
let a = click(&mut t, &off, 152.0, 318.0, Mods::default());
assert_eq!(a[1], Action::Preview("path.appendAnchor".into(), json!({"id": id.0, "x": 150.0, "y": 318.0, "subpath": 0})));
// Hovering exactly on the last anchor measures no segment.
let mut t = PenTool { drawing: true, ..PenTool::default() };
t.pointer(&off, &PointerEvent::new(PointerKind::Move, 60.0, 300.0));
assert!(measure(&t.overlays(&off)).is_empty());
// With no path being drawn there is nothing to measure from.
let mut t = PenTool::default();
t.pointer(&off, &PointerEvent::new(PointerKind::Move, 152.0, 318.0));
assert!(measure(&t.overlays(&off)).is_empty());
}
#[test]
fn alt_over_a_shown_handle_or_an_anchor_converts() {
// The middle anchor is smooth: handles at (150, 100) and (250, 100) round (200, 100).
+8 -3
View File
@@ -1383,9 +1383,7 @@ pub(crate) fn release_guide_at(cx: &ToolContext, p: Point, m: Mods) -> Option<Ac
/// handle that sits where `h` appears on screen, in 45° steps. In a turned view, `angle` is the
/// box's own angle less [`ToolContext::view_angle`].
pub(crate) fn handle_cursor(h: Handle, angle: f64) -> Cursor {
let steps = if angle.is_finite() { (angle / 45.0).round() as i64 } else { 0 };
let h = Handle::ALL.get((h as i64 - steps).rem_euclid(8) as usize).copied().unwrap_or(h);
match h {
match screen_handle(h, angle) {
Handle::Top | Handle::Bottom => Cursor::ResizeV,
Handle::Left | Handle::Right => Cursor::ResizeH,
Handle::TopLeft | Handle::BottomRight => Cursor::ResizeNwSe,
@@ -1393,6 +1391,13 @@ pub(crate) fn handle_cursor(h: Handle, angle: f64) -> Cursor {
}
}
/// The handle of an upright box that sits where handle `h` of a box turned by `angle`
/// (counter-clockwise degrees) appears, in 45° steps.
pub(crate) fn screen_handle(h: Handle, angle: f64) -> Handle {
let steps = if angle.is_finite() { (angle / 45.0).round() as i64 } else { 0 };
Handle::ALL.get((h as i64 - steps).rem_euclid(8) as usize).copied().unwrap_or(h)
}
#[cfg(test)]
mod tests;
+86 -36
View File
@@ -4,15 +4,17 @@
//! Alt from centre), outside the corners rotates, inside moves, and Cmd-dragging a side handle
//! shears along that side. A scale snaps as the Selection tool's does. The Perspective Distort and Free Distort modes (tool option `mode`, or
//! Cmd / Cmd+Alt+Shift while dragging a corner like classic Illustrator) move the corners of the
//! box and preview `object.distort`.
//! box and preview `object.distort`. After a rotation the box stays turned with the selection, as
//! the Selection tool's does: its handles scale, shear and distort along the selection's own sides.
use serde_json::{Value, json};
use vectorcraft_doc::OrientedBox;
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
use super::{BLUE, polygon, rect_corners};
use crate::bbox::{Handle, hit_handle, in_rotate_zone, move_delta, rotate_for_drag, scale_for_drag};
use crate::guides::{PointSnap, Targets};
use crate::select::{matrix_json, selection_bounds};
use crate::select::{handle_cursor, matrix_json, screen_handle, selection_box};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
@@ -69,7 +71,8 @@ impl Op {
#[derive(Clone, Copy, Debug)]
struct Drag {
op: Op,
rect: Rect,
/// The selection's bounding box at the press, turned with the selection.
bx: OrientedBox,
start: Point,
began: bool,
}
@@ -170,28 +173,37 @@ impl FreeTransformTool {
}
}
fn classify(&self, cx: &ToolContext, r: Rect, p: Point, m: Mods) -> Option<Op> {
let tol = cx.tol(5.0);
if let Some(h) = hit_handle(r, p, tol) {
/// What a press at `p` on box `bx` does, read in the box's own frame.
fn classify(&self, cx: &ToolContext, bx: &OrientedBox, p: Point, m: Mods) -> Option<Op> {
let (tol, r, lp) = (cx.tol(5.0), bx.rect, bx.to_local(p));
if let Some(h) = hit_handle(r, lp, tol) {
return Some(self.handle_op(h, m));
}
if self.mode == DistortMode::Free && in_rotate_zone(r, p, tol, cx.tol(18.0)).is_some() {
if self.mode == DistortMode::Free && in_rotate_zone(r, lp, tol, cx.tol(18.0)).is_some() {
return Some(Op::Rotate);
}
r.contains(p).then_some(Op::Move)
r.contains(lp).then_some(Op::Move)
}
fn preview(&mut self, cx: &ToolContext, d: Drag, p: Point, m: Mods) -> Action {
let shift = m.shift || self.constrain;
let bx = d.bx;
let delta = p - d.start;
// The drag along the box's own sides.
let local = bx.to_local(p) - bx.to_local(d.start);
self.guides.clear();
let xf = match d.op {
Op::Corners(h, mode) => {
let q = distort_quad(d.rect, h, delta, mode);
let q = distort_quad(bx.rect, h, local, mode).map(|c| bx.to_doc() * c);
self.quad = Some(q);
self.measure = cx.measurement_labels.then(|| (p, cx.offset_label(delta.x, delta.y)));
let corners: Vec<Value> = q.iter().map(|c| json!([c.x, c.y])).collect();
return Action::Preview("object.distort".into(), json!({ "corners": corners, "from": [d.rect.x0, d.rect.y0, d.rect.x1, d.rect.y1] }));
let r = bx.rect;
let mut params = json!({ "corners": corners, "from": [r.x0, r.y0, r.x1, r.y1] });
if bx.angle != 0.0 {
params["angle"] = json!(bx.angle);
}
return Action::Preview("object.distort".into(), params);
}
Op::Move => {
let v = move_delta(d.start, p, shift, cx.constrain_angle);
@@ -199,22 +211,23 @@ impl FreeTransformTool {
Affine::translate(v)
}
Op::Scale(h) => {
let mut a = scale_for_drag(d.rect, h, p, shift, m.alt);
// Scaled in the box's own frame, then turned back onto the page.
let mut a = scale_for_drag(bx.rect, h, bx.to_local(p), shift, m.alt);
if let Some(t) = &self.targets {
(a, self.guides) = t.snap_scale(cx, &vectorcraft_doc::OrientedBox::aligned(d.rect), h, a, shift, m.alt);
(a, self.guides) = t.snap_scale(cx, &bx, h, a, shift, m.alt);
}
let nr = a.transform_rect_bbox(d.rect);
let nr = a.transform_rect_bbox(bx.rect);
self.measure = cx.transform_tools_guides.then(|| (p, cx.size_label(nr.width(), nr.height())));
a
bx.conjugate(a)
}
Op::Shear(h) => shear_for_side(d.rect, h, delta, m.alt),
Op::Shear(h) => bx.conjugate(shear_for_side(bx.rect, h, local, m.alt)),
Op::Rotate => {
let (a, deg) = rotate_for_drag(d.rect.center(), d.start, p, shift);
let (a, deg) = rotate_for_drag(bx.center(), d.start, p, shift);
self.measure = cx.transform_tools_guides.then(|| (p, format!("{:.1}°", -deg)));
a
}
};
self.quad = Some(rect_corners(d.rect).map(|c| xf * c));
self.quad = Some(bx.corners().map(|c| xf * c));
Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(xf), "copy": false }))
}
}
@@ -233,10 +246,10 @@ impl Tool for FreeTransformTool {
match ev.kind {
PointerKind::Down => {
self.drag = None;
let Some(r) = selection_bounds(cx) else { return vec![] };
if let Some(op) = self.classify(cx, r, p, ev.mods) {
let Some(bx) = selection_box(cx) else { return vec![] };
if let Some(op) = self.classify(cx, &bx, p, ev.mods) {
self.targets = op.handle().is_some().then(|| PointSnap::new(cx, || Targets::for_scale(cx)));
self.drag = Some(Drag { op, rect: r, start: p, began: false });
self.drag = Some(Drag { op, bx, start: p, began: false });
}
vec![]
}
@@ -292,10 +305,12 @@ impl Tool for FreeTransformTool {
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
let mut o = vec![];
let quad = match (self.quad, selection_bounds(cx)) {
(Some(q), _) => q,
(None, Some(r)) => rect_corners(r),
_ => return o,
let quad = match self.quad {
Some(q) => q,
None => match selection_box(cx) {
Some(bx) => bx.corners(),
None => return o,
},
};
o.push(Overlay::Path { path: polygon(&quad, true), color: BLUE, width: 1.0, dashed: false });
for i in 0..4 {
@@ -313,19 +328,17 @@ impl Tool for FreeTransformTool {
}
fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor {
let Some(r) = selection_bounds(cx) else { return Cursor::Arrow };
match self.drag.map(|d| d.op).or_else(|| self.classify(cx, r, p, m)) {
Some(Op::Rotate) => Cursor::rotate_about_in_view(r.center(), p, cx.view_angle()),
let Some(bx) = self.drag.map(|d| d.bx).or_else(|| selection_box(cx)) else { return Cursor::Arrow };
// The box's angle as it shows on screen.
let shown = bx.angle - cx.view_angle();
match self.drag.map(|d| d.op).or_else(|| self.classify(cx, &bx, p, m)) {
Some(Op::Rotate) => Cursor::rotate_about_in_view(bx.center(), p, cx.view_angle()),
Some(Op::Move) => Cursor::Move,
Some(Op::Shear(Handle::Top | Handle::Bottom)) => Cursor::ShearH,
Some(Op::Shear(_)) => Cursor::ShearV,
Some(Op::Scale(h)) => match h {
Handle::Top | Handle::Bottom => Cursor::ResizeV,
Handle::Left | Handle::Right => Cursor::ResizeH,
Handle::TopLeft | Handle::BottomRight => Cursor::ResizeNwSe,
Handle::TopRight | Handle::BottomLeft => Cursor::ResizeNeSw,
}
.turned(cx.view_angle()),
Some(Op::Shear(h)) => match screen_handle(h, shown) {
Handle::Top | Handle::Bottom | Handle::TopLeft | Handle::BottomRight => Cursor::ShearH,
_ => Cursor::ShearV,
},
Some(Op::Scale(h)) => handle_cursor(h, shown),
Some(Op::Corners(..)) => Cursor::Crosshair,
None => Cursor::Arrow,
}
@@ -422,6 +435,43 @@ mod tests {
assert_eq!(t.cursor(&c, Point::new(207.0, 93.0), Mods::default()), Cursor::RotateToward(3), "out from the top right");
}
/// After a 30° turn the box, its handles and their cursors turn with the selection, and a side
/// handle scales along the selection's own width.
#[test]
fn the_box_and_its_handles_turn_with_the_selection() {
let (mut d, id) = doc_with_rect();
let c = Vec2::new(150.0, 150.0);
let turn = Affine::translate(c) * Affine::rotate(-30f64.to_radians()) * Affine::translate(-c);
d.node_mut(id).unwrap().transform(turn, false);
let mut s = Selection::default();
s.add(id);
let p = paint();
let cx = cx(&d, &s, &p);
let bx = selection_box(&cx).unwrap();
assert!((bx.angle - 30.0).abs() < 1e-9, "{bx:?}");
let mut t = FreeTransformTool::default();
let shown: Vec<Point> = t.overlays(&cx).iter().filter_map(|o| if let Overlay::Anchor { p, .. } = o { Some(*p) } else { None }).collect();
for corner in bx.corners() {
assert!(shown.iter().any(|q| q.distance(corner) < 1e-9), "{corner:?} not in {shown:?}");
}
let top = bx.to_doc() * Handle::Top.pos(bx.rect);
assert_eq!(t.cursor(&cx, top, Mods::default()), Cursor::ResizeNwSe, "the top handle, turned 30°");
// The Right handle dragged 100 pt out along the selection's own x axis doubles its width.
let right = bx.to_doc() * Handle::Right.pos(bx.rect);
let to = bx.to_doc() * (Handle::Right.pos(bx.rect) + Vec2::new(100.0, 0.0));
t.pointer(&cx, &ev(PointerKind::Down, right.x, right.y));
let a = t.pointer(&cx, &ev(PointerKind::Drag, to.x, to.y));
assert_eq!(a[0], Action::Begin("Scale".into()));
let Some(Action::Preview(_, v)) = a.last() else { panic!("{a:?}") };
let m: Vec<f64> = v["matrix"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect();
let m = Affine::new([m[0], m[1], m[2], m[3], m[4], m[5]]);
let local = bx.to_doc().inverse() * m * bx.to_doc();
let want = Affine::translate((bx.rect.x0, 0.0)) * Affine::scale_non_uniform(2.0, 1.0) * Affine::translate((-bx.rect.x0, 0.0));
for (got, want) in local.as_coeffs().iter().zip(want.as_coeffs()) {
assert!((got - want).abs() < 1e-9, "{local:?}");
}
}
#[test]
fn cmd_side_handle_shears() {
let (d, id) = doc_with_rect();
+1
View File
@@ -68,6 +68,7 @@ rustix = { workspace = true, features = ["std", "process"] }
[dev-dependencies]
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
vectorcraft-apischema = { workspace = true }
vectorcraft-testkit = { workspace = true }
# A variable font built in code (no font file is committed).
vectorcraft-text = { workspace = true, features = ["test-fonts"] }
+4
View File
@@ -416,6 +416,10 @@ impl VectorcraftApp {
self.pen_end.forget_tool();
self.held_tool.clear();
self.activate_tool(id, true);
let now = self.session.tool_id();
if crate::touch::is_drawing_tool(now) {
self.ui.touch_drawing_tool = Some(now.to_string());
}
}
/// Go back to tool `id` after a tool that stood in for it (the pen's eraser end, [`stylus`]):
+92
View File
@@ -966,6 +966,88 @@ fn two_finger_tap_undoes_and_three_finger_tap_redoes() {
assert_eq!(count(&app), 2, "off: the first finger draws as the pointer, and nothing is undone");
}
/// In the Touch workspace a two-finger tap switches between the Selection tool and the drawing tool
/// chosen last, and undoes nothing; three fingers still redo. Outside it, two fingers undo again.
#[test]
fn two_finger_tap_in_touch_switches_selection_and_drawing_modes() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
app.session.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 40, "height": 40})).unwrap();
crate::workspaces::switch(&mut app.ui, crate::workspaces::TOUCH).unwrap();
app.select_tool("rectangle");
let ctx = egui::Context::default();
frame(&mut app, &ctx, vec![]);
let count = |app: &VectorcraftApp| app.session.active().unwrap().doc.layers[0].children().unwrap().len();
let c = app.canvas_rect.unwrap().center();
let touch = |n: u64, phase, pos| egui::Event::Touch { device_id: egui::TouchDeviceId(1), id: egui::TouchId(n), phase, pos, force: None };
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
let tap = |app: &mut VectorcraftApp, fingers: u64| {
let at = |n: u64| c + vec2(n as f32 * 50.0, 0.0);
frame(app, &ctx, vec![touch(0, egui::TouchPhase::Start, at(0)), egui::Event::PointerMoved(at(0)), button(at(0), true)]);
frame(app, &ctx, vec![touch(0, egui::TouchPhase::Move, at(0) + vec2(6.0, 6.0)), egui::Event::PointerMoved(at(0) + vec2(6.0, 6.0))]);
frame(app, &ctx, (1..fingers).map(|n| touch(n, egui::TouchPhase::Start, at(n))).collect());
frame(app, &ctx, vec![touch(0, egui::TouchPhase::End, at(0)), button(at(0) + vec2(6.0, 6.0), false), egui::Event::PointerGone]);
frame(app, &ctx, (1..fingers).map(|n| touch(n, egui::TouchPhase::End, at(n))).collect());
frame(app, &ctx, vec![]);
};
tap(&mut app, 2);
assert_eq!((app.session.tool_id(), count(&app)), ("selection", 1), "selection mode, nothing undone or drawn");
tap(&mut app, 2);
assert_eq!((app.session.tool_id(), count(&app)), ("rectangle", 1), "back to the drawing tool");
// A tool chosen by hand becomes the drawing tool a switch returns to.
app.select_tool("ellipse");
tap(&mut app, 2);
tap(&mut app, 2);
assert_eq!((app.session.tool_id(), count(&app)), ("ellipse", 1));
// The switches added no undo step: undo still takes the rectangle away, and three fingers redo it.
app.run("edit.undo", json!({})).unwrap();
assert_eq!(count(&app), 0);
tap(&mut app, 3);
assert_eq!(count(&app), 1, "three fingers redo in Touch too");
// Presentation Mode runs neither.
app.ui.screen_mode = 3;
frame(&mut app, &ctx, vec![]);
tap(&mut app, 2);
assert_eq!(app.session.tool_id(), "ellipse", "Presentation Mode: no switch");
app.ui.screen_mode = 0;
// Enable Touch Gestures off: no switch.
app.session.prefs.touch_gestures = false;
app.select_tool("selection");
frame(&mut app, &ctx, vec![]);
tap(&mut app, 2);
assert_eq!(app.session.tool_id(), "selection", "off: no switch");
app.session.prefs.touch_gestures = true;
// Another workspace: two fingers undo again and switch no tool.
crate::workspaces::switch(&mut app.ui, crate::workspaces::ESSENTIALS).unwrap();
app.select_tool("rectangle");
frame(&mut app, &ctx, vec![]);
let before = count(&app);
tap(&mut app, 2);
assert_eq!(app.session.tool_id(), "rectangle");
assert_eq!(count(&app), before - 1, "Essentials: undone");
}
/// Switch Selection and Drawing Modes from the command: Pencil when no drawing tool was chosen yet,
/// a view tool is left for Selection without becoming the drawing tool, and every selection tool
/// switches to drawing mode.
#[test]
fn switch_selection_and_drawing_modes_command() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
assert!(app.ui.touch_drawing_tool.is_none());
app.select_tool("selection");
let r = app.run(crate::touch::TOGGLE_MODE, json!({})).unwrap();
assert_eq!((r, app.session.tool_id()), (json!({"tool": "pencil"}), "pencil"), "no drawing tool chosen yet: Pencil");
app.select_tool("hand");
assert_eq!(app.run(crate::touch::TOGGLE_MODE, json!({})).unwrap(), json!({"tool": "selection"}));
assert_eq!(app.run(crate::touch::TOGGLE_MODE, json!({})).unwrap(), json!({"tool": "pencil"}), "the Hand tool is no drawing tool");
app.select_tool("pen");
for id in ["directSelection", "groupSelection", "magicWand", "lasso"] {
app.select_tool(id);
assert_eq!(app.run(crate::touch::TOGGLE_MODE, json!({})).unwrap(), json!({"tool": "pen"}), "{id}");
}
}
/// The Artboard tool's label holds the artboard's name (never translated); the tools' own
/// labels are interface text.
#[test]
@@ -3881,6 +3963,16 @@ fn the_edited_symbols_nine_slice_guides_are_drawn_and_dragged_on_the_canvas() {
assert_eq!(guides_now(&app).left, 0.25, "one undo step");
app.run("edit.redo", json!({})).unwrap();
assert!((guides_now(&app).left - 0.1).abs() < 1e-3);
// Dragged on past the art's left edge at x = 100, the guide lies outside the art and is drawn
// there, along the art's height.
let (from, to) = (xf.to_screen(Point::new(110.0, 150.0)), xf.to_screen(Point::new(80.0, 150.0)));
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(from), button(from, true)]);
for k in 1..=4 {
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(from + (to - from) * k as f32 / 4.0)]);
}
frame(&mut app, &ctx, vec![button(to, false)]);
assert!((guides_now(&app).left + 0.2).abs() < 1e-3, "{:?}", guides_now(&app));
assert!(dots(&mut app, Point::new(80.0, 100.0), Point::new(80.0, 200.0)) > 10, "the guide drawn outside the art");
app.run("symbol.exitEditing", json!({})).unwrap();
assert_eq!(dots(&mut app, Point::new(110.0, 100.0), Point::new(110.0, 200.0)), 0, "none outside the mode");
}
+4 -3
View File
@@ -532,7 +532,8 @@ pub(super) fn wheel(
/// Enable Touch Gestures: this frame's finger contacts on the canvas ([`crate::touch`]). A second
/// finger drops the press the first one began (its tool interaction rolled back; the rest of the
/// gesture presses nothing), and a tap of two fingers undoes, one of three redoes.
/// gesture presses nothing), and a tap of two fingers undoes, one of three redoes. In the Touch
/// workspace a tap of two fingers switches between selection and drawing modes instead.
pub(super) fn touch_gestures(app: &mut VectorcraftApp, ui: &Ui, rect: egui::Rect, view: vectorcraft_engine::ViewInfo) {
if !app.session.prefs.touch_gestures {
return;
@@ -564,12 +565,12 @@ pub(super) fn touch_gestures(app: &mut VectorcraftApp, ui: &Ui, rect: egui::Rect
drop_press(app, ui, view);
ui.data_mut(|d| d.insert_temp(drag_id(), Drag::Gesture));
}
Some(crate::touch::Touch::Tap(n)) => tap = crate::touch::tap_command(n),
Some(crate::touch::Touch::Tap(n)) => tap = crate::touch::tap_command(n, app.ui.workspace == crate::workspaces::TOUCH),
None => {}
}
}
ui.data_mut(|d| d.insert_temp(key, taps));
// In Presentation Mode a tap of two or three fingers undoes or redoes nothing.
// In Presentation Mode a tap of two or three fingers runs nothing.
if let Some(cmd) = tap
&& app.ui.screen_mode < 3
{
+128 -40
View File
@@ -942,27 +942,23 @@ pub(crate) fn opentype_body(ui: &mut egui::Ui, d: &mut Dialog) {
}
}
/// The key under which the dialog keeps its footer's height for the next frame: the dialog's own
/// id, so the stored value is found on every frame.
fn footer_id(ui: &egui::Ui) -> egui::Id {
ui.layer_id().id.with("char-style-footer")
/// The key under which the dialog keeps the height its section content was drawn at, for the next
/// frame.
fn content_id(ui: &egui::Ui) -> egui::Id {
ui.layer_id().id.with("char-style-content")
}
/// The dialog's body: the section list beside the selected section's content, which scrolls when it
/// is taller than the window (General lists every attribute of the style and OpenType every
/// feature), and the footer below it - Reset Panel, the Preview checkbox and an invalid value's
/// error. The content's height is the space the footer leaves, measured last frame, so the buttons
/// stay on screen at any interface scale; the section list is outside the scroll area and stays put
/// while the content scrolls. Returns whether the dialog asks to be cancelled (never: Cancel comes
/// from the button row in `dialogs::show`).
/// The dialog's body: the section list beside the selected section's content, and below them
/// Reset Panel, the Preview checkbox and an invalid value's error. The content is at most 420 points
/// tall, and scrolls when it is taller than that or than the screen less the rest of the window
/// (as drawn last frame). The section list stays put while the content scrolls. Returns whether
/// the dialog asks to be canceled (never: Cancel comes from the button row in `dialogs::show`).
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
// The content scrolls inside a shorter window: General lists every attribute of the style and
// OpenType every feature, so the window stays within the screen and its buttons stay visible.
// The footer was measured last frame, so the room left for the content keeps it on screen at any
// interface scale; the section list stays put while the content scrolls.
let footer = ui.ctx().data(|m| m.get_temp::<f32>(footer_id(ui)).unwrap_or(120.0));
let room = (ui.available_height() - footer - 8.0).clamp(0.0, 420.0);
ui.horizontal(|ui| {
let window = ui.ctx().memory(|m| m.area_rect(ui.layer_id().id)).map_or(0.0, |r| r.height());
let content = ui.ctx().data(|m| m.get_temp::<f32>(content_id(ui))).unwrap_or(0.0);
let rest = (window - content).max(0.0);
let room = (ui.ctx().content_rect().height() - rest - 2.0 * super::EDGE_GAP).clamp(0.0, 420.0);
let drawn = ui.horizontal(|ui| {
section_list(ui, d);
ui.separator();
// The section list handles a click before this frame's content is laid out, so the section
@@ -971,9 +967,13 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let content_w = ui.available_width();
// Long sections scroll both ways: at a large interface scale the window has fewer points, and
// a wide row (a character-format grid) must stay inside it rather than widen the window.
egui::ScrollArea::both().id_salt(("char-style-sections", section)).max_height(room).max_width(content_w).auto_shrink([false, true]).show(
ui,
|ui| {
let shown = egui::ScrollArea::both()
.id_salt(("char-style-sections", section))
.max_height(room)
.min_scrolled_height(room)
.max_width(content_w)
.auto_shrink([false, true])
.show(ui, |ui| {
ui.vertical(|ui| {
if d.str("section") == "basic" {
basic_body(ui, d);
@@ -996,28 +996,24 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
}
}
});
},
);
});
// `inner_rect` is the height offered before a short section shrinks the area to its content.
shown.inner_rect.height().min(shown.content_size.y)
});
let footer_h = ui
.scope(|ui| {
reset_button(ui, d);
match edited_attrs(d) {
Ok(attrs) => {
let params = json!({"name": d.str("__style"), "to": d.str("name"), "attrs": attrs});
form::preview(app, ui, d, tl!("Character Style Options"), "charStyle.edit", params);
}
Err(why) => attrs_error(ui, &why),
}
})
.response
.rect
.height();
let was = ui.ctx().data(|m| m.get_temp::<f32>(footer_id(ui)));
ui.ctx().data_mut(|m| m.insert_temp(footer_id(ui), footer_h));
if was != Some(footer_h) {
let content_h = drawn.inner;
let was = ui.ctx().data(|m| m.get_temp::<f32>(content_id(ui)));
ui.ctx().data_mut(|m| m.insert_temp(content_id(ui), content_h));
if was.is_none_or(|was| (was - content_h).abs() > 0.5) {
ui.ctx().request_repaint();
}
reset_button(ui, d);
match edited_attrs(d) {
Ok(attrs) => {
let params = json!({"name": d.str("__style"), "to": d.str("name"), "attrs": attrs});
form::preview(app, ui, d, tl!("Character Style Options"), "charStyle.edit", params);
}
Err(why) => attrs_error(ui, &why),
}
false
}
@@ -1127,6 +1123,98 @@ mod tests {
let basic = ctx.memory(|m| m.area_rect(dialog)).expect("the dialog is drawn").width();
assert!((basic - width).abs() < 0.5, "the width does not change with the section: {width} -> {basic}");
}
/// On a 1920 by 1080 screen the dialog opens with its content 420 points tall when the General
/// list is longer than that: the first frame that draws it, with no input, already has the full
/// height, and later frames keep it.
#[test]
fn the_sections_open_at_their_full_height_without_input() {
let mut app = app();
app.run(
"charStyle.new",
json!({"name": "Body", "attrs": {
"font_family": "Source Sans 3", "font_style": "Regular", "size": 24.0, "leading": 30.0,
"kerning": 0.0, "tracking": 5.0, "all_caps": false, "small_caps": null,
"position": {"kind": "normal", "size": 100.0, "position": 0.0}, "underline": false,
"strikethrough": false, "standardVerticalRoman": false, "charAlign": "romanBaseline",
"h_scale": 100.0, "v_scale": 100.0, "baseline_shift": 0.0, "rotation": 0.0,
"language": "English: USA", "overprint_fill": false, "overprint_stroke": false,
"stroke_width": 0.0, "features": ["liga", "calt", "dlig", "swsh", "salt", "titl", "ordn", "frac"]
}}),
)
.unwrap();
open(&mut app, "Body").unwrap();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1920.0, 1080.0));
let mut drawn = 0;
for _ in 0..6 {
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| super::super::show(&mut app, ui.ctx()));
out.textures_delta.clear();
let texts = texts_of(&out);
let top = |label: &str| texts.iter().find(|(text, _)| text == label).map(|r| r.1.top());
// The first frame only measures the window and draws no text.
let (Some(reset), Some(general)) = (top("Reset Panel"), top("General")) else { continue };
drawn += 1;
let content = reset - general;
assert!(content >= 410.0, "the content is 420 points tall in drawn frame {drawn}: {content}");
}
assert!(drawn >= 4, "the dialog is drawn: {drawn} frames");
// On the smallest window the buttons are on the screen in the first frame that draws them.
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 500.0));
for _ in 0..3 {
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| super::super::show(&mut app, ui.ctx()));
out.textures_delta.clear();
let texts = texts_of(&out);
let Some(ok) = texts.iter().find(|(text, _)| text == "OK").map(|r| r.1) else { continue };
let cancel = texts.iter().find(|(text, _)| text == "Cancel").expect("Cancel is drawn with OK").1;
assert!(ok.bottom() <= 501.0 && cancel.bottom() <= 501.0, "the buttons are on an 800 by 500 screen when first drawn: {ok:?} {cancel:?}");
return;
}
panic!("the dialog is drawn on an 800 by 500 screen");
}
/// Going back to the long General list from a shorter section keeps OK and Cancel on a small
/// screen in the first frame after the switch, not only once the window has measured itself again.
#[test]
fn switching_to_a_long_section_keeps_the_buttons_on_a_small_screen() {
let mut app = app();
app.run(
"charStyle.new",
json!({"name": "Body", "attrs": {
"font_family": "Source Sans 3", "font_style": "Regular", "size": 24.0, "leading": 30.0,
"kerning": 0.0, "tracking": 5.0, "all_caps": false, "small_caps": null,
"position": {"kind": "normal", "size": 100.0, "position": 0.0}, "underline": false,
"strikethrough": false, "standardVerticalRoman": false, "charAlign": "romanBaseline",
"h_scale": 100.0, "v_scale": 100.0, "baseline_shift": 0.0, "rotation": 0.0,
"language": "English: USA", "overprint_fill": false, "overprint_stroke": false,
"stroke_width": 0.0, "features": ["liga", "calt", "dlig", "swsh", "salt", "titl", "ordn", "frac"]
}}),
)
.unwrap();
open(&mut app, "Body").unwrap();
for h in [560.0, 640.0, 720.0] {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(900.0, h));
for section in ["basic", "advanced", "color", "opentype"] {
app.ui.dialog.as_mut().unwrap().fields.insert("section".into(), json!(section));
let _ = frame_texts_in(&ctx, &mut app, 900.0, h);
app.ui.dialog.as_mut().unwrap().fields.insert("section".into(), json!("general"));
let mut out =
ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| super::super::show(&mut app, ui.ctx()));
out.textures_delta.clear();
let texts = texts_of(&out);
for label in ["OK", "Cancel"] {
let r = texts.iter().find(|(text, _)| text == label).unwrap_or_else(|| panic!("{label} is drawn")).1;
assert!(r.bottom() <= h + 1.0, "{label} stays on a {h}-point screen going from {section} to General: {r:?}");
}
}
}
}
/// The dialog stays usable in the smallest window the app allows (800 by 500 points): in every
/// section the section list, OK and Cancel are on the screen.
#[test]

Some files were not shown because too many files have changed in this diff Show More