Merge main into fix/nested-link-ids

This commit is contained in:
dotCooCoo
2026-11-13 04:06:39 -08:00
210 changed files with 13614 additions and 934 deletions
Generated
+2
View File
@@ -6476,6 +6476,7 @@ dependencies = [
"vectorcraft-plugins",
"vectorcraft-text",
"vectorcraft-three-d",
"vectorcraft-trace",
]
[[package]]
@@ -6854,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};
@@ -267,8 +267,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
View File
@@ -15,6 +15,7 @@ vectorcraft-doc = { workspace = true }
vectorcraft-pathops = { workspace = true }
vectorcraft-text = { workspace = true }
vectorcraft-plugins = { workspace = true }
vectorcraft-trace = { workspace = true }
image = { workspace = true }
kurbo = { workspace = true }
# Effect › SVG Filters draws its filters with resvg (MIT OR Apache-2.0), at the usvg version the
+1 -1
View File
@@ -296,7 +296,7 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
"path.outlineObject",
"Outline Object",
PATH,
"{} type is drawn, exported and expanded as its glyph outlines and stays editable, and the effects after it reshape those outlines; paths and images do not change",
"{} type is drawn, exported and expanded as its glyph outlines and stays editable, and the effects after it reshape those outlines; paths and images do not change. On an image's own fill or stroke it gives the outline of the image's pixels that are at least half opaque (VectorCraft's own cutoff), and nothing when no pixel is; it gives the image's frame when the image has no transparency, its pixels can't be read, or the image or its outline is too large to trace",
json!({}),
),
g(
+2 -2
View File
@@ -80,8 +80,8 @@ pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks};
pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace};
pub use raster::{RASTERIZE, RasterFx, Rasterize, color_param, follow_items, item_effects_in, outset, raster_effects, without_geometry};
pub use reshape::{
SYMBOL_ART_MAX_NODES, expand_outlined, gives_image_shape, has_gradient_copies, has_item_geometry, has_painted_effects, has_transform_art,
has_transform_copies, has_transform_options, image_paint, needs_outline, outline_art, outline_text, reshape,
SYMBOL_ART_MAX_NODES, expand_outlined, follows_image_pixels, gives_image_shape, has_gradient_copies, has_item_geometry, has_painted_effects,
has_transform_art, has_transform_copies, has_transform_options, image_paint, needs_outline, outline_art, outline_text, reshape,
};
pub use revolve::{
CLASSIC_REVOLVE, REVOLVE, has_revolve, is_revolve, revolve_art, revolve_art_in, revolve_options, validate_revolve, validate_revolve_in,
+133 -15
View File
@@ -10,12 +10,13 @@
//! inline graphics whose own fills or strokes have geometry effects is outlined too: each of those
//! effects reshapes the whole outline that its fill or stroke paints. An image's own fills and
//! strokes paint only the shape that a Convert to Shape or Outline Object effect on them gives
//! them ([`image_rows`]).
//! them ([`image_rows`]): Outline Object follows the outline of the image's opaque area
//! ([`opaque_area`]).
use std::collections::{HashMap, HashSet};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::rc::Rc;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, OnceLock, PoisonError, Weak};
use vectorcraft_doc::live::Hooks;
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, ImageObject, Node, NodeId, NodeKind};
@@ -287,8 +288,8 @@ pub fn gives_image_shape(effects: &[Effect]) -> bool {
/// The fills and strokes of `items` (an image's own) that paint something ([`shapes_image`]), each
/// without the effects before its first Convert to Shape or Outline Object. Convert to Shape sizes
/// its shape from the image's box, and Outline Object gives the image's frame. The others paint
/// nothing.
/// its shape from the image's box, and Outline Object gives the outline of the image's opaque
/// area ([`opaque_area`]), or its frame when it has no transparency. The others paint nothing.
fn image_rows(items: &[AppearanceItem]) -> Vec<AppearanceItem> {
items
.iter()
@@ -303,11 +304,109 @@ fn image_rows(items: &[AppearanceItem]) -> Vec<AppearanceItem> {
.collect()
}
/// Does `item`, one of [`image_rows`], start from the outline of the image's opaque area (is its
/// first effect Outline Object rather than Convert to Shape)?
fn follows_opaque_area(item: &AppearanceItem) -> bool {
item.effects().first().is_some_and(|e| e.id == "path.outlineObject")
}
/// Is `n` an image with a fill or stroke of its own that follows the outline of the image's opaque
/// area ([`image_rows`], [`opaque_area`])? The shape that fill or stroke paints then changes with
/// the image's pixels in the document while `n` stays the same.
pub fn follows_image_pixels(n: &Node) -> bool {
matches!(n.kind, NodeKind::Image(_))
&& n.appearance
.items
.iter()
.any(|i| shapes_image(i) && i.effects().iter().find(|e| gives_shape(e)).is_some_and(|e| e.id == "path.outlineObject"))
}
/// The outline of a set of pixels' opaque area, in the pixel space they decode to, with that size
/// ([`traced_opaque_area`]). `None` for pixels that have no transparency or could not be traced.
type Traced = Option<(Arc<PathData>, (u32, u32))>;
/// One of [`Outlines`]' entries: the pixels, held weakly, and their outline once it is traced.
type Entry = (Weak<Vec<u8>>, Arc<OnceLock<Traced>>);
/// The outlines [`opaque_area`] has traced, by the address of the pixels each was traced from.
struct Outlines {
/// Each outline with its pixels. An entry does not keep its pixels, and while it is kept no
/// other pixels can take their address. Entries whose pixels are gone are dropped before a
/// new outline is traced and every [`PRUNE_EVERY`] lookups.
kept: BTreeMap<usize, Entry>,
/// The lookups since the last pruning.
lookups: usize,
}
static OUTLINES: Mutex<Outlines> = Mutex::new(Outlines { kept: BTreeMap::new(), lookups: 0 });
/// How many lookups [`OUTLINES`] takes between two prunings of its entries for pixels that are gone.
const PRUNE_EVERY: usize = 256;
#[cfg(test)]
thread_local! {
/// The outlines [`traced_opaque_area`] has traced on this thread.
pub(crate) static TRACES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// Does [`OUTLINES`] keep an entry for `pixels`?
#[cfg(test)]
pub(crate) fn keeps_outline(pixels: &Weak<Vec<u8>>) -> bool {
OUTLINES.lock().unwrap_or_else(PoisonError::into_inner).kept.values().any(|(w, _)| w.ptr_eq(pixels))
}
/// The outline of the opaque area of the image `im` in the document
/// ([`vectorcraft_trace::opaque_outline`]: its pixels that are at least half opaque, outlined with
/// fitted curves), its pixels taken from `im` itself or from the first of `docs` that holds them.
/// `None` when the image has no transparency, its pixels can't be read or are too large to read
/// ([`vectorcraft_trace::decode_alpha`]), or its outline would be too complex: Outline Object then
/// follows the image's frame. The outline is traced once for each set of pixels and kept while
/// those pixels are: a caller that asks while another thread traces the same pixels waits for that
/// trace.
pub(crate) fn opaque_area(docs: &[Option<&Document>], im: &ImageObject) -> Option<PathData> {
let blob = im.pixels.as_deref().or_else(|| docs.iter().flatten().find_map(|d| d.images.get(&im.key)))?;
let bytes = &blob.bytes;
let cell = {
let mut o = OUTLINES.lock().unwrap_or_else(PoisonError::into_inner);
o.lookups += 1;
if o.lookups >= PRUNE_EVERY {
o.lookups = 0;
o.kept.retain(|_, (w, _)| w.strong_count() > 0);
}
let key = Arc::as_ptr(bytes).addr();
match o.kept.get(&key) {
Some((_, cell)) => Arc::clone(cell),
None => {
o.kept.retain(|_, (w, _)| w.strong_count() > 0);
let cell = Arc::new(OnceLock::new());
o.kept.insert(key, (Arc::downgrade(bytes), Arc::clone(&cell)));
cell
}
}
};
// Traced without the lock: another image's outline need not wait for this one.
let (path, (w, h)) = cell.get_or_init(|| traced_opaque_area(bytes)).clone()?;
let to_image = Affine::scale_non_uniform(f64::from(im.width) / f64::from(w.max(1)), f64::from(im.height) / f64::from(h.max(1)));
Some(path.transformed(im.xf * to_image))
}
/// [`opaque_area`]'s outline of the encoded image `bytes`. An image whose format has no alpha is
/// not decoded.
fn traced_opaque_area(bytes: &[u8]) -> Traced {
#[cfg(test)]
TRACES.with(|t| t.set(t.get() + 1));
let (w, h, alpha) = vectorcraft_trace::decode_alpha(bytes).ok()??;
let outline = vectorcraft_trace::opaque_outline(w, h, alpha).ok()??;
Some((Arc::new(outline), (w, h)))
}
/// The shapes that the image `n`'s own fills and strokes paint ([`image_rows`]), each with its fill
/// rule and the fill or stroke that paints it: the paths (or compound paths) along its frame that
/// [`reshape`] makes, with each fill's or stroke's geometry effects applied as the renderer applies
/// them. Empty for anything but an image with such fills or strokes. The swatches and patterns that
/// live-effect plug-ins painting their pieces name are looked up in `doc`.
/// rule and the fill or stroke that paints it: the paths (or compound paths) that [`reshape`]
/// makes along its frame or, for those whose first effect is Outline Object, along the outline of
/// its opaque area ([`opaque_area`]), with each fill's or stroke's geometry effects applied as the
/// renderer applies them. Empty for anything but an image with such fills or strokes. The swatches
/// and patterns that live-effect plug-ins painting their pieces name are looked up in `doc`, and
/// the image's pixels in `doc` when the image does not hold them.
pub fn image_paint(doc: Option<&Document>, n: &Node) -> Vec<(BezPath, FillRule, AppearanceItem)> {
if !matches!(n.kind, NodeKind::Image(_)) || !has_item_geometry(n) {
return vec![];
@@ -608,9 +707,10 @@ pub(crate) fn outline_nested_text(doc: Option<&Document>, n: &mut Node, clipping
/// `n` (see [`needs_outline`]) with its object-level geometry effects and its perspective applied:
/// its [`outline_art`] reshaped as one piece, or for an image the image clipped by its reshaped
/// frame. Type's own fills and strokes keep their effects on its whole outline. An image's own
/// fills and strokes that paint a shape ([`image_rows`]) become paths along its frame below and
/// above it, reshaped with it, and keep their effects from their first Convert to Shape or Outline
/// Object on. The result is a group with `n`'s id, name, visibility, transparency, opacity mask
/// fills and strokes that paint a shape ([`image_rows`]) become paths below and above it, along
/// its frame or, from Outline Object, along the outline of its opaque area ([`opaque_area`]),
/// reshaped with it, and keep their effects from their first Convert to Shape or Outline Object
/// on. The result is a group with `n`'s id, name, visibility, transparency, opacity mask
/// and remaining (raster) effects; `None` when `n` has no visible geometry effect, no perspective
/// and is not type or an image with a fill or stroke of its own that [`has_item_geometry`] counts,
/// or has no art (a symbol instance without `symbol_art`). A path with [`has_transform_art`] is
@@ -697,15 +797,33 @@ fn as_art_opts(
}
Node::new(n.id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(image.clone())], clip: true })
};
// The fills and strokes that paint a shape, as paths along the frame below and above
// the image.
// The fills and strokes that paint a shape, as paths below and above the image: along
// the frame, or for those whose first effect is Outline Object along the outline of
// the image's opaque area (traced only when one of them needs it). A run of either
// kind is one path; a fill or stroke whose outline is empty (an image with no opaque
// pixel) paints nothing.
let (below, above) = n.appearance.split_contents();
let (below, above) = (image_rows(below), image_rows(above));
if below.is_empty() && above.is_empty() {
clipped()
} else {
let opaque = std::cell::OnceCell::new();
let rows = |items: Vec<AppearanceItem>| {
(!items.is_empty()).then(|| Arc::new(Node::path(n.id, frame.clone(), Appearance { items, ..Default::default() })))
let mut out = vec![];
let mut items = items.into_iter().peekable();
while let Some(first) = items.next() {
let follows = follows_opaque_area(&first);
let mut run = vec![first];
while let Some(next) = items.next_if(|i| follows_opaque_area(i) == follows) {
run.push(next);
}
let path = if follows { opaque.get_or_init(|| opaque_area(&[symbols, paint_doc], im)).as_ref() } else { None };
let path = path.unwrap_or(&frame);
if !path.is_empty() {
out.push(Arc::new(Node::path(n.id, path.clone(), Appearance { items: run, ..Default::default() })));
}
}
out
};
// Without geometry effects, the image is not clipped.
let image = if fx.is_empty() { image.clone() } else { clipped() };
+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::*;
+1
View File
@@ -4,6 +4,7 @@ use vectorcraft_geom::{Affine, PathData, Point, Rect, Shape, SubPath, shapes};
use super::*;
mod imageoutline;
mod rasterfollow;
mod scale;
mod transformfx;
+338
View File
@@ -0,0 +1,338 @@
//! Outline Object on an image's own fill or stroke: the outline of the image's opaque area, for
//! every pixel format with alpha, and the frame for one without.
use std::sync::Arc;
use serde_json::json;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{AppearanceItem, Document, Effect, FillLayer, ImageBlob, ImageObject, Node, NodeId, NodeKind, StrokeLayer};
use vectorcraft_geom::{Affine, Rect};
use crate::*;
fn fx(id: &str) -> Effect {
Effect { id: id.into(), params: json!({}), visible: true }
}
/// `img` encoded as `format`.
fn encoded(img: image::DynamicImage, format: image::ImageFormat) -> Vec<u8> {
let mut out = vec![];
img.write_to(&mut std::io::Cursor::new(&mut out), format).unwrap();
out
}
/// A 100×100 RGBA image, transparent but for an opaque disc of radius 30 in its middle.
fn disc() -> image::DynamicImage {
image::DynamicImage::ImageRgba8(image::RgbaImage::from_fn(100, 100, |x, y| {
let r = (f64::from(x) + 0.5 - 50.0).hypot(f64::from(y) + 0.5 - 50.0);
image::Rgba([255, 0, 0, if r <= 30.0 { 255 } else { 0 }])
}))
}
/// A document holding the encoded image `bytes` (key "img") and an image object of 100×100 pixels
/// placed at (50, 50) with the appearance items `items`, the image's own row at `contents`.
/// → (document, the image's node).
fn image_doc(bytes: Vec<u8>, items: Vec<AppearanceItem>, contents: usize) -> (Document, Node) {
let mut d = Document::new(200.0, 200.0);
d.images.insert("img".into(), ImageBlob::new("image/png", bytes));
let mut n = Node::new(
NodeId(0),
NodeKind::Image(Box::new(ImageObject {
key: "img".into(),
width: 100,
height: 100,
xf: Affine::translate((50.0, 50.0)),
link: None,
placement: Default::default(),
pixels: None,
})),
);
n.id = d.alloc_id();
n.appearance.items = items;
n.appearance.set_contents_at(contents);
let l = d.layers[0].id;
d.insert(Some(l), 0, n.clone()).unwrap();
(d, n)
}
/// A PNG of `w × h` pixels with the color type `color` at `depth` bits, the chunks `extra` before
/// its pixels, and the filtered rows `rows` stored without compression.
fn png(w: u32, h: u32, color: u8, depth: u8, extra: &[(&[u8; 4], Vec<u8>)], rows: &[u8]) -> Vec<u8> {
fn crc(data: &[u8]) -> u32 {
let mut c = !0u32;
for &b in data {
c ^= u32::from(b);
for _ in 0..8 {
c = if c & 1 == 1 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 };
}
}
!c
}
let mut out = b"\x89PNG\r\n\x1a\n".to_vec();
let mut chunk = |kind: &[u8; 4], data: &[u8]| {
let mut body = kind.to_vec();
body.extend_from_slice(data);
out.extend_from_slice(&u32::try_from(data.len()).unwrap().to_be_bytes());
out.extend_from_slice(&body);
out.extend_from_slice(&crc(&body).to_be_bytes());
};
let mut ihdr = [w.to_be_bytes(), h.to_be_bytes()].concat();
ihdr.extend([depth, color, 0, 0, 0]);
chunk(b"IHDR", &ihdr);
for (kind, data) in extra {
chunk(kind, data);
}
// A zlib stream of stored blocks.
let mut z = vec![0x78, 0x01];
let blocks: Vec<&[u8]> = if rows.is_empty() { vec![rows] } else { rows.chunks(65_535).collect() };
for (i, block) in blocks.iter().enumerate() {
let len = u16::try_from(block.len()).unwrap();
z.push(u8::from(i + 1 == blocks.len()));
z.extend(len.to_le_bytes());
z.extend((!len).to_le_bytes());
z.extend_from_slice(block);
}
let (mut a, mut b) = (1u32, 0u32);
for &byte in rows {
a = (a + u32::from(byte)) % 65_521;
b = (b + a) % 65_521;
}
z.extend(((b << 16) | a).to_be_bytes());
chunk(b"IDAT", &z);
chunk(b"IEND", &[]);
out
}
fn stroke(effects: Vec<Effect>) -> AppearanceItem {
let mut s = StrokeLayer::new(Paint::solid(Color::BLACK), 4.0);
s.effects = effects;
AppearanceItem::Stroke(s)
}
/// The bounds of the paths the image's own fills and strokes paint, below it first.
fn row_bounds(d: &Document, n: &Node) -> Vec<Rect> {
let art = reshape::reshape(Some(d), n, None).unwrap();
let mut out = vec![];
for c in art.children().unwrap() {
if matches!(c.kind, NodeKind::Path { .. }) {
out.push(c.geometric_bounds().unwrap());
}
}
out
}
fn near(r: Rect, want: [f64; 4], tol: f64) -> bool {
[r.x0 - want[0], r.y0 - want[1], r.x1 - want[2], r.y1 - want[3]].iter().all(|d| d.abs() < tol)
}
#[test]
fn outline_object_follows_the_opaque_area_of_an_image_with_alpha() {
let gray_alpha = image::DynamicImage::ImageLumaA8(disc().to_luma_alpha8());
let deep = image::DynamicImage::ImageRgba16(disc().to_rgba16());
for (name, img) in [("rgba", disc()), ("gray and alpha", gray_alpha), ("16 bits a channel", deep)] {
let (d, n) = image_doc(encoded(img, image::ImageFormat::Png), vec![stroke(vec![fx("path.outlineObject")])], 1);
let rows = row_bounds(&d, &n);
assert_eq!(rows.len(), 1, "{name}");
// The disc runs from 70 to 130; the frame from 50 to 150.
assert!(near(rows[0], [70.0, 70.0, 130.0, 130.0], 0.5), "{name}: {:?}", rows[0]);
}
}
#[test]
fn outline_object_follows_the_frame_of_an_image_without_transparency() {
let opaque = image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(100, 100, image::Rgb([0, 0, 255])));
let solid = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(100, 100, image::Rgba([0, 0, 255, 200])));
let formats = [
("jpeg", encoded(opaque.clone(), image::ImageFormat::Jpeg)),
("rgb png", encoded(opaque, image::ImageFormat::Png)),
("rgba png, every pixel more than half opaque", encoded(solid, image::ImageFormat::Png)),
];
for (name, bytes) in formats {
let (d, n) = image_doc(bytes, vec![stroke(vec![fx("path.outlineObject")])], 1);
let rows = row_bounds(&d, &n);
assert!(rows.len() == 1 && near(rows[0], [50.0, 50.0, 150.0, 150.0], 1e-9), "{name}: {rows:?}");
}
// Pixels that can't be read keep the frame too.
let (d, n) = image_doc(b"not an image".to_vec(), vec![stroke(vec![fx("path.outlineObject")])], 1);
assert!(near(row_bounds(&d, &n)[0], [50.0, 50.0, 150.0, 150.0], 1e-9));
}
#[test]
fn convert_to_shape_keeps_the_frame_beside_outline_object() {
// Below the image: a fill with Convert to Shape, then a stroke with Outline Object; above it
// a stroke with Convert to Shape before Outline Object, which starts from the frame.
let rect = Effect { id: "convertToShape.rectangle".into(), params: json!({"relative": true, "extraW": 0, "extraH": 0}), visible: true };
let mut fill = FillLayer::new(Paint::solid(Color::rgb(0.0, 1.0, 0.0)));
fill.effects = vec![rect.clone()];
let items = vec![AppearanceItem::Fill(fill), stroke(vec![fx("path.outlineObject")]), stroke(vec![rect, fx("path.outlineObject")])];
let (d, n) = image_doc(encoded(disc(), image::ImageFormat::Png), items, 2);
let rows = row_bounds(&d, &n);
assert_eq!(rows.len(), 3, "{rows:?}");
assert!(near(rows[0], [50.0, 50.0, 150.0, 150.0], 1e-9), "{rows:?}");
assert!(near(rows[1], [70.0, 70.0, 130.0, 130.0], 0.5), "{rows:?}");
assert!(near(rows[2], [50.0, 50.0, 150.0, 150.0], 1e-9), "{rows:?}");
// The image keeps its place between them.
let art = reshape::reshape(Some(&d), &n, None).unwrap();
assert!(matches!(art.children().unwrap()[2].kind, NodeKind::Image(_)));
}
#[test]
fn a_transparent_image_gives_outline_object_nothing_to_paint() {
let clear = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(100, 100, image::Rgba([0, 0, 0, 100])));
let (d, n) = image_doc(encoded(clear, image::ImageFormat::Png), vec![stroke(vec![fx("path.outlineObject")])], 1);
assert!(row_bounds(&d, &n).is_empty());
let art = reshape::reshape(Some(&d), &n, None).unwrap();
assert!(matches!(art.children().unwrap().as_slice(), [only] if matches!(only.kind, NodeKind::Image(_))));
}
#[test]
fn the_outline_follows_the_images_own_pixels_and_placement() {
// Pixels kept on the image itself (art made outside a document), placed at half size.
let (_, mut n) = image_doc(vec![], vec![stroke(vec![fx("path.outlineObject")])], 1);
if let NodeKind::Image(im) = &mut n.kind {
im.pixels = Some(Arc::new(ImageBlob::new("image/png", encoded(disc(), image::ImageFormat::Png))));
im.xf = Affine::translate((10.0, 20.0)) * Affine::scale(0.5);
}
let art = reshape::reshape(None, &n, None).unwrap();
let b = art.children().unwrap()[0].geometric_bounds().unwrap();
// The disc runs from 20 to 80 pixels: half that, moved by (10, 20).
assert!(near(b, [20.0, 30.0, 50.0, 60.0], 0.3), "{b:?}");
}
#[test]
fn an_off_center_outline_turns_and_reflects_with_a_smaller_copy_of_the_pixels() {
// A 120×60 image whose opaque area is the rectangle from (12, 6) to (40, 24), its pixels a
// copy at half size (as a linked image's preview is), turned a quarter and reflected.
let half = image::DynamicImage::ImageRgba8(image::RgbaImage::from_fn(60, 30, |x, y| {
image::Rgba([255, 0, 0, if (6..20).contains(&x) && (3..12).contains(&y) { 255 } else { 0 }])
}));
let (d, mut n) = image_doc(encoded(half, image::ImageFormat::Png), vec![stroke(vec![fx("path.outlineObject")])], 1);
let xf = Affine::translate((100.0, 40.0)) * Affine::rotate(std::f64::consts::FRAC_PI_2) * Affine::scale_non_uniform(-1.0, 1.0);
if let NodeKind::Image(im) = &mut n.kind {
(im.width, im.height, im.xf) = (120, 60, xf);
}
let rows = row_bounds(&d, &n);
let want = xf.transform_rect_bbox(Rect::new(12.0, 6.0, 40.0, 24.0));
// The rectangle runs from (76, 0) to (94, 28); a flip in either direction, or the copy's
// width and height swapped, would move it.
assert!(near(want, [76.0, 0.0, 94.0, 28.0], 1e-9), "{want:?}");
assert!(rows.len() == 1 && near(rows[0], [want.x0, want.y0, want.x1, want.y1], 0.5), "{rows:?}");
}
#[test]
fn an_image_in_a_group_exports_with_its_outline() {
let (mut d, n) = image_doc(encoded(disc(), image::ImageFormat::Png), vec![stroke(vec![fx("path.outlineObject")])], 1);
let l = d.layers[0].id;
d.remove(n.id).unwrap();
let g = d.alloc_id();
d.insert(Some(l), 0, Node::group(g, vec![Arc::new(n)])).unwrap();
let baked = bake_document(&d).unwrap();
let mut paths = vec![];
for layer in &baked.layers {
layer.walk(&mut |c| {
if matches!(c.kind, NodeKind::Path { .. }) && (c.appearance.stroke_width() - 4.0).abs() < 1e-9 {
paths.push(c.geometric_bounds().unwrap());
}
});
}
assert!(matches!(paths.as_slice(), [b] if near(*b, [70.0, 70.0, 130.0, 130.0], 0.5)), "{paths:?}");
}
/// The bounds of the paths stroked 4 pt in `d` with its effects baked, as exports write it.
fn baked_strokes(d: &Document) -> Vec<Rect> {
let baked = bake_document(d).unwrap();
let mut paths = vec![];
for layer in &baked.layers {
layer.walk(&mut |c| {
if matches!(c.kind, NodeKind::Path { .. }) && (c.appearance.stroke_width() - 4.0).abs() < 1e-9 {
paths.push(c.geometric_bounds().unwrap());
}
});
}
paths
}
#[test]
fn an_image_under_a_clipping_mask_or_with_an_opacity_mask_exports_with_its_outline() {
let disc_outline = |paths: &[Rect]| matches!(paths, [b] if near(*b, [70.0, 70.0, 130.0, 130.0], 0.5));
// Under a clipping mask that covers the whole image.
let (mut d, n) = image_doc(encoded(disc(), image::ImageFormat::Png), vec![stroke(vec![fx("path.outlineObject")])], 1);
let l = d.layers[0].id;
d.remove(n.id).unwrap();
let clip = Node::path(d.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)), Default::default());
let g = d.alloc_id();
d.insert(Some(l), 0, Node::new(g, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(n)], clip: true })).unwrap();
let paths = baked_strokes(&d);
assert!(disc_outline(&paths), "{paths:?}");
// With an opacity mask.
let (mut d, mut n) = image_doc(encoded(disc(), image::ImageFormat::Png), vec![stroke(vec![fx("path.outlineObject")])], 1);
let mut white = Node::path(NodeId(999), vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)), Default::default());
white.appearance.set_fill(Paint::solid(Color::WHITE));
n.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(Node::group(NodeId(998), vec![Arc::new(white)]), true)));
let id = n.id;
*d.node_mut(id).unwrap() = n;
let paths = baked_strokes(&d);
assert!(disc_outline(&paths), "{paths:?}");
}
#[test]
fn outline_object_on_a_fill_and_on_an_indexed_image_follows_the_opaque_area() {
let mut fill = FillLayer::new(Paint::solid(Color::rgb(0.0, 1.0, 0.0)));
fill.effects = vec![fx("path.outlineObject")];
let (d, n) = image_doc(encoded(disc(), image::ImageFormat::Png), vec![AppearanceItem::Fill(fill)], 1);
let rows = row_bounds(&d, &n);
assert!(matches!(rows.as_slice(), [b] if near(*b, [70.0, 70.0, 130.0, 130.0], 0.5)), "{rows:?}");
// A palette image whose first color is transparent (a tRNS chunk).
let mut rows = vec![];
for y in 0..100 {
rows.push(0);
for x in 0..100 {
let r = (f64::from(x) + 0.5 - 50.0).hypot(f64::from(y) + 0.5 - 50.0);
rows.push(u8::from(r <= 30.0));
}
}
let palette = png(100, 100, 3, 8, &[(b"PLTE", vec![0, 0, 0, 255, 0, 0]), (b"tRNS", vec![0, 255])], &rows);
let (d, n) = image_doc(palette, vec![stroke(vec![fx("path.outlineObject")])], 1);
let rows = row_bounds(&d, &n);
assert!(matches!(rows.as_slice(), [b] if near(*b, [70.0, 70.0, 130.0, 130.0], 0.5)), "{rows:?}");
}
#[test]
fn an_image_too_large_or_too_complex_to_outline_keeps_its_frame() {
// 72 megapixels at 8 bits a channel, and 42 at 16: refused before their pixels are read.
for (w, h, depth) in [(9000, 8000, 8), (7000, 6000, 16)] {
let bytes = png(w, h, 6, depth, &[], &[]);
assert!(matches!(vectorcraft_trace::decode_alpha(&bytes), Err(vectorcraft_trace::TraceError::TooLarge { .. })), "{w}×{h}");
let (d, n) = image_doc(bytes, vec![stroke(vec![fx("path.outlineObject")])], 1);
assert!(matches!(row_bounds(&d, &n).as_slice(), [b] if near(*b, [50.0, 50.0, 150.0, 150.0], 1e-9)), "{w}×{h}");
}
// A checkerboard of 2 px squares, whose outline would need more than 100,000 anchor points.
let checker = image::RgbaImage::from_fn(640, 640, |x, y| image::Rgba([0, 0, 0, if (x / 2 + y / 2) % 2 == 0 { 255 } else { 0 }]));
let (d, n) =
image_doc(encoded(image::DynamicImage::ImageRgba8(checker), image::ImageFormat::Png), vec![stroke(vec![fx("path.outlineObject")])], 1);
assert!(matches!(row_bounds(&d, &n).as_slice(), [b] if near(*b, [50.0, 50.0, 150.0, 150.0], 1e-9)));
}
#[test]
fn an_outline_is_traced_once_for_its_pixels_and_dropped_with_them() {
let traces = || reshape::TRACES.with(std::cell::Cell::get);
let (d, n) = image_doc(encoded(disc(), image::ImageFormat::Png), vec![stroke(vec![fx("path.outlineObject")])], 1);
let NodeKind::Image(im) = &n.kind else { panic!("not an image") };
let before = traces();
let first = reshape::opaque_area(&[Some(&d)], im).unwrap();
assert_eq!(traces(), before + 1);
for _ in 0..3 {
row_bounds(&d, &n);
}
assert_eq!(reshape::opaque_area(&[Some(&d)], im).unwrap(), first);
assert_eq!(traces(), before + 1);
let pixels = Arc::downgrade(&d.images.get(&im.key).unwrap().bytes);
assert!(reshape::keeps_outline(&pixels));
// Once the document and its pixels are gone, the next trace drops their entry.
drop(d);
assert!(pixels.upgrade().is_none());
let (other, _) = image_doc(encoded(disc(), image::ImageFormat::Png), vec![], 1);
reshape::opaque_area(&[Some(&other)], im).unwrap();
assert_eq!(traces(), before + 2);
assert!(!reshape::keeps_outline(&pixels));
}
+1 -1
View File
@@ -82,7 +82,7 @@ pub mod rasterfx;
mod recolor;
pub mod recovery;
pub mod scripts;
mod select;
pub(crate) mod select;
pub(crate) mod shaper;
pub mod shortcutset;
pub(crate) mod slices;
+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))
}
+26 -6
View File
@@ -424,25 +424,45 @@ fn a_graphic_style_library_in_a_ai_file_without_layers_loads_its_styles() {
/BasicFilter :\n(Fill Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n/FillStyle : 0 O\n{xa}0 1 Xd\n/Def ;\n (FillStyle) ,\n; /Dict ;\n /Part ,\n;\n"
)
};
// Styles of one paint (`/SimpleStyle`): a red 2 pt stroke and no fill, and a gradient fill,
// which isn't read, so it is left out rather than listed with no paint.
let one_paint = |name: &str, paint: &str| format!("/KnownStyle :\n({name}) /Name ,\n/SimpleStyle :\n{paint}/Paint ;\n/Def ;\n");
let styles = format!(
"%AI9_BeginArtStyles\n{}{}{}%AI9_EndArtStyles\n%AI9_BeginArtStyleList\n(Red)\n(Green)\n(Blue)\n%AI9_EndArtStyleList\n",
"%AI9_BeginArtStyles\n{}{}{}{}{}%AI9_EndArtStyles\n\
%AI9_BeginArtStyleList\n(Red)\n(Green)\n(Blue)\n(Red outline)\n(Sky fill)\n%AI9_EndArtStyleList\n",
style("Blue", "0 0 0 0 0 0 1 Xa\n"),
style("Green", "0 0 0 0 0 1 0 Xa\n"),
style("Red", "0 0 0 0 1 0 0 Xa\n")
style("Red", "0 0 0 0 1 0 0 Xa\n"),
one_paint("Red outline", "0 R\n0 1 1 0 K\n0 J 0 j 2 w 10 M []0 d\n1 0 Xd\n"),
one_paint("Sky fill", "0 O\n0 Bb\n2 (Sky) 0 0 0 100 1 0 0 1 0 0 Bg\n0 BB\n0 1 Xd\n")
);
let data = ai::editing_data(200.0, 100.0, &styles);
let file = ai::ai(&compressed(data.as_bytes()), "1 0 0 rg 10 50 30 40 re f");
let mut s = Session::new();
let p = json!({"dataBase64": vectorcraft_format::base64_encode(&file), "name": "fills.ai"});
let r = s.execute("graphicStyle.loadLibrary", &p).unwrap();
assert_eq!((&r["name"], &r["count"]), (&json!("fills"), &json!(3)), "{r}");
let lib = s.execute("graphicStyle.library", &json!({"library": r["library"]})).unwrap();
assert_eq!((&r["name"], &r["count"]), (&json!("fills"), &json!(4)), "{r}");
let library = r["library"].clone();
let lib = s.execute("graphicStyle.library", &json!({"library": library})).unwrap();
let names: Vec<&str> = lib["styles"].as_array().unwrap().iter().map(|g| g["name"].as_str().unwrap()).collect();
assert_eq!(names, ["Red", "Green", "Blue"], "{lib}");
assert_eq!(names, ["Red", "Green", "Blue", "Red outline"], "{lib}");
// The outline style strokes a rectangle red, 2 pt wide, and leaves it unfilled.
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
let id = NodeId(s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap());
let r = s.execute("graphicStyle.addFromLibrary", &json!({"library": library, "names": ["Red outline"], "apply": true, "ids": [id.0]})).unwrap();
assert_eq!(r["added"], json!(["Red outline"]), "{r}");
let doc = &s.doc().unwrap().doc;
let look = &doc.node(id).unwrap().appearance;
assert!(look.fill().is_none(), "{look:?}");
let strokes: Vec<_> = look.items.iter().filter(|i| matches!(i, vectorcraft_doc::AppearanceItem::Stroke(_))).collect();
assert_eq!(strokes.len(), 1, "{look:?}");
let stroke = look.stroke().unwrap();
assert_eq!((stroke.paint.color(), stroke.width), (Some(vectorcraft_color::Color::cmyk(0.0, 1.0, 1.0, 0.0)), 2.0), "{look:?}");
assert_eq!(doc.graphic_style("Red outline").map(|g| &g.appearance), Some(&**look));
let r = open(&mut s, "fills.ai", &file, json!({})).unwrap();
let doc = &s.doc().unwrap().doc;
let styles: Vec<(&str, u32)> = doc.graphic_styles.iter().map(|g| (g.name.as_str(), g.id)).collect();
assert_eq!(styles, [("Red", 1), ("Green", 2), ("Blue", 3)], "{r}");
assert_eq!(styles, [("Red", 1), ("Green", 2), ("Blue", 3), ("Red outline", 4)], "{r}");
}
/// A `.ai` symbol library (symbols and their list, no layers) opens with its symbols, and Other
+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());
}
+446 -40
View File
@@ -39,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Select Text by Font",
[],
None,
"{family, style?, scope?: \"document\" (default) | {artboards: [index, …] (0-based)} | {layers: [id, …] (layers or sublayers)}} select the text objects using the font, the instances of the symbols whose art uses it, the objects painted with a pattern whose art uses it and the objects whose opacity mask uses it (in type that is not hidden or locked in that art), that are not hidden or locked, in a hidden or locked group or layer, or on a locked artboard; a pattern swatch that nothing paints, or a layer's mask, selects nothing; with scope, only those on one of the artboards (as All on Active Artboard takes them: their bounds overlap it or their center is on it) or in one of the layers → {count}",
"{family, style?, scope?: \"document\" (default) | {artboards: [index, …] (0-based)} | {layers: [id, …] (layers or sublayers)}} select the text objects using the font, the instances of the symbols whose art uses it, the text whose inline graphics draw such a symbol, the objects painted with a pattern whose art uses it (by a visible fill or stroke whose opacity is above 0, a stroke wider than 0) and the objects whose opacity mask uses it (in type that is not hidden or locked in that art), that are not hidden or locked, in a hidden or locked group or layer, or on a locked artboard; a pattern swatch that nothing paints, or a layer's mask, selects nothing; with scope, only those on one of the artboards (as All on Active Artboard takes them: their bounds overlap it or their center is on it) or in one of the layers → {count}",
has_doc,
select_font
),
@@ -108,23 +108,85 @@ fn pattern_being_edited(d: &Document, name: &str) -> bool {
d.pattern_edit.as_ref().is_some_and(|e| e.pattern == name)
}
/// Whether fill or stroke `i` paints: it is visible, its opacity is above 0 and, a stroke, it is
/// wider than 0.
fn paints(i: &AppearanceItem) -> bool {
i.visible() && i.opacity() > 0.0 && !matches!(i, AppearanceItem::Stroke(s) if s.width <= 0.0)
}
/// The name of pattern paint `p`.
fn pattern_name(p: &Paint) -> Option<&str> {
match p {
Paint::Pattern { pattern, .. } => Some(pattern.as_str()),
_ => None,
}
}
/// Whether `r` is laid out as an inline graphic: it has one and holds only its character.
fn draws_inline(r: &TextRun) -> bool {
r.inline.is_some() && vectorcraft_text::edit::is_inline_text(&r.text)
}
/// The fill and, when it is wider than 0, the stroke of the characters of `r`.
fn run_paints(r: &TextRun) -> impl Iterator<Item = &Paint> {
[Some(&r.style.fill), (r.style.stroke_width > 0.0).then_some(&r.style.stroke)].into_iter().flatten()
}
/// [`run_paints`] of run `r` of `t` when its characters draw with them. The characters of an
/// inline graphic draw no glyph: their fill and stroke paint only their underline and
/// strikethrough (not on a path or in vertical type, see `vectorcraft_text::decorations`) and the
/// ruby readings that layout draws with their run (`ruby`, as [`ruby_runs`] finds them).
fn drawn_run_paints<'a>(t: &TextObject, ruby: &HashSet<*const TextRun>, r: &'a TextRun) -> impl Iterator<Item = &'a Paint> {
let drawn = !draws_inline(r) || inline_decorated(t, ruby, r);
run_paints(r).filter(move |_| drawn)
}
/// The inline graphic runs of `t` that layout draws a ruby reading with. A reading takes the run
/// of the first base character on its line, and type on a path has no ruby. `t` is laid out only
/// when it has ruby and an inline graphic painted with a pattern.
fn ruby_runs(t: &TextObject) -> HashSet<*const TextRun> {
let patterned = |r: &TextRun| draws_inline(r) && run_paints(r).any(|p| pattern_name(p).is_some());
if t.ruby.is_empty() || !t.runs.iter().any(patterned) {
return HashSet::new();
}
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
lay.ruby.iter().filter_map(|g| t.runs.get(g.run)).filter(|r| draws_inline(r)).map(std::ptr::from_ref).collect()
}
/// Whether inline graphic run `r` of `t` has an underline or strikethrough that draws, or a ruby
/// reading that layout draws with it (it is in `ruby`).
fn inline_decorated(t: &TextObject, ruby: &HashSet<*const TextRun>, r: &TextRun) -> bool {
let bars = !t.vertical && !matches!(t.kind, TextKind::OnPath { .. });
if bars && (r.style.underline || r.style.strikethrough) {
return true;
}
ruby.contains(&std::ptr::from_ref(r))
}
/// The patterns that the fills and strokes `n` draws name (of a text object, also those of its
/// characters): visible ones, and strokes wider than 0.
fn paint_patterns(n: &Node) -> impl Iterator<Item = &str> {
let runs = match &n.kind {
NodeKind::Text(t) => t.runs.as_slice(),
_ => &[],
};
n.appearance
.items
.iter()
.filter(|i| i.visible() && !matches!(i, AppearanceItem::Stroke(s) if s.width <= 0.0))
.map(AppearanceItem::paint)
.chain(runs.iter().flat_map(|r| [Some(&r.style.fill), (r.style.stroke_width > 0.0).then_some(&r.style.stroke)]).flatten())
.filter_map(|p| match p {
Paint::Pattern { pattern, .. } => Some(pattern.as_str()),
_ => None,
})
/// characters): those that [`paints`] holds for, and the character paints of the runs that draw
/// (as [`drawn_runs`] finds them: not the overset runs of area type or type on a path) where they
/// draw (as [`drawn_run_paints`] says).
fn paint_patterns(n: &Node) -> Vec<&str> {
let mut out: Vec<&str> = n.appearance.items.iter().filter(|i| paints(i)).filter_map(|i| pattern_name(i.paint())).collect();
if let NodeKind::Text(t) = &n.kind
&& t.runs.iter().flat_map(run_paints).any(|p| pattern_name(p).is_some())
{
let ruby = ruby_runs(t);
out.extend(drawn_runs(t).0.into_iter().flat_map(|r| drawn_run_paints(t, &ruby, r)).filter_map(pattern_name));
}
out
}
/// The symbols that the inline graphics in the type of `n` draw (as [`drawn_runs`] finds them: not
/// those in the overset runs of area type or type on a path).
fn inline_symbols(n: &Node) -> Vec<&str> {
match &n.kind {
NodeKind::Text(t) if t.runs.iter().any(draws_inline) => {
drawn_runs(t).0.into_iter().filter(|r| draws_inline(r)).filter_map(|r| r.inline.as_ref().map(|art| art.symbol.as_str())).collect()
}
_ => vec![],
}
}
/// Symbols and patterns, by name: those whose art is in scope.
@@ -135,13 +197,15 @@ struct Resources {
}
impl Resources {
/// Adds what `n` itself shows: the symbol of an instance, the patterns its paints name, and
/// what the art of its opacity mask shows (not hidden or locked objects of that art).
/// Adds what `n` itself shows: the symbol of an instance, the symbols of the inline graphics in
/// its type, the patterns its paints name, and what the art of its opacity mask shows (not
/// hidden or locked objects of that art).
fn add_own(&mut self, n: &Node) {
if let NodeKind::SymbolInstance { symbol, .. } = &n.kind {
self.symbols.insert(symbol.clone());
}
self.patterns.extend(paint_patterns(n).map(str::to_string));
self.symbols.extend(inline_symbols(n).into_iter().map(str::to_string));
self.patterns.extend(paint_patterns(n).into_iter().map(str::to_string));
if let Some(m) = n.extras().mask.as_deref() {
self.add(&m.art);
}
@@ -486,17 +550,16 @@ impl Printed<'_> {
NodeKind::Text(t) => drawn_runs(t),
_ => (vec![], false),
};
let items: Vec<&AppearanceItem> =
n.appearance.items.iter().filter(|i| i.visible() && !matches!(i, AppearanceItem::Stroke(s) if s.width <= 0.0)).collect();
let patterns: Vec<&str> = items
.iter()
.map(|i| i.paint())
.chain(runs.iter().flat_map(|r| [Some(&r.style.fill), (r.style.stroke_width > 0.0).then_some(&r.style.stroke)]).flatten())
.filter_map(|p| match p {
Paint::Pattern { pattern, .. } => Some(pattern.as_str()),
_ => None,
})
.collect();
let items: Vec<&AppearanceItem> = n.appearance.items.iter().filter(|i| paints(i)).collect();
let ruby = match &n.kind {
NodeKind::Text(t) => ruby_runs(t),
_ => HashSet::new(),
};
let run_patterns = runs.iter().flat_map(|r| match &n.kind {
NodeKind::Text(t) => Some(drawn_run_paints(t, &ruby, r)),
_ => None,
});
let patterns: Vec<&str> = items.iter().map(|i| i.paint()).chain(run_patterns.flatten()).filter_map(pattern_name).collect();
let brushed = vectorcraft_brush::has_brush(n);
if (mask.is_some() || !patterns.is_empty() || brushed) && areas.is_none_or(|a| !open || self.meets(n, a)) {
if let Some(m) = mask {
@@ -527,7 +590,7 @@ impl Printed<'_> {
for r in runs {
self.fonts.extend(run_fonts(r));
// Inline graphics draw their symbols' art.
if let Some(art) = &r.inline {
if let Some(art) = r.inline.as_ref().filter(|_| draws_inline(r)) {
self.symbol(&art.symbol);
}
}
@@ -910,14 +973,16 @@ fn replace(s: &mut Session, p: &Value) -> Result<Value> {
}
/// Whether `n` or a descendant that is not hidden or locked, nor in a hidden or locked group,
/// shows type in a font `uses` matches: text set in it, an instance of a symbol in `found`, a paint
/// naming a pattern in `found`, or such type in the art of its opacity mask.
/// shows type in a font `uses` matches: text set in it, an instance of a symbol in `found`, an
/// inline graphic of a symbol in `found`, a paint naming a pattern in `found`, or such type in the
/// art of its opacity mask.
fn reaches(n: &Node, uses: &impl Fn(&TextObject) -> bool, found: &Resources) -> bool {
n.visible
&& !n.locked
&& (matches!(&n.kind, NodeKind::Text(t) if uses(t))
|| matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if found.symbols.contains(symbol))
|| paint_patterns(n).any(|p| found.patterns.contains(p))
|| inline_symbols(n).into_iter().any(|sym| found.symbols.contains(sym))
|| paint_patterns(n).into_iter().any(|p| found.patterns.contains(p))
|| n.extras().mask.as_deref().is_some_and(|m| reaches(&m.art, uses, found))
|| n.children().is_some_and(|ch| ch.iter().any(|c| reaches(c, uses, found))))
}
@@ -964,14 +1029,16 @@ fn select_font(s: &mut Session, p: &Value) -> Result<Value> {
found.patterns.extend(patterns);
}
// The objects the Selection tool can reach that show such type: the instances of those
// symbols, the objects that those patterns paint, and the objects whose opacity mask holds it
// (not layers, which the Selection tool doesn't select).
// symbols, the text whose inline graphics draw them, the objects that those patterns paint,
// and the objects whose opacity mask holds it (not layers, which the Selection tool doesn't
// select).
let masked: HashSet<NodeId> = masked.into_iter().collect();
if !found.symbols.is_empty() || !found.patterns.is_empty() || !masked.is_empty() {
let texts: HashSet<NodeId> = hits.iter().copied().collect();
d.walk(&mut |n: &Node| {
let shows = matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if found.symbols.contains(symbol))
|| paint_patterns(n).any(|p| found.patterns.contains(p))
|| inline_symbols(n).into_iter().any(|sym| found.symbols.contains(sym))
|| paint_patterns(n).into_iter().any(|p| found.patterns.contains(p))
|| (masked.contains(&n.id) && n.extras().mask.as_deref().is_some_and(|m| reaches(&m.art, &uses, &found)));
if shows && !n.is_layer() && !texts.contains(&n.id) && editable(n.id) {
hits.push(n.id);
@@ -1069,6 +1136,37 @@ fn font_list(_: &mut Session, p: &Value) -> Result<Value> {
}
}
/// Gives the inline graphics in the type of [`art_texts_of`] the current bounds of their symbols'
/// art, and lays out again the type whose inline graphics changed. A symbol drawn inline in the
/// art of another symbol takes one more round, so there is at most one round per symbol.
fn resolve_art_inline(doc: &mut Document) {
for _ in 0..=doc.symbols.len() {
let names: BTreeSet<String> =
art_texts_of(doc).iter().flat_map(|(_, t, _)| t.runs.iter().filter_map(|r| r.inline.as_ref().map(|a| a.symbol.clone()))).collect();
let bounds: BTreeMap<String, Option<Rect>> = names
.into_iter()
.map(|name| {
let b = doc.symbol_art_bounds(&name);
(name, b)
})
.collect();
let stale = |t: &TextObject| t.runs.iter().any(|r| r.inline.as_ref().is_some_and(|a| bounds.get(&a.symbol).is_some_and(|b| *b != a.bounds)));
let mut changed = false;
edit_art_texts_of(doc, &stale, &mut |t| {
for a in t.runs.iter_mut().filter_map(|r| r.inline.as_mut()) {
if let Some(b) = bounds.get(&a.symbol) {
a.bounds = *b;
}
}
refresh_bounds(t);
changed = true;
});
if !changed {
break;
}
}
}
pub(crate) fn rescan(s: &mut Session, _: &Value) -> Result<Value> {
let db = vectorcraft_text::FontDb::global();
#[cfg(not(target_arch = "wasm32"))]
@@ -1076,7 +1174,9 @@ pub(crate) fn rescan(s: &mut Session, _: &Value) -> Result<Value> {
#[cfg(target_arch = "wasm32")]
let faces = 0;
// The canvas redraws type in the fonts available now (renderers lay it out again when the
// fonts change), and the bounds follow; that isn't an edit to save.
// fonts change), and the bounds follow, also of the type in opacity masks and in the art of
// symbols and patterns; that isn't an edit to save. The type in art is laid out first, and the
// inline graphics of the type on the layers then take the new bounds of their symbols' art.
for d in &mut s.docs {
let mut texts = vec![];
d.doc.walk(|n| {
@@ -1084,9 +1184,12 @@ pub(crate) fn rescan(s: &mut Session, _: &Value) -> Result<Value> {
texts.push(n.id);
}
});
if !texts.is_empty() {
if !texts.is_empty() || !art_texts_of(&d.doc).is_empty() {
d.change_unsaved(|doc| {
let doc = std::sync::Arc::make_mut(doc);
edit_art_texts_of(doc, &|_| true, &mut refresh_bounds);
resolve_art_inline(doc);
doc.resolve_inline_art();
for id in texts {
if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) {
refresh_bounds(t);
@@ -1751,6 +1854,309 @@ mod tests {
assert_eq!(symbol_texts(&s.doc().unwrap().doc, None)[0].0.runs[0].style.font_family, "Source Sans 3");
}
/// A pattern in a fill whose opacity is 0 paints nothing: Find doesn't select the object for
/// the pattern's type, a selection-only Change leaves that type in its font, and Print doesn't
/// count its font.
#[test]
fn find_font_skips_patterns_at_an_opacity_of_0() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
let t = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Ab", "font": "Source Serif 4"})).unwrap()["id"].as_u64().unwrap();
s.execute("object.pattern.make", &json!({"ids": [t], "name": "Dots", "edit": false})).unwrap();
s.execute("select.set", &json!({"ids": [t]})).unwrap();
s.execute("edit.clear", &json!({})).unwrap();
let painted = s.execute("shape.rectangle", &json!({"x": 0, "y": 20, "width": 50, "height": 20})).unwrap()["id"].as_u64().unwrap();
s.execute("paint.setFill", &json!({"swatch": "Dots", "ids": [painted]})).unwrap();
let printed = |s: &Session| {
let d = &s.doc().unwrap().doc;
printed_type(d, &d.layers, &[Rect::new(-1000.0, -1000.0, 1000.0, 1000.0)]).0.into_iter().map(|(family, ..)| family).collect::<Vec<_>>()
};
assert_eq!(s.execute("select.font", &json!({"family": "Source Serif 4"})).unwrap()["count"], 1);
assert_eq!(printed(&s), ["Source Serif 4"]);
s.edit("Clear the fill", |d, _| {
for it in d.node_mut(NodeId(painted)).unwrap().appearance.items.iter_mut() {
if let AppearanceItem::Fill(f) = it {
f.opacity = 0.0;
}
}
Ok(())
})
.unwrap();
assert_eq!(s.execute("select.font", &json!({"family": "Source Serif 4"})).unwrap()["count"], 0);
assert!(printed(&s).is_empty(), "{:?}", printed(&s));
s.execute("select.set", &json!({"ids": [painted]})).unwrap();
let r = s
.execute("text.replaceFont", &json!({"from": {"family": "Source Serif 4"}, "to": {"family": "Source Sans 3"}, "selectionOnly": true}))
.unwrap();
assert_eq!(r, json!({"runs": 0}));
assert_eq!(pattern_families(&s, "Dots"), ["Source Serif 4"]);
}
/// Characters of area type that don't fit its frame paint nothing: their pattern's type isn't
/// found through them until they are drawn.
#[test]
fn find_font_skips_patterns_of_overset_characters() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
let t = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Ab", "font": "Source Serif 4"})).unwrap()["id"].as_u64().unwrap();
s.execute("object.pattern.make", &json!({"ids": [t], "name": "Dots", "edit": false})).unwrap();
s.execute("select.set", &json!({"ids": [t]})).unwrap();
s.execute("edit.clear", &json!({})).unwrap();
let area = s
.execute(
"text.create",
&json!({"x": 10, "y": 60, "text": "A\nB", "font": "Source Sans 3", "size": 20, "area": {"width": 100, "height": 40}}),
)
.unwrap()["id"]
.as_u64()
.unwrap();
s.execute("text.setRangeStyle", &json!({"id": area, "start": 2, "end": 3, "font": "Inter"})).unwrap();
let paint_run = |s: &mut Session, letter: &'static str| {
s.edit("Paint a run with the pattern", |d, _| {
if let Some(NodeKind::Text(t)) = d.node_mut(NodeId(area)).map(|n| &mut n.kind) {
for r in t.runs.iter_mut().filter(|r| r.text.contains(letter)) {
r.style.fill = Paint::Pattern { pattern: "Dots".into(), xf: Default::default() };
}
}
Ok(())
})
.unwrap();
};
paint_run(&mut s, "B");
let laid = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), text(&s.doc().unwrap().doc, NodeId(area)).unwrap());
assert!(laid.overflow, "the second line doesn't fit the frame");
assert_eq!(s.execute("select.font", &json!({"family": "Source Serif 4"})).unwrap()["count"], 0);
paint_run(&mut s, "A");
assert_eq!(s.execute("select.font", &json!({"family": "Source Serif 4"})).unwrap()["count"], 1);
}
/// An inline graphic that doesn't fit its area type draws nothing, and neither does an inline
/// graphic named on a run of other characters: Find doesn't select the text for its symbol.
#[test]
fn find_font_skips_undrawn_inline_graphics() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
let t = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Ab", "font": "Source Serif 4"})).unwrap()["id"].as_u64().unwrap();
s.execute("object.pattern.make", &json!({"ids": [t], "name": "Dots", "edit": false})).unwrap();
s.execute("select.set", &json!({"ids": [t]})).unwrap();
s.execute("edit.clear", &json!({})).unwrap();
let mark = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Se", "font": "Inter"})).unwrap()["id"].clone();
s.execute("symbol.new", &json!({"name": "Mark", "ids": [mark]})).unwrap();
s.execute("edit.clear", &json!({})).unwrap();
let area = s
.execute(
"text.create",
&json!({"x": 10, "y": 60, "text": "A\nB", "font": "Source Sans 3", "size": 20, "area": {"width": 100, "height": 40}}),
)
.unwrap()["id"]
.as_u64()
.unwrap();
s.execute("text.insertInline", &json!({"id": area, "at": 3, "symbol": "Mark"})).unwrap();
let laid = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), text(&s.doc().unwrap().doc, NodeId(area)).unwrap());
assert!(laid.overflow && laid.inlines.is_empty(), "the inline graphic doesn't fit the frame");
assert_eq!(s.execute("select.font", &json!({"family": "Inter"})).unwrap()["count"], 0);
let point = s.execute("text.create", &json!({"x": 10, "y": 140, "text": "Hi", "font": "Source Sans 3"})).unwrap()["id"].as_u64().unwrap();
s.execute("text.insertInline", &json!({"id": point, "at": 0, "symbol": "Mark"})).unwrap();
assert_eq!(s.execute("select.font", &json!({"family": "Inter"})).unwrap()["count"], 1);
assert_eq!(s.doc().unwrap().selection.objects, [NodeId(point)]);
// A run that names an inline graphic but holds other characters is laid out as those
// characters: its fill paints them, and it draws no symbol.
let plain = s.execute("text.create", &json!({"x": 10, "y": 180, "text": "Ok", "font": "Source Sans 3"})).unwrap()["id"].as_u64().unwrap();
let art = text(&s.doc().unwrap().doc, NodeId(point)).unwrap().runs.iter().find_map(|r| r.inline.clone()).unwrap();
s.edit("Name an inline graphic on plain characters", |d, _| {
if let Some(NodeKind::Text(t)) = d.node_mut(NodeId(plain)).map(|n| &mut n.kind) {
for r in &mut t.runs {
r.inline = Some(art.clone());
r.style.fill = Paint::Pattern { pattern: "Dots".into(), xf: Default::default() };
}
}
Ok(())
})
.unwrap();
assert_eq!(s.execute("select.font", &json!({"family": "Source Serif 4"})).unwrap()["count"], 1);
assert_eq!(s.doc().unwrap().selection.objects, [NodeId(plain)]);
assert_eq!(s.execute("select.font", &json!({"family": "Inter"})).unwrap()["count"], 1);
assert_eq!(s.doc().unwrap().selection.objects, [NodeId(point)]);
}
/// The characters of an inline graphic draw no glyph: their pattern fill paints only their
/// underline, strikethrough or ruby reading. Without one, Find doesn't select the text for the
/// pattern's type and Print doesn't count its font.
#[test]
fn find_font_skips_patterns_of_undecorated_inline_graphics() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
let t = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Ab", "font": "Source Serif 4"})).unwrap()["id"].as_u64().unwrap();
s.execute("object.pattern.make", &json!({"ids": [t], "name": "Dots", "edit": false})).unwrap();
s.execute("select.set", &json!({"ids": [t]})).unwrap();
s.execute("edit.clear", &json!({})).unwrap();
let mark = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone();
s.execute("symbol.new", &json!({"name": "Mark", "ids": [mark]})).unwrap();
s.execute("edit.clear", &json!({})).unwrap();
let point = s.execute("text.create", &json!({"x": 10, "y": 100, "text": "Hi", "font": "Source Sans 3"})).unwrap()["id"].as_u64().unwrap();
s.execute("text.insertInline", &json!({"id": point, "at": 1, "symbol": "Mark"})).unwrap();
let style = |s: &mut Session, f: &dyn Fn(&mut TextObject)| {
s.edit("Style the inline graphic", |d, _| {
if let Some(NodeKind::Text(t)) = d.node_mut(NodeId(point)).map(|n| &mut n.kind) {
f(t);
}
Ok(())
})
.unwrap();
};
let found = |s: &mut Session| s.execute("select.font", &json!({"family": "Source Serif 4"})).unwrap()["count"].as_u64();
let printed = |s: &Session| {
let d = &s.doc().unwrap().doc;
printed_type(d, &d.layers, &[Rect::new(-1000.0, -1000.0, 1000.0, 1000.0)]).0.into_iter().map(|(family, ..)| family).collect::<Vec<_>>()
};
style(&mut s, &|t| {
for r in t.runs.iter_mut().filter(|r| r.inline.is_some()) {
r.style.fill = Paint::Pattern { pattern: "Dots".into(), xf: Default::default() };
}
});
assert_eq!(found(&mut s), Some(0));
assert_eq!(printed(&s), ["Source Sans 3"]);
// An underline paints with the pattern.
style(&mut s, &|t| t.runs.iter_mut().filter(|r| r.inline.is_some()).for_each(|r| r.style.underline = true));
assert_eq!(found(&mut s), Some(1));
assert_eq!(printed(&s), ["Source Sans 3", "Source Serif 4"]);
// Vertical type draws no underline.
style(&mut s, &|t| t.vertical = true);
assert_eq!(found(&mut s), Some(0));
// A ruby reading that starts over the character before the inline graphic draws with that
// character's paint.
style(&mut s, &|t| {
assert!(t.runs[1].inline.is_some(), "the inline graphic is the second run");
t.ruby.push(vectorcraft_doc::RubySpan {
start: 0,
end: t.runs[0].text.len() + t.runs[1].text.len(),
text: "x".into(),
position: Default::default(),
align: Default::default(),
size_percent: 50.0,
overhang: true,
});
});
assert_eq!(found(&mut s), Some(0));
// A ruby reading that starts over the inline graphic paints with the pattern.
style(&mut s, &|t| t.ruby[0].start = t.runs[0].text.len());
assert_eq!(found(&mut s), Some(1));
}
/// Text whose inline graphic draws a symbol shows the symbol's type: Find selects that text
/// and the objects painted with a pattern whose art holds it, and a selection-only Change
/// changes the symbol's type.
#[test]
fn find_font_reaches_symbols_through_inline_graphics() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
let serif = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Se", "font": "Source Serif 4"})).unwrap()["id"].clone();
s.execute("symbol.new", &json!({"name": "Mark", "ids": [serif]})).unwrap();
s.execute("edit.clear", &json!({})).unwrap();
let inline = |s: &mut Session, y: i32| {
let id = s.execute("text.create", &json!({"x": 10, "y": y, "text": "In", "font": "Inter"})).unwrap()["id"].as_u64().unwrap();
s.execute("text.insertInline", &json!({"id": id, "at": 0, "symbol": "Mark"})).unwrap();
id
};
let listed = inline(&mut s, 60);
let tiled = inline(&mut s, 100);
s.execute("object.pattern.make", &json!({"ids": [tiled], "name": "Loop", "edit": false})).unwrap();
s.execute("select.set", &json!({"ids": [tiled]})).unwrap();
s.execute("edit.clear", &json!({})).unwrap();
let painted = s.execute("shape.rectangle", &json!({"x": 0, "y": 140, "width": 50, "height": 20})).unwrap()["id"].as_u64().unwrap();
s.execute("paint.setFill", &json!({"swatch": "Loop", "ids": [painted]})).unwrap();
let mut instances = 0;
s.doc().unwrap().doc.walk(|n| instances += usize::from(matches!(n.kind, NodeKind::SymbolInstance { .. })));
assert_eq!(instances, 0, "the symbol is drawn only by inline graphics");
assert_eq!(s.execute("select.font", &json!({"family": "Source Serif 4"})).unwrap()["count"], 2);
let mut selected = s.doc().unwrap().selection.objects.clone();
selected.sort();
assert_eq!(selected, [NodeId(listed), NodeId(painted)]);
s.execute("select.set", &json!({"ids": [painted]})).unwrap();
let rows = font_rows(&mut s, json!({"selectionOnly": true}));
assert_eq!(rows, [("Inter".to_string(), 2, 1), ("Source Serif 4".to_string(), 1, 1)]);
let r = s
.execute("text.replaceFont", &json!({"from": {"family": "Source Serif 4"}, "to": {"family": "Source Sans 3"}, "selectionOnly": true}))
.unwrap();
assert_eq!(r, json!({"runs": 1}));
assert_eq!(symbol_texts(&s.doc().unwrap().doc, None)[0].0.runs[0].style.font_family, "Source Sans 3");
}
/// Refresh Font List lays out again the type in opacity masks and in the art of symbols and
/// patterns, as it does the type on the layers, and the inline graphics of the type on the
/// layers follow the symbols' type as laid out again.
#[test]
fn rescanning_fonts_refreshes_the_bounds_of_type_in_art() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
let owner = s.execute("shape.rectangle", &json!({"x": 0, "y": 20, "width": 50, "height": 20})).unwrap()["id"].as_u64().unwrap();
let masked = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Mask", "font": "Inter"})).unwrap()["id"].as_u64().unwrap();
mask_with(&mut s, owner, masked);
let tiled = s.execute("text.create", &json!({"x": 10, "y": 60, "text": "Tile", "font": "Inter"})).unwrap()["id"].as_u64().unwrap();
s.execute("object.pattern.make", &json!({"ids": [tiled], "name": "Dots", "edit": false})).unwrap();
s.execute("select.set", &json!({"ids": [tiled]})).unwrap();
s.execute("edit.clear", &json!({})).unwrap();
let stale = Rect::new(0.0, 0.0, 1.0, 1.0);
s.edit("Make the bounds stale", |d, _| {
edit_art_texts_of(d, &|_| true, &mut |t| t.cached_bounds = Some(stale));
Ok(())
})
.unwrap();
let bounds = |s: &Session| art_texts_of(&s.doc().unwrap().doc).iter().map(|(_, t, _)| t.cached_bounds).collect::<Vec<_>>();
assert_eq!(bounds(&s), [Some(stale), Some(stale)]);
s.execute("text.rescanFonts", &json!({})).unwrap();
let after = bounds(&s);
assert_eq!(after.len(), 2);
assert!(after.iter().all(|b| b.is_some_and(|b| b != stale && b.width() > 1.0)), "{after:?}");
let marked = s.execute("text.create", &json!({"x": 10, "y": 100, "text": "Se", "font": "Inter"})).unwrap()["id"].clone();
s.execute("symbol.new", &json!({"name": "Mark", "ids": [marked]})).unwrap();
s.execute("edit.clear", &json!({})).unwrap();
let line = s.execute("text.create", &json!({"x": 10, "y": 140, "text": "In", "font": "Inter"})).unwrap()["id"].as_u64().unwrap();
s.execute("text.insertInline", &json!({"id": line, "at": 0, "symbol": "Mark"})).unwrap();
s.edit("Make the bounds stale", |d, _| {
edit_art_texts_of(d, &|_| true, &mut |t| t.cached_bounds = Some(stale));
d.resolve_inline_art();
Ok(())
})
.unwrap();
let resolved = |s: &Session| {
let d = &s.doc().unwrap().doc;
matches!(d.inline_resolved(text(d, NodeId(line)).unwrap()), std::borrow::Cow::Borrowed(_))
};
assert!(resolved(&s), "the inline graphic took the stale bounds");
s.execute("text.rescanFonts", &json!({})).unwrap();
assert!(resolved(&s), "the inline graphic follows the symbol's type laid out again");
// So does an inline graphic in the art of another symbol.
let held = s.execute("text.create", &json!({"x": 10, "y": 180, "text": "Out", "font": "Inter"})).unwrap()["id"].as_u64().unwrap();
s.execute("text.insertInline", &json!({"id": held, "at": 0, "symbol": "Mark"})).unwrap();
s.execute("symbol.new", &json!({"name": "Outer", "ids": [held]})).unwrap();
s.execute("edit.clear", &json!({})).unwrap();
s.edit("Make the bounds stale", |d, _| {
edit_art_texts_of(d, &|_| true, &mut |t| t.cached_bounds = Some(stale));
Ok(())
})
.unwrap();
let shrunk = s.doc().unwrap().doc.symbol_art_bounds("Mark");
s.edit("Take the stale bounds", |d, _| {
edit_art_texts_of(d, &|_| true, &mut |t| {
for a in t.runs.iter_mut().filter_map(|r| r.inline.as_mut()) {
a.bounds = shrunk;
}
});
Ok(())
})
.unwrap();
let nested = |s: &Session| {
let d = &s.doc().unwrap().doc;
art_texts_of(d).iter().flat_map(|(_, t, _)| t.runs.iter().filter_map(|r| r.inline.as_ref().map(|a| a.bounds))).collect::<Vec<_>>()
};
assert_eq!(nested(&s), [shrunk]);
s.execute("text.rescanFonts", &json!({})).unwrap();
let mark = s.doc().unwrap().doc.symbol_art_bounds("Mark");
assert_ne!(mark, shrunk);
assert_eq!(nested(&s), [mark], "the inline graphic in the art of Outer follows Mark's type laid out again");
}
/// The font family of text object `id`'s first run.
fn text_family(s: &Session, id: u64) -> String {
text(&s.doc().unwrap().doc, NodeId(id)).unwrap().runs[0].style.font_family.clone()
+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
),
+111 -26
View File
@@ -416,6 +416,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 {
@@ -638,9 +658,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
@@ -702,7 +726,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,
}
@@ -751,7 +775,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,
};
}
@@ -767,12 +791,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);
@@ -791,14 +819,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)));
}
@@ -807,13 +836,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 {
@@ -832,15 +869,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` (in the layers or outside them) show `src`, read from the file `link`
@@ -975,15 +1015,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(())
@@ -1061,6 +1127,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),
})
@@ -1203,6 +1270,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);
@@ -1996,15 +2066,21 @@ fn status_rank(status: &str) -> usize {
/// holds them) or overlap an object that is, as Show DCS Transparency Interactions marks them.
fn dcs_transparency(d: &Document) -> BTreeSet<NodeId> {
/// Each visible DCS image's id, bounds and whether it is transparent, and the bounds of each
/// other visible object with its own transparency.
fn walk(d: &Document, n: &Node, held: bool, images: &mut Vec<(NodeId, Rect, bool)>, see_through: &mut Vec<Rect>) {
/// other visible object with its own transparency; bounds are cut to `clip`, the bounds of the
/// clipping paths of the clip groups and layers holding the object.
fn walk(d: &Document, n: &Node, held: bool, clip: Option<Rect>, images: &mut Vec<(NodeId, Rect, bool)>, see_through: &mut Vec<Rect>) {
if !n.visible {
return;
}
let own = n.shows_own_transparency();
// The part of the object that shows through `clip`; none when it is clipped away.
let shown = n.geometric_bounds().and_then(|b| match clip {
Some(c) => Some(c.intersect(b)).filter(|i| i.area() > 0.0),
None => Some(b),
});
match &n.kind {
NodeKind::Image(im) if d.images.get(&im.key).is_some_and(ImageBlob::is_separated) => {
if let Some(b) = n.geometric_bounds() {
if let Some(b) = shown {
images.push((n.id, b, held || own));
// A transparent DCS image is an object with its own transparency for the
// others it overlaps.
@@ -2013,16 +2089,25 @@ fn dcs_transparency(d: &Document) -> BTreeSet<NodeId> {
}
}
}
_ if own => see_through.extend(n.geometric_bounds()),
_ if own => see_through.extend(shown),
_ => {}
}
for c in n.children().into_iter().flatten() {
walk(d, c, held || own, images, see_through);
let Some(children) = n.children() else { return };
// A clip group's or clipping layer's first child clips the others.
let clip = match &n.kind {
NodeKind::Group { clip: true, .. } | NodeKind::Layer { clip: true, .. } => match children.first().and_then(|c| c.geometric_bounds()) {
Some(c) => Some(clip.map_or(c, |o| o.intersect(c))),
None => clip,
},
_ => clip,
};
for c in children {
walk(d, c, held || own, clip, images, see_through);
}
}
let (mut images, mut see_through) = (vec![], vec![]);
for l in &d.layers {
walk(d, l, false, &mut images, &mut see_through);
walk(d, l, false, None, &mut images, &mut see_through);
}
images
.into_iter()
+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 }))
+17 -2
View File
@@ -21,7 +21,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Fill",
[],
None,
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual (as paint.editGradient; default classic), spread?: pad|repeat|reflect (as paint.editGradient; default pad), repeats?: 1..100 (as paint.editGradient; default 1), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial, conic or diamond `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern) → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}",
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual (as paint.editGradient; default classic), spread?: pad|repeat|reflect (as paint.editGradient; default pad), repeats?: 1..100 (as paint.editGradient; default 1), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial, conic or diamond `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white; a colorized image whose link is missing takes a solid color as its new colorize color, and refuses a process color while its color is a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern) → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}",
has_doc,
|s, p| set_paint(s, p, true)
),
@@ -690,7 +690,9 @@ fn placed_across(d: &Document, ids: &[NodeId], paint: Paint, p: &Value, fill: bo
/// The images among `ids` that a fill of `paint` colorizes: grayscale and 1-bit images, for a solid
/// color (or none on a colorized image, which takes the color off). The color may be black, white,
/// a process color or a spot color; on an image with an alpha channel, only a spot color, black or
/// white, and other colors are an error.
/// white, and other colors are an error. A colorized image that shows a preview that isn't
/// grayscale, such as a missing link's placeholder, takes a solid color as its new colorize paint;
/// while its paint is one an alpha channel allows, a process color is an error there too.
fn colorized_images(d: &Document, ids: &[NodeId], paint: &Paint) -> std::result::Result<Vec<NodeId>, String> {
let mut out = vec![];
for id in ids {
@@ -701,6 +703,19 @@ fn colorized_images(d: &Document, ids: &[NodeId], paint: &Paint) -> std::result:
out.push(*id);
continue;
}
// The Fill proxy shows such an image's colorize paint. Its pixels are not known until the
// file is relinked: a process color replaces only a process color, which the image's
// pixels already accepted, so a relink can't bring back an alpha channel under one.
if matches!(paint, Paint::Solid { .. })
&& let Some(old) = n.extras().colorize.as_deref()
&& d.image_blob(im).is_some_and(|b| !b.takes_colorize())
{
if !colorizes_transparency(d, paint) && colorizes_transparency(d, old) {
return Err(PROCESS_ON_TRANSPARENCY.into());
}
out.push(*id);
continue;
}
// A color-masked image is colorized as it shows: its masked pixels, which have an alpha
// channel.
let Some(gray) = d.image_blob(&d.shown_image(n, im)).and_then(|b| b.gray_source()) else { continue };
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> {
@@ -528,6 +528,7 @@ pub(super) const ALLOWED: &[&str] = &[
"select.same.textStrokeColor",
"select.segments",
"select.set",
"select.storedPath",
"select.toggle",
"select.update",
"shape.arc",
+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),
+153
View File
@@ -49,6 +49,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
set
),
cmd!(
"select.storedPath",
"Select Path",
[],
None,
"{id: object id, fingerprint: text (\"v1:\" and 16 lowercase hex digits)} the Actions panel's Select Path step: select only the path or compound path `id` when its fingerprint (a hash of its kind, fill rule and anchors, its members' for a compound path) is still `fingerprint` and it can be selected: not hidden or locked, nor in a hidden or locked layer or group, a template layer or on a locked artboard, and inside the isolated object in isolation mode. A missing object, another kind of object, a path whose kind, fill rule or anchors changed, or one that cannot be selected is an error, and the selection stays as it was → {count, ids} (the resulting selection)",
has_doc,
stored_path
),
cmd!(
"select.add",
"Add to Selection",
@@ -449,6 +458,150 @@ fn selection_result(s: &Session) -> Result<Value> {
Ok(json!({ "count": selection.len(), "ids": selection.objects }))
}
/// The prefix of every fingerprint [`path_fingerprint`] gives: the version of its hash.
const FINGERPRINT_PREFIX: &str = "v1:";
/// FNV-1a (64 bits) over the bytes written to it, starting from the offset basis.
pub(crate) struct Fnv(pub(crate) u64);
impl Default for Fnv {
fn default() -> Self {
Self(0xcbf2_9ce4_8422_2325)
}
}
impl std::io::Write for Fnv {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0 = buf.iter().fold(self.0, |h, b| (h ^ u64::from(*b)).wrapping_mul(0x0100_0000_01b3));
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
/// The fingerprint of the path or compound path `n` that `select.storedPath` compares: FNV-1a (64
/// bits) over a kind byte, the fill rule and the anchors of the path, or of each member of the
/// compound path in order, as JSON, written as [`FINGERPRINT_PREFIX`] and 16 lowercase hex digits.
/// None for any object [`is_storable_kind`] does not take.
pub fn path_fingerprint(n: &Node) -> Option<String> {
fn feed(h: &mut Fnv, n: &Node) -> Option<()> {
use std::io::Write;
match &n.kind {
NodeKind::Path { path, rule, .. } => {
h.write_all(b"P").ok()?;
serde_json::to_writer(&mut *h, rule).ok()?;
serde_json::to_writer(&mut *h, path).ok()
}
NodeKind::Compound { children, rule } => {
h.write_all(b"C").ok()?;
serde_json::to_writer(&mut *h, rule).ok()?;
h.write_all(&(children.len() as u64).to_le_bytes()).ok()?;
children.iter().try_for_each(|c| feed(h, c))
}
_ => None,
}
}
if !is_storable_kind(n) {
return None;
}
let mut h = Fnv::default();
feed(&mut h, n)?;
Some(format!("{FINGERPRINT_PREFIX}{:016x}", h.0))
}
/// Whether `n` is an object a Select Path step can store: a path that is not a guide, or a compound
/// path whose members all are.
fn is_storable_kind(n: &Node) -> bool {
let path = |n: &Node| matches!(n.kind, NodeKind::Path { guide: false, .. });
match &n.kind {
NodeKind::Compound { children, .. } => children.iter().all(|c| path(c)),
_ => path(n),
}
}
/// The `id` and `fingerprint` of `select.storedPath`'s params `p`, or why they are not ones: an
/// object with only a non-negative whole `id` and a `fingerprint` that [`path_fingerprint`] could give.
pub fn stored_path_params(p: &Value) -> std::result::Result<(NodeId, &str), String> {
let o = p.as_object().ok_or("`params` must be an object")?;
if let Some(key) = o.keys().find(|k| !matches!(k.as_str(), "id" | "fingerprint")) {
let keys = "id, fingerprint";
return Err(format!("unknown parameter `{key}`; the command takes: {keys}"));
}
let value = o.get("id").unwrap_or(&Value::Null);
let id = value.as_u64().map(NodeId).ok_or_else(|| format!("invalid object id {value}: expected a non-negative integer"))?;
let fingerprint = o.get("fingerprint").and_then(Value::as_str).unwrap_or_default();
let hex = fingerprint.strip_prefix(FINGERPRINT_PREFIX).unwrap_or_default();
if hex.len() != 16 || !hex.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) {
return Err("`fingerprint` must be \"v1:\" and 16 lowercase hex digits".into());
}
Ok((id, fingerprint))
}
/// Why the object `id` of `st` cannot be the path of a Select Path step: it is not there, is not a
/// path (not a guide) or compound path ([`is_storable_kind`]), is a member of a compound path, cannot
/// be selected ([`is_selectable`]), or is on a locked artboard, in isolation mode too.
fn storable_path(st: &DocState, id: NodeId) -> std::result::Result<(), String> {
let value = id.0;
let n = st.doc.node(id).ok_or_else(|| format!("no such object id {value}"))?;
let in_compound = st.doc.parent_of(id).and_then(|p| st.doc.node(p)).is_some_and(|p| matches!(p.kind, NodeKind::Compound { .. }));
if !is_storable_kind(n) || in_compound {
return Err(WHOLE_PATH.into());
}
// In isolation mode `is_selectable` does not look at the locked artboards, so they are checked here too.
let locks = st.doc.locked_artboards();
if !is_selectable(&st.doc, st.isolation, &locks, id) || locks.holds_id(id) {
return Err("the path is hidden or locked, or cannot be selected here".into());
}
Ok(())
}
/// What [`storable_selection`] asks for when the selection is not a whole path or compound path.
const WHOLE_PATH: &str = "select one whole path or compound path";
/// The one object selected in `st` when it can be the path of a Select Path step: a whole path or
/// compound path that can be selected ([`storable_path`]). An error for no selection, several
/// objects, a path with ruler guides, anchors or segments of a path alone, a group, type or any other object, a guide, or a
/// member of a compound path. It does not hash the path, so menus can ask it every frame.
pub fn storable_selection(st: &DocState) -> std::result::Result<NodeId, String> {
let sel = &st.selection;
let id = match sel.objects.as_slice() {
[] => return Err("select a path".into()),
[id] if sel.guides.is_empty() => *id,
_ => return Err("select only one path".into()),
};
if sel.anchors.get(&id).is_some_and(|a| !a.is_empty()) || sel.segments.get(&id).is_some_and(|s| !s.is_empty()) {
return Err(WHOLE_PATH.into());
}
storable_path(st, id)?;
Ok(id)
}
/// The params of a `select.storedPath` step for the path selected in `st` ([`storable_selection`]):
/// `{id, fingerprint}`.
pub fn stored_path_step(st: &DocState) -> std::result::Result<Value, String> {
let id = storable_selection(st)?;
let fingerprint = st.doc.node(id).and_then(path_fingerprint).ok_or(WHOLE_PATH)?;
Ok(json!({ "id": id.0, "fingerprint": fingerprint }))
}
/// `select.storedPath {id, fingerprint}`: selects only the path `id` when it is still the path the
/// step stored ([`path_fingerprint`]) and can be selected ([`storable_path`]); else an error, and
/// the selection stays as it was.
fn stored_path(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "select.storedPath";
let (id, fingerprint) = stored_path_params(p).map_err(|e| bad(C, e))?;
let st = s.doc()?;
let value = id.0;
let n = st.doc.node(id).ok_or_else(|| bad(C, format!("no such object id {value}")))?;
if path_fingerprint(n).as_deref() != Some(fingerprint) {
return Err(bad(C, format!("object {value} is not the stored path: it changed, or the document is another one")));
}
storable_path(st, id).map_err(|e| bad(C, e))?;
s.select(|_, sel| sel.set([id]))?;
selection_result(s)
}
fn key(s: &mut Session, p: &Value) -> Result<Value> {
let id = p.get("id").map(|value| checked_id(s, value, "select.key")).transpose()?;
let guide = match p.get("guide").filter(|v| !v.is_null()) {
+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,
+6 -4
View File
@@ -26,12 +26,13 @@ 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};
pub use cmd::pathops::auto_simplify_level;
pub use cmd::rasterfx::{export_pdf, flatten_raster_effects};
pub use cmd::select::{path_fingerprint, storable_selection, stored_path_params, stored_path_step};
pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};
pub use cmd::{EyedropperOptions, PathfinderOptions};
pub use journal::Journal;
@@ -216,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(())
}
+1
View File
@@ -176,6 +176,7 @@ mod smartguides;
mod smooth_path;
mod spelling;
mod store;
mod stored_path;
mod strokegeom;
mod strokegradient;
mod strokereach;
+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();
+43
View File
@@ -462,6 +462,49 @@ fn a_colorized_missing_link_placeholder_is_not_flattened() {
assert_eq!(colorize(&s, id), Some(Paint::solid(Color::rgb8(255, 0, 0))), "the image keeps its paint");
}
/// A fill color on a colorized image that shows the missing-link placeholder recolors the image,
/// as the Fill proxy shows its colorize paint, and adds no fill item.
#[test]
fn a_fill_color_recolors_a_colorized_missing_link_placeholder() {
let mut s = session();
let bytes = std::sync::Arc::new(vectorcraft_doc::links::missing_placeholder().unwrap());
let id = image(&mut s, "pic", ImageBlob { mime: "image/png".into(), bytes: bytes.clone(), proxy: Some(bytes) });
s.edit("Colorize", |d, _| {
d.node_mut(id).unwrap().extras_mut().colorize = Some(Box::new(Paint::solid(Color::rgb8(255, 0, 0))));
Ok(())
})
.unwrap();
let appearance = s.doc().unwrap().doc.node(id).unwrap().appearance.clone();
s.execute("paint.setFill", &json!({"color": "#0000ff", "ids": [id.0]})).unwrap();
assert_eq!(colorize(&s, id).and_then(|p| p.color()).map(|c| c.to_hex()), Some("#0000ff".into()));
assert_eq!(s.doc().unwrap().doc.node(id).unwrap().appearance, appearance, "no fill item");
assert_eq!(s.proxy_paints().0.color().map(|c| c.to_hex()), Some("#0000ff".into()));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(colorize(&s, id), Some(Paint::solid(Color::rgb8(255, 0, 0))));
}
/// The pixels behind the placeholder may have an alpha channel, so a missing-link image colorized
/// with a spot color takes another spot color, black or white, and refuses a process color.
#[test]
fn a_colorized_missing_link_placeholder_in_a_spot_color_refuses_a_process_color() {
let mut s = session();
let bytes = std::sync::Arc::new(vectorcraft_doc::links::missing_placeholder().unwrap());
let id = image(&mut s, "pic", ImageBlob { mime: "image/png".into(), bytes: bytes.clone(), proxy: Some(bytes) });
s.execute("swatch.new", &json!({"name": "Ink 2", "color": {"c": 100, "m": 0, "y": 0, "k": 0}, "spot": true})).unwrap();
s.edit("Colorize", |d, _| {
let ink = Paint::Solid { color: Color::rgb8(255, 0, 0), swatch: Some("Ink".into()), tint: 1.0 };
d.node_mut(id).unwrap().extras_mut().colorize = Some(Box::new(ink));
Ok(())
})
.unwrap();
let before = colorize(&s, id);
let e = s.execute("paint.setFill", &json!({"color": "#0000ff", "ids": [id.0]})).unwrap_err().to_string();
assert!(e.contains("a process color can't colorize a grayscale image with transparency"), "{e}");
assert_eq!(colorize(&s, id), before);
s.execute("paint.setFill", &json!({"swatch": "Ink 2", "ids": [id.0]})).unwrap();
assert!(matches!(colorize(&s, id), Some(Paint::Solid { swatch: Some(w), .. }) if w == "Ink 2"), "{:?}", colorize(&s, id));
}
#[test]
fn flattened_colorized_image_shows_the_art_under_white_pixels() {
let mut s = session();
+116
View File
@@ -256,6 +256,122 @@ fn an_images_stroke_with_outline_object_draws_exports_and_expands_along_its_fram
assert!(kids[0].appearance.fill_paint().is_none());
}
/// A 100×100 PNG, transparent but for an opaque red disc of radius 30 in its middle.
fn disc_png() -> Vec<u8> {
let mut png = vec![];
image::RgbaImage::from_fn(100, 100, |x, y| {
let r = (f64::from(x) + 0.5 - 50.0).hypot(f64::from(y) + 0.5 - 50.0);
image::Rgba([255, 0, 0, if r <= 30.0 { 255 } else { 0 }])
})
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
png
}
#[test]
fn an_images_stroke_with_outline_object_follows_its_opaque_area() {
let mut s = session();
// The frame runs from 50 to 150, the disc from 70 to 130.
let r = run(&mut s, "file.place", json!({"name": "disc.png", "dataBase64": vectorcraft_format::base64_encode(&disc_png()), "at": [100, 100]}));
let im = NodeId(r["ids"][0].as_u64().unwrap());
run(&mut s, "appearance.addStroke", json!({}));
run(&mut s, "appearance.setItem", json!({"index": 0, "weight": 8, "color": "#000000"}));
run(&mut s, "effect.apply", json!({"effect": "path.outlineObject", "item": 0}));
// Under the image, the stroke's outer 4 pt shows around the disc, from 66 to 70; nothing runs
// along the frame.
let shade = |s: &Session, x: u32, y: u32| match pixel(s, x, y) {
p if p == RED => "red",
[r, g, b, _] if r.max(g).max(b) < 40 => "black",
[r, g, b, _] if r.min(g).min(b) > 215 => "white",
p => panic!("({x}, {y}) is {p:?}"),
};
assert_eq!([shade(&s, 64, 100), shade(&s, 67, 100), shade(&s, 100, 100), shade(&s, 100, 132)], ["white", "black", "red", "black"]);
assert_eq!([shade(&s, 49, 100), shade(&s, 52, 100)], ["white", "white"]);
// SVG and PDF hold the disc's outline stroked 8 pt.
let doc = s.doc().unwrap().doc.clone();
let disc = |b: vectorcraft_geom::Rect| [b.x0 - 70.0, b.y0 - 70.0, b.x1 - 130.0, b.y1 - 130.0].iter().all(|d| d.abs() < 0.5);
let svg = vectorcraft_svg::export(&doc, &Default::default());
assert!(svg.contains("<image") && svg.contains("stroke-width=\"8\""), "{svg}");
let back = vectorcraft_pdf::import(&vectorcraft_pdf::export(&doc, &Default::default()).unwrap()).unwrap();
let mut outlines = 0;
for l in &back.layers {
l.walk(&mut |n| {
let stroked = (n.appearance.stroke_width() - 8.0).abs() < 0.01;
outlines += usize::from(matches!(n.kind, NodeKind::Path { .. }) && stroked && n.geometric_bounds().is_some_and(disc));
});
}
assert_eq!(outlines, 1);
// Expand Appearance leaves the disc's outline below the image.
run(&mut s, "effect.expandAppearance", json!({}));
let n = node(&s, im);
let kids = n.children().unwrap();
assert!(kids.len() == 2 && matches!(kids[1].kind, NodeKind::Image(_)), "{n:?}");
assert!(disc(kids[0].geometric_bounds().unwrap()), "{:?}", kids[0].geometric_bounds());
run(&mut s, "edit.undo", json!({}));
// An Offset Path of 3 pt after Outline Object moves the stroke 3 pt out: it shows from 63 to 70.
run(&mut s, "effect.apply", json!({"effect": "path.offsetPath", "params": {"offset": 3}, "item": 0}));
assert_eq!([shade(&s, 61, 100), shade(&s, 64, 100)], ["white", "black"]);
// Relinked to an opaque image, the stroke follows the frame 3 pt out: it shows from 43 to 50.
let dir = vectorcraft_testkit::temp_dir("outline-object-relink");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("opaque.png");
let mut png = vec![];
image::RgbaImage::from_pixel(100, 100, image::Rgba([255, 0, 0, 255]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
std::fs::write(&path, png).unwrap();
run(&mut s, "links.relink", json!({"ids": [im.0], "path": path}));
assert_eq!([shade(&s, 41, 100), shade(&s, 45, 100), shade(&s, 64, 100)], ["white", "black", "red"]);
}
#[test]
fn an_images_outline_object_stroke_exports_selects_and_measures_along_its_opaque_area() {
let mut s = session();
// The frame runs from 50 to 150, the disc from 70 to 130.
let r = run(&mut s, "file.place", json!({"name": "disc.png", "dataBase64": vectorcraft_format::base64_encode(&disc_png()), "at": [100, 100]}));
let im = NodeId(r["ids"][0].as_u64().unwrap());
run(&mut s, "appearance.addStroke", json!({}));
run(&mut s, "appearance.setItem", json!({"index": 0, "weight": 20, "color": "#000000"}));
run(&mut s, "effect.apply", json!({"effect": "path.outlineObject", "item": 0}));
// EPS and the PDF-compatible .ai draw the disc's outline stroked 20 pt, 20 pt inside the
// image's frame on each side (EPS moves the art to its bounding box).
let disc_outlines = |d: &vectorcraft_doc::Document| {
let mut frame = None;
d.walk(|c| {
if matches!(c.kind, NodeKind::Image(_)) {
frame = c.geometric_bounds();
}
});
let f = frame.unwrap();
let mut n = 0;
d.walk(|c| {
let disc = |b: vectorcraft_geom::Rect| {
[b.x0 - f.x0 - 20.0, b.y0 - f.y0 - 20.0, f.x1 - b.x1 - 20.0, f.y1 - b.y1 - 20.0].iter().all(|d| d.abs() < 0.6)
};
let stroked = (c.appearance.stroke_width() - 20.0).abs() < 0.01;
n += usize::from(matches!(c.kind, NodeKind::Path { .. }) && stroked && c.geometric_bounds().is_some_and(disc));
});
n
};
let saved = |s: &mut Session, p: Value| vectorcraft_format::base64_decode(run(s, "file.saveCopy", p)["dataBase64"].as_str().unwrap()).unwrap();
let eps = saved(&mut s, json!({"format": "eps", "previewFormat": "none", "thumbnails": false}));
assert_eq!(disc_outlines(&vectorcraft_eps::import_with(&eps, false).unwrap().document), 1);
let ai = saved(&mut s, json!({"format": "ai"}));
assert_eq!(disc_outlines(&crate::cmd::fileio::page_document(&ai, 0, &Default::default()).unwrap().0), 1);
// An Offset Path of 30 pt after Outline Object: the stroke runs round a circle of radius 60,
// from 30 to 50 on the left, where along the frame it would run from 10 to 30.
run(&mut s, "effect.apply", json!({"effect": "path.offsetPath", "params": {"offset": 30}, "item": 0}));
assert_eq!((pixel(&s, 35, 100), pixel(&s, 15, 100)), ([0, 0, 0, 255], [255; 4]));
run(&mut s, "select.none", json!({}));
assert_eq!(click(&mut s, 35.0, 100.0), [im]);
run(&mut s, "select.none", json!({}));
assert!(click(&mut s, 15.0, 100.0).is_empty());
// Use Preview Bounds measures the stroke round the circle.
s.prefs.use_preview_bounds = true;
let b = s.transform_bounds(&[im]).unwrap();
assert!([b.x0 - 30.0, b.y0 - 30.0, b.x1 - 170.0, b.y1 - 170.0].iter().all(|d| d.abs() < 1.0), "{b:?}");
}
/// Click with the Selection tool at (x, y) on a 100% view: what is selected afterwards.
fn click(s: &mut Session, x: f64, y: f64) -> Vec<NodeId> {
let v = ViewInfo::default();
+198
View File
@@ -382,6 +382,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 {
@@ -657,6 +693,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");
@@ -682,6 +798,88 @@ fn dcs_transparency_marks_transparent_and_overlapped_dcs_images() {
assert!(error(&mut s, "links.list", json!({ "dcsTransparency": "yes" })).contains("dcsTransparency"), "a flag that isn't true or false");
}
#[test]
fn dcs_transparency_looks_only_at_the_part_of_an_image_its_clipping_path_shows() {
let dir = temp_dir("transparency-clipped");
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 group = NodeId(r["ids"][0].as_u64().unwrap());
let image = image_of(&s, &r["ids"][0]).id;
let (clip, all) = {
let doc = &s.doc().unwrap().doc;
let NodeKind::Group { children, clip: true } = &doc.node(group).unwrap().kind else { panic!("a clipping group") };
(children[0].geometric_bounds().unwrap(), doc.node(image).unwrap().geometric_bounds().unwrap())
};
assert!(all.x1 - clip.x1 > 2.0, "the file's clipping path shows the left half: {clip:?} of {all:?}");
// A half-transparent square over the part of the image the clipping path hides.
let x = (clip.x1 + all.x1) / 2.0 - 1.0;
let over = s.execute("shape.rectangle", &json!({"x": x, "y": all.y0, "width": 2, "height": 2})).unwrap()["id"].as_u64().unwrap();
s.execute("transparency.set", &json!({"ids": [over], "opacity": 50})).unwrap();
let marked = |s: &mut Session| s.execute("links.list", &json!({ "dcsTransparency": true })).unwrap()["links"][0]["dcsTransparency"] == true;
assert!(!marked(&mut s), "the square is over the hidden part only");
s.execute("transparency.set", &json!({"ids": [over], "opacity": 100})).unwrap();
let shown = s.execute("shape.rectangle", &json!({"x": clip.x0, "y": all.y0, "width": 2, "height": 2})).unwrap()["id"].as_u64().unwrap();
s.execute("transparency.set", &json!({"ids": [shown], "opacity": 50})).unwrap();
assert!(marked(&mut s), "a square over the part that shows");
}
#[test]
fn dcs_transparency_skips_a_transparent_object_its_own_clipping_paths_hide() {
let dir = temp_dir("transparency-clipped-over");
let path = write(&dir, "pic.eps", &two_plates());
// A session with the file placed, and the top-left corner of the image, inside the part its
// clipping path shows.
let placed = || {
let mut s = session();
let r = s.execute("file.place", &json!({ "path": path, "at": [100, 100] })).unwrap();
let b = image_of(&s, &r["ids"][0]).geometric_bounds().unwrap();
(s, b.x0, b.y0)
};
let rect = |s: &mut Session, x: f64, y: f64, width: f64| {
s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": width, "height": 2})).unwrap()["id"].as_u64().unwrap()
};
let make = |s: &mut Session, ids: [u64; 2]| {
s.execute("select.set", &json!({ "ids": ids })).unwrap();
s.execute("object.clippingMask.make", &json!({})).unwrap()["id"].as_u64().unwrap()
};
let marked = |s: &mut Session| s.execute("links.list", &json!({ "dcsTransparency": true })).unwrap()["links"][0]["dcsTransparency"] == true;
// A half-transparent square over the part of the image that shows.
let square = |s: &mut Session, x: f64, y: f64| {
let id = rect(s, x, y, 2.0);
s.execute("transparency.set", &json!({"ids": [id], "opacity": 50})).unwrap();
assert!(marked(s), "the square over the image");
id
};
// Its own clip group's clipping path is beside it.
let (mut s, x, y) = placed();
let over = square(&mut s, x, y);
let beside = rect(&mut s, x + 5.0, y, 2.0);
make(&mut s, [over, beside]);
assert!(!marked(&mut s), "a clip group that hides the square");
// Its layer's clipping path is beside it.
let (mut s, x, y) = placed();
s.execute("layer.new", &json!({})).unwrap();
square(&mut s, x, y);
rect(&mut s, x + 5.0, y, 2.0);
s.execute("select.none", &json!({})).unwrap();
assert!(s.execute("layer.clippingMask.toggle", &json!({})).unwrap()["clip"].as_bool().unwrap());
assert!(!marked(&mut s), "a clipping layer that hides the square");
// Each of two nested clipping paths shows part of the inner group, and only the inner one
// covers the square.
let (mut s, x, y) = placed();
let over = square(&mut s, x, y);
let inner = rect(&mut s, x, y, 40.0);
let group = make(&mut s, [over, inner]);
assert!(marked(&mut s), "the inner clipping path shows the square");
let outer = rect(&mut s, x + 10.0, y, 20.0);
make(&mut s, [group, outer]);
assert!(!marked(&mut s), "the two clipping paths together hide the square");
}
#[test]
fn dcs_transparency_counts_a_transparent_dcs_image_over_another() {
let dir = temp_dir("transparency-two");
+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
@@ -768,6 +768,42 @@ fn nested_objects_that_repeat_ids_open_with_ids_of_their_own() {
assert!(image(&s, layer_image).link.is_some(), "the image in the layers stays linked");
}
/// 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"}));
+202
View File
@@ -0,0 +1,202 @@
//! `select.storedPath`, the Actions panel's Select Path step: the step stores a path's id and a
//! fingerprint of it, and playback selects that path only while it is still there unchanged and
//! can be selected.
use std::io::Write;
use serde_json::{Value, json};
use crate::*;
/// A document with two rectangles and an ellipse, nothing selected → the session and their ids.
fn doc() -> (Session, [u64; 3]) {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
let mut add = |shape: &str, x: u32| s.execute(shape, &json!({"x": x, "y": 10, "width": 40, "height": 30})).unwrap()["id"].as_u64().unwrap();
let ids = [add("shape.rectangle", 10), add("shape.rectangle", 80), add("shape.ellipse", 150)];
s.execute("select.none", &json!({})).unwrap();
(s, ids)
}
fn select(s: &mut Session, ids: &[u64]) {
s.execute("select.set", &json!({"ids": ids})).unwrap();
}
fn selected(s: &Session) -> Vec<u64> {
s.doc().unwrap().selection.objects.iter().map(|id| id.0).collect()
}
/// The Select Path step's params for `id`, selected alone.
fn step_for(s: &mut Session, id: u64) -> Value {
select(s, &[id]);
stored_path_step(s.doc().unwrap()).unwrap()
}
#[test]
fn the_hash_is_fnv_1a_64() {
// The published FNV-1a 64-bit test values for "", "a" and "foobar".
for (bytes, want) in [(&b""[..], 0xcbf2_9ce4_8422_2325_u64), (b"a", 0xaf63_dc4c_8601_ec8c), (b"foobar", 0x8594_4171_f739_67e8)] {
let mut h = crate::cmd::select::Fnv::default();
h.write_all(bytes).unwrap();
assert_eq!(h.0, want, "{bytes:?}");
}
}
#[test]
fn the_step_stores_the_id_and_a_fingerprint_and_plays_by_selecting_the_path() {
let (mut s, [a, b, _]) = doc();
let p = step_for(&mut s, a);
assert_eq!(p["id"], json!(a));
let fingerprint = p["fingerprint"].as_str().unwrap();
assert!(fingerprint.starts_with("v1:") && fingerprint.len() == 19, "{fingerprint}");
assert_eq!(step_for(&mut s, a), p, "the same path gives the same step");
assert_ne!(step_for(&mut s, b)["fingerprint"], p["fingerprint"], "another path gives another fingerprint");
select(&mut s, &[b]);
let r = s.execute("select.storedPath", &p).unwrap();
assert_eq!(r, json!({"count": 1, "ids": [a]}));
assert_eq!(selected(&s), [a], "only the stored path is selected");
}
#[test]
fn a_compound_path_is_one_step_and_a_path_in_a_group_is_one_too() {
let (mut s, [a, b, c]) = doc();
select(&mut s, &[a, b]);
let compound = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap();
let p = step_for(&mut s, compound);
let member = s.doc().unwrap().doc.children(Some(vectorcraft_doc::NodeId(compound))).unwrap()[0].id.0;
select(&mut s, &[member]);
assert_eq!(stored_path_step(s.doc().unwrap()).unwrap_err(), "select one whole path or compound path", "a member alone is not stored");
s.execute("select.none", &json!({})).unwrap();
s.execute("select.storedPath", &p).unwrap();
assert_eq!(selected(&s), [compound]);
select(&mut s, &[c, compound]);
let group = s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap();
assert_eq!(stored_path_step(s.doc().unwrap()).unwrap_err(), "select one whole path or compound path", "a group is not stored");
let inner = step_for(&mut s, c);
select(&mut s, &[group]);
s.execute("select.storedPath", &inner).unwrap();
assert_eq!(selected(&s), [c], "the path inside the group is selected alone");
}
#[test]
fn only_one_whole_path_that_can_be_selected_is_stored() {
let (mut s, [a, b, _]) = doc();
let whole = "select one whole path or compound path";
assert_eq!(stored_path_step(s.doc().unwrap()).unwrap_err(), "select a path");
select(&mut s, &[a, b]);
assert_eq!(stored_path_step(s.doc().unwrap()).unwrap_err(), "select only one path");
s.execute("guide.add", &json!({"vertical": true, "pos": 200})).unwrap();
select(&mut s, &[a]);
s.execute("guide.select", &json!({"indexes": [0], "toggle": true})).unwrap();
assert_eq!(s.doc().unwrap().selection.guides.len(), 1, "the guide is selected with the path");
assert_eq!(stored_path_step(s.doc().unwrap()).unwrap_err(), "select only one path", "a path with a guide");
s.execute("guide.select", &json!({"indexes": [0], "toggle": true})).unwrap();
assert_eq!(stored_path_step(s.doc().unwrap()).unwrap(), step_for(&mut s, a), "the path alone again");
s.execute("select.anchors", &json!({"id": a, "anchors": [[0, 0]], "mode": "set"})).unwrap();
assert_eq!(stored_path_step(s.doc().unwrap()).unwrap_err(), whole, "an anchor alone");
let t = s.execute("text.create", &json!({"x": 10, "y": 200, "text": "Type"})).unwrap()["id"].as_u64().unwrap();
select(&mut s, &[t]);
assert_eq!(stored_path_step(s.doc().unwrap()).unwrap_err(), whole, "type");
select(&mut s, &[a]);
s.execute("object.lock", &json!({})).unwrap();
let mut st = s.doc().unwrap().clone();
st.selection.objects = vec![vectorcraft_doc::NodeId(a)];
assert_eq!(stored_path_step(&st).unwrap_err(), "the path is hidden or locked, or cannot be selected here");
let guide = {
let mut d = (*st.doc).clone();
if let Some(vectorcraft_doc::NodeKind::Path { guide, .. }) = d.node_mut(vectorcraft_doc::NodeId(a)).map(|n| &mut n.kind) {
*guide = true;
}
d
};
assert!(path_fingerprint(guide.node(vectorcraft_doc::NodeId(a)).unwrap()).is_none(), "a guide is not stored");
}
#[test]
fn a_changed_missing_hidden_or_other_path_is_an_error_and_keeps_the_selection() {
let (mut s, [a, b, c]) = doc();
let p = step_for(&mut s, a);
let fails = |s: &mut Session, p: &Value, want: &str| {
select(s, &[c]);
let e = s.execute("select.storedPath", p).unwrap_err().to_string();
assert!(e.contains(want), "{e}");
assert_eq!(selected(s), [c], "the selection stays as it was");
};
select(&mut s, &[a]);
s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
fails(&mut s, &p, "is not the stored path");
let moved = step_for(&mut s, a);
s.execute("object.hide", &json!({})).unwrap();
fails(&mut s, &moved, "hidden or locked");
s.execute("object.showAll", &json!({})).unwrap();
let shown = step_for(&mut s, b);
s.execute("edit.clear", &json!({})).unwrap();
fails(&mut s, &shown, "no such object id");
// Another document: the id names another path there, or nothing.
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
s.execute("shape.rectangle", &json!({"x": 1, "y": 1, "width": 7, "height": 9})).unwrap();
let c2 = s.execute("shape.ellipse", &json!({"x": 20, "y": 20, "width": 9, "height": 7})).unwrap()["id"].as_u64().unwrap();
select(&mut s, &[c2]);
assert!(s.execute("select.storedPath", &moved).is_err());
assert_eq!(selected(&s), [c2]);
}
#[test]
fn a_path_on_a_locked_artboard_is_refused_in_isolation_mode_too() {
let (mut s, [a, b, _]) = doc();
select(&mut s, &[a, b]);
let group = s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap();
let p = step_for(&mut s, a);
s.execute("select.none", &json!({})).unwrap();
s.execute("object.isolate", &json!({"id": group})).unwrap();
s.execute("artboard.new", &json!({"x": 0, "y": 0, "width": 300, "height": 200})).unwrap();
s.execute("artboard.setProps", &json!({"index": 1, "locked": true})).unwrap();
assert!(s.doc().unwrap().isolation.is_some(), "the group stays isolated");
assert!(s.doc().unwrap().doc.on_locked_artboard(vectorcraft_doc::NodeId(a)), "the path is on the locked artboard");
let mut st = s.doc().unwrap().clone();
st.selection.objects = vec![vectorcraft_doc::NodeId(a)];
assert_eq!(stored_path_step(&st).unwrap_err(), "the path is hidden or locked, or cannot be selected here", "capture");
let before = selected(&s);
let e = s.execute("select.storedPath", &p).unwrap_err().to_string();
assert!(e.contains("hidden or locked"), "{e}");
assert_eq!(selected(&s), before, "the selection stays as it was");
}
#[test]
fn a_failing_step_in_a_batch_undoes_the_steps_before_it() {
let (mut s, [a, _, c]) = doc();
let p = step_for(&mut s, a);
select(&mut s, &[a]);
s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
select(&mut s, &[c]);
let before = s.doc().unwrap().doc.clone();
let steps = [json!({"command": "object.move", "params": {"dx": 1, "dy": 0}}), json!({"command": "select.storedPath", "params": p})];
assert!(s.execute("command.batch", &json!({"commands": steps})).is_err());
assert!(s.doc().unwrap().doc == before, "the move before the step is undone");
}
#[test]
fn params_are_an_id_and_a_v1_fingerprint() {
let (mut s, [a, ..]) = doc();
let p = step_for(&mut s, a);
let f = p["fingerprint"].clone();
for bad in [
json!({"fingerprint": f}),
json!({"id": -1, "fingerprint": f}),
json!({"id": "1", "fingerprint": f}),
json!({"id": a}),
json!({"id": a, "fingerprint": 5}),
json!({"id": a, "fingerprint": "v2:0123456789abcdef"}),
json!({"id": a, "fingerprint": "v1:0123456789ABCDEF"}),
json!({"id": a, "fingerprint": "v1:0123456789abcde"}),
json!({"id": a, "fingerprint": f, "extra": 1}),
json!([a]),
] {
assert!(stored_path_params(&bad).is_err(), "{bad}");
s.execute("select.none", &json!({})).unwrap();
assert!(s.execute("select.storedPath", &bad).is_err(), "{bad}");
assert!(selected(&s).is_empty(), "{bad}");
}
assert!(stored_path_params(&p).is_ok());
}
@@ -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]
+159 -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.
@@ -4217,6 +4318,63 @@ proptest! {
survive("style library in an EPS", || vectorcraft_engine::cmd::fileio::load("x.eps", &eps).ok().map(|l| l.doc))?;
}
/// Graphic style libraries whose styles are one paint (`/SimpleStyle`): any paint operators in
/// any order, with any operands or none, `Xd` missing or repeated, and the paint left open. The
/// library is also loaded in the Graphic Style Libraries panel and its styles applied to artwork.
#[test]
fn ai_style_libraries_of_one_paint_styles_never_panic(
// Each operator, and whether it is a paint operator rather than any text.
ops in prop::collection::vec(prop_oneof![
prop::sample::select(vec![
"0 R", "0 O", "0 1 1 0 K", "1 0 0 0 k", "0 0 0 0 1 0 0 Xa", "0 0 0 0 1 0 0 XA", "0.5 G", "0 g", "K", "Xa",
"0 J 0 j 2 w 10 M []0 d", "-1 w", "1e400 w", "0 M", "[3 -1]0 d", "[]0 d",
"1 0 Xd", "0 1 Xd", "1 1 Xd", "0 0 Xd", "1 Xd", "Xd", "0 1 0 0 0 Xy", "1 0.5 1 1 0 Xy", "Xy",
"0 Bb", "2 (Sky) 0 0 0 100 1 0 0 1 0 0 Bg", "0 BB", "(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p",
"(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] P", "0 XR",
]).prop_map(|op| (op.to_string(), true)),
"[ -~]{0,12}".prop_map(|text| (text, false)),
], 0..12),
closed in any::<bool>(),
) {
use vectorcraft_testkit::ai;
let close = if closed { "/Paint ;\n/Def ;\n" } else { "" };
// C, a red 2 pt stroke, comes first, so that it reads whatever A holds.
let header = format!(
"%AI9_BeginArtStyles\n/KnownStyle :\n(C) /Name ,\n/SimpleStyle :\n0 R\n0 1 1 0 K\n2 w\n1 0 Xd\n/Paint ;\n/Def ;\n\
/KnownStyle :\n(A) /Name ,\n/SimpleStyle :\n{}\n{close}/KnownStyle :\n(B) /Name ,\n/SimpleStyle :\n1 0 Xd\n/Paint ;\n/Def ;\n\
%AI9_EndArtStyles\n%AI9_BeginArtStyleList\n(A)\n(B)\n(C)\n%AI9_EndArtStyleList\n",
ops.iter().map(|(op, _)| op.as_str()).collect::<Vec<_>>().join("\n")
);
let library = ai::editing_data(200.0, 100.0, &header);
survive("one-paint style library", || Some(vectorcraft_eps::layered_ai(library.as_bytes(), Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?;
let file = ai::ai(library.as_bytes(), ai::page_pdf());
// How many objects C's look reached, what applying it answered, and whether that look is
// C's red 2 pt stroke with no fill.
let (mut styled, mut applied, mut red_stroke) = (0, Value::Null, false);
survive("one-paint styles applied", || {
let mut s = rich_session();
let p = json!({"dataBase64": vectorcraft_format::base64_encode(&file), "name": "fuzz.ai"});
let library = no_panic(s.execute("graphicStyle.loadLibrary", &p)).ok()?["library"].clone();
no_panic(s.execute("select.all", &json!({}))).ok()?;
// Every style is added and the first one, A when it reads, applied; then C is applied.
let _ = no_panic(s.execute("graphicStyle.addFromLibrary", &json!({"library": library, "apply": true})));
applied = no_panic(s.execute("graphicStyle.addFromLibrary", &json!({"library": library, "names": ["C"], "apply": true}))).ok()?;
let d = (*s.doc().ok()?.doc).clone();
if let Some(look) = applied["applied"].as_str().and_then(|name| d.graphic_style(name)).map(|g| &g.appearance) {
let strokes = look.items.iter().filter(|i| matches!(i, vectorcraft_doc::AppearanceItem::Stroke(_))).count();
red_stroke = look.fill().is_none()
&& strokes == 1
&& look.stroke().is_some_and(|s| (s.paint.color(), s.width) == (Some(vectorcraft_color::Color::cmyk(0.0, 1.0, 1.0, 0.0)), 2.0));
d.walk(|n| styled += usize::from(n.appearance == *look));
}
Some(d)
})?;
// Text that isn't a paint operator can leave a string or a comment open over the style list.
if ops.iter().all(|(_, paint)| *paint) {
prop_assert!(applied["applied"].is_string() && red_stroke && styled > 0, "the stroke style wasn't applied: {applied}");
}
}
/// Objects on Path records of any shape: ids in any order, repeated or missing, any number of
/// objects, pivots and angles out of range or not finite, spacings, order tags that disagree,
/// groups nested or cut short, and versioned content left open.
+184 -2
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]
@@ -4423,7 +4487,7 @@ fn type_attributes_the_data_cant_hold_are_reported() {
let back = read_back(&doc);
assert!(matches!(back.layers[0].children().unwrap()[0].kind, NodeKind::Text(_)));
// Any other attribute the text document doesn't hold is reported too: No Break, a paragraph's
// left-to-right direction set on it, Optical Margin Alignment, a font version, a font that isn't
// left-to-right direction set on it, Optical Margin Alignment, a font that isn't
// installed whose name doesn't come back (a hyphen in its family), a Lab color,
// small capitals at another size, right-to-left letters aligned to the start of a paragraph
// without a direction of its own, tracking that isn't a whole number.
@@ -4432,7 +4496,6 @@ fn type_attributes_the_data_cant_hold_are_reported() {
|t| t.runs[0].style.tracking = 12.5,
|t| t.para.direction = Some(vectorcraft_doc::ParaDirection::LeftToRight),
|t| t.optical_margin = true,
|t| t.runs[0].style.font_version = Some("2.0".into()),
|t| t.runs[0].style.font_family = "my-missing font".into(),
|t| t.runs[0].style.fill = Paint::solid(MINT),
|t| t.runs[0].style.small_caps = Some(80.0),
@@ -4455,11 +4518,130 @@ fn type_attributes_the_data_cant_hold_are_reported() {
missing.runs[0].style.font_family = "Missing Sans Probe".into();
missing.runs[0].style.font_style = "Bold Italic".into();
assert!(with(missing).is_empty());
// A version that isn't installed draws in the face the font resolves to, and comes back in it.
let mut uninstalled = t.clone();
uninstalled.runs[0].style.font_version = Some("2.0".into());
assert!(with(uninstalled).is_empty());
edit(&mut t);
assert_eq!(with(t), [writer::TYPE_ATTRIBUTES]);
}
}
/// Installs the copies of each font `copies` lists, the first of each loaded first.
fn install_copies(copies: &[(bool, vectorcraft_text::test_fonts::CopyNames<'_>)]) {
for (inter, names) in copies {
let font = vectorcraft_text::test_fonts::named_copy(*inter, *names).unwrap();
assert_eq!(vectorcraft_text::FontDb::global().add_font(font), 1, "{names:?}");
}
}
/// A run's text, font family and font version, as [`fonts_saved`] reads them back.
type SavedRun = (String, String, Option<String>);
/// A 200 × 100 document of point type whose runs are `runs` (text, family, version).
fn runs_doc(runs: &[(&str, &str, Option<&str>)]) -> Document {
use vectorcraft_doc::{CharStyle, TextObject, TextRun};
let mut doc = Document::new(200.0, 100.0);
let layer = doc.layers[0].id;
let mut t = TextObject::point(vectorcraft_geom::Point::new(10.0, 70.0), "", style(18.0, Color::BLACK));
t.runs = runs
.iter()
.map(|(text, family, version)| {
let st = CharStyle { font_family: (*family).into(), font_version: version.map(Into::into), ..style(18.0, Color::BLACK) };
TextRun::new(*text, st)
})
.collect();
let n = text_node(&mut doc, t);
doc.insert(Some(layer), usize::MAX, n).unwrap();
doc
}
/// [`runs_doc`] saved and read back: the save's warnings, and the runs read back.
fn fonts_saved(runs: &[(&str, &str, Option<&str>)]) -> (Vec<String>, Vec<SavedRun>) {
let doc = runs_doc(runs);
let warnings = export_ai_editing(&doc, &opts()).unwrap().warnings;
let back = read_back(&doc);
let NodeKind::Text(t) = &back.layers[0].children().unwrap()[0].kind else { panic!("type comes back as type") };
(warnings, t.runs.iter().map(|r| (r.text.clone(), r.style.font_family.clone(), r.style.font_version.clone())).collect())
}
/// How many fonts the font set of [`runs_doc`] names, the hidden characters' font aside.
fn fonts_written(runs: &[(&str, &str, Option<&str>)]) -> usize {
text_document(&program(&runs_doc(runs))).matches("/CoolTypeFont").count() - 1
}
/// Type in a font installed beside a family spelled alike but for case and punctuation
/// (`Sample/Ink`, PostScript name `SampleInk`, beside `SAMPLE INK`), or beside a copy whose
/// PostScript name differs only in case (the type naming its version), is written under its
/// PostScript name and read back in its own family and version, so nothing about its font is
/// reported lost.
#[test]
fn type_in_a_font_beside_a_family_spelled_alike_keeps_its_font() {
install_copies(&[
(true, ("SAVETEST INK", "Regular", "Version 1.000", "SAVETESTINK-Regular")),
(false, ("Savetest/Ink", "Regular", "Version 1.000", "SavetestInk")),
(true, ("CASESAVE INK", "Regular", "Version 1.000", "CASESAVEINK")),
(false, ("Casesave Ink", "Regular", "Version 2.000", "CasesaveInk")),
]);
let (warnings, back) = fonts_saved(&[("Kept", "Savetest/Ink", None)]);
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(back, [("Kept".to_string(), "Savetest/Ink".to_string(), None)]);
let (warnings, back) = fonts_saved(&[("Kept", "Casesave Ink", Some("Version 2.000"))]);
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(back, [("Kept".to_string(), "Casesave Ink".to_string(), Some("Version 2.000".to_string()))]);
}
/// Type in a static font's version that the family and style don't resolve to (two installed
/// copies of one PostScript name, family and style) is written with that version (`/5`) and comes
/// back in it, without a note; two runs in two versions are two fonts of the set.
#[test]
fn type_in_another_version_of_a_static_font_keeps_it() {
install_copies(&[(true, ("Rttest Ink", "Regular", "1.0", "RttestInk-Regular")), (false, ("Rttest Ink", "Regular", "1.1", "RttestInk-Regular"))]);
let (warnings, back) = fonts_saved(&[("Kept", "Rttest Ink", Some("1.1"))]);
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(back, [("Kept".to_string(), "Rttest Ink".to_string(), Some("1.1".to_string()))]);
let (warnings, back) = fonts_saved(&[("Old", "Rttest Ink", None), ("New", "Rttest Ink", Some("1.1"))]);
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(back, [("Old".to_string(), "Rttest Ink".to_string(), None), ("New".to_string(), "Rttest Ink".to_string(), Some("1.1".to_string()))]);
// A run naming the version its family and style resolve to anyway, or one that isn't
// installed, is in the same face as a run naming none: one font of the set, and no note.
let (warnings, back) = fonts_saved(&[("Same", "Rttest Ink", Some("1.0")), ("Gone", "Rttest Ink", Some("9.9")), ("Plain", "Rttest Ink", None)]);
assert!(warnings.is_empty(), "{warnings:?}");
assert!(back.iter().all(|(_, family, version)| family == "Rttest Ink" && version.is_none()), "{back:?}");
assert_eq!(fonts_written(&[("Same", "Rttest Ink", Some("1.0")), ("Gone", "Rttest Ink", Some("9.9")), ("Plain", "Rttest Ink", None)]), 1);
}
/// Type in the copy of a font that its family names, beside a copy of the same PostScript name in
/// another family that the name alone gives first, is written with its version (`/5`), so it comes
/// back in its own copy and family, without a note.
#[test]
fn type_in_a_copy_its_postscript_name_alone_doesnt_give_keeps_it() {
install_copies(&[
(true, ("RvdupsaveInk", "Regular", "2.0", "RvdupsaveInk-Regular")),
(false, ("Rvdupsave Ink", "Regular", "1.0", "RvdupsaveInk-Regular")),
]);
let (warnings, back) = fonts_saved(&[("Kept", "Rvdupsave Ink", None)]);
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(back.iter().map(|r| r.1.as_str()).collect::<Vec<_>>(), ["Rvdupsave Ink"]);
let (warnings, back) = fonts_saved(&[("Kept", "RvdupsaveInk", None)]);
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(back.iter().map(|r| r.1.as_str()).collect::<Vec<_>>(), ["RvdupsaveInk"]);
}
/// A version string longer than the reader takes (256 bytes) isn't one it can pick a copy by: type
/// in such a copy, which its name alone doesn't give back, is reported as losing its font.
#[test]
fn a_version_longer_than_the_reader_takes_is_reported() {
let long = "9".repeat(300);
install_copies(&[
(true, ("Longtest Ink", "Regular", "1.0", "LongtestInk-Regular")),
(false, ("Longtest Ink", "Regular", &long, "LongtestInk-Regular")),
]);
let (warnings, back) = fonts_saved(&[("Long", "Longtest Ink", Some(&long))]);
assert_eq!(warnings, [writer::TYPE_ATTRIBUTES]);
assert_eq!(back, [("Long".to_string(), "Longtest Ink".to_string(), None)]);
}
/// On a large canvas, area type, threaded frames and type on a path stay type: the frames and
/// lengths are written at the canvas's scale. Frames of a thread at two scales, and area type
/// scaled by two factors, are written as outlines.
+32 -18
View File
@@ -338,6 +338,8 @@ pub(in crate::ai_editing) fn loses(t: &TextObject) -> bool {
CharStyle {
font_family: s.font_family.clone(),
font_style: s.font_style.clone(),
// The font record's version (`/5`) names the face; whether it comes back is `font_kept`'s.
font_version: s.font_version.clone(),
size: s.size,
leading: s.leading,
tracking: s.tracking.round(),
@@ -452,15 +454,17 @@ struct FontRecord {
/// the font's order, in the axis's own units. Empty for a static font, which has none: without
/// it, an app that opens the file takes a variable font's default instance (#6388).
design: Vec<f32>,
/// For a variable font, its version string (`/5`, name ID 5); empty for a static font.
/// Its version string (`/5`, name ID 5): for a variable font, and for a static font whose
/// PostScript name alone gives the reader another installed copy (two copies of one font, see
/// the reader's `document_font`); else empty.
version: String,
}
/// The font the font set names for style `st`: an installed face's PostScript name, and for a
/// variable font the setting of each of its axes (the named instance's, or the style's own
/// Variable Font setting, clamped to the axis as layout clamps it) and its version; else its family
/// and style as `family-style` (the family alone for Regular), the form the reader's
/// `family_style` splits.
/// The font the font set names for style `st`: an installed face's PostScript name, its version
/// for a variable font or when the name alone gives the reader another installed copy, and for a
/// variable font the setting of each of its axes (the named instance's, or the style's own Variable
/// Font setting, clamped to the axis as layout clamps it); else its family and style as
/// `family-style` (the family alone for Regular), the form the reader's `family_style` splits.
fn font_name(st: &CharStyle) -> FontRecord {
let db = vectorcraft_text::FontDb::global();
// The face of that family and style (the font database gives a fallback face for others).
@@ -479,8 +483,11 @@ fn font_name(st: &CharStyle) -> FontRecord {
set.map_or(own, |v| (v as f32).clamp(a.min.min(a.max), a.max.max(a.min)))
})
.collect();
let version = if design.is_empty() { String::new() } else { face.version.clone() };
FontRecord { name: face.postscript_name(), truetype: face.is_truetype(), design, version }
let name = face.postscript_name();
// The reader takes the face of a name alone as `postscript_face` finds it.
let by_name = crate::import::postscript_face(&name, None).is_some_and(|f| f.id() == face.id());
let version = if design.is_empty() && by_name { String::new() } else { face.version.clone() };
FontRecord { name, truetype: face.is_truetype(), design, version }
}
None if st.font_style.is_empty() || st.font_style == "Regular" => record(st.font_family.clone()),
None => record(format!("{}-{}", st.font_family, st.font_style.replace(' ', ""))),
@@ -518,12 +525,19 @@ fn fixed(v: f32) -> i64 {
if v.is_finite() { (f64::from(v) * 65536.0).round() as i64 } else { 0 }
}
/// Does the font of style `st` come back as itself: the family and style the reader makes of the
/// name it is written under (as its `char_style` does)?
/// Does the font of style `st` come back as itself: are the family and style the reader makes of
/// the record it is written as (its name, and its version as the reader takes it, as its
/// `char_style` does) the style's own, and the face they and the version read back resolve to the
/// face of the style? A version that isn't installed draws in the face the font resolves to anyway.
fn font_kept(st: &CharStyle) -> bool {
let (family, style) = crate::family_style(&font_name(st).name);
let family = vectorcraft_text::FontDb::global().find_family(&family).unwrap_or(family);
family == st.font_family && style == st.font_style
let record = font_name(st);
let version = crate::import::record_version(&record.version);
let (family, style, read) = crate::import::document_font(&record.name, version);
let db = vectorcraft_text::FontDb::global();
let face = |family: &str, style: &str, version: Option<&str>| db.face_version(family, style, version).map(|f| f.id());
family == st.font_family
&& style == st.font_style
&& face(&family, &style, read.as_deref()) == face(&st.font_family, &st.font_style, st.font_version.as_deref())
}
/// Whether `c` is a strong right-to-left letter (`Some(true)`: Hebrew, Arabic, Syriac, Thaana, NKo
@@ -1022,13 +1036,13 @@ impl TextDoc {
.iter()
.map(|f| {
let mut font = vec![(0, V::Str(f.name.clone())), (2, V::Int(i64::from(f.truetype)))];
// A variable font's place in its design space and its version (#6388); a static
// font has neither.
// A variable font's place in its design space (#6388), and the version of a
// variable font or of a static one its name alone doesn't give back.
if !f.design.is_empty() {
font.push((4, V::List(f.design.iter().map(|v| V::Int(fixed(*v))).collect())));
if !f.version.is_empty() {
font.push((5, V::Str(f.version.clone())));
}
}
if !f.version.is_empty() {
font.push((5, V::Str(f.version.clone())));
}
dict(vec![(99, V::Name("CoolTypeFont")), (0, dict(font))])
})
+2 -1
View File
@@ -89,10 +89,11 @@ pub(crate) use ai::{
BRUSH_MANAGER, BrushesRead, MAX_EDITING_OBJECTS, MAX_GRADIENT_STOPS, MAX_LISTED, MAX_LISTED_NAME, MAX_SYMBOLS, brush_plugin_name, brushes_kept,
read_art_style, read_brush_object, read_style_list, style_moves_with, symbol_art_member, warp_number,
};
pub(crate) use ate::{MAX_BYTES as MAX_TEXT_DOCUMENT, MAX_FRAMES, MAX_SEGMENTS, MAX_STORY, MAX_VALUES as MAX_TEXT_VALUES};
pub(crate) use ate::{MAX_BYTES as MAX_TEXT_DOCUMENT, MAX_FRAMES, MAX_SEGMENTS, MAX_STORY, MAX_VALUES as MAX_TEXT_VALUES, record_version};
pub use native::{AiLayers, ai_alone, ai_alone_on_page, ai_alone_with, ai_layers_alone, ai_opened_artboard, is_loss, layered_ai, layered_ai_from};
pub(crate) use native::{MAX_TEXT, scale_document};
pub use text::family_style;
pub(crate) use text::{document_font, postscript_face};
/// The page of a PostScript file without a bounding box (US Letter).
const LETTER: [f64; 4] = [0.0, 0.0, 612.0, 792.0];
+8 -2
View File
@@ -508,10 +508,16 @@ impl Reader<'_> {
None => (full.as_str(), None),
};
st.cmap = cmap;
let (family, style) = super::super::super::text::family_style(name);
// The installed face of that PostScript name, as type from a text document
// takes it; else the name split into a family and style.
let text = super::super::super::text::named_font(name, None);
let (family, style, version) = text.unwrap_or_else(|| {
let (family, style) = super::super::super::text::family_style(name);
(family, style, None)
});
st.style.font_family = family;
st.style.font_style = style;
st.style.font_version = None;
st.style.font_version = version;
}
if let Some(size) = vals.get(at + 1).and_then(V::num).filter(|s| s.is_finite() && *s > 0.0) {
st.style.size = size.min(MAX_SIZE);
+6 -1
View File
@@ -1231,13 +1231,18 @@ fn styled(object: &Node, look: &Node, st: &crate::import::ai::style::ArtStyle, p
/// A graphic style's appearance from art style `st`, on no object: `None` when the style isn't one
/// this reads, or paints with a gradient or a pattern (placed on each object as its drawn look has them).
/// A style that is one paint is its fill and stroke ([`crate::import::ai::style::ArtStyle::one_paint`]).
pub(super) fn appearance_from_art_style(st: &crate::import::ai::style::ArtStyle) -> Option<Appearance> {
use crate::import::ai::style::StyleItem;
if !st.plain {
return None;
}
let style_items = match &st.one_paint {
Some(one) => one.as_ref().ok()?,
None => &st.items,
};
let mut items = vec![];
for (n, item) in st.items.iter().enumerate() {
for (n, item) in style_items.iter().enumerate() {
let mut layer = match item {
StyleItem::Fill(paint) => AppearanceItem::Fill(FillLayer::new(paint.clone())),
// A graphic style's brush isn't checked against the file's brushes: it is left out.
+114 -9
View File
@@ -32,6 +32,10 @@
//! gradient (`Bb` … `flag (name) x y angle length … Bg` … `BB`); `stroke fill Xd` says whether it
//! paints at all. A blend filter's `mode opacity isolate knockout shape Xy` is its transparency.
//!
//! A style with no filters is one paint (`/SimpleStyle :` … `/Paint ;` in place of `/ActiveStyle`):
//! the fill's and stroke's colors and the stroke's style as an object's own paint operators write
//! them, `stroke fill Xd` turning each on, and an `Xy` for its transparency.
//!
//! A stroke with a Calligraphic or Art brush is its own basic filter, `(Adobe Calligraphic Brush
//! Tool)` or `(Adobe ArtOnPath Brush Tool)`, with its stroke's style and the brush's settings for
//! the object as entries (see [`super::brush`]).
@@ -206,6 +210,11 @@ pub(super) struct ArtStyle {
/// paint is a pattern: its [`StyleItem::Pattern`]. A text document's characters name such
/// styles for their fills and strokes (`ate`). `None` for any other paint or style.
pub simple_pattern: Option<StyleItem>,
/// A style that is one paint: its fill and stroke, bottom first, each there when its
/// `stroke fill Xd` turns it on (none when neither is on). `Err` when it turns on a fill or
/// stroke whose paint isn't read here (a gradient, a pattern, a color that isn't read) or has
/// no `Xd`. `None` for a style with filters, whose paint is [`Self::items`].
pub one_paint: Option<Result<Vec<StyleItem>, ()>>,
}
impl ArtStyle {
@@ -412,12 +421,22 @@ pub(super) fn art_styles_in(data: &[u8], names: &mut Names) -> HashMap<String, A
let mut in_basic = false;
// A style that is one paint being read, and its pattern so far.
let mut simple: Option<Option<StyleItem>> = None;
let mut finish = |name: Option<String>, filters: &mut Vec<Filter>, simple_pattern: Option<StyleItem>| {
// The same style's paint operators so far.
let mut one_paint: Option<OnePaint> = None;
let mut finish = |name: Option<String>, filters: &mut Vec<Filter>, simple_pattern: Option<StyleItem>, one_paint: Option<OnePaint>| {
if let Some(name) = name {
// Only a style that is one paint holds a pattern for type: one with filters as well
// paints with more than that.
let simple_pattern = simple_pattern.filter(|_| filters.is_empty());
styles.insert(name, ArtStyle { simple_pattern, ..style(filters) });
let mut st = ArtStyle { simple_pattern, ..style(filters) };
if let Some(one) = one_paint.filter(|_| filters.is_empty()) {
if let Some((mode, opacity)) = one.paint.xy {
let (isolate, knockout) = one.paint.xy_group.unwrap_or((false, Knockout::Off));
st.transparency = Some((opacity, super::read::blend_mode(mode), isolate, knockout));
}
st.one_paint = Some(one.items());
}
styles.insert(name, st);
}
filters.clear();
};
@@ -454,12 +473,12 @@ pub(super) fn art_styles_in(data: &[u8], names: &mut Names) -> HashMap<String, A
}
Tok::Name(n) => match n {
b"KnownStyle" => {
finish(name.take(), &mut filters, simple.take().flatten());
finish(name.take(), &mut filters, simple.take().flatten(), one_paint.take());
operands.clear();
open.clear();
(pending, in_basic, style_entry, other_entry) = (None, false, None, None);
}
b"SimpleStyle" if filters.is_empty() => simple = Some(None),
b"SimpleStyle" if filters.is_empty() => (simple, one_paint) = (Some(None), Some(OnePaint::default())),
b"FillStyle" => style_entry = Some(Kind::Fill),
b"StrokeStyle" | b"BlendStyle" => style_entry = Some(Kind::Other),
b"Name" if name.is_none() && filters.is_empty() => name = operands.iter().rev().find_map(|v| names.text(v)),
@@ -559,7 +578,7 @@ pub(super) fn art_styles_in(data: &[u8], names: &mut Names) -> HashMap<String, A
f.repeated |= f.kinds.insert(key, kind).is_some();
}
}
style_paint_op(&mut filters, &mut simple, op, &operands, names);
style_paint_op(&mut filters, &mut simple, &mut one_paint, op, &operands, names);
if op == "," || op == ";" || op.starts_with('X') || op.starts_with('B') || op.len() <= 2 {
operands.clear();
}
@@ -571,7 +590,7 @@ pub(super) fn art_styles_in(data: &[u8], names: &mut Names) -> HashMap<String, A
operands.clear();
}
}
finish(name, &mut filters, simple.take().flatten());
finish(name, &mut filters, simple.take().flatten(), one_paint.take());
styles
}
@@ -729,13 +748,99 @@ fn pattern_operands(vals: &[V], names: &mut Names) -> Option<(String, Option<[f6
}
/// A paint operator in a style being read: one of the last filter's (`filters`), else one of a style
/// that is one paint (`simple`, while one is read).
fn style_paint_op(filters: &mut [Filter], simple: &mut Option<Option<StyleItem>>, op: &str, vals: &[V], names: &mut Names) {
/// that is one paint (`simple` and `one_paint`, while one is read).
fn style_paint_op(
filters: &mut [Filter],
simple: &mut Option<Option<StyleItem>>,
one_paint: &mut Option<OnePaint>,
op: &str,
vals: &[V],
names: &mut Names,
) {
if let Some(f) = filters.last_mut() {
paint_op(f, op, vals, names);
} else if let Some(paint) = simple.as_mut() {
return;
}
if let Some(paint) = simple.as_mut() {
simple_paint_op(paint, op, vals, names);
}
if let Some(one) = one_paint.as_mut() {
one.op(op, vals, names);
}
}
/// The paint operators of a style that is one paint (`/SimpleStyle :` … `/Paint ;`), read as a
/// fill's or stroke's style reads them.
#[derive(Default)]
struct OnePaint {
paint: Filter,
/// Its `stroke fill Xd` was read.
xd: bool,
/// Its last fill paint is a pattern (`p`) or may be a gradient (`Bg`).
fill_unread: bool,
/// Its last stroke paint is a pattern (`P`) or may be a gradient (`Bg`).
stroke_unread: bool,
/// Inside a gradient instance (`flag Bb` … `BB`): whether it paints the stroke (flag 1).
instance: Option<bool>,
}
impl OnePaint {
fn op(&mut self, op: &str, vals: &[V], names: &mut Names) {
match op {
"Xd" => self.xd |= vals.iter().filter_map(V::num).count() >= 2,
"p" => self.fill_unread = true,
"P" => self.stroke_unread = true,
// The flag of the instance it is in says which one it paints, as an object's does
// (`Instance::begin` in `paint.rs`); outside one it is read as either one's.
"Bb" => self.instance = Some(vals.iter().filter_map(V::num).next_back() == Some(1.0)),
"BB" => self.instance = None,
"Bg" => match self.instance {
Some(true) => self.stroke_unread = true,
Some(false) => self.fill_unread = true,
None => (self.fill_unread, self.stroke_unread) = (true, true),
},
// A color replaces what came before it; one that isn't read leaves none.
"Xa" | "k" | "g" | "x" | "Xx" | "Xk" | "Xs" | "Xz" | "xa" | "xx" | "xk" | "xs" | "xz" => {
(self.fill_unread, self.paint.fill) = (false, None);
}
"XA" | "K" | "G" | "X" | "XX" | "XK" | "XS" | "XZ" => (self.stroke_unread, self.paint.stroke) = (false, None),
_ => {}
}
paint_op(&mut self.paint, op, vals, names);
}
/// Its fill and stroke ([`ArtStyle::one_paint`]).
fn items(&self) -> Result<Vec<StyleItem>, ()> {
let f = &self.paint;
if !self.xd {
return Err(());
}
let (stroked, filled) = f.paints;
let mut items = vec![];
if filled {
match &f.fill {
Some(p) if !self.fill_unread => items.push(StyleItem::Fill(p.clone())),
_ => return Err(()),
}
}
if stroked {
match &f.stroke {
Some(p) if !self.stroke_unread => items.push(StyleItem::Stroke {
paint: p.clone(),
width: f.width,
cap: f.cap,
join: f.join,
miter: f.miter,
dash: f.dash.clone(),
profile: None,
align: StrokeAlign::Center,
brush: None,
}),
_ => return Err(()),
}
}
Ok(items)
}
}
/// A paint operator of a style that is one paint: a pattern (`p`) is kept as its
+45
View File
@@ -1512,3 +1512,48 @@ fn only_a_style_of_one_pattern_paint_holds_a_pattern_for_type() {
assert!(styles(&body).get("S").is_none_or(|s| s.simple_pattern.is_none()), "{body}");
}
}
/// A style that is one paint reads as the fill and stroke its `Xd` turns on, in the paint's colors
/// and the stroke's style; one that turns on a paint not read here, or has no `Xd`, isn't read.
#[test]
fn a_style_of_one_paint_reads_as_the_fill_and_stroke_its_xd_turns_on() {
let one = |paint: &str| {
let data = format!("/KnownStyle :\n(S) /Name ,\n/SimpleStyle :\n{paint}/Paint ;\n/Def ;\n");
art_styles_in(data.as_bytes(), &mut Names::default()).remove("S").and_then(|s| s.one_paint)
};
let stroke = "0 R\n0 1 1 0 K\n1 J 2 j 3 w 4 M [2 1]0 d\n";
let Some(Ok(items)) = one(&format!("0 O\n0 0 1 0 k\n{stroke}1 1 Xd\n")) else { panic!("{:?}", one(stroke)) };
let [StyleItem::Fill(fill), StyleItem::Stroke { paint, width, cap, join, miter, dash, profile: None, align: StrokeAlign::Center, brush: None }] =
items.as_slice()
else {
panic!("{items:?}")
};
assert_eq!((fill.color(), paint.color()), (Some(Color::cmyk(0.0, 0.0, 1.0, 0.0)), Some(Color::cmyk(0.0, 1.0, 1.0, 0.0))));
assert_eq!((*width, *cap, *join, *miter), (3.0, LineCap::Round, LineJoin::Bevel, 4.0));
assert_eq!(dash.as_ref().map(|d| d.pattern.clone()), Some(vec![2.0, 1.0]));
// Only what `Xd` turns on; nothing when it turns on neither.
assert!(matches!(one(&format!("0 0 1 0 k\n{stroke}1 0 Xd\n")), Some(Ok(i)) if matches!(i.as_slice(), [StyleItem::Stroke { .. }])));
assert_eq!(one(&format!("0 0 1 0 k\n{stroke}0 0 Xd\n")), Some(Ok(vec![])));
// The last paint of each one counts: a solid color after a pattern or a gradient replaces it.
let solid_fill = |before: &str| one(&format!("{before}\n0 0 1 0 k\n0 1 Xd\n"));
for before in ["(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p", "0 Bb\n2 (Sky) 0 0 0 100 1 0 0 1 0 0 Bg\n0 BB"] {
assert!(matches!(solid_fill(before), Some(Ok(i)) if matches!(i.as_slice(), [StyleItem::Fill(_)])), "{before}");
}
let solid_stroke = one("(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] P\n0 1 1 0 K\n1 0 Xd\n");
assert!(matches!(solid_stroke, Some(Ok(i)) if matches!(i.as_slice(), [StyleItem::Stroke { .. }])));
// A gradient instance's flag says which one it paints: a stroke gradient leaves a solid fill read.
let fill_and_stroke_gradient = one("0 0 1 0 k\n1 Bb\n2 (Ramp) 0 0 90 1 1 0 0 1 0 0 1 Bg\n0 BB\n0 1 Xd\n");
assert!(matches!(fill_and_stroke_gradient, Some(Ok(i)) if matches!(i.as_slice(), [StyleItem::Fill(_)])));
// Not read: no `Xd`, a gradient or pattern fill, a pattern stroke, a gradient stroke after a solid one, a paint
// turned on with no color.
for paint in [
format!("0 0 1 0 k\n{stroke}"),
"0 Bb\n2 (Sky) 0 0 0 100 1 0 0 1 0 0 Bg\n0 BB\n0 1 Xd\n".to_string(),
"(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n0 1 Xd\n".to_string(),
"(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] P\n1 0 Xd\n".to_string(),
"0 R\n0 1 1 0 K\n1 Bb\n0 0 0 0 Bh\n2 (Ramp) 0 0 90 1 1 0 0 1 0 0 1 Bg\n0 BB\n2 w\n1 0 Xd\n".to_string(),
"0 0 1 0 k\n1 1 Xd\n".to_string(),
] {
assert_eq!(one(&paint), Some(Err(())), "{paint}");
}
}
+22
View File
@@ -3930,6 +3930,28 @@ fn point_type(attrs: &str, text: &str) -> String {
format!("0 To\n1 0 0 1 10 50 0 Tp\nTP\n0 Tr\n0 0 0 1 k\n/_Helvetica 12 Tf\n{attrs}\n({text}) Tx\nTO\n")
}
/// Legacy type is set in the installed face of exactly the PostScript name its `Tf` gives, as type
/// from a text document is: not a face whose name differs only in case and loads first, nor the
/// copy the family and style resolve to when the name's own is another (it names its version).
#[test]
fn legacy_type_takes_the_face_of_its_exact_postscript_name() {
use vectorcraft_text::test_fonts::named_copy;
let db = vectorcraft_text::FontDb::global();
for (inter, names) in [
(true, ("TWINLEGACY INK", "Regular", "Version 1.000", "TWINLEGACYINK-Regular")),
(false, ("Twinlegacy Ink", "Regular", "Version 2.000", "TwinlegacyInk-Regular")),
] {
assert_eq!(db.add_font(named_copy(inter, names).unwrap()), 1, "{names:?}");
}
let face = |font: &str| {
let texts = legacy_texts(&point_type("", "Hi").replace("/_Helvetica 12 Tf", &format!("/_{font} 12 Tf")));
let f = db.style_face(&texts[0].runs[0].style).unwrap();
(f.postscript_name(), f.version.clone())
};
assert_eq!(face("TwinlegacyInk-Regular"), ("TwinlegacyInk-Regular".to_string(), "Version 2.000".to_string()));
assert_eq!(face("TWINLEGACYINK-Regular"), ("TWINLEGACYINK-Regular".to_string(), "Version 1.000".to_string()));
}
/// `TC` and `TW` (§15.4.7) set the paragraph's letter and word spacing, `Tq` its hanging
/// punctuation and `Th` (§22) its hyphenation; each paragraph keeps the values in force at its
/// first character, and they last into the next text object.
+54 -11
View File
@@ -459,7 +459,8 @@ fn spend_tabs(tabs: &mut Vec<TabStop>, budget: &mut usize) -> bool {
#[derive(Clone, Debug)]
struct Style {
font: Option<String>,
/// The font its record names, as the fonts the app has set it ([`DocFont::font`]).
font: Option<Font>,
size: f64,
/// `None`: auto.
leading: Option<f64>,
@@ -490,10 +491,44 @@ struct Style {
standard_vertical_roman: bool,
}
/// The longest font version string read from a font record: name ID 5 strings are short (`Version
/// 7.200`, or with the tools that built the font, some 80 characters).
const MAX_FONT_VERSION: usize = 256;
/// The version a font record's key 5 gives `v`, as the reader takes it: `None` when it is empty or
/// longer than [`MAX_FONT_VERSION`] bytes. The `.ai` save checks a record it writes against this.
pub(crate) fn record_version(v: &str) -> Option<&str> {
(!v.is_empty() && v.len() <= MAX_FONT_VERSION).then_some(v)
}
/// A font as type is set in it: family, style and the version it names
/// ([`super::text::document_font`]).
type Font = (String, String, Option<String>);
/// A font of a text document's font list, as its record (a `CoolTypeFont`) names it.
#[derive(Debug, Default)]
struct DocFont {
/// Key 0: the PostScript name.
name: String,
/// Key 5, when the record keeps it: the font's version string (name ID 5), which tells installed
/// copies of one font apart.
version: Option<String>,
/// [`Self::font`]'s answer, found once for every run that names the record.
font: std::sync::OnceLock<Font>,
}
impl DocFont {
/// The font type set in this record's font takes: the installed copy it names
/// ([`super::text::document_font`]).
fn font(&self) -> &Font {
self.font.get_or_init(|| super::text::document_font(&self.name, self.version.as_deref()))
}
}
/// The text document of an editing copy.
pub(super) struct Texts {
stories: Vec<Val>,
fonts: Vec<String>,
fonts: Vec<DocFont>,
/// The character and paragraph styles a story starts from.
defaults: Val,
para_defaults: Val,
@@ -552,7 +587,15 @@ impl Texts {
.get("0")?
.list()
.iter()
.map(|f| f.get("0").and_then(|f| f.get("0")).and_then(|f| f.get("0")).and_then(Val::str).unwrap_or_default().to_string())
.map(|f| {
let record = f.get("0").and_then(|f| f.get("0"));
let text = |k: &str| record.and_then(|r| r.get(k)).and_then(Val::str);
DocFont {
name: text("0").unwrap_or_default().to_string(),
version: text("5").and_then(record_version).map(str::to_string),
font: std::sync::OnceLock::new(),
}
})
.collect();
let main = doc.get("1")?;
let sheets = |key: &str| -> Vec<Val> {
@@ -774,12 +817,12 @@ impl Texts {
let pick = |k: &str| chain.iter().find_map(|s| s.get(k)).or_else(|| self.defaults.get(k));
let num = |k: &str| pick(k).and_then(Val::num);
let on = |k: &str| !matches!(pick(k), Some(Val::Bool(false)));
let font = num("0").and_then(|i| self.fonts.get(i as usize)).filter(|f| !f.is_empty()).cloned();
let font = num("0").and_then(|i| self.fonts.get(i as usize)).filter(|f| !f.name.is_empty());
let size = num("1").filter(|v| *v > 0.0 && *v < 1e5).unwrap_or(12.0);
// Key 5 of a paint names the art style that paints it ([`Self::with_patterns`]).
let pattern = |k: &str| pick(k).and_then(|p| p.get("5")).and_then(Val::str).and_then(|name| self.patterns.get(name)).cloned();
Style {
font,
font: font.map(|f| f.font().clone()),
size,
leading: if on("4") { None } else { num("5").filter(|v| *v > 0.0 && *v < 1e5) },
tracking: num("8").filter(|v| v.abs() < 1e5).unwrap_or(0.0),
@@ -1386,13 +1429,12 @@ fn para_style(p: &Para, scale: f64) -> ParaStyle {
}
}
/// The character style of character attributes `s`: its font from the fonts the app has, its
/// lengths (size, leading, baseline shift, stroke width) scaled by `scale`, and its patterns placed
/// in the type's text space by `to_text` (the document → its text space), where they stay as the
/// document has them.
/// The character style of character attributes `s`: its font from the fonts the app has (the
/// installed copy its font record names, [`DocFont::font`]), its lengths (size, leading, baseline
/// shift, stroke width) scaled by `scale`, and its patterns placed in the type's text space by
/// `to_text` (the document → its text space), where they stay as the document has them.
fn char_style(s: &Style, scale: f64, to_text: Affine) -> CharStyle {
let (family, style) = s.font.as_deref().map_or_else(|| (CharStyle::default().font_family, "Regular".to_string()), crate::family_style);
let family = vectorcraft_text::FontDb::global().find_family(&family).unwrap_or(family);
let (family, style, version) = s.font.clone().unwrap_or_else(|| (CharStyle::default().font_family, "Regular".to_string(), None));
// A fill or stroke that is on takes its pattern in place of its colour.
let paint = |color: Option<Color>, pattern: &Option<Paint>| match (color, pattern) {
// A pattern that can't be placed (the type's text space out of range) keeps the colour.
@@ -1407,6 +1449,7 @@ fn char_style(s: &Style, scale: f64, to_text: Affine) -> CharStyle {
CharStyle {
font_family: family,
font_style: style,
font_version: version,
size: s.size * scale,
// Auto leading stays auto: the layout gives each line the paragraph's share of the largest
// type on it, as the file's own layout does ("The largest leading value in a line of type
+34
View File
@@ -63,3 +63,37 @@ fn abbreviated_styles_in_postscript_names_are_spelled_out() {
fn an_installed_face_of_the_postscript_name_names_the_font() {
assert_eq!(crate::family_style("ABCDEF+SourceSans3-Semibold"), ("Source Sans 3".to_string(), "Semibold".to_string()));
}
/// The PostScript name and version of the face each `show` of a program that sets `fonts` in turn
/// draws its type in.
fn faces_shown(fonts: &[&str]) -> Vec<(String, String)> {
let body: String = fonts.iter().map(|f| format!("/{f} findfont 12 scalefont setfont 10 20 moveto (Hi) show\n")).collect();
let eps = format!("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n{body}showpage\n%%EOF\n");
let r = import(eps.as_bytes()).unwrap();
let mut faces = vec![];
r.document.walk(|n| {
if let NodeKind::Text(t) = &n.kind {
let face = FontDb::global().style_face(&t.runs[0].style).unwrap();
faces.push((face.postscript_name(), face.version.clone()));
}
});
faces
}
/// Type a PostScript program sets is drawn in the installed face of exactly the font's PostScript
/// name, as type from a text document is: not another family spelled alike but for case and
/// punctuation, nor a face whose name differs only in case and loads first.
#[test]
fn shown_type_takes_the_face_of_its_exact_postscript_name() {
use vectorcraft_text::test_fonts::named_copy;
for (inter, names) in [
(true, ("FUZZSHOW INK", "Regular", "Version 1.000", "FUZZSHOWINK-Regular")),
(false, ("Fuzzshow/Ink", "Regular", "Version 1.000", "FuzzshowInk")),
(true, ("CASESHOW INK", "Regular", "Version 1.000", "CASESHOWINK")),
(false, ("Caseshow Ink", "Regular", "Version 2.000", "CaseshowInk")),
] {
assert_eq!(FontDb::global().add_font(named_copy(inter, names).unwrap()), 1, "{names:?}");
}
let names: Vec<String> = faces_shown(&["FuzzshowInk", "CaseshowInk", "CASESHOWINK"]).into_iter().map(|f| f.0).collect();
assert_eq!(names, ["FuzzshowInk", "CaseshowInk", "CASESHOWINK"]);
}
+67
View File
@@ -3128,6 +3128,73 @@ fn type_from_the_text_document_takes_its_character_styles_attributes() {
assert_eq!(style(2), (solid(Color::cmyk(0.0, 1.0, 0.0, 0.0)), Some(18.0)), "the run's own colour");
}
/// The PostScript name and version of the face that type from a text document whose font record is
/// `record` (in place of `/0 (Helvetica)`) is set in.
fn face_set_from(record: &str) -> (String, String) {
let doc = text_document(&[("Hamburg\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]).replacen("/0 (Helvetica)", record, 1);
let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &text_object(0))), &doc);
let r = import(&eps(&page_square(10, 10), &art)).unwrap();
let texts = text_of(&r.document.layers[1]);
assert_eq!(texts.len(), 1, "{record}: {:?}", r.warnings);
let face = vectorcraft_text::FontDb::global().style_face(&texts[0].runs[0].style).unwrap();
(face.postscript_name(), face.version.clone())
}
/// Installs the copies of each font `copies` lists, the first of each loaded first.
fn install(copies: &[(bool, vectorcraft_text::test_fonts::CopyNames<'_>)]) {
for (inter, names) in copies {
let font = vectorcraft_text::test_fonts::named_copy(*inter, *names).unwrap();
assert_eq!(vectorcraft_text::FontDb::global().add_font(font), 1, "{names:?}");
}
}
/// Type from the text document is set in the installed face of exactly the PostScript name its
/// font record gives, whatever other installed copies answer to that name too: one whose family
/// is spelled alike but for case and punctuation (`SAMPLE INK` beside `Sample/Ink`, PostScript name
/// `SampleInk`), one whose PostScript name differs only in case or punctuation (in its own family or
/// another), or another copy of the same family and style under another PostScript name (an older
/// build of the font: the type names its version). Those copies set the type at other widths.
#[test]
fn type_from_the_text_document_takes_the_face_of_its_exact_postscript_name() {
install(&[
(true, ("FUZZTEST INK", "Regular", "Version 1.000", "FUZZTESTINK-Regular")),
(false, ("Fuzztest/Ink", "Regular", "Version 1.000", "FuzztestInk")),
(true, ("CASETEST INK", "Regular", "Version 1.000", "CASETESTINK")),
(false, ("Casetest Ink", "Regular", "Version 2.000", "CasetestInk")),
(true, ("Copytest Ink", "Regular", "Version 1.000", "CopytestInk-Light")),
(false, ("Copytest Ink", "Regular", "Version 2.000", "CopytestInk-Regular")),
(true, ("NeuetestInk", "Bold", "Version 1.000", "NeuetestInkBold")),
(false, ("Neuetest Ink", "Bold", "Version 1.000", "NeuetestInk-Bold")),
]);
assert_eq!(face_set_from("/0 (FuzztestInk)").0, "FuzztestInk", "not the family spelled alike");
assert_eq!(face_set_from("/0 (FUZZTESTINK-Regular)").0, "FUZZTESTINK-Regular");
assert_eq!(face_set_from("/0 (CasetestInk)").0, "CasetestInk", "not the name in other case, loaded first");
assert_eq!(face_set_from("/0 (CASETESTINK)").0, "CASETESTINK");
assert_eq!(face_set_from("/0 (CopytestInk-Regular)"), ("CopytestInk-Regular".to_string(), "Version 2.000".to_string()));
assert_eq!(face_set_from("/0 (CopytestInk-Light)"), ("CopytestInk-Light".to_string(), "Version 1.000".to_string()));
assert_eq!(face_set_from("/0 (NeuetestInk-Bold)").0, "NeuetestInk-Bold", "not the name without its hyphen, in another family");
// A name in a case no installed face has still finds the font.
assert_eq!(face_set_from("/0 (casetestink)").0, "CASETESTINK");
}
/// A font record may keep the font's version string (key 5, name ID 5): of two installed copies
/// with one PostScript name, family and style, the type is set in that version, also when the
/// record spells the name with other punctuation. A version that isn't installed, or a record
/// without one, gives the copy the family and style resolve to.
#[test]
fn type_from_the_text_document_takes_the_version_its_font_record_keeps() {
install(&[(true, ("Vertest Ink", "Regular", "1.0", "VertestInk-Regular")), (false, ("Vertest Ink", "Regular", "1.1", "VertestInk-Regular"))]);
let version = |record: &str| face_set_from(record).1;
assert_eq!(version("/0 (VertestInk-Regular) /5 (1.1)"), "1.1");
assert_eq!(version("/0 (VertestInk-Regular) /5 (1.0)"), "1.0");
assert_eq!(version("/0 (VertestInk-Regular)"), "1.0");
assert_eq!(version("/0 (VertestInk-Regular) /5 (9.9)"), "1.0");
assert_eq!(version("/0 (VertestInk-Regular) /5 ()"), "1.0");
// A record whose name differs from the copies' in punctuation alone still picks its version.
assert_eq!(version("/0 (Vertest-Ink-Regular) /5 (1.1)"), "1.1");
assert_eq!(version("/0 (Vertest-Ink-Regular)"), "1.0");
}
/// A story with no runs, and a run or paragraph that names no style, is set in `[Normal Character
/// Style]` and `[Normal Paragraph Style]` before the text document's base attributes. A Large
/// Canvas copy (1:10) writes the styles at the art's scale (1.2 for 12 points, 0.6 for a 6-point
+67 -12
View File
@@ -42,20 +42,75 @@ fn style_word(w: &str) -> String {
.to_string()
}
/// A font's PostScript name without a subset prefix (`ABCDEF+`) or the `*1` of a re-encoded copy
/// (`ABCDEF+RollerBabyBV*1` is `RollerBabyBV`).
fn bare_name(name: &str) -> &str {
let name = match name.split_once('+') {
Some((prefix, rest)) if prefix.len() == 6 && prefix.bytes().all(|b| b.is_ascii_uppercase()) => rest,
_ => name,
};
match name.rsplit_once('*') {
Some((base, n)) if !base.is_empty() && !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()) => base,
_ => name,
}
}
/// The font of type that a text document's font record names: by `name`, its PostScript name, and
/// `version`, the version string (name ID 5) the record keeps, if any. An installed face of that
/// name gives its font ([`named_font`]). Without one, `name` is split ([`family_style`]) and the
/// family spelled as an available family is ([`FontDb::find_family`]: `MicrosoftYaHei` is
/// Microsoft YaHei). → (family, style, version).
///
/// [`FontDb::find_family`]: vectorcraft_text::FontDb::find_family
pub(crate) fn document_font(name: &str, version: Option<&str>) -> (String, String, Option<String>) {
named_font(name, version).unwrap_or_else(|| {
let (family, style) = family_style(name);
let family = vectorcraft_text::FontDb::global().find_family(&family).unwrap_or(family);
(family, style, None)
})
}
/// The font of type set in the installed face of PostScript name `name` (a subset prefix and a
/// re-encoded copy's `*1` aside) in `version` ([`postscript_face`]): that face's own family and
/// style, and its version when the family and style alone resolve to another installed copy of
/// the font. A library can keep two copies under one family and style that differ in their
/// PostScript names or their versions, and draw the same text at different widths. → (family,
/// style, version); `None` when no installed face has the name.
pub(crate) fn named_font(name: &str, version: Option<&str>) -> Option<(String, String, Option<String>)> {
let face = postscript_face(bare_name(name), version)?;
// As the PDF import names a version: only one that the family and style don't resolve to.
let default = vectorcraft_text::FontDb::global().face(&face.family, &face.style);
let version = default.filter(|d| d.version != face.version).map(|_| face.version.clone());
Some((face.family.clone(), face.style.clone(), version))
}
/// The installed face of PostScript name `name` (without a subset prefix), among the installed
/// faces that answer to it ([`FontDb::postscript_faces`]): those of exactly that name when there
/// are any (PostScript names are case-sensitive, so `SampleSans` isn't the face named
/// `SAMPLESANS`, and `SampleSerif-Bold` isn't `SampleSerifBold`), and of those the one in
/// `version` when it is installed, else the first. `None` when no installed face has the name.
///
/// [`FontDb::postscript_faces`]: vectorcraft_text::FontDb::postscript_faces
pub(crate) fn postscript_face(name: &str, version: Option<&str>) -> Option<Arc<vectorcraft_text::FontFace>> {
let db = vectorcraft_text::FontDb::global();
let found = db.find_postscript(name)?;
if found.postscript_name() == name && version.is_none_or(|v| v == found.version) {
return Some(found);
}
let faces = db.postscript_faces(name);
let named: Vec<_> = faces.iter().filter(|f| f.postscript_name() == name).cloned().collect();
let pool = if named.is_empty() { faces } else { named };
let in_version = version.and_then(|v| pool.iter().find(|f| f.version == v).cloned());
Some(in_version.or_else(|| pool.into_iter().next()).unwrap_or(found))
}
/// A font's family and style from its PostScript name, without a subset prefix (`ABCDEF+`) or the
/// `*1` of a re-encoded copy (`ABCDEF+RollerBabyBV*1`): an installed or bundled face of that
/// exact name gives its own (a family can hold hyphens: `rounded-x-mplus-1c-black` is Rounded-X
/// M+ 1c, black); else the name is split, `Helvetica-BoldOblique` → (`Helvetica`, `Bold
/// Oblique`), `TimesNewRomanPS-BoldMT` → (`Times New Roman`, `Bold`).
pub fn family_style(name: &str) -> (String, String) {
let name = match name.split_once('+') {
Some((prefix, rest)) if prefix.len() == 6 && prefix.bytes().all(|b| b.is_ascii_uppercase()) => rest,
_ => name,
};
let name = match name.rsplit_once('*') {
Some((base, n)) if !base.is_empty() && !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()) => base,
_ => name,
};
let name = bare_name(name);
if let Some(face) = vectorcraft_text::FontDb::global().find_postscript(name) {
return (face.family.clone(), face.style.clone());
}
@@ -551,11 +606,11 @@ impl Interp<'_> {
if !(size.is_finite() && size > 1e-3 && size < 1e5) {
return ps_err("undefinedresult", "a font size");
}
let (family, font_style) = family_style(&name);
// The available family's own spelling: `MicrosoftYaHei` is Microsoft YaHei, not "Microsoft Ya Hei".
let font_family = vectorcraft_text::FontDb::global().find_family(&family).unwrap_or(family);
// The installed face of that PostScript name, as type from a text document takes it; else
// the available family's own spelling: `MicrosoftYaHei` is Microsoft YaHei, not "Microsoft Ya Hei".
let (font_family, font_style, font_version) = document_font(&name, None);
let fill = if self.g.paint.is_none() { Paint::solid(vectorcraft_color::Color::BLACK) } else { self.g.paint.clone() };
let style = CharStyle { font_family, font_style, size, fill, stroke: Paint::None, ..CharStyle::default() };
let style = CharStyle { font_family, font_style, font_version, size, fill, stroke: Paint::None, ..CharStyle::default() };
let mut t = TextObject::point(Point::ZERO, text, style);
t.vertical = vertical;
let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
+18
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",
@@ -1439,6 +1451,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"",
"{set?: index, action?: index, step?: index, command, params?: object} insert the command of a menu item as a step, without running it; the step plays as choosing the item runs it. command and params (default {}) must be those of a menu item that can be a step: one that runs an engine command, whose label does not end with \"…\", outside the VectorCraft, File, Window and Help menus, and that is not Undo, Redo, a Paste, a batch (command.batch), a preference (prefs.*) or action (action.*) command, an Object › Align item, Image Trace's Make or Make and Expand, Pattern's Make or Edit Pattern, a saved selection, saved view or recent font item, or a plug-in's item. The place is as ui.action.insertStop takes it: with set and action, after step, else after the action's last step; without them, the recording in progress. The new step becomes the panel's selected step, and its action and set become the selected ones. A command and params that no such menu item has, a param of another type, params that are not an object, no action and no recording, or a step that would give the action more than 10,000 steps or its set's file more than 8 MiB, counting the steps that the recording in progress adds to the set, is an error and changes nothing. The panel menu's Insert Menu Item… (dialog `actionInsertMenuItem` with find, command, params, set, action and step) runs this on OK → {set, action, step}, or {recording: true}",
),
(
"ui.action.insertSelectPath",
"Insert Select Path",
"",
"{set?: index, action?: index, step?: index} insert a Select Path step for the path selected in the active document, without changing the selection: the step `select.storedPath {id, fingerprint}` stores the path's id and a fingerprint of its kind, fill rule and anchors, and playback selects only that path while it is still there unchanged and can be selected, else the step is an error. The selection must be one whole path or compound path that can be selected: not anchors or segments alone, several objects, a group, type or another kind of object, a guide, a member of a compound path, or a hidden or locked object. The place is as ui.action.insertStop takes it: with set and action, after step, else after the action's last step; without them, the recording in progress. The new step becomes the panel's selected step, and its action and set become the selected ones. No document, a selection that is not such a path, a param of another type, params that are not an object, no action and no recording, or a step that would give the action more than 10,000 steps or its set's file more than 8 MiB, counting the steps that the recording in progress adds to the set, is an error and changes nothing. The panel menu's Insert Select Path runs this, for the selected step or action or the recording in progress → {set, action, step}, or {recording: true}",
),
(
"ui.action.insertSelectObject",
"Select Object",
+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 [
+5 -3
View File
@@ -54,13 +54,15 @@ pub(super) fn evaluates_live(n: &Node) -> bool {
/// through live objects it evaluates? It can when `n` holds type (laid out in the fonts installed),
/// a symbol instance (made from its symbol's art), an object with a 3D effect or a color
/// adjustment (shaded or recolored through the color settings), one with Vector Halftone (its
/// dots sampled from everything it draws), or one with a live-effect plug-in that paints its pieces
/// (drawn only while the swatches and patterns they name are in the document), in it or in the
/// opacity masks of the objects in it.
/// dots sampled from everything it draws), one with a live-effect plug-in that paints its pieces
/// (drawn only while the swatches and patterns they name are in the document), or an image with a
/// fill or stroke that follows the outline of its pixels ([`effects::follows_image_pixels`]: the
/// document's pixels for it can change), in it or in the opacity masks of the objects in it.
pub(super) fn reads_outside(n: &Node) -> bool {
let mut reads = false;
n.walk(&mut |c| {
reads |= matches!(c.kind, NodeKind::Text(_) | NodeKind::SymbolInstance { .. })
|| effects::follows_image_pixels(c)
|| effects::has_revolve(c)
|| effects::has_solid(c)
|| effects::has_adjustment(c)

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