mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 09:39:52 +00:00
Merge main into feat/align-artboards
This commit is contained in:
@@ -4,6 +4,7 @@ pub mod actionset;
|
||||
mod addons;
|
||||
mod apiref;
|
||||
pub(crate) mod appearance;
|
||||
pub mod arrowheads;
|
||||
mod artboardalign;
|
||||
mod assets;
|
||||
mod attributes;
|
||||
@@ -298,6 +299,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
// Split out of `paint` (M3.8); kept next to it so lists in registry order (the command
|
||||
// palette, `engine.commands`) show them where they always were.
|
||||
v.extend(stroke::specs());
|
||||
v.extend(arrowheads::specs());
|
||||
v.extend(appearance::specs());
|
||||
v.extend(style::specs());
|
||||
v.extend(swatch::specs());
|
||||
|
||||
@@ -20,6 +20,7 @@ use vectorcraft_geom::Affine;
|
||||
|
||||
use super::super::fileio::{MAX_RASTER_ALLOC, ppi};
|
||||
use super::super::menucmds::has_image;
|
||||
use super::super::paint::{PROCESS_ON_TRANSPARENCY, gray_colorized_with_process};
|
||||
use super::super::{CommandSpec, Result, Session, bad, checked_ids_param, cmd, hex_with_alpha, number_or, switch_or, text_param};
|
||||
|
||||
/// Remove Background's command.
|
||||
@@ -39,7 +40,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Remove Background…",
|
||||
["Object", "Plug-ins", "Image Tools"],
|
||||
None,
|
||||
"{background?: \"edges\" (default: the most common color along the image's edges; nothing is cleared when the edges hold at least as many clear pixels as pixels of that color) | \"corners\" (the average of the image's four corner pixels that are not clear; nothing is cleared when all four are clear) | \"colors\" (the colors in `colors`), colors?: [color…] or one string of them split by commas or spaces (1 to 16 colors, each a hex color \"#rrggbb\" or \"#rgb\" with or without the # or one of black, white, red, green and blue; needed with background: colors, and checked but not used with edges or corners), contiguous?: bool (true: only background pixels that reach in from an edge are cleared; false: they are cleared wherever they are; true), match?: \"distance\" (default: a color's RGB distance from a background color) | \"channels\" (its largest difference on one of red, green and blue), tolerance?: 0..100 (with distance, % of the largest RGB distance; with channels, % of 255 steps on one channel; a pixel this near a background color is cleared; 10), softness?: 0..100 (% past the tolerance over which pixels fade from cleared to kept; 10), ids?: [id…] (default: the selected images)} make the background of each image transparent in its own pixels, as one undo step: the image stays an image, embedded as a PNG with an alpha channel at the depth of its pixels (16-bit images keep 16 bits and grayscale images stay gray) with the source's color profile and resolution, and a linked image is embedded; a CMYK image, a linked image that shows its preview (embed or relink it first), an image too large to change, and a result with no pixel left are refused, and an image whose pixels do not change is left as it is (no undo step when none changes) → {changed: the images changed, ids, cleared: the pixels made fully clear in them}",
|
||||
"{background?: \"edges\" (default: the most common color along the image's edges; nothing is cleared when the edges hold at least as many clear pixels as pixels of that color) | \"corners\" (the average of the image's four corner pixels that are not clear; nothing is cleared when all four are clear) | \"colors\" (the colors in `colors`), colors?: [color…] or one string of them split by commas or spaces (1 to 16 colors, each a hex color \"#rrggbb\" or \"#rgb\" with or without the # or one of black, white, red, green and blue; needed with background: colors, and checked but not used with edges or corners), contiguous?: bool (true: only background pixels that reach in from an edge are cleared; false: they are cleared wherever they are; true), match?: \"distance\" (default: a color's RGB distance from a background color) | \"channels\" (its largest difference on one of red, green and blue), tolerance?: 0..100 (with distance, % of the largest RGB distance; with channels, % of 255 steps on one channel; a pixel this near a background color is cleared; 10), softness?: 0..100 (% past the tolerance over which pixels fade from cleared to kept; 10), ids?: [id…] (default: the selected images)} make the background of each image transparent in its own pixels, as one undo step: the image stays an image, embedded as a PNG with an alpha channel at the depth of its pixels (16-bit images keep 16 bits and grayscale images stay gray) with the source's color profile and resolution, and a linked image is embedded; a CMYK image, a linked image that shows its preview (embed or relink it first), an image too large to change, a result with no pixel left, and a grayscale image colorized with a process color (which can't colorize gray pixels with transparency) are refused, and an image whose pixels do not change is left as it is (no undo step when none changes) → {changed: the images changed, ids, cleared: the pixels made fully clear in them}",
|
||||
has_image,
|
||||
remove_background
|
||||
)]
|
||||
@@ -182,8 +183,14 @@ fn changes(s: &Session, p: &Value, settings: &Settings, mut kept: Option<&mut De
|
||||
// Every image is checked before any is decoded.
|
||||
let mut images = vec![];
|
||||
for id in ids {
|
||||
let Some(NodeKind::Image(im)) = st.doc.node(id).map(|n| &n.kind) else { continue };
|
||||
let Some(n) = st.doc.node(id) else { continue };
|
||||
let NodeKind::Image(im) = &n.kind else { continue };
|
||||
let blob = st.doc.images.get(&im.key).ok_or_else(|| bad(C, "the image's pixels are missing"))?;
|
||||
// The result has an alpha channel, which a grayscale image colorized with a process color
|
||||
// can't take.
|
||||
if gray_colorized_with_process(&st.doc, n).is_some() {
|
||||
return Err(bad(C, PROCESS_ON_TRANSPARENCY));
|
||||
}
|
||||
// The preview stands in for a file that hasn't been read, or that is kept at its preview.
|
||||
if im.link.is_some() && blob.is_proxy() {
|
||||
return Err(bad(C, "the linked image shows its preview: embed or relink it first"));
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
use std::io::Cursor;
|
||||
use std::sync::Arc;
|
||||
|
||||
use image::{DynamicImage, ImageBuffer, Luma, Rgb};
|
||||
use image::{DynamicImage, ImageBuffer, Luma, LumaA, Rgb};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::Affine;
|
||||
|
||||
@@ -333,6 +334,54 @@ fn sixteen_bit_and_gray_images_keep_their_depth_and_channels() {
|
||||
assert_eq!((out.get_pixel(0, 0).0, out.get_pixel(3, 3).0), ([61_000, 0], [7_000, 65_535]));
|
||||
}
|
||||
|
||||
/// A process color can't colorize a grayscale image with transparency, so a gray image colorized
|
||||
/// with one keeps its pixels. Black, white and spot colors can, so those images are cleared.
|
||||
#[test]
|
||||
fn a_gray_image_colorized_with_a_process_color_is_refused() {
|
||||
let gray = || {
|
||||
png(DynamicImage::ImageLuma8(ImageBuffer::from_fn(8, 8, |x, y| Luma([if (2..6).contains(&x) && (2..6).contains(&y) { 30u8 } else { 240 }]))))
|
||||
};
|
||||
let (mut s, id) = open(&gray());
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.node(id).unwrap().extras().colorize.is_some(), "the fill colorizes the image");
|
||||
let (before, steps) = (s.doc().unwrap().doc.clone(), undo_len(&s));
|
||||
let e = s.execute(C, &json!({})).unwrap_err();
|
||||
assert!(e.to_string().contains("a process color can't colorize a grayscale image with transparency"), "{e}");
|
||||
assert_eq!((&s.doc().unwrap().doc, undo_len(&s)), (&before, steps), "the document and its undo steps are unchanged");
|
||||
// Another image that already has an alpha channel and a process colorize color, as an opened
|
||||
// file can hold, neither lets this one through nor stops a run on an image that can be cleared.
|
||||
let held = ImageBlob::png(png(DynamicImage::ImageLumaA8(ImageBuffer::from_pixel(2, 2, LumaA([128u8, 100])))));
|
||||
let other = place_linked(&mut s, "held", held, (2, 2));
|
||||
s.edit("Embed and colorize", |d, _| {
|
||||
let n = d.node_mut(other).unwrap();
|
||||
n.extras_mut().colorize = Some(Box::new(Paint::solid(Color::rgb8(255, 0, 0))));
|
||||
if let NodeKind::Image(im) = &mut n.kind {
|
||||
im.link = None;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let (before, steps) = (s.doc().unwrap().doc.clone(), undo_len(&s));
|
||||
let e = s.execute(C, &json!({"ids": [id.0]})).unwrap_err();
|
||||
assert!(e.to_string().contains("a process color can't colorize a grayscale image with transparency"), "{e}");
|
||||
assert_eq!((&s.doc().unwrap().doc, undo_len(&s)), (&before, steps), "with another image refused already, nothing changes");
|
||||
s.execute("paint.setFill", &json!({"color": "#000000", "ids": [id.0]})).unwrap();
|
||||
assert_eq!(s.execute(C, &json!({"ids": [id.0]})).unwrap()["changed"], 1, "an image colorized with black is cleared beside the other");
|
||||
let DynamicImage::ImageLumaA8(out) = pixels(&s, id) else { panic!("gray with alpha") };
|
||||
assert_eq!(out.get_pixel(0, 0).0[1], 0, "the background is cleared");
|
||||
assert!(s.doc().unwrap().doc.node(id).unwrap().extras().colorize.is_some(), "the image stays colorized");
|
||||
for fill in [json!({"color": "#000000"}), json!({"color": "#ffffff"}), json!({"swatch": "Ink"})] {
|
||||
let (mut s, id) = open(&gray());
|
||||
s.execute("swatch.new", &json!({"name": "Ink", "color": {"c": 0, "m": 100, "y": 100, "k": 0}, "spot": true})).unwrap();
|
||||
s.execute("paint.setFill", &fill).unwrap();
|
||||
assert!(s.doc().unwrap().doc.node(id).unwrap().extras().colorize.is_some(), "{fill} colorizes the image");
|
||||
s.execute(C, &json!({})).unwrap();
|
||||
let DynamicImage::ImageLumaA8(out) = pixels(&s, id) else { panic!("gray with alpha") };
|
||||
assert_eq!(out.get_pixel(0, 0).0[1], 0, "{fill}: the background is cleared");
|
||||
assert!(s.doc().unwrap().doc.node(id).unwrap().extras().colorize.is_some(), "{fill} still colorizes the image");
|
||||
}
|
||||
}
|
||||
|
||||
/// The ICC profile the colors are in stays with them.
|
||||
#[test]
|
||||
fn the_color_profile_is_kept() {
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
//! Custom arrowheads (Stroke panel menu › Edit Arrowheads…). The arrowheads file is a native
|
||||
//! document kept next to the preferences ([`Session::attach_arrowheads_file`]). Each of its symbols
|
||||
//! is one arrowhead, listed by the symbol's name after the built-in heads in the Stroke panel's
|
||||
//! arrowhead menus. A head points right (+x): the symbol's registration point is its tip, and the
|
||||
//! art's height (its extent across the line) is drawn as wide as the built-in heads, four times
|
||||
//! the head's weight. A head covers what its art paints: the visible fills, and the visible
|
||||
//! strokes outlined, of the paths, compound paths and groups in it, and its gradient meshes, with
|
||||
//! their geometry effects applied and clip groups clipped. A head is one solid shape in the stroke's paint: art at 0 %
|
||||
//! opacity is left out, and other opacities, blend modes and opacity masks do not change the
|
||||
//! shape. Saving the file reloads the heads. A stroke that uses a custom head carries a copy of its art
|
||||
//! ([`vectorcraft_doc::CustomArrowhead`]), so its document draws it anywhere.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, CustomArrowhead, Document, Node, NodeId, NodeKind, Symbol, SymbolRegistration};
|
||||
use vectorcraft_geom::{Affine, FillRule, PathData, Point, SubPath};
|
||||
use vectorcraft_pathops::BoolOp;
|
||||
|
||||
use super::{CommandSpec, Result, Session, always, bad, cmd};
|
||||
|
||||
/// The arrowheads file's name, in the folder of the preferences.
|
||||
pub const FILE_NAME: &str = "Arrowheads.vectorcraft";
|
||||
/// The largest arrowheads file read, and the most its document or its images may unpack to when
|
||||
/// compressed.
|
||||
pub const MAX_FILE_BYTES: u64 = 64 << 20;
|
||||
/// The most heads read from the file; symbols past it are skipped.
|
||||
pub const MAX_HEADS: usize = 256;
|
||||
/// The most anchors a symbol's art, and the shapes it paints, may have; a symbol past it is skipped.
|
||||
pub const MAX_ART_ANCHORS: usize = 16 * CustomArrowhead::MAX_ANCHORS;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
query "stroke.arrowheads.list",
|
||||
"Arrowheads",
|
||||
[],
|
||||
None,
|
||||
"{} → {arrowheads: [{name (stroke.set `startArrow`/`endArrow` as {custom: name}), reach, halfWidth (in head weights)}], file: the arrowheads file's path or null (no file here, as on the web), skipped: [{name, reason}] symbols of the file that are not heads, error: why the file could not be read, or null} the custom arrowheads, listed after the built-in ones in the Stroke panel's arrowhead menus",
|
||||
always,
|
||||
list
|
||||
),
|
||||
cmd!(
|
||||
"stroke.arrowheads.edit",
|
||||
"Edit Arrowheads",
|
||||
[],
|
||||
None,
|
||||
"{} open the arrowheads file (a native document next to the preferences; created first with one example head when it is missing). Each symbol in it is one custom arrowhead named after the symbol: it points right, its registration point is the tip, and its height is drawn as wide as the built-in heads; its visible fills and outlined strokes make the head. Saving the file reloads the heads → {path, created: whether the file was made now}",
|
||||
has_file,
|
||||
edit
|
||||
),
|
||||
cmd!(
|
||||
"stroke.arrowheads.reload",
|
||||
"Reload Arrowheads",
|
||||
[],
|
||||
None,
|
||||
"{} read the arrowheads file again (a save of it from VectorCraft does this already) → {count, skipped: [{name, reason}]}",
|
||||
has_file,
|
||||
reload
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// The custom arrowheads read from the arrowheads file.
|
||||
#[derive(Default)]
|
||||
pub struct Arrowheads {
|
||||
path: Option<String>,
|
||||
heads: Vec<Arc<CustomArrowhead>>,
|
||||
skipped: Vec<(String, String)>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
impl Arrowheads {
|
||||
/// The arrowheads file's path; `None` without one (the web, runs without preferences).
|
||||
pub fn path(&self) -> Option<&str> {
|
||||
self.path.as_deref()
|
||||
}
|
||||
/// The heads, in the order of the file's symbols.
|
||||
pub fn heads(&self) -> &[Arc<CustomArrowhead>] {
|
||||
&self.heads
|
||||
}
|
||||
/// Head `name`.
|
||||
pub fn get(&self, name: &str) -> Option<Arc<CustomArrowhead>> {
|
||||
self.heads.iter().find(|h| h.name == name).cloned()
|
||||
}
|
||||
fn set(&mut self, read: Read) {
|
||||
(self.heads, self.skipped) = (read.heads.into_iter().map(Arc::new).collect(), read.skipped);
|
||||
self.error = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// The heads a document holds, and the symbols that are not heads with the reason.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Read {
|
||||
pub heads: Vec<CustomArrowhead>,
|
||||
pub skipped: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// The heads of document `doc`: one per symbol, up to [`MAX_HEADS`] (see the module docs).
|
||||
pub fn heads_of(doc: &Document) -> Read {
|
||||
let mut d = doc.clone();
|
||||
let brushes = vectorcraft_brush::library(&d);
|
||||
let mut out = Read::default();
|
||||
for (i, sym) in doc.symbols.iter().enumerate() {
|
||||
if i >= MAX_HEADS {
|
||||
out.skipped.push((sym.name.clone(), format!("only the first {MAX_HEADS} symbols are read")));
|
||||
continue;
|
||||
}
|
||||
if out.heads.iter().any(|h| h.name == sym.name) {
|
||||
out.skipped.push((sym.name.clone(), "another symbol has this name".into()));
|
||||
continue;
|
||||
}
|
||||
match head(&mut d, &brushes, sym) {
|
||||
Ok(h) => out.heads.push(h),
|
||||
Err(why) => out.skipped.push((sym.name.clone(), why.into())),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Symbol `sym` of `d` as a head.
|
||||
fn head(d: &mut Document, brushes: &[vectorcraft_brush::Brush], sym: &Symbol) -> std::result::Result<CustomArrowhead, &'static str> {
|
||||
const TOO_DETAILED: &str = "its art is too long or too detailed for a head";
|
||||
const UNITED: &str = "its shapes could not be combined into one";
|
||||
if art_anchors(&sym.art) > MAX_ART_ANCHORS {
|
||||
return Err(TOO_DETAILED);
|
||||
}
|
||||
let art = baked_art(d, sym);
|
||||
let mut shapes = Shapes::default();
|
||||
painted(d, brushes, &art, &mut shapes);
|
||||
if shapes.too_detailed {
|
||||
return Err(TOO_DETAILED);
|
||||
}
|
||||
if shapes.failed {
|
||||
return Err(UNITED);
|
||||
}
|
||||
let area = shapes.united().ok_or(UNITED)?;
|
||||
let b = area.bounds().filter(|b| b.height() > 1e-9 && b.is_finite()).ok_or("its art paints nothing")?;
|
||||
let tip = b.center() + d.symbol_registration(&sym.name).offset_from_center(b.width(), b.height());
|
||||
// Head units: x back from the tip, y across to the right of +x, 4 units to the art's height.
|
||||
let k = 4.0 / b.height();
|
||||
let local = Affine::new([-k, 0.0, 0.0, k, k * tip.x, -k * tip.y]);
|
||||
let h = CustomArrowhead::new(sym.name.clone(), area.transformed(local));
|
||||
if h.path.subpaths.is_empty() {
|
||||
return Err(TOO_DETAILED);
|
||||
}
|
||||
Ok(h)
|
||||
}
|
||||
|
||||
/// The anchors of the paths in `n` and of the pieces of its gradient meshes ([`mesh_anchors`]), its
|
||||
/// members' included.
|
||||
fn art_anchors(n: &Node) -> usize {
|
||||
let own = match &n.kind {
|
||||
NodeKind::Mesh(m) => mesh_anchors(m),
|
||||
_ => n.path_data().map_or(0, PathData::anchor_count),
|
||||
};
|
||||
n.children().map_or(own, |c| c.iter().fold(own, |sum, c| sum.saturating_add(art_anchors(c))))
|
||||
}
|
||||
|
||||
/// The art of symbol `sym` of `d` with every geometry effect in it applied (its objects', fills',
|
||||
/// strokes' and groups'), as the canvas draws it: the art baked as for export, on a copy of `d`.
|
||||
fn baked_art(d: &Document, sym: &Symbol) -> Node {
|
||||
// A plain new layer, so the file's own layers' effects stay out of the art.
|
||||
let mut tmp = d.clone();
|
||||
tmp.layers = Document::new(1.0, 1.0).layers;
|
||||
let Some(children) = tmp.layers.first_mut().and_then(|l| Arc::make_mut(l).children_mut()) else { return (*sym.art).clone() };
|
||||
*children = vec![sym.art.clone()];
|
||||
let baked = vectorcraft_render::effects::bake_document(&tmp);
|
||||
baked.as_ref().and_then(|b| b.layers.first()?.children()?.first().map(|c| (**c).clone())).unwrap_or_else(|| (*sym.art).clone())
|
||||
}
|
||||
|
||||
/// The shapes a symbol's art paints, up to [`MAX_ART_ANCHORS`] anchors in all.
|
||||
#[derive(Default)]
|
||||
struct Shapes {
|
||||
list: Vec<(PathData, FillRule)>,
|
||||
anchors: usize,
|
||||
/// More anchors were asked for than fit; the shapes past the limit were not made.
|
||||
too_detailed: bool,
|
||||
/// A clip group's art could not be combined or clipped, so a shape it paints is missing.
|
||||
failed: bool,
|
||||
}
|
||||
|
||||
impl Shapes {
|
||||
/// Whether `anchors` more fit; when they do not, the shapes are too detailed.
|
||||
fn room(&mut self, anchors: usize) -> bool {
|
||||
self.too_detailed |= self.anchors.saturating_add(anchors) > MAX_ART_ANCHORS;
|
||||
!self.too_detailed
|
||||
}
|
||||
fn push(&mut self, shape: (PathData, FillRule)) {
|
||||
if self.room(shape.0.anchor_count()) {
|
||||
self.anchors += shape.0.anchor_count();
|
||||
self.list.push(shape);
|
||||
}
|
||||
}
|
||||
/// The shapes united, or `None` when one of them could not be (the union would miss it).
|
||||
fn united(&self) -> Option<PathData> {
|
||||
let refs: Vec<(&PathData, FillRule)> = self.list.iter().map(|(p, r)| (p, *r)).collect();
|
||||
vectorcraft_pathops::try_unite_all(&refs).ok()
|
||||
}
|
||||
/// The shapes of `inside` clipped to `outline` (filled under `rule`), added as one.
|
||||
fn push_clipped(&mut self, inside: Shapes, outline: &PathData, rule: FillRule) {
|
||||
if inside.too_detailed || !self.room(inside.anchors) {
|
||||
self.too_detailed = true;
|
||||
return;
|
||||
}
|
||||
self.failed |= inside.failed;
|
||||
let clip = |art: PathData| {
|
||||
vectorcraft_pathops::try_boolean(&art, FillRule::NonZero, outline, rule, BoolOp::Intersect, vectorcraft_pathops::DEFAULT_PRECISION).ok()
|
||||
};
|
||||
let Some(clipped) = inside.united().and_then(clip) else {
|
||||
self.failed = true;
|
||||
return;
|
||||
};
|
||||
if !clipped.subpaths.is_empty() {
|
||||
self.push((clipped, FillRule::NonZero));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The shapes the shown paths and compound paths in `n` (baked art) paint: their filled areas and
|
||||
/// their strokes' outlines, its groups' members included (baking has already made a group's own
|
||||
/// fills and strokes art among its members), and the areas its gradient meshes cover. A clip
|
||||
/// group's clipped art is clipped to
|
||||
/// its clipping path, whose own fill and stroke paint unclipped, as on the canvas; a group used as
|
||||
/// the clipping path paints nothing ([`clip_paint`]). Hidden or fully
|
||||
/// transparent art is left out.
|
||||
fn painted(d: &mut Document, brushes: &[vectorcraft_brush::Brush], n: &Node, out: &mut Shapes) {
|
||||
if !n.visible || n.opacity <= 0.0 || out.too_detailed {
|
||||
return;
|
||||
}
|
||||
match &n.kind {
|
||||
NodeKind::Path { guide: true, .. } => {}
|
||||
NodeKind::Path { .. } | NodeKind::Compound { .. } => {
|
||||
fills(n, out);
|
||||
// The outline holds a copy of the path for each fill too, which adds nothing new.
|
||||
if let Some(o) = super::pathops::outline_strokes(d, brushes, n) {
|
||||
fills_in(&o, out);
|
||||
}
|
||||
}
|
||||
NodeKind::Group { children, clip: true } => {
|
||||
let Some((clip, rest)) = children.split_first() else { return };
|
||||
clip_paint(clip).iter().for_each(|part| painted(d, brushes, part, out));
|
||||
// Nothing to clip by hides the clipped art, as on the canvas.
|
||||
let Some((outline, rule)) = vectorcraft_render::effects::clip_outline(clip) else { return };
|
||||
let mut inside = Shapes { anchors: out.anchors, ..Default::default() };
|
||||
rest.iter().for_each(|c| painted(d, brushes, c, &mut inside));
|
||||
inside.anchors -= out.anchors;
|
||||
out.push_clipped(inside, &PathData::from_bezpath(&outline), rule);
|
||||
}
|
||||
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => children.iter().for_each(|c| painted(d, brushes, c, out)),
|
||||
NodeKind::Mesh(_) => mesh_area(n, out),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// What clipping path `clip` paints ([`Node::clip_paint`]): its fills and its strokes, each as a
|
||||
/// copy of it keeping only those. A group or another object that does not paint as a clipping
|
||||
/// path gives neither.
|
||||
fn clip_paint(clip: &Node) -> Vec<Node> {
|
||||
let paint = clip.clip_paint();
|
||||
[paint.fill, paint.stroke].into_iter().flatten().collect()
|
||||
}
|
||||
|
||||
/// The areas the shown paths and gradient meshes in `n` (an outlined stroke) fill, clipped by its
|
||||
/// clip groups, whose clipping paths' own fills paint unclipped. A stroke with a gradient along or
|
||||
/// across it is outlined as gradient meshes clipped to the stroke's outline.
|
||||
fn fills_in(n: &Node, out: &mut Shapes) {
|
||||
if !n.visible || n.opacity <= 0.0 || out.too_detailed {
|
||||
return;
|
||||
}
|
||||
match &n.kind {
|
||||
NodeKind::Mesh(_) => mesh_area(n, out),
|
||||
NodeKind::Group { children, clip: true } => {
|
||||
let Some((clip, rest)) = children.split_first() else { return };
|
||||
clip_paint(clip).iter().for_each(|part| fills_in(part, out));
|
||||
let Some((outline, rule)) = vectorcraft_render::effects::clip_outline(clip) else { return };
|
||||
let mut inside = Shapes { anchors: out.anchors, ..Default::default() };
|
||||
rest.iter().for_each(|c| fills_in(c, &mut inside));
|
||||
inside.anchors -= out.anchors;
|
||||
out.push_clipped(inside, &PathData::from_bezpath(&outline), rule);
|
||||
}
|
||||
_ => match n.children() {
|
||||
Some(children) => children.iter().for_each(|c| fills_in(c, out)),
|
||||
None => fills(n, out),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The anchors [`mesh_area`] makes at most of mesh `m`: its outline's, one a patch along each side,
|
||||
/// when no point of it is fully transparent, else four by four quads of four anchors a patch.
|
||||
fn mesh_anchors(m: &vectorcraft_doc::GradientMesh) -> usize {
|
||||
let (rows, cols) = (m.rows as usize, m.cols as usize);
|
||||
if opaque(m) { rows.saturating_add(cols).saturating_mul(2) } else { rows.saturating_mul(cols).saturating_mul(64) }
|
||||
}
|
||||
|
||||
fn opaque(m: &vectorcraft_doc::GradientMesh) -> bool {
|
||||
m.points.iter().all(|p| p.opacity > 0.0)
|
||||
}
|
||||
|
||||
/// The area gradient mesh `n` paints: its outline when no point of it is fully transparent, else
|
||||
/// the pieces of it that are not fully transparent (four by four quads a patch). A mesh whose
|
||||
/// pieces do not fit in `out` makes the shapes too detailed.
|
||||
fn mesh_area(n: &Node, out: &mut Shapes) {
|
||||
let NodeKind::Mesh(m) = &n.kind else { return };
|
||||
if !out.room(mesh_anchors(m)) {
|
||||
return;
|
||||
}
|
||||
if opaque(m) {
|
||||
let outline = m.outline();
|
||||
if !outline.subpaths.is_empty() {
|
||||
out.push((outline, FillRule::NonZero));
|
||||
}
|
||||
return;
|
||||
}
|
||||
for q in m.quads(4) {
|
||||
if q.opacity > 0.0 || q.corners.iter().any(|(_, o)| *o > 0.0) {
|
||||
out.push((PathData::single(SubPath::polyline(&q.pts, true)), FillRule::NonZero));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The area path `n` fills, when a shown fill paints it.
|
||||
fn fills(n: &Node, out: &mut Shapes) {
|
||||
let paints = |i: &AppearanceItem| matches!(i, AppearanceItem::Fill(f) if f.visible && f.opacity > 0.0 && !f.paint.is_none());
|
||||
if n.appearance.items.iter().any(paints)
|
||||
&& let Some(shape) = super::pathops::node_path(n)
|
||||
{
|
||||
out.push(shape);
|
||||
}
|
||||
}
|
||||
|
||||
/// The document a missing arrowheads file starts as: one example head, a solid triangle.
|
||||
pub fn starter() -> Document {
|
||||
let mut doc = Document::new(612.0, 792.0);
|
||||
doc.title = "Arrowheads".into();
|
||||
let name = "Example Arrowhead";
|
||||
let tri = SubPath::polyline(&[Point::new(-10.0, -8.0), Point::new(10.0, 0.0), Point::new(-10.0, 8.0)], true);
|
||||
let art = Node::path(NodeId(0), PathData::single(tri), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 1.0));
|
||||
let art = doc.reid(&art);
|
||||
doc.symbols.push(Symbol { name: name.into(), art: Arc::new(art), dynamic: false });
|
||||
// The tip is the right edge's middle.
|
||||
doc.set_symbol_registration(name, SymbolRegistration(5));
|
||||
let id = doc.alloc_id();
|
||||
let instance = Node::new(id, NodeKind::SymbolInstance { symbol: name.into(), xf: Affine::translate((306.0, 396.0)) });
|
||||
let layer = doc.layers.first().map(|l| l.id);
|
||||
let _ = doc.insert(layer, 0, instance);
|
||||
doc
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Keep the custom arrowheads in the file at `path` and read the heads it holds (none while it
|
||||
/// is missing; an unreadable file is reported by `stroke.arrowheads.list`).
|
||||
pub fn attach_arrowheads_file(&mut self, path: String) {
|
||||
self.arrowheads.path = Some(path);
|
||||
self.reload_arrowheads();
|
||||
}
|
||||
|
||||
/// Read the arrowheads file again.
|
||||
fn reload_arrowheads(&mut self) {
|
||||
let Some(path) = self.arrowheads.path.clone() else { return };
|
||||
match read(&path) {
|
||||
Ok(Some(doc)) => self.arrowheads.set(heads_of(&doc)),
|
||||
Ok(None) => self.arrowheads.set(Read::default()),
|
||||
Err(e) => {
|
||||
self.arrowheads.set(Read::default());
|
||||
self.arrowheads.error = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A document was saved to `path`: when that is the arrowheads file, its heads are read back
|
||||
/// from what was written.
|
||||
pub(crate) fn arrowheads_saved(&mut self, path: &str) {
|
||||
if self.arrowheads.path.as_deref().is_some_and(|a| same_file(a, path)) {
|
||||
self.reload_arrowheads();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn same_file(a: &str, b: &str) -> bool {
|
||||
let canon = |p: &str| std::fs::canonicalize(p).unwrap_or_else(|_| std::path::PathBuf::from(p));
|
||||
std::path::Path::new(a) == std::path::Path::new(b) || canon(a) == canon(b)
|
||||
}
|
||||
|
||||
/// The document in the arrowheads file at `path`; `None` when there is no file.
|
||||
fn read(path: &str) -> std::result::Result<Option<Document>, String> {
|
||||
let size = match std::fs::metadata(path) {
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(e) => return Err(format!("{path}: {e}")),
|
||||
Ok(m) if !m.is_file() => return Err(format!("{path}: not a file")),
|
||||
Ok(m) => m.len(),
|
||||
};
|
||||
if size > MAX_FILE_BYTES {
|
||||
return Err(format!("{path}: larger than {} MB", MAX_FILE_BYTES >> 20));
|
||||
}
|
||||
let bytes = crate::file_access::unconfined(|| super::fileio::read_file(path)).map_err(|e| e.to_string())?;
|
||||
// A compressed file's document, and its images, may unpack to no more than an uncompressed
|
||||
// file may hold.
|
||||
let parse = || vectorcraft_format::load_file_capped(&bytes, MAX_FILE_BYTES).map(|f| f.doc).map_err(|e| format!("{path}: {e}"));
|
||||
crate::guard::catch_panic(parse).map_err(|_| format!("{path}: could not be read"))?.map(Some)
|
||||
}
|
||||
|
||||
fn has_file(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.arrowheads.path.is_some() { Ok(()) } else { Err("no arrowheads file here".into()) }
|
||||
}
|
||||
|
||||
fn skipped(s: &Session) -> Vec<Value> {
|
||||
s.arrowheads.skipped.iter().map(|(name, reason)| json!({"name": name, "reason": reason})).collect()
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let heads: Vec<Value> = s.arrowheads.heads.iter().map(|h| json!({"name": h.name, "reach": h.reach(), "halfWidth": h.half_width()})).collect();
|
||||
Ok(json!({"arrowheads": heads, "file": s.arrowheads.path, "skipped": skipped(s), "error": s.arrowheads.error}))
|
||||
}
|
||||
|
||||
fn reload(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
const C: &str = "stroke.arrowheads.reload";
|
||||
s.reload_arrowheads();
|
||||
if let Some(e) = &s.arrowheads.error {
|
||||
return Err(bad(C, e.clone()));
|
||||
}
|
||||
Ok(json!({"count": s.arrowheads.heads.len(), "skipped": skipped(s)}))
|
||||
}
|
||||
|
||||
fn edit(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
const C: &str = "stroke.arrowheads.edit";
|
||||
let path = s.arrowheads.path.clone().ok_or_else(|| bad(C, "no arrowheads file here"))?;
|
||||
if let Some(dir) = std::path::Path::new(&path).parent() {
|
||||
std::fs::create_dir_all(dir).map_err(|e| bad(C, format!("{}: {e}", dir.display())))?;
|
||||
}
|
||||
// Made only when no file is there, also when another run makes one at the same moment.
|
||||
let bytes = vectorcraft_format::save_file(&starter());
|
||||
let created = match crate::file_access::unconfined(|| super::fileio::write_new_file(&path, &bytes)) {
|
||||
Ok(()) => true,
|
||||
Err(_) if std::path::Path::new(&path).exists() => false,
|
||||
Err(e) => return Err(bad(C, e.to_string())),
|
||||
};
|
||||
if created {
|
||||
s.reload_arrowheads();
|
||||
}
|
||||
// Already open: that document comes to the front.
|
||||
let open = s.documents().iter().position(|st| st.path.as_deref().is_some_and(|p| same_file(p, &path)));
|
||||
match open {
|
||||
Some(i) => {
|
||||
s.set_active(i);
|
||||
}
|
||||
None => {
|
||||
crate::file_access::unconfined(|| s.execute("document.open", &json!({ "path": path })))?;
|
||||
}
|
||||
}
|
||||
Ok(json!({"path": path, "created": created}))
|
||||
}
|
||||
@@ -144,7 +144,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Make",
|
||||
["Object", "Image Trace"],
|
||||
None,
|
||||
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), savedPreset?: {settings} (a saved preset's settings, which the journal records so a replay traces with them after the preset is changed, renamed or deleted; used in place of the saved preset `preset` names), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), colorGroup?: name (color mode, documentLibrary with library \"document\": the colors of that document color group instead of every swatch; \"\" is every swatch), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), ignoreBackground: false (with ignoreWhite: only the areas of the ignored color that reach the image's edge are left out), keepColors?: [\"#rrggbb\"…] (not mode logo: only the palette colors within about 30 levels of a listed color are traced; empty: every color), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, at most ten; white is its own color, and a named color with every channel 245 or more is taken as that white, except with logoColors named, logoWhite false and no ignoreWhite; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white while white is its own color, and stay holes with ignoreWhite or without a white of its own; fills/strokes are ignored; gradients and photos are refused), logoWhite: true (mode logo with logoColors named; false traces the named colors only: white areas take the nearest of them, a named white is kept and enclosed transparent areas stay holes; with ignoreWhite, white areas are left out either way)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down; Flat Logo names only the size it traced a larger image at; or a keepColors color matches none the trace found, or nothing was traced)}",
|
||||
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), savedPreset?: {settings} (a saved preset's settings, which the journal records so a replay traces with them after the preset is changed, renamed or deleted; used in place of the saved preset `preset` names), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), colorGroup?: name (color mode, documentLibrary with library \"document\": the colors of that document color group instead of every swatch; \"\" is every swatch), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), ignoreBackground: false (with ignoreWhite: only the areas of the ignored color that reach the image's edge are left out), keepColors?: [\"#rrggbb\"…] (not mode logo: only the palette colors within about 30 levels of a listed color are traced; empty: every color), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, at most ten; white is its own color, and a named color with every channel 245 or more is taken as that white, except with logoColors named, logoWhite false and no ignoreWhite; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white while white is its own color, and stay holes with ignoreWhite or without a white of its own; fills/strokes are ignored; gradients and photos are refused, judged by the image's own colors also when logoColors are named), logoWhite: true (mode logo with logoColors named; false traces the named colors only: white areas take the nearest of them, a named white is kept and enclosed transparent areas stay holes; with ignoreWhite, white areas are left out either way)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down; Flat Logo names only the size it traced a larger image at; or a keepColors color matches none the trace found, or nothing was traced)}",
|
||||
has_selection_or_ids,
|
||||
|s, p| trace_make(s, p, false)
|
||||
),
|
||||
|
||||
@@ -210,8 +210,12 @@ fn load_profile(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let remembered = s.store_profile(&info.name, &bytes);
|
||||
// A new file of a working space: the active settings still hold the old one.
|
||||
let working = cms::active_settings();
|
||||
if working.rgb == info.name || working.cmyk == info.name {
|
||||
let is_working = working.rgb == info.name || working.cmyk == info.name;
|
||||
if is_working {
|
||||
cms::set_active(&working).map_err(|e| bad(C, e.to_string()))?;
|
||||
}
|
||||
// A new file of a working space, or of the CMYK profile View → Proof Setup targets: redraw.
|
||||
if is_working || proof_targets(&info.name) {
|
||||
touch_all(s);
|
||||
}
|
||||
Ok(json!({"name": info.name, "kind": info.kind, "remembered": remembered}))
|
||||
@@ -233,10 +237,17 @@ fn remove_profile(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(C, format!("no profile named `{name}`")));
|
||||
}
|
||||
s.unstore_profile(&info.name);
|
||||
touch_all(s);
|
||||
if proof_targets(&info.name) {
|
||||
touch_all(s);
|
||||
}
|
||||
Ok(json!({"removed": info.name}))
|
||||
}
|
||||
|
||||
/// Whether View → Proof Setup targets the CMYK profile `name`.
|
||||
fn proof_targets(name: &str) -> bool {
|
||||
matches!(proof::view().setup.target, ProofTarget::Cmyk(t) if t == name)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn load_profile(_: &mut Session, _: &Value) -> Result<Value> {
|
||||
Err(bad("color.loadProfile", "loading profiles from disk isn't available in the browser"))
|
||||
@@ -763,17 +774,33 @@ fn proof_setup(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(on) = on_param(p, "proof", C)? {
|
||||
v.proof_colors = on;
|
||||
}
|
||||
proof::set_view(v.clone());
|
||||
touch_all(s);
|
||||
store_view(s, v.clone());
|
||||
Ok(view_json(&v))
|
||||
}
|
||||
|
||||
/// Make `v` the proof view and redraw every document. When `v` equals the current view, the view
|
||||
/// and the revisions stay as they are; Separations Preview's visible plates are compared as a set.
|
||||
fn store_view(s: &mut Session, v: proof::ProofView) {
|
||||
let shown = |v: &proof::ProofView| proof::ProofView {
|
||||
separations: v.separations.as_ref().map(|vis| {
|
||||
let mut vis = vis.clone();
|
||||
vis.sort();
|
||||
vis.dedup();
|
||||
vis
|
||||
}),
|
||||
..v.clone()
|
||||
};
|
||||
if shown(&v) != shown(&proof::view()) {
|
||||
proof::set_view(v);
|
||||
touch_all(s);
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_view(s: &mut Session, p: &Value, cmd: &str, f: impl FnOnce(&mut proof::ProofView, Option<bool>)) -> Result<Value> {
|
||||
let on = on_param(p, "on", cmd)?;
|
||||
let mut v = proof::view();
|
||||
f(&mut v, on);
|
||||
proof::set_view(v.clone());
|
||||
touch_all(s);
|
||||
store_view(s, v.clone());
|
||||
Ok(view_json(&v))
|
||||
}
|
||||
|
||||
@@ -805,7 +832,15 @@ fn separations(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
match p.get("plates") {
|
||||
None | Some(Value::Null) => {}
|
||||
Some(Value::Array(a)) if a.iter().all(Value::is_string) => {
|
||||
v.separations = Some(a.iter().filter_map(Value::as_str).map(check).collect::<Result<Vec<_>>>()?);
|
||||
let mut vis: Vec<String> = Vec::new();
|
||||
for n in a.iter().filter_map(Value::as_str) {
|
||||
let n = check(n)?;
|
||||
// A plate named twice is shown once.
|
||||
if !vis.contains(&n) {
|
||||
vis.push(n);
|
||||
}
|
||||
}
|
||||
v.separations = Some(vis);
|
||||
}
|
||||
Some(_) => {
|
||||
let key = "plates";
|
||||
@@ -824,8 +859,7 @@ fn separations(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
vis.push(n);
|
||||
}
|
||||
}
|
||||
proof::set_view(v);
|
||||
touch_all(s);
|
||||
store_view(s, v);
|
||||
Ok(plates_json(s))
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ use vectorcraft_doc::NodeId;
|
||||
use vectorcraft_svg::{CssOptions, CssSheet, css_rules};
|
||||
|
||||
use super::fileio::{default_name, encode_with_warnings, isolated, write_file};
|
||||
use super::{CommandSpec, Result, Session, bad, cmd, has_doc, str_param, targets};
|
||||
use super::{CommandSpec, Result, Session, bad, cmd, has_doc, named_ids, targets};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -21,13 +21,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
selection
|
||||
),
|
||||
cmd!(query "css.generate", "Generate CSS", [], None, "{…options as css.selection} css.selection of every object in the document", has_doc, generate),
|
||||
cmd!(query "css.generate", "Generate CSS", [], None, "{…options as css.selection} css.selection of every object in the document; ids and id are not used, and a malformed one is an error", has_doc, generate),
|
||||
cmd!(
|
||||
"css.export",
|
||||
"Export CSS",
|
||||
[],
|
||||
None,
|
||||
"{path?, scope?: selection|all (default selection; ids? picks the objects), …options as css.selection, id?: object id (default: the selection)} write the CSS (css.selection, or css.generate for all) to path and, with rasterize, each picture its rules refer to next to it (PNG of what the object draws, with the clips, masks and effects of the groups and layers that hold it, 1 px per point) → {path, bytes, rules: n, images: [path…], warnings}; no path → {data: the CSS, name: <document>.css, rules, images: [{name, dataBase64}], warnings}; warnings are css.selection's and the pictures' as document.export gives them (an image that couldn't be decoded, type in the fallback font or that doesn't fit its frame), each once",
|
||||
"{path?: string, scope?: selection|all (default selection when left out; ids? picks the objects), …options as css.selection, id?: object id (default: the selection)} a path or scope given as anything but a string, null included, is an error, and so is a malformed ids or id with scope all, which does not use them; write the CSS (css.selection, or css.generate for all) to path and, with rasterize, each picture its rules refer to next to it (PNG of what the object draws, with the clips, masks and effects of the groups and layers that hold it, 1 px per point) → {path, bytes, rules: n, images: [path…], warnings}; no path → {data: the CSS, name: <document>.css, rules, images: [{name, dataBase64}], warnings}; warnings are css.selection's and the pictures' as document.export gives them (an image that couldn't be decoded, type in the fallback font or that doesn't fit its frame), each once",
|
||||
has_doc,
|
||||
export
|
||||
),
|
||||
@@ -77,16 +77,34 @@ fn selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(result(&sheet(s, p, ids, "css.selection")?))
|
||||
}
|
||||
|
||||
/// Checks the form of the `ids` or `id` that `p` gives a command that exports every object and does
|
||||
/// not use them: a malformed one is an error, as it is for the selection.
|
||||
fn unused_ids(p: &Value) -> Result<()> {
|
||||
named_ids(p).map(|_| ())
|
||||
}
|
||||
|
||||
fn generate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
unused_ids(p)?;
|
||||
let ids = everything(s)?;
|
||||
Ok(result(&sheet(s, p, ids, "css.generate")?))
|
||||
}
|
||||
|
||||
/// The string `p` gives for `key`, or `None` when `p` leaves it out. Any value given that is not a
|
||||
/// string, null included, is an error: "`{key}` must be a string".
|
||||
fn given_text<'a>(p: &'a Value, key: &str, cmd: &str) -> Result<Option<&'a str>> {
|
||||
p.get(key).map(|v| v.as_str().ok_or_else(|| bad(cmd, format!("`{key}` must be a string")))).transpose()
|
||||
}
|
||||
|
||||
fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "css.export";
|
||||
let ids = match str_param(p, "scope").unwrap_or("selection") {
|
||||
let scope = given_text(p, "scope", C)?;
|
||||
let path = given_text(p, "path", C)?;
|
||||
let ids = match scope.unwrap_or("selection") {
|
||||
"selection" => targets(s, p)?,
|
||||
"all" => everything(s)?,
|
||||
"all" => {
|
||||
unused_ids(p)?;
|
||||
everything(s)?
|
||||
}
|
||||
other => return Err(bad(C, format!("scope must be selection or all (got {other})"))),
|
||||
};
|
||||
let sheet = sheet(s, p, ids, C)?;
|
||||
@@ -115,7 +133,7 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
let n = sheet.rules.len();
|
||||
match str_param(p, "path") {
|
||||
match path {
|
||||
Some(path) => {
|
||||
write_file(path, css.as_bytes())?;
|
||||
let folder = std::path::Path::new(path).parent();
|
||||
|
||||
@@ -568,7 +568,7 @@ fn appearance(o: &DimensionOptions, mode: ColorMode, ext: bool, arrows: bool) ->
|
||||
ArrowStyle::Tick => Some(Arrowhead::Slash),
|
||||
ArrowStyle::None => None,
|
||||
};
|
||||
st.start_arrow = head;
|
||||
st.start_arrow = head.clone();
|
||||
st.end_arrow = head;
|
||||
st.arrow_scale = (o.arrow_scale, o.arrow_scale);
|
||||
st.arrow_align = vectorcraft_doc::appearance::ArrowAlign::Tip;
|
||||
|
||||
@@ -38,7 +38,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
doc_move
|
||||
),
|
||||
cmd!(query "document.inspect", "Inspect Document", [], None, "{depth?, childLimit?, ids?: [id…] (list just these nodes, of any level, in this order, under `nodes` in place of `layers`; an unknown id is an error; at most 10000), kind?: panel label such as Layer; Group; Path; Type or Image (exact, ignoring case; list every such node under `nodes`, each with `path`, its ancestor ids with the layer first, searching the subtrees of `ids` when given), limit?: at most this many entries of the list, offset?: entries skipped first (default 0; with `ids`, `kind`, `limit` or `offset` the reply adds `total`, the entries before paging)} → layer tree (each node: id, name, kind, visible, locked, bounds, opacity (0–1) and opacityPct (0–100 %, the scale object.setProps takes) when below 1, blend when not Normal, fill, stroke and strokeWidth of objects and compound paths; a compound path's members have compoundMember: true instead of paint, which is the compound's), artboards, selection, history (a sliced tree reports childCount); without the scope options the reply is the whole layer tree", has_doc, doc_inspect),
|
||||
cmd!(query "document.inspect", "Inspect Document", [], None, "{depth?, childLimit?, ids?: [id…] (list just these nodes, of any level, in this order, under `nodes` in place of `layers`; an unknown id is an error; at most 10000), kind?: panel label such as Layer; Group; Path; Type or Image (exact, ignoring case; list every such node under `nodes`, each with `path`, its ancestor ids with the layer first, searching the subtrees of `ids` when given), limit?: at most this many entries of the list, offset?: entries skipped first (default 0; with `ids`, `kind`, `limit` or `offset` the reply adds `total`, the entries before paging)} → path (the document's file; null when automation read roots are in force and the file is outside them), layer tree (each node: id, name, kind, visible, locked, bounds, opacity (0–1) and opacityPct (0–100 %, the scale object.setProps takes) when below 1, blend when not Normal, fill, stroke and strokeWidth of objects and compound paths; a compound path's members have compoundMember: true instead of paint, which is the compound's), artboards, selection, history (a sliced tree reports childCount); without the scope options the reply is the whole layer tree", has_doc, doc_inspect),
|
||||
cmd!(query "document.node", "Inspect Object", [], None, "{id, summary?: compact summary, depth?: child levels in the summary (default all), childLimit?: children shown per node (default all; a level that shows fewer reports childCount)} → one object", has_doc, doc_node),
|
||||
cmd!(query "document.find", "Find Objects", [], None, "{name?: substring of the Layers panel name, kind?: panel label such as Group; Path; Type or Image (exact), text?: substring of type content, within?: [x, y, width, height] (an object's geometric bounds inside this rectangle, edges included; document points as document.inspect's bounds; layers never match this or the size filters), intersects?: [x, y, width, height] (bounds touching or overlapping it), minWidth?, maxWidth?, minHeight?, maxHeight?: pt (the bounds' size), minThickness?, maxThickness?: pt (the smaller of the bounds' width and height), minArea?, maxArea?: pt² (the bounds' width × height), minAnchors?, maxAnchors?: anchor points of a path (a compound path's members together; other objects never match), select?: bool (also select the matches, leaving out hidden and locked ones, those inside another match, and a compound path's members unless the compound path is isolated; Select > Reselect repeats it), limit?: max matches listed (default 100, 0 counts only)} → {matches: [{id, name, kind, path}], total, selected (with select: the count selected)}; at least one filter, all of them matching", has_doc, doc_find),
|
||||
cmd!(
|
||||
@@ -50,7 +50,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
doc_compare
|
||||
),
|
||||
cmd!(query "document.json", "Document JSON", [], None, "{} → complete document model", has_doc, |s, _| Ok(serde_json::to_value(&*s.doc()?.doc).unwrap_or(Value::Null))),
|
||||
cmd!(query "document.json", "Document JSON", [], None, "{} → complete document model (with automation read roots, a linked or placed file outside them is named by its file name alone, without its relative path, size or date, in brush art too, and so is a data set's image value outside them; a relative path that leads outside them is left out; a stored brush entry that does not read as a brush is left out; a folder outside them is left out of export_settings)", has_doc, |s, _| {
|
||||
let st = s.doc()?;
|
||||
let d = super::links::with_hidden_paths_named(&st.doc, super::links::LinkBase::of(st));
|
||||
Ok(serde_json::to_value(&*d).unwrap_or(Value::Null))
|
||||
}),
|
||||
cmd!(
|
||||
"document.setUnits",
|
||||
"Units",
|
||||
|
||||
@@ -546,12 +546,14 @@ fn expand_piece_leaves(
|
||||
}
|
||||
|
||||
/// Path `l` as the canvas paints its fills and strokes: its visible geometry effects applied to its
|
||||
/// path (a plain path then), and every geometry effect of its own removed. `None` when it has no
|
||||
/// path (a plain path then), and every geometry effect of its own removed, with each raster effect
|
||||
/// following the Transform effects after it
|
||||
/// ([`without_geometry`](vectorcraft_render::effects::without_geometry)). `None` when it has no
|
||||
/// visible geometry effect. The swatches and patterns that live-effect plug-ins painting their
|
||||
/// pieces name are looked up in `doc`. This merges the pieces of such a plug-in into one path, so a
|
||||
/// path that one paints goes through [`expand_pieces`] instead.
|
||||
pub(crate) fn reshaped(doc: &Document, l: &Node) -> Option<Node> {
|
||||
use vectorcraft_render::effects::{GeomContext, apply_geometry_carrying, has_geometry, is_geometry};
|
||||
use vectorcraft_render::effects::{GeomContext, apply_geometry_carrying, has_geometry, without_geometry};
|
||||
if !has_geometry(&l.appearance.effects) {
|
||||
return None;
|
||||
}
|
||||
@@ -562,7 +564,7 @@ pub(crate) fn reshaped(doc: &Document, l: &Node) -> Option<Node> {
|
||||
if carry.moves(&m.appearance) {
|
||||
carry.apply(&mut m.appearance);
|
||||
}
|
||||
m.appearance.effects.retain(|e| !is_geometry(&e.id));
|
||||
m.appearance.effects = without_geometry(&m.appearance.effects);
|
||||
Some(m)
|
||||
}
|
||||
|
||||
@@ -604,9 +606,10 @@ fn with_path(l: &Node, path: PathData) -> Node {
|
||||
}
|
||||
|
||||
/// The effects of fill `f` that stay on its expanded art: all but its geometry effects, which
|
||||
/// shape the outline it paints ([`fill_shape`]).
|
||||
/// shape the outline it paints ([`fill_shape`]), with each raster effect following the Transform
|
||||
/// effects after it ([`without_geometry`](vectorcraft_render::effects::without_geometry)).
|
||||
fn fill_effects(f: &FillLayer) -> Vec<vectorcraft_doc::Effect> {
|
||||
f.effects.iter().filter(|e| !vectorcraft_render::effects::is_geometry(&e.id)).cloned().collect()
|
||||
vectorcraft_render::effects::without_geometry(&f.effects)
|
||||
}
|
||||
|
||||
/// Replace every pattern fill of the selected paths by the tiles it paints (see [`expand_patterns`]).
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -398,7 +398,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
let editing = vectorcraft_svg::editing(&text).map(|e| (e.intact, move || vectorcraft_format::base64_decode(&e.data)));
|
||||
// Relative links are found in the SVG's folder (when `name` is a path).
|
||||
let folder = std::path::Path::new(name).parent().map(|f| f.to_string_lossy()).filter(|f| !f.is_empty()).map(|f| absolute_path(&f));
|
||||
restore_or_import(editing, || import_svg(&text, folder.as_deref()))?
|
||||
restore_or_import(editing, || open_svg(&text, folder.as_deref()))?
|
||||
}
|
||||
"pdf" | "ai" | "ait" => {
|
||||
// The file is read and checked once, for the document it carries, its pages, the
|
||||
@@ -573,6 +573,33 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
/// own): rasters stay linked, SVG files become art, missing ones placeholders (see
|
||||
/// [`vectorcraft_svg::ImportOptions`]).
|
||||
pub(crate) fn import_svg(text: &str, folder: Option<&str>) -> Result<(Document, Vec<String>)> {
|
||||
import_svg_finding(text, folder, &std::collections::HashSet::new()).map(|(doc, warnings, _)| (doc, warnings))
|
||||
}
|
||||
|
||||
/// [`import_svg`] for a document opened from an SVG: with the variable data the file carries (SVG
|
||||
/// Options › Include Variable Data), read as [`crate::cmd::variablecmds::svg_library`] says. Variable
|
||||
/// data that can't be read is left out with a warning; the art opens either way.
|
||||
fn open_svg(text: &str, folder: Option<&str>) -> Result<(Document, Vec<String>)> {
|
||||
let lib = match crate::cmd::variablecmds::svg_library(text) {
|
||||
None => return import_svg(text, folder),
|
||||
Some(Err(e)) => {
|
||||
let (doc, mut warnings) = import_svg(text, folder)?;
|
||||
warnings.push(format!("the file's variable data wasn't read: {e}"));
|
||||
return Ok((doc, warnings));
|
||||
}
|
||||
Some(Ok(lib)) => lib,
|
||||
};
|
||||
let (mut doc, mut warnings, found) = import_svg_finding(text, folder, &lib.elements())?;
|
||||
warnings.extend(crate::cmd::variablecmds::give_svg_library(&mut doc, lib, &found));
|
||||
Ok((doc, warnings))
|
||||
}
|
||||
|
||||
/// [`import_svg`], also returning the object made of each element whose id `ids` lists.
|
||||
fn import_svg_finding(
|
||||
text: &str,
|
||||
folder: Option<&str>,
|
||||
ids: &std::collections::HashSet<String>,
|
||||
) -> Result<(Document, Vec<String>, vectorcraft_svg::Found)> {
|
||||
// The importer reads on a thread of its own (for its stack): what may be read is what may be
|
||||
// read here.
|
||||
let scope = crate::file_access::ScopeHandle::capture();
|
||||
@@ -588,7 +615,7 @@ pub(crate) fn import_svg(text: &str, folder: Option<&str>) -> Result<(Document,
|
||||
Some((bytes, link))
|
||||
})
|
||||
};
|
||||
vectorcraft_svg::import_with(text, &vectorcraft_svg::ImportOptions { folder, read: Some(&read) }).map_err(err)
|
||||
vectorcraft_svg::import_finding(text, &vectorcraft_svg::ImportOptions { folder, read: Some(&read) }, ids).map_err(err)
|
||||
}
|
||||
|
||||
/// Open a file's bytes as the new active document (what `document.open` does) →
|
||||
|
||||
@@ -1031,6 +1031,8 @@ impl SaveJob {
|
||||
}
|
||||
// The other open documents that place this one show the new version.
|
||||
let written = self.plan.path.clone().filter(|_| matches!(self.plan.format.id, "vectorcraft" | "template"));
|
||||
// A save of the arrowheads file lists its heads in the arrowhead menus.
|
||||
let native = self.plan.format.id == "vectorcraft";
|
||||
if let Some(st) = s.document_mut(uid) {
|
||||
self.finish(st);
|
||||
}
|
||||
@@ -1038,6 +1040,9 @@ impl SaveJob {
|
||||
crate::cmd::recovery::forget(s, uid);
|
||||
}
|
||||
if let Some(path) = written {
|
||||
if native {
|
||||
s.arrowheads_saved(&path);
|
||||
}
|
||||
crate::cmd::links::refresh_placed(s, uid, &path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +90,12 @@ pub const OPTIONS: &[FormatOption] = &[
|
||||
default: "false",
|
||||
description: "embed the fonts type uses as @font-face, subset to the characters used (whole when the font's licence forbids subsetting, left out with a warning when it forbids embedding)",
|
||||
},
|
||||
FormatOption {
|
||||
name: "variableData",
|
||||
ty: "boolean",
|
||||
default: "false",
|
||||
description: "Include Variable Data: write the text variables, the data sets and the type object each text variable is bound to as a variable library (variableSets) that document.open and variable.loadLibrary read back; variables of other kinds, and what is past the limits variable.loadLibrary reads (256 text variables, 1,000 data sets, 8 MiB), are left out with a warning",
|
||||
},
|
||||
FormatOption {
|
||||
name: "svg",
|
||||
ty: "object",
|
||||
|
||||
@@ -43,12 +43,62 @@ impl Visitor for Blocked {
|
||||
}
|
||||
}
|
||||
|
||||
/// A test's folder tree, removed with everything in it when dropped, also when the test panics,
|
||||
/// and also when the test locked a folder in it. A removal that fails (a walker still holding a
|
||||
/// file open) is ignored.
|
||||
struct Tree(PathBuf);
|
||||
|
||||
impl std::ops::Deref for Tree {
|
||||
type Target = PathBuf;
|
||||
fn deref(&self) -> &PathBuf {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Tree {
|
||||
fn drop(&mut self) {
|
||||
if std::fs::remove_dir_all(&self.0).is_err() {
|
||||
unlock(&self.0);
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Gives the owner read, write and search permission on `folder` and on every folder in it, so
|
||||
/// that a tree holding a folder a test locked can be removed. Links are not followed.
|
||||
#[cfg(unix)]
|
||||
fn unlock(folder: &Path) {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let Ok(meta) = std::fs::symlink_metadata(folder) else { return };
|
||||
if !meta.is_dir() {
|
||||
return;
|
||||
}
|
||||
let mode = meta.permissions().mode() & 0o7777;
|
||||
if mode & 0o700 != 0o700 {
|
||||
let _ = std::fs::set_permissions(folder, std::fs::Permissions::from_mode(mode | 0o700));
|
||||
}
|
||||
let Ok(entries) = std::fs::read_dir(folder) else { return };
|
||||
for entry in entries.flatten() {
|
||||
if entry.file_type().is_ok_and(|t| t.is_dir()) {
|
||||
unlock(&entry.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Folders on other systems are removed as they are.
|
||||
#[cfg(not(unix))]
|
||||
fn unlock(_: &Path) {}
|
||||
|
||||
/// A fresh folder (canonical, as a search reports it) holding `paths`: files, and folders where
|
||||
/// they end in `/`.
|
||||
fn tree(tag: &str, paths: &[&str]) -> PathBuf {
|
||||
let root = vectorcraft_testkit::temp_dir(&format!("findfiles-{tag}"));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
/// they end in `/`. The folder is removed when the returned [`Tree`] is dropped.
|
||||
fn tree(tag: &str, paths: &[&str]) -> Tree {
|
||||
let mut root = Tree(vectorcraft_testkit::temp_dir(&format!("findfiles-{tag}")));
|
||||
// Empties what a tree whose removal failed left, keeping the folder as `temp_dir` made it
|
||||
// (owner-only on Unix).
|
||||
for entry in std::fs::read_dir(&root.0).unwrap().flatten() {
|
||||
let path = entry.path();
|
||||
let _ = if entry.file_type().is_ok_and(|t| t.is_dir()) { std::fs::remove_dir_all(&path) } else { std::fs::remove_file(&path) };
|
||||
}
|
||||
for p in paths {
|
||||
let path = root.join(p);
|
||||
if p.ends_with('/') {
|
||||
@@ -58,7 +108,54 @@ fn tree(tag: &str, paths: &[&str]) -> PathBuf {
|
||||
std::fs::write(&path, b"font").unwrap();
|
||||
}
|
||||
}
|
||||
plain(std::fs::canonicalize(&root).unwrap())
|
||||
root.0 = plain(std::fs::canonicalize(&root.0).unwrap());
|
||||
root
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tree_is_removed_when_its_test_ends() {
|
||||
let path = {
|
||||
let root = tree("removed", &["a/b/one.ttf", "c/"]);
|
||||
assert!(root.join("a/b/one.ttf").is_file() && root.join("c").is_dir());
|
||||
root.to_path_buf()
|
||||
};
|
||||
assert!(!path.exists(), "{} is left", path.display());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tree_is_removed_when_its_test_panics() {
|
||||
let mut path = PathBuf::new();
|
||||
let r = vectorcraft_testkit::catch_quiet::<()>(|| {
|
||||
let root = tree("unwound", &["a/b/one.ttf"]);
|
||||
path = root.to_path_buf();
|
||||
panic!("a failed assertion");
|
||||
});
|
||||
assert_eq!(r.err().as_deref(), Some("a failed assertion"));
|
||||
assert!(!path.as_os_str().is_empty() && !path.exists(), "{} is left", path.display());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_tree_is_private_to_this_user() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let root = tree("private", &["a/one.ttf"]);
|
||||
let mode = std::fs::metadata(&*root).unwrap().permissions().mode();
|
||||
assert_eq!(mode & 0o077, 0, "{}: mode {mode:o}", root.display());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_tree_with_a_locked_folder_is_removed_when_its_test_panics() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut path = PathBuf::new();
|
||||
let r = vectorcraft_testkit::catch_quiet::<()>(|| {
|
||||
let root = tree("locked", &["a/locked/b/one.ttf"]);
|
||||
path = root.to_path_buf();
|
||||
std::fs::set_permissions(root.join("a/locked"), std::fs::Permissions::from_mode(0o000)).unwrap();
|
||||
panic!("a failed assertion");
|
||||
});
|
||||
assert_eq!(r.err().as_deref(), Some("a failed assertion"));
|
||||
assert!(!path.as_os_str().is_empty() && !path.exists(), "{} is left", path.display());
|
||||
}
|
||||
|
||||
/// The rules of a search of `root`: its `Users/me` is the home folder, `refused` an app's folder.
|
||||
|
||||
@@ -850,14 +850,23 @@ fn move_node(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
fn rect_from(p: &Value, default: Rect) -> Rect {
|
||||
/// The rectangle from `x`, `y`, `width` and `height`, each defaulting to `default`'s. Sides are kept as given.
|
||||
fn rect_unclamped(p: &Value, default: Rect) -> Rect {
|
||||
let x = f64_or(p, "x", default.x0);
|
||||
let y = f64_or(p, "y", default.y0);
|
||||
let w = f64_or(p, "width", default.width()).max(1.0);
|
||||
let h = f64_or(p, "height", default.height()).max(1.0);
|
||||
let w = f64_or(p, "width", default.width());
|
||||
let h = f64_or(p, "height", default.height());
|
||||
Rect::new(x, y, x + w, y + h)
|
||||
}
|
||||
|
||||
/// [`rect_unclamped`] with each supplied side raised to at least 1 pt, as `artboard.setProps` uses it.
|
||||
/// A side left out keeps `default`'s, even below 1 pt.
|
||||
fn rect_from(p: &Value, default: Rect) -> Rect {
|
||||
let r = rect_unclamped(p, default);
|
||||
let floor = |key: &str, side: f64| if p.get(key).and_then(Value::as_f64).is_some() { side.max(1.0) } else { side };
|
||||
Rect::new(r.x0, r.y0, r.x0 + floor("width", r.width()), r.y0 + floor("height", r.height()))
|
||||
}
|
||||
|
||||
/// An artboard command's `color` param, its background color: None when absent, `Some(None)` for
|
||||
/// null (no color), else `Some(Some(color))`.
|
||||
fn artboard_color(p: &Value, cmd: &str) -> Result<Option<Option<vectorcraft_color::Color>>> {
|
||||
@@ -942,7 +951,7 @@ fn artboard_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
src + side.offset(src)
|
||||
}
|
||||
None if p.get("beside").is_some_and(|v| !v.is_null()) => return Err(bad("artboard.new", "`beside` needs `side`")),
|
||||
None => rect_from(p, def),
|
||||
None => rect_unclamped(p, def),
|
||||
};
|
||||
// A size taken from another artboard (`side`, or width or height left out) keeps to the limit too.
|
||||
if !super::newdoc::side_in_range(r.width()) || !super::newdoc::side_in_range(r.height()) {
|
||||
|
||||
@@ -28,7 +28,9 @@ use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::links::{Align, Preserve, hash_bytes};
|
||||
use vectorcraft_doc::{Appearance, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind, PageBox, PlacedDocument, PlacementOptions};
|
||||
use vectorcraft_doc::{
|
||||
Appearance, DataValue, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind, PageBox, PlacedDocument, PlacementOptions,
|
||||
};
|
||||
use vectorcraft_geom::{Affine, PathData, Rect, shapes};
|
||||
|
||||
use super::fileio::{self, RasterImage, absolute_path, file_created, file_stamp, read_file, write_file};
|
||||
@@ -1195,6 +1197,78 @@ pub(crate) fn with_found_paths(d: &mut Document, base: LinkBase) {
|
||||
});
|
||||
}
|
||||
|
||||
/// `d`, a document saved at `doc_path`, as a reply to the caller asking now may show it: without
|
||||
/// read roots in force, `d` itself; with them, a copy in which each link [`path_shown`] does not
|
||||
/// allow names its file alone, without its relative path or the size and date kept of its file,
|
||||
/// and each data set's image value it does not allow names its file alone too. The folder Export
|
||||
/// for Screens last wrote to is left out of the export settings when it is outside the read roots.
|
||||
/// A link whose path is allowed but whose relative path leads outside the read roots loses its
|
||||
/// relative path. Links in the layers, symbols, graph designs, patterns, the pattern being edited
|
||||
/// as it was before, opacity masks and the art of the stored brushes are all checked. A stored
|
||||
/// brush entry that does not read as a brush is left out of the copy, since its links cannot be
|
||||
/// checked, and a stored brush library that is not a list becomes an empty one.
|
||||
pub(crate) fn with_hidden_paths_named<'a>(d: &'a Document, base: LinkBase) -> Cow<'a, Document> {
|
||||
if crate::file_access::current().is_none() {
|
||||
return Cow::Borrowed(d);
|
||||
}
|
||||
let doc_path = base.doc;
|
||||
// A stored relative path is followed from the folder of the file the document was read from,
|
||||
// and a link is also looked for from the document's own folder: it is shown only when both
|
||||
// places it names are inside the read roots.
|
||||
let relative_hidden = |r: &str| [base.relative, base.doc].into_iter().any(|from| !path_shown(r, from));
|
||||
let mut d = d.clone();
|
||||
// The pattern being edited as it was before is checked as one more pattern, then put back.
|
||||
let original = d.pattern_edit.as_mut().and_then(|e| e.original.take());
|
||||
let edited = original.is_some();
|
||||
d.patterns.extend(original);
|
||||
// The art of each stored brush is checked as more layers, then put back.
|
||||
let stored = d.unknown.get(vectorcraft_brush::DOC_KEY).map(|v| v.as_array().cloned().unwrap_or_default());
|
||||
let mut brushes: Vec<vectorcraft_brush::Brush> =
|
||||
stored.iter().flatten().filter_map(|raw| vectorcraft_brush::Brush::from_json(raw.clone()).ok()).collect();
|
||||
let layers = d.layers.len();
|
||||
d.layers.extend(brushes.iter().flat_map(|b| b.art()).map(|n| Arc::new(n.clone())));
|
||||
d.update_links_where(
|
||||
|l| !path_shown(&l.path, base.doc) || l.relative.as_deref().is_some_and(relative_hidden),
|
||||
|l| {
|
||||
if path_shown(&l.path, base.doc) {
|
||||
l.relative = None;
|
||||
} else {
|
||||
let name = l.name().to_string();
|
||||
*l = LinkInfo { path: name, relative: None, modified: None, size: None, ..l.clone() };
|
||||
}
|
||||
},
|
||||
);
|
||||
let mut checked = d.layers.split_off(layers).into_iter();
|
||||
if edited && let (Some(def), Some(e)) = (d.patterns.pop(), d.pattern_edit.as_mut()) {
|
||||
e.original = Some(def);
|
||||
}
|
||||
if stored.is_some() {
|
||||
// Each brush is written as it reads, so fields it does not read are left out too.
|
||||
let mut kept = vec![];
|
||||
for b in &mut brushes {
|
||||
for (slot, art) in b.art_mut().into_iter().zip(&mut checked) {
|
||||
*slot = Arc::unwrap_or_clone(art);
|
||||
}
|
||||
kept.extend(serde_json::to_value(&*b).ok());
|
||||
}
|
||||
d.unknown.insert(vectorcraft_brush::DOC_KEY.into(), Value::Array(kept));
|
||||
}
|
||||
if d.export_settings.get("folder").and_then(Value::as_str).is_some_and(|f| crate::file_access::check_read(f).is_err()) {
|
||||
d.export_settings.remove("folder");
|
||||
}
|
||||
let hidden = |v: &DataValue| matches!(v, DataValue::Image(path) if !path_shown(path, doc_path));
|
||||
if d.variables.datasets.iter().any(|set| set.values.values().any(hidden)) {
|
||||
for v in d.variables.datasets.iter_mut().flat_map(|set| set.values.values_mut()) {
|
||||
if hidden(&*v)
|
||||
&& let DataValue::Image(path) = v
|
||||
{
|
||||
*path = LinkInfo::new(path.as_str()).name().to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
Cow::Owned(d)
|
||||
}
|
||||
|
||||
// ---------- commands ----------
|
||||
|
||||
fn check(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::borrow::Cow;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{BlendMode, Color, GradientPaint, Paint};
|
||||
use vectorcraft_doc::{Appearance, CharStyle, Document, Node, NodeId, NodeKind};
|
||||
use vectorcraft_doc::{Appearance, CharStyle, Document, GraySource, Node, NodeId, NodeKind};
|
||||
|
||||
use super::appearance::{ItemTarget, appearance_targets, edit_items, edit_items_then, item_target};
|
||||
use super::gradient::{item_paint_bounds, place_paint, place_run_paint, run_paint_mut, run_stroke_weight, unplaced};
|
||||
@@ -718,7 +718,8 @@ fn colorized_images(d: &Document, ids: &[NodeId], paint: &Paint) -> std::result:
|
||||
}
|
||||
|
||||
/// The message for a process color on a grayscale image with an alpha channel.
|
||||
const PROCESS_ON_TRANSPARENCY: &str = "a process color can't colorize a grayscale image with transparency: use a spot color, black or white";
|
||||
pub(super) const PROCESS_ON_TRANSPARENCY: &str =
|
||||
"a process color can't colorize a grayscale image with transparency: use a spot color, black or white";
|
||||
|
||||
/// Can `paint` colorize a grayscale image with an alpha channel: a color linked to a spot swatch
|
||||
/// of `d`, black or white?
|
||||
@@ -737,11 +738,7 @@ pub(super) fn check_colorized(d: &Document) -> std::result::Result<(), String> {
|
||||
let mut refused = false;
|
||||
let mut visit = |n: &Node| {
|
||||
vectorcraft_doc::swatches::walk_with_masks(n, &mut |m| {
|
||||
if let (NodeKind::Image(im), Some(paint)) = (&m.kind, m.extras().colorize.as_deref())
|
||||
&& !refused
|
||||
&& !colorizes_transparency(d, paint)
|
||||
&& d.image_blob(im).and_then(|b| b.gray_source()).is_some_and(|g| g.alpha)
|
||||
{
|
||||
if !refused && gray_colorized_with_process(d, m).is_some_and(|g| g.alpha) {
|
||||
refused = true;
|
||||
}
|
||||
})
|
||||
@@ -761,6 +758,13 @@ pub(super) fn check_colorized_kept(was_ok: bool, d: &Document) -> std::result::R
|
||||
if was_ok { check_colorized(d) } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// How the pixels of `n` are gray, when `n` is a grayscale or 1-bit image colorized with a color
|
||||
/// that [`colorizes_transparency`] refuses. Such an image can't have an alpha channel.
|
||||
pub(super) fn gray_colorized_with_process(d: &Document, n: &Node) -> Option<GraySource> {
|
||||
let (NodeKind::Image(im), Some(paint)) = (&n.kind, n.extras().colorize.as_deref()) else { return None };
|
||||
if colorizes_transparency(d, paint) { None } else { d.image_blob(im).and_then(|b| b.gray_source()) }
|
||||
}
|
||||
|
||||
/// Is `e` the refusal of [`check_colorized`]?
|
||||
pub(super) fn is_colorize_refusal(e: &EngineError) -> bool {
|
||||
e.to_string().contains(PROCESS_ON_TRANSPARENCY)
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -896,7 +896,7 @@ pub(crate) fn outlined_stroke(
|
||||
};
|
||||
let art = d.reid(&art);
|
||||
let mut art = with_transparency(d, art, st.opacity, st.blend);
|
||||
let raster: Vec<_> = st.effects.iter().filter(|e| !vectorcraft_render::effects::is_geometry(&e.id)).cloned().collect();
|
||||
let raster = vectorcraft_render::effects::without_geometry(&st.effects);
|
||||
if !raster.is_empty() {
|
||||
if !art.appearance.effects.is_empty() {
|
||||
art = Node::group(d.alloc_id(), vec![Arc::new(art)]);
|
||||
@@ -946,7 +946,7 @@ pub(crate) fn outlined_stroke(
|
||||
/// geometry effects leave nothing to paint, an empty group takes the path's place.
|
||||
/// `None` when `l` has no stroke to outline.
|
||||
pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Brush], l: &Node) -> Option<Node> {
|
||||
use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry, has_transform_art, is_geometry, reshape};
|
||||
use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry, has_transform_art, reshape, without_geometry};
|
||||
if !l.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if outlinable(st))) {
|
||||
return None;
|
||||
}
|
||||
@@ -975,7 +975,7 @@ pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Br
|
||||
parts.push(styled_copy(d, l, std::mem::take(&mut fills)));
|
||||
}
|
||||
let mut plain = moved;
|
||||
plain.effects_mut().retain(|e| !is_geometry(&e.id));
|
||||
*plain.effects_mut() = without_geometry(plain.effects());
|
||||
parts.push(styled_copy(d, &shape, vec![plain]));
|
||||
}
|
||||
AppearanceItem::Stroke(st) if outlinable(st) => {
|
||||
@@ -983,7 +983,7 @@ pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Br
|
||||
parts.push(styled_copy(d, l, std::mem::take(&mut fills)));
|
||||
}
|
||||
let mut plain = st.clone();
|
||||
plain.effects.retain(|e| !is_geometry(&e.id));
|
||||
plain.effects = without_geometry(&st.effects);
|
||||
let stroked = match path.bounds() {
|
||||
Some(b) if has_geometry(&st.effects) => apply_geometry_with(&st.effects, &path, b, &ctx.item(item)),
|
||||
_ => path.clone(),
|
||||
|
||||
@@ -169,6 +169,10 @@ pub(crate) const NOT_FILES: &[(&str, &str)] = &[
|
||||
("document.exportForScreens", "folders"),
|
||||
];
|
||||
|
||||
/// Commands and the keys from the top of their params down to a key in [`FILE_KEYS`] that, there,
|
||||
/// names part of the document, not a file: a custom arrowhead's outline in `stroke.set`.
|
||||
const NESTED_NOT_FILES: &[(&str, &[&str])] = &[("stroke.set", &["startArrow", "Custom", "path"]), ("stroke.set", &["endArrow", "Custom", "path"])];
|
||||
|
||||
/// Is command id `id` one a plug-in may never call, from its id alone ([`DENIED_PREFIXES`],
|
||||
/// [`FILE_WORDS`])?
|
||||
fn denied_id(id: &str) -> bool {
|
||||
@@ -232,20 +236,32 @@ pub(crate) fn names_key(text: &str, key: &str) -> bool {
|
||||
|
||||
/// Does `args`, the arguments of a call of command `id`, carry a key in [`FILE_KEYS`] at any depth
|
||||
/// (in objects and in arrays), such as an effect's `textureImage.path`? Only a key [`NOT_FILES`]
|
||||
/// names for `id`, at the top level, isn't counted. The walk keeps its own stack, so deep
|
||||
/// arguments can't overflow the thread's.
|
||||
/// names for `id`, at the top level, and one at the end of a [`NESTED_NOT_FILES`] key list for
|
||||
/// `id`, aren't counted. The walk keeps its own stack, so deep arguments can't overflow the
|
||||
/// thread's.
|
||||
fn names_file(id: &str, args: &Value) -> bool {
|
||||
let mut stack = vec![(args, true)];
|
||||
while let Some((v, top)) = stack.pop() {
|
||||
// Each value, whether it is the params themselves, and the keys of a NESTED_NOT_FILES list
|
||||
// still to follow below it.
|
||||
let mut stack: Vec<(&Value, bool, Option<&[&str]>)> = vec![(args, true, None)];
|
||||
while let Some((v, top, trail)) = stack.pop() {
|
||||
match v {
|
||||
Value::Array(a) => stack.extend(a.iter().map(|x| (x, false))),
|
||||
Value::Array(a) => stack.extend(a.iter().map(|x| (x, false, None))),
|
||||
Value::Object(o) => {
|
||||
for (k, x) in o {
|
||||
let k = k.as_str();
|
||||
if FILE_KEYS.contains(&k) && !(top && NOT_FILES.contains(&(id, k))) {
|
||||
let rest = if top {
|
||||
NESTED_NOT_FILES
|
||||
.iter()
|
||||
.find(|(c, keys)| *c == id && keys.first() == Some(&k))
|
||||
.map(|(_, keys)| keys.get(1..).unwrap_or_default())
|
||||
} else {
|
||||
trail.filter(|keys| keys.first() == Some(&k)).map(|keys| keys.get(1..).unwrap_or_default())
|
||||
};
|
||||
let document = (top && NOT_FILES.contains(&(id, k))) || rest.is_some_and(<[&str]>::is_empty);
|
||||
if FILE_KEYS.contains(&k) && !document {
|
||||
return true;
|
||||
}
|
||||
stack.push((x, false));
|
||||
stack.push((x, false, rest.filter(|keys| !keys.is_empty())));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -585,6 +601,16 @@ mod tests {
|
||||
assert!(!names_file("object.setLiveShape", &json!({"path": [[0, 0], [1, 1]]})));
|
||||
assert!(names_file("object.setLiveShape", &json!({"options": {"path": "/tmp/x"}})));
|
||||
assert!(names_file("object.move", &json!({"path": [[0, 0]]})));
|
||||
// A custom arrowhead's outline in stroke.set, only where stroke.set takes one.
|
||||
let outline = json!({"subpaths": []});
|
||||
assert!(!names_file("stroke.set", &json!({"endArrow": {"Custom": {"name": "A", "path": outline}}})));
|
||||
assert!(!names_file("stroke.set", &json!({"startArrow": {"Custom": {"name": "A", "path": outline}}, "endArrow": "Triangle"})));
|
||||
assert!(names_file("stroke.set", &json!({"endArrow": {"Custom": {"name": "A", "path": outline, "file": "/tmp/x"}}})));
|
||||
assert!(names_file("stroke.set", &json!({"endArrow": {"Custom": {"path": {"path": "/tmp/x"}}}})));
|
||||
assert!(names_file("stroke.set", &json!({"endArrow": {"path": "/tmp/x"}})));
|
||||
assert!(names_file("stroke.set", &json!({"options": {"endArrow": {"Custom": {"path": "/tmp/x"}}}})));
|
||||
assert!(names_file("stroke.set", &json!({"endArrow": [{"Custom": {"path": "/tmp/x"}}]})));
|
||||
assert!(names_file("object.move", &json!({"endArrow": {"Custom": {"path": "/tmp/x"}}})));
|
||||
// Deep nesting is walked without recursion.
|
||||
let mut deep = json!({"file": "/tmp/x"});
|
||||
for _ in 0..10_000 {
|
||||
|
||||
@@ -692,5 +692,6 @@ pub(super) const DENIED: &[&str] = &[
|
||||
"object.blend.options",
|
||||
"object.liquify",
|
||||
"object.pathfinder.options",
|
||||
"stroke.arrowheads.edit",
|
||||
"type.compositeFonts.set",
|
||||
];
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
//! art retained underneath. Recipes select topology regions; source edits re-evaluate them in
|
||||
//! the same undo transaction. Rendering and exporters only see ordinary groups and paths.
|
||||
use super::pathops::Beside;
|
||||
use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, checked_ids_param, cmd, f64_or, has_doc, id_param, object_param, point_param};
|
||||
use super::{
|
||||
CommandSpec, EngineError, Result, Session, bad, bool_or, checked_ids_param, cmd, f64_or, has_doc, id_param, number_or, object_param, point_param,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap},
|
||||
@@ -34,7 +36,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Select Shaper Face",
|
||||
[],
|
||||
None,
|
||||
"{point: [x,y], source?: bool} click selects a group, then a face; source enters construction isolation and selects an original (while an opacity mask or a pattern is edited, it selects the original and the editing layer stays isolated); while a group's originals are isolated, it selects one of that group's originals → {id}",
|
||||
"{point: [x,y], source?: bool, tolerance?: pt (3)} click selects a group, then a face; with the group or one of its faces selected, a click within tolerance of an original shape's outline enters construction isolation and selects that original; source enters construction isolation and selects an original (while an opacity mask or a pattern is edited, it selects the original and the editing layer stays isolated); while a group's originals are isolated, it selects one of that group's originals → {id}",
|
||||
has_doc,
|
||||
select
|
||||
),
|
||||
@@ -734,16 +736,50 @@ fn group_at(d: &Document, point: Point, construction: bool, within: Option<NodeI
|
||||
});
|
||||
groups.last().copied()
|
||||
}
|
||||
/// The topmost editable Shaper Group that is selected, or has a face selected, with a visible,
|
||||
/// unlocked original shape whose outline lies within `tolerance` of `point`, and the topmost such
|
||||
/// shape in it; `None` when a Shaper Group that [`group_at`] finds at `point` lies above it.
|
||||
fn outline_at(d: &Document, selection: &vectorcraft_doc::Selection, point: Point, tolerance: f64) -> Option<(NodeId, NodeId)> {
|
||||
let mut hit = None;
|
||||
let editable = d.editable();
|
||||
d.walk(|n| {
|
||||
if n.extras().shaper.is_none() || !editable(n.id) {
|
||||
return;
|
||||
}
|
||||
let picked = selection.contains(n.id) || selection.objects.iter().any(|id| d.parent_of(*id) == Some(n.id));
|
||||
let on_outline =
|
||||
|s: &&Arc<Node>| b::node_outline(s).is_some_and(|(path, _)| path.nearest(point).is_some_and(|(_, _, _, _, dist)| dist <= tolerance));
|
||||
let source = if picked { sources(n).iter().rev().filter(|s| s.visible && !s.locked).find(on_outline).map(|s| s.id) } else { None };
|
||||
if let Some(source) = source {
|
||||
hit = Some((n.id, source));
|
||||
} else if n.geometric_bounds().is_some_and(|b| b.inflate(6.0, 6.0).contains(point)) {
|
||||
hit = None;
|
||||
}
|
||||
});
|
||||
hit
|
||||
}
|
||||
fn select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let point = point_param(p, "point").filter(|p| finite(*p)).ok_or_else(|| bad("shaper.select", "missing point"))?;
|
||||
let st = s.doc()?;
|
||||
let source = bool_or(p, "source", false);
|
||||
let tolerance = number_or(p, "tolerance", 3.0, "shaper.select")?;
|
||||
if !tolerance.is_finite() || !(0.0..=1000.0).contains(&tolerance) {
|
||||
return Err(bad("shaper.select", "invalid tolerance"));
|
||||
}
|
||||
// In Construction mode a source pick stays in the group whose source shapes are isolated.
|
||||
let isolated =
|
||||
st.isolation.and_then(|i| st.doc.parent_of(i)).filter(|g| st.doc.node(*g).and_then(source_group).is_some_and(|s| st.isolation == Some(s.id)));
|
||||
let Some(group) = group_at(&st.doc, point, source, isolated.filter(|_| source)) else { return Ok(json!({"id": null})) };
|
||||
// With a group or one of its faces selected, a click on one of its original shapes' outlines
|
||||
// enters Construction mode with the topmost such shape selected, wherever the outline lies.
|
||||
let outline = if source { None } else { outline_at(&st.doc, &st.selection, point, tolerance) };
|
||||
let Some(group) = outline.map(|(g, _)| g).or_else(|| group_at(&st.doc, point, source, isolated.filter(|_| source))) else {
|
||||
return Ok(json!({"id": null}));
|
||||
};
|
||||
let node = st.doc.node(group).ok_or(EngineError::NoNode(group))?;
|
||||
let target = if source {
|
||||
let source = source || outline.is_some();
|
||||
let target = if let Some((_, id)) = outline {
|
||||
id
|
||||
} else if source {
|
||||
sources(node)
|
||||
.iter()
|
||||
.rev()
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{
|
||||
ArrowAlign, Arrowhead, CharStyle, Dash, LineCap, LineJoin, Node, NodeKind, SavedProfile, Scaling, StrokeAlign, StrokeLayer, Unit, WidthProfile,
|
||||
ArrowAlign, Arrowhead, CharStyle, CustomArrowhead, Dash, LineCap, LineJoin, Node, NodeKind, SavedProfile, Scaling, StrokeAlign, StrokeLayer,
|
||||
Unit, WidthProfile,
|
||||
};
|
||||
use vectorcraft_geom::PathData;
|
||||
|
||||
use super::appearance::{ItemTarget, edit_items, item_target};
|
||||
use super::paint::painted;
|
||||
@@ -20,7 +22,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Stroke Options",
|
||||
["Window", "Stroke"],
|
||||
None,
|
||||
"{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), 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)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does) → {objects: how many objects took the stroke options}",
|
||||
"{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null|{custom: name} (a custom arrowhead of the arrowheads file, stroke.arrowheads.list)|{Custom: {name, path}} (a custom head with its art, as document.inspect reports one; refused when the path fills no area, parts thinner than a millionth of a head unit counting as none, or has more than 4096 anchors, more than 4096 subpaths or a point more than 64 head units from the tip) (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), 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)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does) → {objects: how many objects took the stroke options}",
|
||||
has_doc,
|
||||
stroke_set
|
||||
),
|
||||
@@ -242,6 +244,34 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None => Ok(None),
|
||||
Some(Value::Null) => Ok(Some(None)),
|
||||
Some(Value::String(n)) if n == "none" => Ok(Some(None)),
|
||||
// A custom head by name, from the arrowheads file (stroke.arrowheads.list).
|
||||
Some(Value::Object(o)) if o.len() == 1 && o.contains_key("custom") => {
|
||||
let Some(Value::String(name)) = o.get("custom") else { return Err(bad(C, format!("{k}: custom must be a name"))) };
|
||||
let head = s.arrowheads.get(name).ok_or_else(|| bad(C, format!("no custom arrowhead {name} (see stroke.arrowheads.list)")))?;
|
||||
Ok(Some(Some(Arrowhead::Custom(head))))
|
||||
}
|
||||
// A custom head with its art: an outline the head would drop is refused, not drawn empty.
|
||||
Some(Value::Object(o)) if o.contains_key("Custom") => {
|
||||
let raw = o.get("Custom").and_then(|c| c.get("path")).cloned().unwrap_or(Value::Null);
|
||||
let path: PathData = serde_json::from_value(raw).map_err(|e| bad(C, format!("{k}: bad custom arrowhead path: {e}")))?;
|
||||
// The region the outline fills (non-zero rule), down to slivers a millionth of a
|
||||
// head unit thin. Measured only once `fits` has bounded the anchors.
|
||||
let fills = |p: &PathData| {
|
||||
let area = vectorcraft_pathops::unite_all_with(&[(p, vectorcraft_geom::FillRule::NonZero)], 1e-6);
|
||||
area.subpaths.iter().map(|sp| sp.area().abs()).sum::<f64>() > 1e-12
|
||||
};
|
||||
if !CustomArrowhead::fits(&path) || !fills(&path) {
|
||||
let (most, far) = (CustomArrowhead::MAX_ANCHORS, CustomArrowhead::MAX_UNITS);
|
||||
return Err(bad(
|
||||
C,
|
||||
format!(
|
||||
"{k}: a custom arrowhead's path needs an area to fill and at most {most} anchors and {most} subpaths, each anchor within {far} head units of its tip"
|
||||
),
|
||||
));
|
||||
}
|
||||
let head = p.get(k).cloned().unwrap_or(Value::Null);
|
||||
serde_json::from_value::<Arrowhead>(head).map(|a| Some(Some(a))).map_err(|e| bad(C, format!("{k}: bad custom arrowhead: {e}")))
|
||||
}
|
||||
Some(v) => serde_json::from_value::<Arrowhead>(v.clone()).map(|a| Some(Some(a))).map_err(|_| bad(C, format!("unknown arrowhead {v}"))),
|
||||
}
|
||||
};
|
||||
@@ -258,11 +288,11 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let set = |st: &mut StrokeLayer| {
|
||||
change.apply(st);
|
||||
if let Some(a) = sa {
|
||||
st.start_arrow = a;
|
||||
if let Some(a) = &sa {
|
||||
st.start_arrow = a.clone();
|
||||
}
|
||||
if let Some(a) = ea {
|
||||
st.end_arrow = a;
|
||||
if let Some(a) = &ea {
|
||||
st.end_arrow = a.clone();
|
||||
}
|
||||
if let Some(a) = arrow_align {
|
||||
st.arrow_align = a;
|
||||
|
||||
@@ -243,6 +243,7 @@ pub fn para_attrs(v: &ParaStyle) -> Map<String, Value> {
|
||||
("burasagari", json!(v.burasagari)),
|
||||
("kinsoku", json!(v.kinsoku)),
|
||||
("kinsoku_type", json!(v.kinsoku_type)),
|
||||
("bunri_kinshi", json!(v.bunri_kinshi)),
|
||||
("roman_hanging", json!(v.roman_hanging)),
|
||||
("kashida", json!(v.kashida)),
|
||||
("korean_line_break_anywhere", json!(v.korean_line_break_anywhere)),
|
||||
|
||||
@@ -440,6 +440,59 @@ fn korean_line_break_anywhere_is_set_per_paragraph_and_saved_only_when_off() {
|
||||
assert!(!text(&s).para_at(0).korean_line_break_anywhere && !text(&s).para_at(1).korean_line_break_anywhere);
|
||||
}
|
||||
|
||||
/// Paragraph panel menu › Bunri-Kinshi: off for new type and for a paragraph saved before it, and
|
||||
/// saved only when on; `text.setFormat {bunriKinshi}` sets it per paragraph in one undo step and
|
||||
/// refuses a value that is not true or false, kinsoku None keeps it, and paragraph styles carry it.
|
||||
#[test]
|
||||
fn bunri_kinshi_is_set_per_paragraph_and_saved_only_when_on() {
|
||||
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": 10, "size": 20, "text": "あ——い\nう……え"})).unwrap()["id"].as_u64().unwrap();
|
||||
let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => (**t).clone(),
|
||||
_ => panic!("text"),
|
||||
};
|
||||
assert!(!text(&s).para_at(0).bunri_kinshi, "new type has it off");
|
||||
assert!(serde_json::to_value(text(&s).para_at(0)).unwrap().get("bunri_kinshi").is_none());
|
||||
let old: vectorcraft_doc::ParaStyle = serde_json::from_value(json!({"justify": "Left"})).unwrap();
|
||||
assert!(!old.bunri_kinshi, "saved before the option: off");
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
// Only the second paragraph.
|
||||
let second = "あ——い\n".len();
|
||||
s.execute("text.setFormat", &json!({"bunriKinshi": true, "start": second, "end": second})).unwrap();
|
||||
assert!(!text(&s).para_at(0).bunri_kinshi && text(&s).para_at(1).bunri_kinshi);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
|
||||
let saved = serde_json::to_value(text(&s).para_at(1)).unwrap();
|
||||
assert_eq!(saved["bunri_kinshi"], true);
|
||||
let back: vectorcraft_doc::ParaStyle = serde_json::from_value(saved).unwrap();
|
||||
assert!(back.bunri_kinshi);
|
||||
let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
|
||||
let Some(NodeKind::Text(back)) = reopened.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") };
|
||||
assert!(!back.para_at(0).bunri_kinshi && back.para_at(1).bunri_kinshi, "the file keeps it per paragraph");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(!text(&s).para_at(1).bunri_kinshi);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert!(text(&s).para_at(1).bunri_kinshi);
|
||||
for bad in [json!("yes"), json!(1)] {
|
||||
assert!(s.execute("text.setFormat", &json!({"ids": [id], "bunriKinshi": bad, "kinsoku": "none"})).is_err(), "{bad}");
|
||||
}
|
||||
assert!(s.execute("text.setRangeStyle", &json!({"id": id, "bunriKinshi": false})).is_err());
|
||||
assert!(text(&s).para_at(1).bunri_kinshi && text(&s).para_at(1).kinsoku != vectorcraft_doc::Kinsoku::None, "a refused call changes nothing");
|
||||
// Kinsoku None keeps the setting, without effect, and turning kinsoku back on restores it.
|
||||
s.execute("text.setFormat", &json!({"ids": [id], "kinsoku": "none"})).unwrap();
|
||||
assert!(text(&s).para_at(1).bunri_kinshi);
|
||||
s.execute("text.setFormat", &json!({"ids": [id], "kinsoku": "hard"})).unwrap();
|
||||
assert!(text(&s).para_at(1).bunri_kinshi);
|
||||
// A paragraph style carries it.
|
||||
s.execute("paraStyle.new", &json!({"name": "Pairs", "attrs": {"bunri_kinshi": true}})).unwrap();
|
||||
s.execute("paraStyle.apply", &json!({"name": "Pairs", "id": id})).unwrap();
|
||||
assert!(text(&s).para_at(0).bunri_kinshi && text(&s).para_at(1).bunri_kinshi);
|
||||
s.execute("paraStyle.new", &json!({"name": "Split", "attrs": {"bunri_kinshi": false}})).unwrap();
|
||||
s.execute("paraStyle.apply", &json!({"name": "Split", "id": id})).unwrap();
|
||||
assert!(!text(&s).para_at(0).bunri_kinshi && !text(&s).para_at(1).bunri_kinshi);
|
||||
}
|
||||
|
||||
/// Paragraph panel › Insert Kashidas: `text.setFormat {kashida}` sets it per paragraph in one
|
||||
/// undo step, the paragraph is saved with it only when it is not None and opens with it, an
|
||||
/// unknown value changes nothing, and paragraph styles carry it.
|
||||
|
||||
@@ -27,6 +27,7 @@ mod csv;
|
||||
mod library;
|
||||
|
||||
use csv::{MAX_GRAPH_CSV, dataset_export, dataset_import};
|
||||
pub(crate) use library::{give_svg_library, svg_library};
|
||||
use library::{load_library, save_library};
|
||||
|
||||
/// The longest variable or dataset name, in characters (names arrive from MCP and files).
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::{
|
||||
BTreeMap, BTreeSet, DataSet, DataValue, EngineError, MAX_NAME, Result, Session, Value, Variable, VariableKind, absolute_path, bad, json, named,
|
||||
read_file_head, str_param, variables_unlocked, write_file,
|
||||
BTreeMap, BTreeSet, DataSet, DataValue, Document, EngineError, MAX_NAME, NodeId, NodeKind, Result, Session, Value, Variable, VariableKind,
|
||||
Variables, absolute_path, bad, json, named, read_file_head, str_param, variables_unlocked, write_file,
|
||||
};
|
||||
|
||||
/// The namespace of a variable library's elements, the default namespace of its data sets' values,
|
||||
@@ -169,12 +171,20 @@ fn without_doctype(xml: &str, cmd: &str) -> Result<String> {
|
||||
}
|
||||
|
||||
/// A variable library as Load Variable Library reads it: the names of its text variables in the
|
||||
/// order it declares them, the name and `trait` of each variable of another kind, and its data
|
||||
/// sets with their text values.
|
||||
struct Library {
|
||||
/// order it declares them, the name and `trait` of each variable of another kind, its data sets
|
||||
/// with their text values, and the element id each text variable's `docRef` names.
|
||||
pub(crate) struct Library {
|
||||
text: Vec<String>,
|
||||
other: Vec<(String, String)>,
|
||||
datasets: Vec<DataSet>,
|
||||
refs: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl Library {
|
||||
/// The element ids the text variables' `docRef`s name.
|
||||
pub(crate) fn elements(&self) -> std::collections::HashSet<String> {
|
||||
self.refs.iter().map(|(_, e)| e.clone()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a variable library: XML whose `variableSets` element holds `variableSet` elements, each
|
||||
@@ -200,7 +210,7 @@ fn parse_library(xml: &str, cmd: &str) -> Result<Library> {
|
||||
return Err(bad(cmd, "the file holds no `variableSets` element"));
|
||||
};
|
||||
let sets: Vec<roxmltree::Node> = root.children().filter(|n| is(n, "variableSet")).collect();
|
||||
let (mut text, mut other, mut declared) = (Vec::new(), Vec::new(), BTreeSet::new());
|
||||
let (mut text, mut other, mut declared, mut refs) = (Vec::new(), Vec::new(), BTreeSet::new(), Vec::new());
|
||||
for var in sets.iter().flat_map(|s| s.children()).filter(|n| is(n, "variables")).flat_map(|v| v.children()).filter(|n| is(n, "variable")) {
|
||||
let name = named(var.attribute("varName"), "varName", cmd)?;
|
||||
if !declared.insert(name.clone()) {
|
||||
@@ -210,7 +220,14 @@ fn parse_library(xml: &str, cmd: &str) -> Result<Library> {
|
||||
return Err(bad(cmd, format!("the variable library declares more than {MAX_LIBRARY_VARIABLES} variables")));
|
||||
}
|
||||
match var.attribute("trait") {
|
||||
Some("textcontent") => text.push(name),
|
||||
Some("textcontent") => {
|
||||
// `docRef="id('…')"` names the element of the variable's bound object.
|
||||
let element = var.attribute("docRef").and_then(|r| r.trim().strip_prefix("id('")?.strip_suffix("')")).filter(|e| !e.is_empty());
|
||||
if let Some(element) = element {
|
||||
refs.push((name.clone(), element.to_string()));
|
||||
}
|
||||
text.push(name);
|
||||
}
|
||||
Some(t) => other.push((name, t.chars().take(MAX_NAME).collect())),
|
||||
None => return Err(bad(cmd, format!("variable `{name}` has no `trait`"))),
|
||||
}
|
||||
@@ -253,7 +270,152 @@ fn parse_library(xml: &str, cmd: &str) -> Result<Library> {
|
||||
}
|
||||
datasets.push(DataSet { name, values });
|
||||
}
|
||||
Ok(Library { text, other, datasets })
|
||||
Ok(Library { text, other, datasets, refs })
|
||||
}
|
||||
|
||||
/// The variable library an SVG carries (SVG Options › Include Variable Data), read as
|
||||
/// [`parse_library`] reads one, or `None` when the SVG holds no `variableSets` element. The
|
||||
/// element is read on its own, from its start tag to the end tag that closes it
|
||||
/// ([`variable_sets_range`]), with each reference to an
|
||||
/// entity whose name starts with `ns_` replaced by the value the text before the element declares
|
||||
/// for it (up to [`MAX_LIBRARY_ENTITIES`] of them, plain text of at most [`MAX_NAME`] bytes), as
|
||||
/// files name the library's namespaces with such entities. The `ns_vars`, `ns_custom` and
|
||||
/// `ns_flows` entities take the library's namespaces when the file doesn't declare them. An
|
||||
/// element larger than [`MAX_LIBRARY_BYTES`], before or after the replacements, is refused.
|
||||
pub(crate) fn svg_library(svg: &str) -> Option<Result<Library>> {
|
||||
const C: &str = "document.open";
|
||||
let (start, end) = variable_sets_range(svg)?;
|
||||
let read = || {
|
||||
let end = end.ok_or_else(|| bad(C, "the variable library declares no variables"))?;
|
||||
let element = svg.get(start..end).unwrap_or_default();
|
||||
if element.len() > MAX_LIBRARY_BYTES {
|
||||
return Err(bad(C, format!("the variable library is larger than {MAX_LIBRARY_BYTES} bytes")));
|
||||
}
|
||||
let mut entities: Vec<(String, String)> = Vec::new();
|
||||
for decl in svg.get(..start).unwrap_or_default().split("<!ENTITY").skip(1).take(MAX_LIBRARY_ENTITIES) {
|
||||
let decl = decl.trim_start();
|
||||
let Some((name, rest)) = decl.split_once(|c: char| c.is_ascii_whitespace()) else { continue };
|
||||
let rest = rest.trim_start();
|
||||
let Some(quote) = rest.chars().next().filter(|q| matches!(q, '"' | '\'')) else { continue };
|
||||
let Some(value) = rest.get(1..).and_then(|v| v.split(quote).next()) else { continue };
|
||||
if name.starts_with("ns_") && name.len() <= MAX_NAME && value.len() <= MAX_NAME && !value.contains(['<', '&', '%', '"']) {
|
||||
entities.push((format!("&{name};"), value.to_string()));
|
||||
}
|
||||
}
|
||||
for (name, value) in [("&ns_vars;", NS_VARIABLES), ("&ns_custom;", NS_CUSTOM), ("&ns_flows;", NS_FLOWS)] {
|
||||
if !entities.iter().any(|(n, _)| n == name) {
|
||||
entities.push((name.to_string(), value.to_string()));
|
||||
}
|
||||
}
|
||||
// Counted before anything is replaced, so a hostile file can't make a large text first.
|
||||
let grown =
|
||||
entities.iter().map(|(name, value)| element.matches(name.as_str()).count() * value.len().saturating_sub(name.len())).sum::<usize>();
|
||||
if element.len() + grown > MAX_LIBRARY_BYTES {
|
||||
return Err(bad(C, format!("the variable library's entities expand to more than {MAX_LIBRARY_BYTES} bytes")));
|
||||
}
|
||||
let mut element = element.to_string();
|
||||
for (name, value) in &entities {
|
||||
element = element.replace(name.as_str(), value);
|
||||
}
|
||||
parse_library(&format!("<svg>{element}</svg>"), C)
|
||||
};
|
||||
Some(read())
|
||||
}
|
||||
|
||||
/// Where the first `variableSets` element of `svg` that is markup starts, and where the end tag
|
||||
/// that closes it ends (`None` when no end tag does), or `None` when there is no such element.
|
||||
/// Comments, CDATA sections, processing instructions and the document type declaration are
|
||||
/// skipped, so text inside them that looks like the element is not taken for it.
|
||||
fn variable_sets_range(svg: &str) -> Option<(usize, Option<usize>)> {
|
||||
const NAME: &str = "variableSets";
|
||||
// The element's name ends at white space, `>` or `/`.
|
||||
let named = |rest: &str| rest.strip_prefix(NAME).is_some_and(|r| r.starts_with(|c: char| c.is_ascii_whitespace() || matches!(c, '>' | '/')));
|
||||
if !svg.contains("<variableSets") {
|
||||
return None;
|
||||
}
|
||||
let (mut at, mut start, mut depth) = (0, None, 0usize);
|
||||
while let Some(off) = svg.get(at..).and_then(|s| s.find('<')) {
|
||||
at += off;
|
||||
let rest = svg.get(at..).unwrap_or_default();
|
||||
// The end of a construct that holds text, or of the tag at `at`.
|
||||
let past = |close: &str| rest.find(close).map(|e| at + e + close.len());
|
||||
let next = if rest.starts_with("<!--") {
|
||||
past("-->")
|
||||
} else if rest.starts_with("<![CDATA[") {
|
||||
past("]]>")
|
||||
} else if rest.starts_with("<?") {
|
||||
past("?>")
|
||||
} else if rest.starts_with("<!DOCTYPE") {
|
||||
// An internal subset ends at `]`, then the declaration at `>`.
|
||||
match (rest.find('['), rest.find('>')) {
|
||||
(Some(open), Some(gt)) if open < gt => {
|
||||
rest.get(open..).and_then(|s| s.find(']')).and_then(|c| rest.get(open + c..)?.find('>').map(|g| at + open + c + g + 1))
|
||||
}
|
||||
(_, gt) => gt.map(|g| at + g + 1),
|
||||
}
|
||||
} else if let Some(close) = rest.strip_prefix("</") {
|
||||
let tag_end = past(">");
|
||||
if let Some(s) = start
|
||||
&& named(close)
|
||||
{
|
||||
depth = depth.saturating_sub(1);
|
||||
if depth == 0 {
|
||||
return Some((s, tag_end));
|
||||
}
|
||||
}
|
||||
tag_end
|
||||
} else {
|
||||
let tag_end = past(">");
|
||||
if named(rest.get(1..).unwrap_or_default()) {
|
||||
let empty = tag_end.and_then(|e| svg.get(..e)).is_some_and(|t| t.ends_with("/>"));
|
||||
if start.is_none() {
|
||||
start = Some(at);
|
||||
}
|
||||
if empty && depth == 0 {
|
||||
return Some((at, tag_end));
|
||||
}
|
||||
depth += usize::from(!empty);
|
||||
}
|
||||
tag_end
|
||||
};
|
||||
let Some(next) = next else { break };
|
||||
at = next;
|
||||
}
|
||||
start.map(|s| (s, None))
|
||||
}
|
||||
|
||||
/// Give `doc`, opened from an SVG, the variable data `lib` it carries ([`svg_library`]): the
|
||||
/// library's text variables, its data sets, no active data set, and each text variable bound to
|
||||
/// the type object made of the element its `docRef` names, or the one type object among the
|
||||
/// objects a group made of it holds (`found`: the object made of each element, by id). A `docRef`
|
||||
/// that names no type object, or one another variable is bound to already, binds nothing. The art
|
||||
/// stays as the file draws it. The warning about the variables of other kinds that were left out,
|
||||
/// if any.
|
||||
pub(crate) fn give_svg_library(doc: &mut Document, lib: Library, found: &HashMap<String, NodeId>) -> Option<String> {
|
||||
let mut vars =
|
||||
Variables { variables: lib.text.iter().map(|n| Variable { name: n.clone(), kind: VariableKind::Text }).collect(), ..Variables::default() };
|
||||
vars.datasets = lib.datasets;
|
||||
// A type object written with a paint of its own, or with fills and strokes of its appearance,
|
||||
// is a group around its text (and the appearance's paths).
|
||||
let text_of = |id: NodeId| match doc.node(id).map(|n| &n.kind) {
|
||||
Some(NodeKind::Text(_)) => Some(id),
|
||||
Some(NodeKind::Group { children, .. }) => {
|
||||
let mut texts = children.iter().filter(|c| matches!(c.kind, NodeKind::Text(_)));
|
||||
match (texts.next(), texts.next()) {
|
||||
(Some(one), None) => Some(one.id),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
for (name, element) in &lib.refs {
|
||||
let Some(id) = found.get(element).and_then(|id| text_of(*id)) else { continue };
|
||||
if !vars.bindings.contains_key(&id) {
|
||||
vars.bind(id, name);
|
||||
}
|
||||
}
|
||||
doc.variables = vars.into();
|
||||
left_out_warning(lib.other.iter().map(|(_, t)| t.as_str()))
|
||||
}
|
||||
|
||||
/// Load Variable Library: replace the document's variables and data sets with a variable library's
|
||||
|
||||
@@ -90,7 +90,7 @@ pub(crate) fn strip_replay_only(id: &str, p: &mut Value) {
|
||||
/// Queries that apply the edit the active tool is computing before they run, as edits do
|
||||
/// ([`Session::finish_tool_edit`]): a batch runs edits, Find Objects with `select`, Select All
|
||||
/// Instances and Lock Guides change the selection, and the proof views, the preferences and added
|
||||
/// fonts give the documents new revisions (they draw again, and type lays out again).
|
||||
/// fonts can give the documents new revisions (they draw again, and type lays out again).
|
||||
const WAIT_FOR_TOOL_EDIT: &[&str] = &[
|
||||
"script.run",
|
||||
"command.batch",
|
||||
|
||||
@@ -287,7 +287,8 @@ fn document_base(s: &Session) -> Value {
|
||||
"title": st.title(),
|
||||
// The File Info title (`file.info`): the title the file gave itself, else the document's name. `title` is the tab's name.
|
||||
"fileInfoTitle": d.info_title(),
|
||||
"path": st.path,
|
||||
// Null for a confined caller when the file is outside its read roots.
|
||||
"path": st.path.as_deref().filter(|p| crate::file_access::check_read(p).is_ok()),
|
||||
"dirty": st.is_dirty(),
|
||||
"revision": st.revision,
|
||||
"units": d.units.label(),
|
||||
|
||||
@@ -151,6 +151,8 @@ pub struct Session {
|
||||
pub symbol_libraries: cmd::symbollib::Libraries,
|
||||
/// User Defined and loaded brush libraries (Window → Brush Libraries); not saved.
|
||||
pub brush_libraries: cmd::brushlib::Libraries,
|
||||
/// The custom arrowheads of the arrowheads file (`stroke.arrowheads.*`); none without one.
|
||||
pub arrowheads: cmd::arrowheads::Arrowheads,
|
||||
/// The id each loaded library outside a request's read roots has been shown under, by its own id
|
||||
/// ([`cmd::swatchlib::concealed_library_ids`]), kept for the session.
|
||||
shown_library_ids: std::sync::Mutex<std::collections::BTreeMap<String, String>>,
|
||||
|
||||
@@ -80,6 +80,7 @@ impl Session {
|
||||
style_libraries: Default::default(),
|
||||
symbol_libraries: Default::default(),
|
||||
brush_libraries: Default::default(),
|
||||
arrowheads: Default::default(),
|
||||
shown_library_ids: Default::default(),
|
||||
libraries: Default::default(),
|
||||
pdf_preset_dir: None,
|
||||
|
||||
@@ -9,6 +9,7 @@ pub(super) mod adjust;
|
||||
mod ai_open_path;
|
||||
mod appearance;
|
||||
mod areafit;
|
||||
mod arrowheads;
|
||||
mod assets;
|
||||
mod assets_3d;
|
||||
mod attributes;
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
//! Custom arrowheads (`cmd/arrowheads.rs`): heads read from the arrowheads file's symbols, listed,
|
||||
//! set with `stroke.set`, reloaded when the file is saved, and kept with the documents that use
|
||||
//! them.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, Arrowhead, Document, Node, StrokeLayer, Symbol, SymbolRegistration};
|
||||
use vectorcraft_geom::{PathData, Point, SubPath};
|
||||
|
||||
use crate::cmd::arrowheads::{self, heads_of};
|
||||
use crate::*;
|
||||
|
||||
fn polygon(pts: &[(f64, f64)], ap: Appearance) -> Node {
|
||||
let pts: Vec<Point> = pts.iter().map(|&(x, y)| Point::new(x, y)).collect();
|
||||
Node::path(NodeId(0), PathData::single(SubPath::polyline(&pts, true)), ap)
|
||||
}
|
||||
|
||||
fn add_symbol(d: &mut Document, name: &str, art: Node) {
|
||||
let art = d.reid(&art);
|
||||
d.symbols.push(Symbol { name: name.into(), art: Arc::new(art), dynamic: false });
|
||||
}
|
||||
|
||||
fn filled() -> Appearance {
|
||||
Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 1.0)
|
||||
}
|
||||
|
||||
/// A folder of its own for test `name`, emptied.
|
||||
fn folder(name: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("vectorcraft-arrowheads-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn session_with_line() -> (Session, NodeId) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
let r = s.execute("shape.line", &json!({"x1": 10, "y1": 100, "x2": 150, "y2": 100})).unwrap();
|
||||
(s, NodeId(r["id"].as_u64().unwrap()))
|
||||
}
|
||||
|
||||
fn stroke(s: &Session, id: NodeId) -> StrokeLayer {
|
||||
s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().unwrap().clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_symbol_is_a_head_pointing_right_with_its_tip_at_the_registration_point() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
// 20 high, so a unit is 5 pt: tip on the right edge's middle.
|
||||
add_symbol(&mut d, "Wedge", polygon(&[(0.0, -10.0), (20.0, 0.0), (0.0, 10.0)], filled()));
|
||||
d.set_symbol_registration("Wedge", SymbolRegistration(5));
|
||||
// A stroke alone paints its outline: a 10 × 4 band, tip at its centre (the default).
|
||||
let mut line = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 4.0);
|
||||
line.stroke_mut().unwrap().cap = vectorcraft_doc::LineCap::Butt;
|
||||
let band = Node::path(NodeId(0), PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0)], false)), line);
|
||||
add_symbol(&mut d, "Band", band);
|
||||
// Neither filled nor stroked: no head.
|
||||
add_symbol(&mut d, "Ghost", polygon(&[(0.0, 0.0), (5.0, 0.0), (5.0, 5.0)], Appearance::default()));
|
||||
// Far longer than wide.
|
||||
add_symbol(&mut d, "Needle", polygon(&[(0.0, 0.0), (1000.0, 0.5), (0.0, 1.0)], filled()));
|
||||
// A fill at 0 % opacity paints nothing.
|
||||
let mut clear = filled();
|
||||
if let Some(vectorcraft_doc::AppearanceItem::Fill(f)) = clear.items.first_mut() {
|
||||
f.opacity = 0.0;
|
||||
}
|
||||
add_symbol(&mut d, "Clear", polygon(&[(0.0, 0.0), (5.0, 0.0), (5.0, 5.0)], clear));
|
||||
// More anchors than a symbol's art may have: skipped before its shapes are united.
|
||||
let dense: Vec<(f64, f64)> = (0..=arrowheads::MAX_ART_ANCHORS).map(|i| ((i as f64).cos(), (i as f64).sin())).collect();
|
||||
add_symbol(&mut d, "Dense", polygon(&dense, filled()));
|
||||
// A 20 × 4 bar clipped to a 4 × 4 square: a 4 × 4 head.
|
||||
let mut mask = polygon(&[(0.0, 0.0), (4.0, 0.0), (4.0, 4.0), (0.0, 4.0)], Appearance::default());
|
||||
if let NodeKind::Path { clipping, .. } = &mut mask.kind {
|
||||
*clipping = true;
|
||||
}
|
||||
let bar = polygon(&[(0.0, 0.0), (20.0, 0.0), (20.0, 4.0), (0.0, 4.0)], filled());
|
||||
add_symbol(&mut d, "Clipped", Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(mask), Arc::new(bar)], clip: true }));
|
||||
// An 8 × 4 bar grown by 2 pt on every side (Offset Path): 12 × 8, so 6 units long.
|
||||
let mut grown = filled();
|
||||
grown.effects.push(vectorcraft_doc::Effect { id: "path.offsetPath".into(), params: json!({"offset": 2}), visible: true });
|
||||
add_symbol(&mut d, "Grown", polygon(&[(0.0, 0.0), (8.0, 0.0), (8.0, 4.0), (0.0, 4.0)], grown));
|
||||
// The same effect on the fill alone.
|
||||
let mut fill_grown = filled();
|
||||
fill_grown.items[0].effects_mut().push(vectorcraft_doc::Effect { id: "path.offsetPath".into(), params: json!({"offset": 2}), visible: true });
|
||||
add_symbol(&mut d, "Fill Grown", polygon(&[(0.0, 0.0), (8.0, 0.0), (8.0, 4.0), (0.0, 4.0)], fill_grown));
|
||||
// And on a group holding the bar.
|
||||
let mut group = Node::group(NodeId(0), vec![Arc::new(polygon(&[(0.0, 0.0), (8.0, 0.0), (8.0, 4.0), (0.0, 4.0)], filled()))]);
|
||||
group.appearance.effects.push(vectorcraft_doc::Effect { id: "path.offsetPath".into(), params: json!({"offset": 2}), visible: true });
|
||||
add_symbol(&mut d, "Group Grown", group);
|
||||
// A clipping path stroked 2 pt: its stroke paints unclipped and does not widen the clip.
|
||||
let mut frame = polygon(&[(0.0, 0.0), (4.0, 0.0), (4.0, 4.0), (0.0, 4.0)], Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0));
|
||||
if let NodeKind::Path { clipping, .. } = &mut frame.kind {
|
||||
*clipping = true;
|
||||
}
|
||||
let bar = polygon(&[(0.0, 0.0), (20.0, 0.0), (20.0, 4.0), (0.0, 4.0)], filled());
|
||||
add_symbol(&mut d, "Framed", Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(frame), Arc::new(bar)], clip: true }));
|
||||
// A group's own fill paints its unpainted 8 × 4 member.
|
||||
let mut painted_group = Node::group(NodeId(0), vec![Arc::new(polygon(&[(0.0, 0.0), (8.0, 0.0), (8.0, 4.0), (0.0, 4.0)], Appearance::default()))]);
|
||||
painted_group.appearance = filled().into();
|
||||
add_symbol(&mut d, "Group Fill", painted_group);
|
||||
// A 10 × 4 band stroked with a gradient along it.
|
||||
let gradient = Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default())));
|
||||
let mut along = Appearance::basic(Paint::None, gradient, 4.0);
|
||||
if let Some(st) = along.stroke_mut() {
|
||||
(st.cap, st.gradient_mode) = (vectorcraft_doc::LineCap::Butt, vectorcraft_doc::StrokeGradientMode::Along);
|
||||
}
|
||||
let band = Node::path(NodeId(0), PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0)], false)), along);
|
||||
add_symbol(&mut d, "Gradient Band", band);
|
||||
// An 8 × 4 mesh of two patches whose right one is fully transparent: a 4 × 4 head.
|
||||
let corners = vectorcraft_doc::live::grid_points(vectorcraft_geom::Rect::new(0.0, 0.0, 8.0, 4.0), 1, 2);
|
||||
let points = corners
|
||||
.iter()
|
||||
.map(|&p| vectorcraft_doc::MeshPoint { p, color: Color::BLACK, opacity: if p.x > 1.0 { 0.0 } else { 1.0 }, handles: Default::default() })
|
||||
.collect();
|
||||
let mesh = vectorcraft_doc::GradientMesh { rows: 1, cols: 2, points };
|
||||
add_symbol(&mut d, "Half Mesh", Node::new(NodeId(0), NodeKind::Mesh(mesh)));
|
||||
// An effect on the file's layer is not part of any symbol.
|
||||
Arc::make_mut(&mut d.layers[0]).appearance.effects.push(vectorcraft_doc::Effect {
|
||||
id: "path.offsetPath".into(),
|
||||
params: json!({"offset": 5}),
|
||||
visible: true,
|
||||
});
|
||||
let read = heads_of(&d);
|
||||
let names: Vec<&str> = read.heads.iter().map(|h| h.name.as_str()).collect();
|
||||
assert_eq!(names, ["Wedge", "Band", "Clipped", "Grown", "Fill Grown", "Group Grown", "Framed", "Group Fill", "Gradient Band", "Half Mesh"]);
|
||||
let b = read.heads[9].path.bounds().unwrap();
|
||||
assert!((b.width() - 4.0).abs() < 1e-3 && (b.height() - 4.0).abs() < 1e-3, "the mesh's painted patch: {b:?}");
|
||||
let b = read.heads[7].path.bounds().unwrap();
|
||||
assert!((b.width() - 8.0).abs() < 1e-3 && (b.height() - 4.0).abs() < 1e-3, "the group's fill: {b:?}");
|
||||
let (g, plain) = (read.heads[8].path.bounds().unwrap(), read.heads[1].path.bounds().unwrap());
|
||||
let near = |a: f64, b: f64| (a - b).abs() < 1e-3;
|
||||
assert!(near(g.x0, plain.x0) && near(g.x1, plain.x1) && near(g.y0, plain.y0) && near(g.y1, plain.y1), "the gradient stroke's outline: {g:?}");
|
||||
let framed = read.heads[6].path.to_bezpath();
|
||||
let b = vectorcraft_geom::Shape::bounding_box(&framed);
|
||||
assert!((b.width() - 4.0).abs() < 1e-3 && (b.height() - 4.0).abs() < 1e-3, "6 × 6 with the stroke: {b:?}");
|
||||
assert_ne!(vectorcraft_geom::Shape::winding(&framed, Point::new(0.1, 0.1)), 0, "the clipped bar fills the middle");
|
||||
for h in &read.heads[3..6] {
|
||||
let b = h.path.bounds().unwrap();
|
||||
assert!((b.width() - 6.0).abs() < 1e-3 && (b.height() - 4.0).abs() < 1e-3, "{}: the effect's outline: {b:?}", h.name);
|
||||
}
|
||||
let skipped: Vec<&str> = read.skipped.iter().map(|(n, _)| n.as_str()).collect();
|
||||
assert_eq!(skipped, ["Ghost", "Needle", "Clear", "Dense"], "{:?}", read.skipped);
|
||||
let b = read.heads[2].path.bounds().unwrap();
|
||||
assert!((b.width() - 4.0).abs() < 1e-3 && (b.height() - 4.0).abs() < 1e-3, "clipped to the square: {b:?}");
|
||||
let b = read.heads[0].path.bounds().unwrap();
|
||||
assert!(b.x0.abs() < 1e-6 && (b.x1 - 4.0).abs() < 1e-6 && (b.y0 + 2.0).abs() < 1e-6 && (b.y1 - 2.0).abs() < 1e-6, "{b:?}");
|
||||
assert!((read.heads[0].reach() - 4.0f64.hypot(2.0)).abs() < 1e-6);
|
||||
let b = read.heads[1].path.bounds().unwrap();
|
||||
assert!((b.x0 + 5.0).abs() < 1e-3 && (b.x1 - 5.0).abs() < 1e-3 && (b.y0 + 2.0).abs() < 1e-3 && (b.y1 - 2.0).abs() < 1e-3, "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_arrowheads_file_is_made_opened_and_reloaded_when_saved() {
|
||||
let dir = folder("edit");
|
||||
let file = dir.join(arrowheads::FILE_NAME).to_string_lossy().to_string();
|
||||
let (mut s, id) = session_with_line();
|
||||
s.attach_arrowheads_file(file.clone());
|
||||
let list = s.execute("stroke.arrowheads.list", &json!({})).unwrap();
|
||||
assert_eq!((list["arrowheads"].as_array().unwrap().len(), list["file"].as_str()), (0, Some(file.as_str())));
|
||||
assert!(s.execute("stroke.set", &json!({"endArrow": {"custom": "Example Arrowhead"}})).is_err(), "no such head yet");
|
||||
let line_doc = s.doc().unwrap().uid;
|
||||
let r = s.execute("stroke.arrowheads.edit", &json!({})).unwrap();
|
||||
assert_eq!(r["created"], true);
|
||||
assert_eq!(s.doc().unwrap().path.as_deref(), Some(file.as_str()), "the file is the active document");
|
||||
let list = s.execute("stroke.arrowheads.list", &json!({})).unwrap();
|
||||
assert_eq!(list["arrowheads"][0]["name"], "Example Arrowhead");
|
||||
// A second head drawn in the file is listed once the file is saved.
|
||||
let st = s.doc_mut().unwrap();
|
||||
let d = Arc::make_mut(&mut st.doc);
|
||||
add_symbol(d, "Square", polygon(&[(0.0, 0.0), (4.0, 0.0), (4.0, 4.0), (0.0, 4.0)], filled()));
|
||||
assert_eq!(s.execute("stroke.arrowheads.list", &json!({})).unwrap()["arrowheads"].as_array().unwrap().len(), 1);
|
||||
s.execute("document.save", &json!({})).unwrap();
|
||||
let list = s.execute("stroke.arrowheads.list", &json!({})).unwrap();
|
||||
let names: Vec<&str> = list["arrowheads"].as_array().unwrap().iter().filter_map(|h| h["name"].as_str()).collect();
|
||||
assert_eq!(names, ["Example Arrowhead", "Square"]);
|
||||
// Editing again from the line's document brings the open file to the front, without making
|
||||
// it anew or opening it twice.
|
||||
s.execute("document.activate", &json!({"index": 0})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().uid, line_doc);
|
||||
assert_eq!(s.execute("stroke.arrowheads.edit", &json!({})).unwrap()["created"], false);
|
||||
assert_eq!((s.documents().len(), s.doc().unwrap().path.as_deref()), (2, Some(file.as_str())));
|
||||
// The line takes the head with its art.
|
||||
s.execute("document.activate", &json!({"index": 0})).unwrap();
|
||||
s.execute("stroke.set", &json!({"ids": [id.0], "endArrow": {"custom": "Square"}})).unwrap();
|
||||
let Some(Arrowhead::Custom(c)) = stroke(&s, id).end_arrow else { panic!("a custom head") };
|
||||
assert_eq!(c.name, "Square");
|
||||
assert!(s.execute("stroke.set", &json!({"endArrow": {"custom": 3}})).is_err());
|
||||
assert!(s.execute("stroke.set", &json!({"endArrow": {"custom": "Circle"}})).is_err());
|
||||
// A head given with its art is refused when the art could not be drawn.
|
||||
let mut far = serde_json::to_value(Arrowhead::Custom(c.clone())).unwrap();
|
||||
far["Custom"]["path"] = serde_json::to_value(PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(1e6, 1.0)], true))).unwrap();
|
||||
assert!(s.execute("stroke.set", &json!({"ids": [id.0], "endArrow": far})).is_err());
|
||||
let mut empty = serde_json::to_value(Arrowhead::Custom(c)).unwrap();
|
||||
empty["Custom"]["path"] = json!({"subpaths": []});
|
||||
assert!(s.execute("stroke.set", &json!({"ids": [id.0], "endArrow": empty})).is_err());
|
||||
// One anchor, or anchors on one line, fill nothing.
|
||||
for pts in [vec![Point::new(1.0, 1.0)], vec![Point::new(0.0, 0.0), Point::new(2.0, 0.0), Point::new(4.0, 0.0)]] {
|
||||
let mut flat =
|
||||
serde_json::to_value(Arrowhead::Custom(Arc::new(vectorcraft_doc::CustomArrowhead::new("Flat".into(), PathData::default())))).unwrap();
|
||||
flat["Custom"]["path"] = serde_json::to_value(PathData::single(SubPath::polyline(&pts, false))).unwrap();
|
||||
assert!(s.execute("stroke.set", &json!({"ids": [id.0], "endArrow": flat})).is_err(), "{pts:?}");
|
||||
}
|
||||
// A lone anchor draws no segment, whatever its handles.
|
||||
let mut lone = vectorcraft_geom::Anchor::corner(Point::new(1.0, 0.0));
|
||||
(lone.h_in, lone.h_out) = (Point::new(0.0, 1.0), Point::new(2.0, -1.0));
|
||||
let mut handles =
|
||||
serde_json::to_value(Arrowhead::Custom(Arc::new(vectorcraft_doc::CustomArrowhead::new("Lone".into(), PathData::default())))).unwrap();
|
||||
handles["Custom"]["path"] = serde_json::to_value(PathData::single(SubPath::new(vec![lone], true))).unwrap();
|
||||
assert!(s.execute("stroke.set", &json!({"ids": [id.0], "endArrow": handles.clone()})).is_err(), "a lone anchor");
|
||||
// Two equal squares winding opposite ways cancel out.
|
||||
let sq = [Point::new(0.0, -1.0), Point::new(2.0, -1.0), Point::new(2.0, 1.0), Point::new(0.0, 1.0)];
|
||||
let back: Vec<Point> = sq.iter().rev().copied().collect();
|
||||
let cancel = PathData::new(vec![SubPath::polyline(&sq, true), SubPath::polyline(&back, true)]);
|
||||
handles["Custom"]["path"] = serde_json::to_value(cancel).unwrap();
|
||||
assert!(s.execute("stroke.set", &json!({"ids": [id.0], "endArrow": handles})).is_err(), "opposite squares");
|
||||
// A bow tie's lobes wind opposite ways, so its signed area is 0, and it still fills both.
|
||||
let tie = [Point::new(0.0, -1.0), Point::new(2.0, 1.0), Point::new(2.0, -1.0), Point::new(0.0, 1.0)];
|
||||
let mut bow =
|
||||
serde_json::to_value(Arrowhead::Custom(Arc::new(vectorcraft_doc::CustomArrowhead::new("Bow".into(), PathData::default())))).unwrap();
|
||||
bow["Custom"]["path"] = serde_json::to_value(PathData::single(SubPath::polyline(&tie, true))).unwrap();
|
||||
assert!(matches!(stroke(&s, id).end_arrow, Some(Arrowhead::Custom(h)) if h.name == "Square"), "the head stays as it was");
|
||||
s.execute("stroke.set", &json!({"ids": [id.0], "endArrow": bow.clone()})).unwrap();
|
||||
assert!(matches!(stroke(&s, id).end_arrow, Some(Arrowhead::Custom(h)) if h.name == "Bow"));
|
||||
// So does a very thin bar.
|
||||
let bar = [Point::new(0.0, 0.0), Point::new(4.0, 0.0), Point::new(4.0, 0.001), Point::new(0.0, 0.001)];
|
||||
bow["Custom"]["path"] = serde_json::to_value(PathData::single(SubPath::polyline(&bar, true))).unwrap();
|
||||
s.execute("stroke.set", &json!({"ids": [id.0], "endArrow": bow})).unwrap();
|
||||
let Some(Arrowhead::Custom(h)) = stroke(&s, id).end_arrow else { panic!("a custom head") };
|
||||
assert_eq!(h.path.anchor_count(), 4, "the thin bar");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_keeps_its_custom_heads_without_the_arrowheads_file() {
|
||||
let (mut s, id) = session_with_line();
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
add_symbol(&mut d, "Wedge", polygon(&[(0.0, -10.0), (20.0, 0.0), (0.0, 10.0)], filled()));
|
||||
let head = Arrowhead::Custom(Arc::new(heads_of(&d).heads.remove(0)));
|
||||
// As document.inspect reports it, so it reads back unchanged.
|
||||
s.execute("stroke.set", &json!({"startArrow": head, "endArrow": "Triangle"})).unwrap();
|
||||
assert_eq!(stroke(&s, id).start_arrow, Some(head.clone()));
|
||||
let inspect = s.execute("document.inspect", &json!({})).unwrap().to_string();
|
||||
assert!(inspect.contains("\"Wedge\""), "inspect names the head");
|
||||
let ink = |s: &Session| {
|
||||
vectorcraft_render::Renderer::new().render(&s.doc().unwrap().doc, 200, 200, vectorcraft_geom::Affine::IDENTITY, &Default::default())
|
||||
};
|
||||
let with_head = ink(&s);
|
||||
// Saved and opened in a session that has no arrowheads file: the head is drawn the same.
|
||||
let saved = s.execute("document.export", &json!({"format": "vectorcraft"})).unwrap()["dataBase64"].clone();
|
||||
let mut o = Session::new();
|
||||
o.execute("document.open", &json!({"name": "a.vectorcraft", "dataBase64": saved})).unwrap();
|
||||
assert_eq!(stroke(&o, id).start_arrow, Some(head));
|
||||
assert_eq!(ink(&o).pixels, with_head.pixels);
|
||||
// SVG and PDF write it as they write the built-in heads.
|
||||
let svg = |s: &mut Session| s.execute("document.export", &json!({"format": "svg"})).unwrap()["dataBase64"].as_str().unwrap().len();
|
||||
let headed = svg(&mut o);
|
||||
o.execute("stroke.set", &json!({"ids": [id.0], "startArrow": null})).unwrap();
|
||||
assert!(headed > svg(&mut o), "the head is in the SVG");
|
||||
// Undo puts it back.
|
||||
o.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(matches!(stroke(&o, id).start_arrow, Some(Arrowhead::Custom(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unreadable_arrowheads_file_lists_no_heads_and_says_why() {
|
||||
let dir = folder("bad");
|
||||
let file = dir.join(arrowheads::FILE_NAME);
|
||||
std::fs::write(&file, b"not a drawing").unwrap();
|
||||
let mut s = Session::new();
|
||||
s.attach_arrowheads_file(file.to_string_lossy().to_string());
|
||||
let list = s.execute("stroke.arrowheads.list", &json!({})).unwrap();
|
||||
assert!(list["arrowheads"].as_array().unwrap().is_empty());
|
||||
assert!(list["error"].is_string(), "{list}");
|
||||
assert!(s.execute("stroke.arrowheads.reload", &json!({})).is_err());
|
||||
// A folder in its place is not read either.
|
||||
std::fs::remove_file(&file).unwrap();
|
||||
std::fs::create_dir(&file).unwrap();
|
||||
assert!(s.execute("stroke.arrowheads.reload", &json!({})).is_err());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
// Without a file (the web, runs without preferences) there is nothing to edit.
|
||||
let mut bare = Session::new();
|
||||
assert!(bare.execute("stroke.arrowheads.edit", &json!({})).is_err());
|
||||
assert_eq!(bare.execute("stroke.arrowheads.list", &json!({})).unwrap()["file"], serde_json::Value::Null);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_heads_listed_after_a_save_are_read_from_the_file_and_editing_keeps_it() {
|
||||
let dir = folder("saved");
|
||||
let file = dir.join(arrowheads::FILE_NAME).to_string_lossy().to_string();
|
||||
let (mut s, _) = session_with_line();
|
||||
s.attach_arrowheads_file(file.clone());
|
||||
assert_eq!(s.execute("stroke.arrowheads.edit", &json!({})).unwrap()["created"], true);
|
||||
// A speck that saving rounds to a point is a head only before the save.
|
||||
let st = s.doc_mut().unwrap();
|
||||
add_symbol(Arc::make_mut(&mut st.doc), "Speck", polygon(&[(0.0, 0.0), (0.3, 0.1), (0.0, 0.2)], filled()));
|
||||
assert!(heads_of(&s.doc().unwrap().doc).heads.iter().any(|h| h.name == "Speck"), "a head before rounding");
|
||||
s.execute("document.save", &json!({"precision": 0})).unwrap();
|
||||
let list = s.execute("stroke.arrowheads.list", &json!({})).unwrap();
|
||||
let names: Vec<&str> = list["arrowheads"].as_array().unwrap().iter().filter_map(|h| h["name"].as_str()).collect();
|
||||
assert_eq!(names, ["Example Arrowhead"]);
|
||||
assert_eq!(list["skipped"][0]["name"], "Speck", "{list}");
|
||||
// Editing again leaves the file as it is.
|
||||
let saved = std::fs::read(&file).unwrap();
|
||||
s.execute("document.activate", &json!({"index": 0})).unwrap();
|
||||
assert_eq!(s.execute("stroke.arrowheads.edit", &json!({})).unwrap()["created"], false);
|
||||
assert_eq!(std::fs::read(&file).unwrap(), saved, "the file is left as it was");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_arrowheads_file_that_unpacks_past_the_size_limit_is_not_read() {
|
||||
let dir = folder("unpacked");
|
||||
let file = dir.join(arrowheads::FILE_NAME);
|
||||
let mut d = arrowheads::starter();
|
||||
d.title = "a".repeat(arrowheads::MAX_FILE_BYTES as usize);
|
||||
let bytes = vectorcraft_format::save_with(&d, &vectorcraft_format::SaveOptions { compress: true, ..Default::default() }).unwrap();
|
||||
assert!((bytes.len() as u64) < arrowheads::MAX_FILE_BYTES / 16, "compressed small: {}", bytes.len());
|
||||
std::fs::write(&file, &bytes).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.attach_arrowheads_file(file.to_string_lossy().to_string());
|
||||
let list = s.execute("stroke.arrowheads.list", &json!({})).unwrap();
|
||||
assert!(list["arrowheads"].as_array().unwrap().is_empty(), "{}", list["arrowheads"]);
|
||||
assert!(list["error"].is_string(), "{}", list["error"]);
|
||||
// The same head in a file of an ordinary size is read.
|
||||
std::fs::write(&file, vectorcraft_format::save_file(&arrowheads::starter())).unwrap();
|
||||
assert_eq!(s.execute("stroke.arrowheads.reload", &json!({})).unwrap()["count"], 1);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_symbol_whose_shapes_cannot_be_combined_is_skipped() {
|
||||
let wedge = || Arc::new(polygon(&[(0.0, -10.0), (20.0, 0.0), (0.0, 10.0)], filled()));
|
||||
// Too far out for the shape operations to measure: the union would leave it out.
|
||||
let far = || Arc::new(polygon(&[(0.0, 0.0), (1e200, 0.0), (0.0, 1e200)], filled()));
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
add_symbol(&mut d, "Wedge", (*wedge()).clone());
|
||||
add_symbol(&mut d, "Broken", Node::group(NodeId(0), vec![wedge(), far()]));
|
||||
// The same as clipped art.
|
||||
let mut mask = polygon(&[(0.0, -10.0), (20.0, -10.0), (20.0, 10.0), (0.0, 10.0)], Appearance::default());
|
||||
if let NodeKind::Path { clipping, .. } = &mut mask.kind {
|
||||
*clipping = true;
|
||||
}
|
||||
add_symbol(&mut d, "Broken Clip", Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(mask), wedge(), far()], clip: true }));
|
||||
let read = heads_of(&d);
|
||||
let names: Vec<&str> = read.heads.iter().map(|h| h.name.as_str()).collect();
|
||||
assert_eq!(names, ["Wedge"]);
|
||||
let skipped: Vec<&str> = read.skipped.iter().map(|(n, _)| n.as_str()).collect();
|
||||
assert_eq!(skipped, ["Broken", "Broken Clip"], "{:?}", read.skipped);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_group_used_as_the_clipping_path_adds_none_of_its_own_fills() {
|
||||
// A group holding a filled 20 × 4 bar clips a filled 4 × 4 square: the group paints nothing as
|
||||
// the clipping path, so the head is the square alone.
|
||||
let bar = polygon(&[(0.0, 0.0), (20.0, 0.0), (20.0, 4.0), (0.0, 4.0)], filled());
|
||||
let mask = Node::group(NodeId(0), vec![Arc::new(bar)]);
|
||||
let square = polygon(&[(0.0, 0.0), (4.0, 0.0), (4.0, 4.0), (0.0, 4.0)], filled());
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
add_symbol(&mut d, "Group Clip", Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(mask), Arc::new(square)], clip: true }));
|
||||
let read = heads_of(&d);
|
||||
assert_eq!(read.heads.len(), 1, "{:?}", read.skipped);
|
||||
let b = read.heads[0].path.bounds().unwrap();
|
||||
assert!((b.width() - 4.0).abs() < 1e-3 && (b.height() - 4.0).abs() < 1e-3, "the square alone: {b:?}");
|
||||
}
|
||||
@@ -793,8 +793,9 @@ fn shape_builder_applies_a_merge_before_what_comes_next() {
|
||||
let labels: Vec<&str> = s.doc().unwrap().history.undo.iter().rev().take(2).map(|e| e.label.as_str()).collect();
|
||||
assert_eq!(labels, ["Move", "Shape Builder"]);
|
||||
}),
|
||||
// The proof views redraw every document (a new revision); they are left off, as the
|
||||
// other tests of the process-wide proof view expect.
|
||||
// The proof views apply the merge first by command id (`WAIT_FOR_TOOL_EDIT`), also when
|
||||
// the view does not change and no document gets a new revision. They are left off, as
|
||||
// the other tests of the process-wide proof view expect.
|
||||
("Separations Preview", |s, _, _| {
|
||||
s.execute("view.separationsPreview", &json!({"on": false})).unwrap();
|
||||
}),
|
||||
@@ -807,6 +808,13 @@ fn shape_builder_applies_a_merge_before_what_comes_next() {
|
||||
("Proof Setup", |s, _, _| {
|
||||
s.execute("view.proofSetup", &json!({})).unwrap();
|
||||
}),
|
||||
("a proof view that changes", |s, _, _| {
|
||||
let _v = crate::tests_colormgmt::DefaultView::new();
|
||||
s.execute("view.proofColors", &json!({"on": true})).unwrap();
|
||||
let on = s.doc().unwrap().revision;
|
||||
s.execute("view.proofColors", &json!({"on": false})).unwrap();
|
||||
assert!(s.doc().unwrap().revision > on, "the view changes and the documents get new revisions");
|
||||
}),
|
||||
("a preference", |s, _, _| {
|
||||
let every = s.prefs.gridline_every;
|
||||
s.execute("prefs.set", &json!({"key": "gridlineEvery", "value": every + 10.0})).unwrap();
|
||||
|
||||
@@ -455,6 +455,7 @@ fn overprint_black_multiplies_in_preview() {
|
||||
#[test]
|
||||
fn view_toggles_redraw_without_dirtying() {
|
||||
let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _v = DefaultView::new();
|
||||
let mut s = session();
|
||||
let rev = s.doc().unwrap().revision;
|
||||
let r = s.execute("view.proofColors", &json!({"on": true})).unwrap();
|
||||
@@ -475,6 +476,101 @@ fn view_toggles_redraw_without_dirtying() {
|
||||
s.execute("view.overprintPreview", &json!({"on": false})).unwrap();
|
||||
}
|
||||
|
||||
/// Sets the default proof view when made and again when dropped, also after a failed assertion,
|
||||
/// so the next test starts from the default view.
|
||||
pub(crate) struct DefaultView;
|
||||
|
||||
impl DefaultView {
|
||||
pub(crate) fn new() -> Self {
|
||||
vectorcraft_render::proof::set_view(Default::default());
|
||||
DefaultView
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DefaultView {
|
||||
fn drop(&mut self) {
|
||||
vectorcraft_render::proof::set_view(Default::default());
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `cmd` with `p` twice from the default proof view: the first run changes the view and
|
||||
/// redraws, and the second asks for the view that is already showing and leaves the revision alone.
|
||||
fn repeat_keeps_revision(cmd: &str, p: Value) {
|
||||
let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _v = DefaultView::new();
|
||||
let mut s = session();
|
||||
let rev = s.doc().unwrap().revision;
|
||||
s.execute(cmd, &p).unwrap();
|
||||
let changed = s.doc().unwrap().revision;
|
||||
assert!(changed > rev, "{cmd} {p}: the first run changes the view");
|
||||
s.execute(cmd, &p).unwrap();
|
||||
assert_eq!(s.doc().unwrap().revision, changed, "{cmd} {p}: the same view again");
|
||||
}
|
||||
|
||||
/// Runs `cmd` with `p` once from the default proof view, which `p` leaves as it is.
|
||||
fn default_view_keeps_revision(cmd: &str, p: Value) {
|
||||
let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _v = DefaultView::new();
|
||||
let mut s = session();
|
||||
let rev = s.doc().unwrap().revision;
|
||||
s.execute(cmd, &p).unwrap();
|
||||
assert_eq!(s.doc().unwrap().revision, rev, "{cmd} {p}");
|
||||
assert_eq!(vectorcraft_render::proof::view(), Default::default(), "{cmd} {p}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proof_setup_with_nothing_changed_keeps_the_revision() {
|
||||
repeat_keeps_revision("view.proofSetup", json!({"target": "deuteranopia"}));
|
||||
repeat_keeps_revision("view.proofSetup", json!({"simulatePaper": true, "proof": true}));
|
||||
default_view_keeps_revision("view.proofSetup", json!({}));
|
||||
default_view_keeps_revision("view.proofSetup", json!({"target": "workingCmyk", "proof": false}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proof_colors_with_nothing_changed_keeps_the_revision() {
|
||||
repeat_keeps_revision("view.proofColors", json!({"on": true}));
|
||||
default_view_keeps_revision("view.proofColors", json!({"on": false}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overprint_preview_with_nothing_changed_keeps_the_revision() {
|
||||
repeat_keeps_revision("view.overprintPreview", json!({"on": true}));
|
||||
default_view_keeps_revision("view.overprintPreview", json!({"on": false}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separations_preview_with_nothing_changed_keeps_the_revision() {
|
||||
repeat_keeps_revision("view.separationsPreview", json!({"on": true}));
|
||||
repeat_keeps_revision("view.separationsPreview", json!({"only": "Cyan"}));
|
||||
repeat_keeps_revision("view.separationsPreview", json!({"plates": ["Magenta", "Black"]}));
|
||||
default_view_keeps_revision("view.separationsPreview", json!({"on": false}));
|
||||
}
|
||||
|
||||
/// Separations Preview's visible plates are a set: the same plates in another order, or with one
|
||||
/// named twice, leave the revision alone, and a plate named twice hides with one `toggle`.
|
||||
#[test]
|
||||
fn separations_preview_compares_the_visible_plates_as_a_set() {
|
||||
let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _v = DefaultView::new();
|
||||
let mut s = session();
|
||||
let sep = |s: &mut Session, p: Value| {
|
||||
s.execute("view.separationsPreview", &p).unwrap();
|
||||
s.doc().unwrap().revision
|
||||
};
|
||||
let shown = sep(&mut s, json!({"plates": ["Magenta", "Black"]}));
|
||||
assert_eq!(sep(&mut s, json!({"plates": ["Black", "Magenta"]})), shown, "another order");
|
||||
assert_eq!(sep(&mut s, json!({"plates": ["Magenta", "Black", "Black"]})), shown, "a plate named twice");
|
||||
// Cyan hidden and shown again goes to the end of the list; every plate is shown.
|
||||
sep(&mut s, json!({"on": false}));
|
||||
sep(&mut s, json!({"on": true}));
|
||||
sep(&mut s, json!({"toggle": "Cyan"}));
|
||||
let all = sep(&mut s, json!({"toggle": "Cyan"}));
|
||||
assert_eq!(sep(&mut s, json!({"plates": ["Cyan", "Magenta", "Yellow", "Black"]})), all, "every plate in plate order");
|
||||
sep(&mut s, json!({"plates": ["Cyan", "Cyan"]}));
|
||||
let r = s.execute("view.separationsPreview", &json!({"toggle": "Cyan"})).unwrap();
|
||||
assert!(r["plates"].as_array().unwrap().iter().all(|p| p["visible"] == false), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proof_setup_simulates_tritanopia() {
|
||||
let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
|
||||
|
||||
@@ -98,6 +98,69 @@ fn export_writes_the_sheet_and_rasterized_art() {
|
||||
assert!(s.execute("css.export", &json!({})).is_err(), "nothing selected: nothing to export");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_refuses_a_scope_or_path_that_is_not_a_string() {
|
||||
let mut s = session();
|
||||
run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 40}));
|
||||
run(&mut s, "shape.ellipse", json!({"x": 60, "y": 0, "width": 40, "height": 40}));
|
||||
let refused = |s: &mut Session, p: Value, key: &str| {
|
||||
let e = s.execute("css.export", &p).expect_err(&p.to_string()).to_string();
|
||||
assert_eq!(e, format!("invalid parameters for `css.export`: `{key}` must be a string"), "{p}");
|
||||
};
|
||||
for scope in [json!(5), json!(true), json!(["all"]), Value::Null] {
|
||||
refused(&mut s, json!({"scope": scope}), "scope");
|
||||
}
|
||||
for path in [json!(5), Value::Null] {
|
||||
refused(&mut s, json!({"path": path}), "path");
|
||||
refused(&mut s, json!({"path": path, "scope": "all"}), "path");
|
||||
}
|
||||
// Left out, each keeps its default: the selection (the ellipse), and the CSS as data.
|
||||
let r = run(&mut s, "css.export", json!({}));
|
||||
assert_eq!((r["rules"].clone(), r["data"].is_string()), (json!(1), true), "{r}");
|
||||
assert_eq!(run(&mut s, "css.export", json!({"scope": "all"}))["rules"], json!(2));
|
||||
let e = s.execute("css.export", &json!({"scope": "page"})).unwrap_err().to_string();
|
||||
assert!(e.contains("scope must be selection or all (got page)"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_object_refuses_malformed_ids_it_does_not_use() {
|
||||
let mut s = session();
|
||||
let rect = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 40}))["id"].as_u64().unwrap();
|
||||
run(&mut s, "shape.ellipse", json!({"x": 60, "y": 0, "width": 40, "height": 40}));
|
||||
for (cmd, base) in [("css.generate", json!({})), ("css.export", json!({"scope": "all"}))] {
|
||||
for (key, value, says) in [
|
||||
("ids", json!("abc"), "`ids` must be an array of object ids, not \"abc\""),
|
||||
("ids", json!([1.5]), "object ids are non-negative integers, not 1.5"),
|
||||
("id", json!(-1), "object ids are non-negative integers, not -1"),
|
||||
("id", json!("$step"), "`id` is the step reference \"$step\""),
|
||||
] {
|
||||
let mut p = base.clone();
|
||||
p[key] = value;
|
||||
let e = s.execute(cmd, &p).expect_err(&format!("{cmd} {p}")).to_string();
|
||||
assert!(e.contains(says), "{cmd} {p}: {e}");
|
||||
}
|
||||
// Well-formed ids are still not used: every object.
|
||||
let mut p = base.clone();
|
||||
p["ids"] = json!([rect]);
|
||||
let rules = run(&mut s, cmd, p)["rules"].clone();
|
||||
let count = rules.as_array().map_or(rules.as_u64(), |r| Some(r.len() as u64));
|
||||
assert_eq!(count, Some(2), "{cmd}: {rules}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn export_with_a_scope_that_is_not_a_string_writes_nothing() {
|
||||
let mut s = session();
|
||||
run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 40}));
|
||||
let dir = std::env::temp_dir().join(format!("vc-css-scope-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("site.css");
|
||||
assert!(s.execute("css.export", &json!({"path": path.to_string_lossy(), "scope": 5})).is_err());
|
||||
assert!(!path.exists(), "no file for a refused scope");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn export_to_a_path_puts_the_pictures_next_to_the_sheet() {
|
||||
|
||||
@@ -291,6 +291,20 @@ fn file_new_and_artboard_new_take_the_same_largest_side() {
|
||||
assert!(err.contains(in_meters), "{p}: {err}");
|
||||
}
|
||||
assert_eq!(boards(&s).len(), 2);
|
||||
|
||||
// A side below 1 pt is kept as given, as file.new keeps it, and a size taken from such an artboard keeps it too.
|
||||
let mut s = new_doc(json!({"width": 0.5, "height": 0.25}));
|
||||
assert_eq!(boards(&s), [Rect::new(0.0, 0.0, 0.5, 0.25)]);
|
||||
s.execute("artboard.new", &json!({"x": 0, "y": 10, "width": 0.5, "height": 0.75})).unwrap();
|
||||
s.execute("artboard.new", &json!({"x": 0, "y": 20})).unwrap();
|
||||
assert_eq!(boards(&s)[1..], [Rect::new(0.0, 10.0, 0.5, 10.75), Rect::new(0.0, 20.0, 0.5, 20.75)]);
|
||||
// artboard.setProps keeps such sides when it changes something else, on a locked artboard too,
|
||||
// and still raises a supplied side to 1 pt.
|
||||
s.execute("artboard.setProps", &json!({"index": 1, "name": "Small"})).unwrap();
|
||||
s.execute("artboard.setProps", &json!({"index": 2, "locked": true})).unwrap();
|
||||
s.execute("artboard.setProps", &json!({"index": 2, "name": "Small locked"})).unwrap();
|
||||
s.execute("artboard.setProps", &json!({"index": 0, "height": 0.5})).unwrap();
|
||||
assert_eq!(boards(&s), [Rect::new(0.0, 0.0, 0.5, 1.0), Rect::new(0.0, 10.0, 0.5, 10.75), Rect::new(0.0, 20.0, 0.5, 20.75)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -191,6 +191,27 @@ fn a_fitted_gradient_turned_by_a_transform_expands_as_the_canvas_draws_it() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A Transform effect after a drop shadow, on the object or on the fill, scales and turns the
|
||||
/// shadow on the canvas; Object › Expand of the gradient fill keeps the shadow the canvas drew.
|
||||
#[test]
|
||||
fn a_shadow_before_a_transform_expands_as_the_canvas_draws_it() {
|
||||
for item in [json!(null), json!(0)] {
|
||||
let mut s = session();
|
||||
let id = gradient_rect(&mut s, Rect::new(80.0, 80.0, 120.0, 120.0), white_black(), false);
|
||||
for (effect, params) in [
|
||||
("stylize.dropShadow", json!({"x": 10, "y": 0, "blur": 0, "opacity": 100})),
|
||||
("distort.transform", json!({"scaleH": 200, "scaleV": 200, "rotate": 90})),
|
||||
] {
|
||||
run(&mut s, "effect.apply", json!({"effect": effect, "params": params, "ids": [id.0], "item": item}));
|
||||
}
|
||||
let before = render(&s);
|
||||
run(&mut s, "object.expand", json!({"object": false, "stroke": false, "steps": 64}));
|
||||
let after = render(&s);
|
||||
let diff = differing(&before, &after, 64);
|
||||
assert_eq!(diff, 0, "item {item}: {diff} pixels differ");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_placed_gradient_moved_by_a_warp_expands_as_the_canvas_draws_it() {
|
||||
// The object's own effect, and the fill's (the first appearance item).
|
||||
|
||||
@@ -308,6 +308,30 @@ fn the_outline_of_a_reshaped_stroke_keeps_its_drop_shadow() {
|
||||
assert_eq!(differing(&before, &render(&s), 64), 0, "the shadowed outline draws as the stroke did");
|
||||
}
|
||||
|
||||
/// A Transform effect after a drop shadow, on the object or on the stroke (brushed or not), turns
|
||||
/// and scales the shadow on the canvas; outlining the stroke keeps the shadow the canvas drew.
|
||||
#[test]
|
||||
fn a_shadow_before_a_transform_outlines_as_the_canvas_draws_it() {
|
||||
let effects = [
|
||||
("stylize.dropShadow", json!({"x": 10, "y": 0, "blur": 0, "opacity": 100})),
|
||||
("distort.transform", json!({"scaleH": 50, "scaleV": 50, "rotate": 90})),
|
||||
];
|
||||
for (item, brush) in [(Value::Null, false), (json!(1), false), (json!(1), true)] {
|
||||
// Flatten Transparency draws a brushed stroke's drop shadow differently with or without a
|
||||
// Transform effect, so the brushed stroke is checked with the other two commands.
|
||||
for (command, p) in outlining().into_iter().filter(|(command, _)| !(brush && *command == "object.flattenTransparency")) {
|
||||
let mut s = session();
|
||||
square_with(&mut s, json!({"color": "#ff0000"}), item.clone(), &effects);
|
||||
if brush {
|
||||
s.execute("brush.apply", &json!({"name": "Tapered Stroke"})).unwrap();
|
||||
}
|
||||
let before = render(&s);
|
||||
outlined_by(&mut s, command, &p);
|
||||
assert_eq!(differing(&before, &render(&s), 64), 0, "{command}, item {item}, brush {brush}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The brush art of a brushed stroke with a geometry effect follows the path the effect makes.
|
||||
#[test]
|
||||
fn a_brush_draws_along_the_path_the_stroke_effects_make() {
|
||||
|
||||
@@ -2109,7 +2109,7 @@ fn empty_artboard_checks_match_the_visual_bounds_on_varied_strokes() {
|
||||
st.cap = [LineCap::Butt, LineCap::Round, LineCap::Square][next(3) as usize];
|
||||
st.align = [StrokeAlign::Center, StrokeAlign::Inside, StrokeAlign::Outside][next(3) as usize];
|
||||
st.miter_limit = 1.0 + next(20) as f64;
|
||||
let mut head = || (next(3) == 0).then(|| Arrowhead::ALL[next(Arrowhead::ALL.len() as u64) as usize]);
|
||||
let mut head = || (next(3) == 0).then(|| Arrowhead::ALL[next(Arrowhead::ALL.len() as u64) as usize].clone());
|
||||
(st.start_arrow, st.end_arrow) = (head(), head());
|
||||
st.arrow_scale = (50.0 + next(250) as f64, 50.0 + next(250) as f64);
|
||||
if next(3) == 0 {
|
||||
|
||||
@@ -1084,6 +1084,102 @@ fn link_info_leaves_out_a_placed_documents_path_outside_the_read_roots() {
|
||||
assert_eq!((&i["link"], &i["path"]), (&json!(src), &json!(src)), "unconfined: {i}");
|
||||
}
|
||||
|
||||
/// `document.inspect` and `document.json` for a confined caller: the document's file, a placed
|
||||
/// document and a linked image outside the read roots are not named by path, and the document
|
||||
/// keeps its links.
|
||||
#[test]
|
||||
fn document_replies_leave_out_paths_outside_the_read_roots() {
|
||||
let away = Folder::new("doc-lrr-away");
|
||||
let home = Folder::new("doc-lrr-home");
|
||||
let src = away.file("logo.vectorcraft");
|
||||
source(&src, 100.0, 50.0, RED, 0);
|
||||
let pic = away.file("photo.png");
|
||||
write(&pic, &crate::tests_links::png(60, 30, BLUE));
|
||||
let mut s = session();
|
||||
let id = place(&mut s, &src);
|
||||
let r = s.execute("file.place", &json!({"path": pic, "at": [50, 50]})).unwrap();
|
||||
assert_eq!(r["linked"], true, "{r}");
|
||||
s.execute("variable.define", &json!({"name": "Photo", "kind": "image"})).unwrap();
|
||||
s.execute("dataset.new", &json!({"name": "Row", "values": {"Photo": pic}})).unwrap();
|
||||
let image = r["ids"][0].as_u64().unwrap();
|
||||
let poster = away.file("poster.vectorcraft");
|
||||
save(&mut s, &poster);
|
||||
// The folder Export for Screens wrote to, and a pattern of the linked image being edited: its
|
||||
// definition before editing is kept beside the tile art.
|
||||
let out = away.file("out");
|
||||
s.execute("document.exportForScreens", &json!({"folder": out, "formats": [{"format": "svg"}]})).unwrap();
|
||||
let pattern = s.execute("object.pattern.make", &json!({"ids": [image], "edit": false})).unwrap()["name"].clone();
|
||||
s.execute("object.pattern.edit", &json!({"name": pattern})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.pattern_edit.as_ref().is_some_and(|e| e.original.is_some()));
|
||||
let ask = |s: &mut Session, root: &std::path::Path, cmd: &str| {
|
||||
let roots = crate::file_access::AutomationRoots::new(&[root], &[]).unwrap().unwrap();
|
||||
crate::file_access::confine(Some(&roots), || s.execute(cmd, &json!({}))).unwrap()
|
||||
};
|
||||
let inspect = ask(&mut s, &home.0, "document.inspect");
|
||||
assert!(!inspect.to_string().contains("doc-lrr-away"), "{inspect}");
|
||||
assert!(inspect["path"].is_null() && inspect["uid"].is_u64(), "{inspect}");
|
||||
let model = ask(&mut s, &home.0, "document.json").to_string();
|
||||
assert!(!model.contains("doc-lrr-away"), "{model}");
|
||||
for name in ["logo.vectorcraft", "photo.png"] {
|
||||
assert!(model.contains(&format!("\"path\":\"{name}\"")), "{name}: {model}");
|
||||
}
|
||||
assert!(model.contains("{\"image\":\"photo.png\"}"), "{model}");
|
||||
// The document keeps its links, and a caller whose read roots hold the files sees every path.
|
||||
assert_eq!(placed(&s, id).link.path, src);
|
||||
for model in [ask(&mut s, &away.0, "document.json"), s.execute("document.json", &json!({})).unwrap()] {
|
||||
let model = model.to_string();
|
||||
for path in [&src, &pic, &out] {
|
||||
assert!(model.contains(&json!(path).to_string()), "{path}: {model}");
|
||||
}
|
||||
}
|
||||
assert_eq!(ask(&mut s, &away.0, "document.inspect")["path"], json!(poster));
|
||||
assert_eq!(s.execute("document.inspect", &json!({})).unwrap()["path"], json!(poster));
|
||||
}
|
||||
|
||||
/// `document.json` for a confined caller: a linked image in a brush's art outside the read roots
|
||||
/// is named by its file name, a stored brush entry that does not read as a brush is left out, and a
|
||||
/// link inside them whose stored relative path leads outside them loses that relative path.
|
||||
#[test]
|
||||
fn document_json_checks_brush_art_and_relative_paths_against_the_read_roots() {
|
||||
let away = Folder::new("doc-brr-away");
|
||||
let home = Folder::new("doc-brr-home");
|
||||
let pic = away.file("photo.png");
|
||||
write(&pic, &crate::tests_links::png(60, 30, BLUE));
|
||||
let near_pic = home.file("near.png");
|
||||
write(&near_pic, &crate::tests_links::png(20, 10, RED));
|
||||
let mut s = session();
|
||||
let r = s.execute("file.place", &json!({"path": pic, "at": [50, 50]})).unwrap();
|
||||
assert_eq!(r["linked"], true, "{r}");
|
||||
let image = r["ids"][0].as_u64().unwrap();
|
||||
let r = s.execute("file.place", &json!({"path": near_pic, "at": [150, 50]})).unwrap();
|
||||
assert_eq!(r["linked"], true, "{r}");
|
||||
s.execute("brush.new", &json!({"type": "art", "name": "Photo Band", "ids": [image]})).unwrap();
|
||||
save(&mut s, &home.file("poster.vectorcraft"));
|
||||
{
|
||||
let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
let lib = d.unknown.get_mut(vectorcraft_brush::DOC_KEY).and_then(Value::as_array_mut).unwrap();
|
||||
lib.push(json!({"name": "Broken Band", "type": "unknown kind", "note": pic}));
|
||||
d.update_links(|l| {
|
||||
if l.name() == "near.png" {
|
||||
l.relative = Some("../doc-brr-away/near.png".into());
|
||||
}
|
||||
});
|
||||
}
|
||||
let unconfined = s.execute("document.json", &json!({})).unwrap().to_string();
|
||||
for part in [json!(pic).to_string(), "Broken Band".into(), "../doc-brr-away/near.png".into()] {
|
||||
assert!(unconfined.contains(&part), "{part}: {unconfined}");
|
||||
}
|
||||
let roots = crate::file_access::AutomationRoots::new(&[&home.0], &[]).unwrap().unwrap();
|
||||
let model = crate::file_access::confine(Some(&roots), || s.execute("document.json", &json!({}))).unwrap();
|
||||
let text = model.to_string();
|
||||
assert!(!text.contains("doc-brr-away") && !text.contains("Broken Band"), "{text}");
|
||||
assert!(text.contains("\"path\":\"photo.png\"") && text.contains("Photo Band"), "{text}");
|
||||
assert!(text.contains(&json!(near_pic).to_string()), "{text}");
|
||||
// The document keeps its brushes and links.
|
||||
let stored = s.doc().unwrap().doc.unknown.get(vectorcraft_brush::DOC_KEY).unwrap().to_string();
|
||||
assert!(stored.contains("Broken Band") && stored.contains(&json!(pic).to_string()), "{stored}");
|
||||
}
|
||||
|
||||
/// Save As reads its defaults from the catalog; using them must retain current editable links.
|
||||
#[test]
|
||||
fn native_save_catalog_defaults_keep_placed_documents_editable() {
|
||||
|
||||
@@ -452,6 +452,60 @@ fn a_painted_effect_on_a_fill_and_refused_results() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A fill's Drop Shadow before a painted effect that returns two fills, and a 200% Transform effect
|
||||
/// after it: the shadow of each returned fill follows the Transform effect. The square the effect
|
||||
/// returns spans 30 to 70 pt once scaled, so both half-opaque shadows, 20 pt to the right, cover
|
||||
/// 85 pt as they cover 75 pt.
|
||||
#[test]
|
||||
fn a_shadow_on_each_fill_a_painted_effect_returns_follows_the_transform_after_it() {
|
||||
let mut s = session();
|
||||
let id = rect(&mut s, 10.0, "#ff0000");
|
||||
let out = json!({"objects": [{"path": square_at(40.0), "fills": [solid("#00ff00"), solid("#0000ff")]}]}).to_string();
|
||||
install_wat(&mut s, &tpl::returning(&painting("test.engine.shadowedfills", true), &out));
|
||||
let fill = node(&s, id).appearance.items.iter().position(|i| i.is_fill()).unwrap();
|
||||
for (effect, params) in [
|
||||
("stylize.dropShadow", json!({"x": 10, "y": 0, "blur": 0, "opacity": 50})),
|
||||
("plugin.test.engine.shadowedfills", json!({})),
|
||||
("distort.transform", json!({"scaleH": 200, "scaleV": 200})),
|
||||
] {
|
||||
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": effect, "params": params, "item": fill}));
|
||||
}
|
||||
let img = drawn(&s);
|
||||
let (near, far) = (img.pixel(75, 50), img.pixel(85, 50));
|
||||
assert_ne!(near, WHITE, "the shadows reach past the square");
|
||||
assert_eq!(far, near, "both shadows at 20 pt");
|
||||
}
|
||||
|
||||
/// A Transform effect with Scale Strokes & Effects on after a painted effect on a fill leaves the
|
||||
/// Drop Shadow after it as it is on every paint the piece is drawn with.
|
||||
#[test]
|
||||
fn a_transform_after_a_painted_fill_effect_leaves_the_later_shadow_on_every_paint() {
|
||||
let mut s = session();
|
||||
let id = rect(&mut s, 10.0, "#ff0000");
|
||||
let two = json!({"objects": [{"path": square_at(40.0), "fills": [solid("#00ff00"), solid("#0000ff")]}]}).to_string();
|
||||
install_wat(&mut s, &tpl::returning(&painting("test.engine.twopaints", true), &two));
|
||||
let fill = node(&s, id).appearance.items.iter().position(|i| i.is_fill()).unwrap();
|
||||
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.twopaints", "item": fill}));
|
||||
let mut n = node(&s, id);
|
||||
let later = [
|
||||
vectorcraft_doc::Effect { id: "distort.transform".into(), params: json!({"scaleH": 200, "scaleV": 200, "strokes": true}), visible: true },
|
||||
vectorcraft_doc::Effect { id: "stylize.dropShadow".into(), params: json!({"x": 10.0, "y": 0.0, "blur": 0.0}), visible: true },
|
||||
];
|
||||
n.appearance.items[fill].effects_mut().extend(later);
|
||||
let art = vectorcraft_render::effects::reshape(Some(&s.doc().unwrap().doc), &n, None).unwrap();
|
||||
let mut offsets = vec![];
|
||||
art.walk(&mut |m| {
|
||||
for item in &m.appearance.items {
|
||||
for r in vectorcraft_render::effects::raster_effects(item.effects()) {
|
||||
if let vectorcraft_render::effects::RasterFx::DropShadow { dx, dy, .. } = r {
|
||||
offsets.push((dx, dy));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
assert_eq!(offsets, [(10.0, 0.0), (10.0, 0.0)], "one shadow on each of the piece's two fills");
|
||||
}
|
||||
|
||||
/// A result refused for a swatch the document doesn't have stays cached: once the document has the
|
||||
/// swatch, the next check of that result clears the error.
|
||||
#[test]
|
||||
|
||||
@@ -259,6 +259,93 @@ fn shaper_double_click_selects_an_original_for_construction_editing() {
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![g]);
|
||||
}
|
||||
|
||||
/// With a Shaper Group or one of its faces selected, a Shaper click on an original shape's outline
|
||||
/// enters Construction mode with that shape selected, even where the outline lies inside a merged
|
||||
/// face. A click away from every outline still selects a face, and a click on an outline of a group
|
||||
/// that is not selected selects the group.
|
||||
#[test]
|
||||
fn shaper_click_on_an_original_outline_enters_construction_mode() {
|
||||
let mut s = session();
|
||||
let a = rectangle(&mut s, 0.0, "#ff0000");
|
||||
let b = rectangle(&mut s, 50.0, "#0000ff");
|
||||
let g = scribble(&mut s, &[[25.0, 20.0], [75.0, 35.0], [30.0, 50.0], [125.0, 65.0]]);
|
||||
let sources = node(&s, g).children().unwrap()[0].id;
|
||||
let v = ViewInfo { smart_guides: false, ..Default::default() };
|
||||
s.select_tool("shaper", v).unwrap();
|
||||
let click = |s: &mut Session, x: f64, y: f64| {
|
||||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||||
s.pointer(&PointerEvent::new(kind, x, y), v).unwrap();
|
||||
}
|
||||
};
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
click(&mut s, 101.0, 50.0);
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![g]);
|
||||
assert_eq!(s.doc().unwrap().isolation, None);
|
||||
// The right side of the back shape runs through the merged face.
|
||||
click(&mut s, 101.0, 50.0);
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
|
||||
assert_eq!(s.doc().unwrap().isolation, Some(sources));
|
||||
s.execute("object.exitIsolation", &json!({})).unwrap();
|
||||
// A click inside the face, away from the outlines, selects the face.
|
||||
click(&mut s, 75.0, 50.0);
|
||||
let face = s.doc().unwrap().selection.objects.clone();
|
||||
assert_eq!(face.len(), 1);
|
||||
assert_ne!(face, vec![g]);
|
||||
assert_eq!(s.doc().unwrap().isolation, None);
|
||||
// With the face selected, a click on the front shape's outline picks that shape.
|
||||
click(&mut s, 149.0, 50.0);
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![b]);
|
||||
assert_eq!(s.doc().unwrap().isolation, Some(sources));
|
||||
s.execute("object.exitIsolation", &json!({})).unwrap();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
for tolerance in [json!("3"), json!(-1), json!(1e9)] {
|
||||
assert!(s.execute("shaper.select", &json!({"point": [101, 50], "tolerance": tolerance})).is_err(), "{tolerance}");
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![g]);
|
||||
assert_eq!(s.doc().unwrap().isolation, None);
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
|
||||
s.execute("shaper.select", &json!({"point": [104, 50], "tolerance": 5})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
|
||||
s.execute("object.exitIsolation", &json!({})).unwrap();
|
||||
// A wide tolerance (a zoomed-out view) reaches an outline from well outside the group's bounds.
|
||||
s.execute("shaper.select", &json!({"point": [165, 50], "tolerance": 20})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![b]);
|
||||
assert_eq!(s.doc().unwrap().isolation, Some(sources));
|
||||
// A locked or hidden original shape is not picked by its outline.
|
||||
for hide in ["object.lock", "object.hide"] {
|
||||
s.execute("select.set", &json!({"ids": [b.0]})).unwrap();
|
||||
s.execute(hide, &json!({})).unwrap();
|
||||
s.execute("object.exitIsolation", &json!({})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [g.0]})).unwrap();
|
||||
s.execute("shaper.select", &json!({"point": [149, 50]})).unwrap();
|
||||
assert_ne!(s.doc().unwrap().selection.objects, vec![b], "{hide}");
|
||||
assert_eq!(s.doc().unwrap().isolation, None, "{hide}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("object.isolate", &json!({"id": g.0})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// A click on a selected Shaper Group's original outline where another Shaper Group lies above it
|
||||
/// selects the group above, as a click there did before.
|
||||
#[test]
|
||||
fn shaper_outline_click_under_another_group_selects_the_group_above() {
|
||||
let mut s = session();
|
||||
rectangle(&mut s, 0.0, "#ff0000");
|
||||
rectangle(&mut s, 50.0, "#0000ff");
|
||||
let lower = scribble(&mut s, &[[25.0, 20.0], [75.0, 35.0], [30.0, 50.0], [125.0, 65.0]]);
|
||||
let c = rectangle(&mut s, 20.0, "#00ff00");
|
||||
let d = rectangle(&mut s, 70.0, "#ffff00");
|
||||
let upper = NodeId(
|
||||
s.execute("shaper.scribble", &json!({"points": [[45, 20], [95, 35], [50, 50], [145, 65]], "ids": [c.0, d.0]})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap(),
|
||||
);
|
||||
s.execute("select.set", &json!({"ids": [lower.0]})).unwrap();
|
||||
s.execute("shaper.select", &json!({"point": [100, 50]})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![upper]);
|
||||
assert_eq!(s.doc().unwrap().isolation, None);
|
||||
}
|
||||
|
||||
/// The arrow widget beside a selected Shaper Group switches it to Construction mode and back, with
|
||||
/// the Shaper tool and with the Selection tool. In Construction mode a Shaper tap selects a source
|
||||
/// shape, and the mode lasts through undo and redo of an edit made in it.
|
||||
|
||||
@@ -51,7 +51,7 @@ fn profiles_come_from_the_preset_catalogue() {
|
||||
assert!(spec.params.contains("arrowAlign"));
|
||||
// …and every arrowhead name `startArrow`/`endArrow` accept.
|
||||
for a in vectorcraft_doc::Arrowhead::ALL {
|
||||
let name = serde_json::to_value(a).unwrap();
|
||||
let name = serde_json::to_value(&a).unwrap();
|
||||
assert!(spec.params.contains(name.as_str().unwrap()), "{name}");
|
||||
s.execute("stroke.set", &json!({"endArrow": name})).unwrap();
|
||||
assert_eq!(stroke(&s, id).end_arrow, Some(a));
|
||||
|
||||
@@ -568,6 +568,7 @@ fn type_commands_refuse_a_number_or_boolean_given_as_text() {
|
||||
("text.setFormat", json!({"ids": [id.0], "hyphenateCapitalized": 0}), "hyphenateCapitalized"),
|
||||
("text.setFormat", json!({"ids": [id.0], "romanHangingPunctuation": "true"}), "romanHangingPunctuation"),
|
||||
("text.setFormat", json!({"ids": [id.0], "koreanLineBreakAnywhere": 1}), "koreanLineBreakAnywhere"),
|
||||
("text.setFormat", json!({"ids": [id.0], "bunriKinshi": "on"}), "bunriKinshi"),
|
||||
("text.getRange", json!({"id": id.0, "effective": "true"}), "effective"),
|
||||
("type.opticalMarginAlignment", json!({"on": "true"}), "on"),
|
||||
("type.systemLayout", json!({"on": "true"}), "on"),
|
||||
|
||||
@@ -2916,3 +2916,191 @@ fn applying_a_graph_row_again_records_no_undo_step() {
|
||||
s.execute("dataset.select", &json!({"name": "B"})).unwrap();
|
||||
assert_eq!(super::history(&s), (before.0 + 1, 0, true));
|
||||
}
|
||||
|
||||
/// The document's SVG with the options `svg` → (its text, the export's warnings).
|
||||
fn svg_export(s: &mut Session, svg: Value) -> (String, Vec<Value>) {
|
||||
let r = s.execute("document.export", &json!({"format": "svg", "svg": svg})).unwrap();
|
||||
let bytes = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap();
|
||||
(String::from_utf8(bytes).unwrap(), r["warnings"].as_array().cloned().unwrap_or_default())
|
||||
}
|
||||
|
||||
/// A new session with `svg` opened as `name` → (the session, the open's warnings).
|
||||
fn svg_opened(svg: &str, name: &str) -> (Session, Vec<Value>) {
|
||||
let mut o = Session::new();
|
||||
let r = o.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(svg.as_bytes())})).unwrap();
|
||||
let warnings = r["warnings"].as_array().cloned().unwrap_or_default();
|
||||
(o, warnings)
|
||||
}
|
||||
|
||||
/// Include Variable Data writes the text variables, the data sets' text values and each text
|
||||
/// variable's bound type object into the SVG, and opening the SVG reads them back: the type
|
||||
/// objects are bound again, so a data set changes them. Variables of other kinds are left out with
|
||||
/// a warning. Unnamed objects get ids with Minimal ids, minified files, unique ids and style
|
||||
/// entities alike, and Load Variable Library reads the same file.
|
||||
#[test]
|
||||
fn an_svg_with_variable_data_opens_with_its_variables_data_sets_and_bindings() {
|
||||
let mut s = session();
|
||||
let t = text(&mut s, "Alice");
|
||||
let u = text(&mut s, "9.99");
|
||||
// A fill of its appearance writes the type in a group with the fill's path.
|
||||
s.execute("appearance.addFill", &json!({"ids": [u.0]})).unwrap();
|
||||
let r = rect(&mut s);
|
||||
for (name, kind) in [("Name", "text"), ("Shown", "visibility"), ("Price", "text")] {
|
||||
s.execute("variable.define", &json!({"name": name, "kind": kind})).unwrap();
|
||||
}
|
||||
s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "Price", "ids": [u.0]})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "Shown", "ids": [r.0]})).unwrap();
|
||||
s.execute("dataset.new", &json!({"name": "A & <B>", "values": {"Name": "Bob", "Price": "1.00\nper pound & tax\r", "Shown": false}})).unwrap();
|
||||
s.execute("dataset.new", &json!({"name": "Second", "values": {"Name": ""}})).unwrap();
|
||||
let before = s.doc().unwrap().doc.variables.clone();
|
||||
let text_only: Vec<vectorcraft_doc::DataSet> = before
|
||||
.datasets
|
||||
.iter()
|
||||
.map(|d| {
|
||||
let mut d = d.clone();
|
||||
d.values.retain(|k, _| k != "Shown");
|
||||
d
|
||||
})
|
||||
.collect();
|
||||
|
||||
for opts in [
|
||||
json!({"variableData": true}),
|
||||
json!({"variableData": true, "objectIds": "minimal", "minify": true}),
|
||||
json!({"variableData": true, "objectIds": "unique"}),
|
||||
json!({"variableData": true, "styling": "entities", "encoding": "latin1"}),
|
||||
] {
|
||||
let (svg, warnings) = svg_export(&mut s, opts.clone());
|
||||
assert!(warnings.contains(&json!("Variables that aren't text variables were left out of the SVG's variable data")), "{opts}: {warnings:?}");
|
||||
assert_eq!(svg.matches("docRef=\"id('").count(), 2, "{opts}: {svg}");
|
||||
assert_eq!(s.doc().unwrap().doc.variables, before, "{opts}: exporting changes nothing");
|
||||
|
||||
let (mut o, warnings) = svg_opened(&svg, "vars.svg");
|
||||
assert!(warnings.is_empty(), "{opts}: {warnings:?}");
|
||||
let vars = o.doc().unwrap().doc.variables.clone();
|
||||
assert_eq!(vars.variables.iter().map(|v| (v.name.as_str(), v.kind.label())).collect::<Vec<_>>(), [("Name", "text"), ("Price", "text")]);
|
||||
assert_eq!(vars.datasets, text_only, "{opts}");
|
||||
assert_eq!(vars.active_dataset, None);
|
||||
let bound = |name: &str| {
|
||||
let ids = vars.objects_of(name);
|
||||
assert_eq!(ids.len(), 1, "{opts}: {name}");
|
||||
ids[0]
|
||||
};
|
||||
let (name, price) = (bound("Name"), bound("Price"));
|
||||
assert_eq!((plain(&o, name), plain(&o, price)), ("Alice".into(), "9.99".into()), "{opts}: the art stays as the file draws it");
|
||||
let v = o.execute("dataset.select", &json!({"name": "A & <B>"})).unwrap();
|
||||
assert_eq!(v["applied"], json!(2), "{opts}: {v}");
|
||||
assert_eq!(plain(&o, name), "Bob");
|
||||
|
||||
let mut lib = session();
|
||||
lib.execute("variable.loadLibrary", &json!({"xml": svg})).unwrap();
|
||||
assert_eq!(lib.doc().unwrap().doc.variables.datasets, text_only, "{opts}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Without Include Variable Data the SVG holds no variable data, and a document without variables
|
||||
/// writes none with it on. Outlined type is written unbound, characters XML can't hold are left
|
||||
/// out of the values, and variable data past the 8 MiB or 2,000,000-node cap is left out, each
|
||||
/// with a warning; past 256 text variables or 1,000 data sets the rest are left out with a
|
||||
/// warning. Look-alike text in comments and other markup is skipped when the file opens. A file
|
||||
/// whose variable data can't be read opens its art with a warning, and so does one whose
|
||||
/// namespace entities would grow the library past its cap.
|
||||
#[test]
|
||||
fn an_svg_without_readable_variable_data_opens_its_art() {
|
||||
let mut s = session();
|
||||
let t = text(&mut s, "Alice");
|
||||
let (plain_svg, _) = svg_export(&mut s, json!({}));
|
||||
assert!(!plain_svg.contains("variableSets"));
|
||||
let (none, _) = svg_export(&mut s, json!({"variableData": true}));
|
||||
assert!(!none.contains("variableSets"), "no variables, no variable data");
|
||||
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap();
|
||||
let (off, _) = svg_export(&mut s, json!({}));
|
||||
assert!(!off.contains("variableSets"), "off by default");
|
||||
// Outlined type opens as art: the variable is written unbound, and the values lose the
|
||||
// characters XML can't hold, each with a warning.
|
||||
s.execute("dataset.new", &json!({"name": "Row", "values": {"Name": "a\u{b}b\u{1}c"}})).unwrap();
|
||||
let (outlined, warnings) = svg_export(&mut s, json!({"variableData": true, "outlineText": true}));
|
||||
assert!(outlined.contains("<variable varName=\"Name\"") && !outlined.contains("docRef"), "{outlined}");
|
||||
for w in [
|
||||
"Type written as outlines opens as art: its text variables were written unbound in the SVG's variable data",
|
||||
"Characters XML can't hold were left out of data set values in the SVG's variable data",
|
||||
] {
|
||||
assert!(warnings.contains(&json!(w)), "{warnings:?}");
|
||||
}
|
||||
let (o, _) = svg_opened(&outlined, "outlined.svg");
|
||||
let vars = &o.doc().unwrap().doc.variables;
|
||||
assert!(vars.bindings.is_empty() && vars.variables.len() == 1, "{vars:?}");
|
||||
assert_eq!(vars.datasets[0].values["Name"], vectorcraft_doc::DataValue::Text("a bc".into()));
|
||||
// Variable data larger than Load Variable Library reads is left out with a warning.
|
||||
s.execute("dataset.new", &json!({"name": "Huge", "values": {"Name": "x".repeat(8 << 20)}})).unwrap();
|
||||
let (huge, warnings) = svg_export(&mut s, json!({"variableData": true}));
|
||||
assert!(!huge.contains("variableSets"));
|
||||
assert!(warnings.contains(&json!("The variable data is larger than 8 MiB, which Load Variable Library reads, and was left out of the SVG")));
|
||||
|
||||
let head = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"20\" height=\"20\">";
|
||||
let art = "<rect x=\"1\" y=\"1\" width=\"5\" height=\"5\"/></svg>";
|
||||
let untraited = format!("{head}<variableSets><variableSet><variables><variable varName=\"A\"/></variables></variableSet></variableSets>{art}");
|
||||
let refs = "&ns_big;".repeat(40_000);
|
||||
let big = "x".repeat(250);
|
||||
let growing = format!(
|
||||
"<!DOCTYPE svg [<!ENTITY ns_big \"{big}\">]>{head}<variableSets><variableSet><variables><variable varName=\"A\" trait=\"textcontent\" category=\"{refs}\"/></variables></variableSet></variableSets>{art}"
|
||||
);
|
||||
// Variable data under 8 MiB with more XML nodes than Load Variable Library reads (a million
|
||||
// one-character paragraphs, minified) is left out with a warning.
|
||||
let mut lines = session();
|
||||
lines.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
|
||||
lines.execute("dataset.new", &json!({"name": "Row", "values": {"Name": "a\n".repeat(1_000_000)}})).unwrap();
|
||||
let (dense, warnings) = svg_export(&mut lines, json!({"variableData": true, "minify": true}));
|
||||
assert!(!dense.contains("variableSets"));
|
||||
assert!(
|
||||
warnings.contains(&json!(
|
||||
"The variable data holds more than 2,000,000 XML nodes, which Load Variable Library reads, and was left out of the SVG"
|
||||
)),
|
||||
"{warnings:?}"
|
||||
);
|
||||
|
||||
// More text variables and data sets than Load Variable Library reads: the first 256 and the
|
||||
// first 1,000 are written, each with a warning, and the file opens with them.
|
||||
let mut many = session();
|
||||
{
|
||||
let vars = &mut std::sync::Arc::make_mut(&mut many.doc_mut().unwrap().doc).variables;
|
||||
vars.variables = (0..300).map(|i| vectorcraft_doc::Variable { name: format!("V{i}"), kind: vectorcraft_doc::VariableKind::Text }).collect();
|
||||
vars.datasets = (0..1_100)
|
||||
.map(|i| vectorcraft_doc::DataSet {
|
||||
name: format!("D{i}"),
|
||||
values: [("V0".to_string(), vectorcraft_doc::DataValue::Text(format!("{i}")))].into_iter().collect(),
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
let (capped, warnings) = svg_export(&mut many, json!({"variableData": true}));
|
||||
for w in [
|
||||
"Text variables past the first 256, which Load Variable Library reads, were left out of the SVG's variable data",
|
||||
"Data sets past the first 1,000, which Load Variable Library reads, were left out of the SVG's variable data",
|
||||
] {
|
||||
assert!(warnings.contains(&json!(w)), "{warnings:?}");
|
||||
}
|
||||
let (o, warnings) = svg_opened(&capped, "many.svg");
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
let vars = &o.doc().unwrap().doc.variables;
|
||||
assert_eq!((vars.variables.len(), vars.datasets.len()), (256, 1_000));
|
||||
assert_eq!(vars.datasets[999].values["V0"], vectorcraft_doc::DataValue::Text("999".into()));
|
||||
|
||||
// Text that only looks like variable data, in a comment, a CDATA section, a processing
|
||||
// instruction or the document type declaration, is skipped for the element itself.
|
||||
let library = "<variableSets><variableSet><variables><variable varName=\"A\" trait=\"textcontent\"/></variables></variableSet></variableSets>";
|
||||
let decoys = format!(
|
||||
"<!DOCTYPE svg [<!-- > <variableSets> -->]>{head}<!-- a > <variableSets> --><metadata><![CDATA[ a > <variableSets> ]]></metadata><?pi a > <variableSets> ?>{library}<!-- </variableSets> -->{art}"
|
||||
);
|
||||
let (o, warnings) = svg_opened(&decoys, "decoys.svg");
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
assert_eq!(o.doc().unwrap().doc.variables.variables.len(), 1);
|
||||
|
||||
for (svg, why) in [(untraited, "variable `A` has no `trait`"), (growing, "entities expand to more than")] {
|
||||
let (o, warnings) = svg_opened(&svg, "broken.svg");
|
||||
assert!(warnings.iter().any(|w| w.as_str().is_some_and(|w| w.contains("variable data wasn't read") && w.contains(why))), "{warnings:?}");
|
||||
let d = &o.doc().unwrap().doc;
|
||||
assert!(d.variables.variables.is_empty());
|
||||
assert_eq!(vectorcraft_testkit::svg_shapes(&vectorcraft_svg::export(d, &Default::default())), 1, "the art opens");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user