mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 10:01:58 +00:00
Merge remote-tracking branch 'origin/main' into fix/6969-rasterize-gray
# Conflicts: # docs/target-app-parity.md
This commit is contained in:
@@ -238,9 +238,11 @@ fn main() -> ExitCode {
|
||||
/// A headless session that remembers what the user keeps between runs: the PDF presets saved, the
|
||||
/// ICC profiles loaded, the plug-ins installed (#2168, #2174, #2541), the custom dictionary's words (#4936) and the Color Settings (#6335), in the folders
|
||||
/// and files next to the preferences that the desktop app uses too, and lists the scripts of the Scripts folder there
|
||||
/// (`script.listInstalled`). `VECTORCRAFT_NO_PREFS` and `VECTORCRAFT_CONFIG_DIR` apply as they do there.
|
||||
/// (`script.listInstalled`) and starts new documents from the New Document profiles there. `VECTORCRAFT_NO_PREFS` and
|
||||
/// `VECTORCRAFT_CONFIG_DIR` apply as they do there.
|
||||
fn with_stores(mut h: Headless) -> Headless {
|
||||
prefs_dir::attach_scripts_dir(&mut h.session);
|
||||
prefs_dir::attach_new_doc_profiles(&mut h.session);
|
||||
prefs_dir::attach_profile_store(&mut h.session);
|
||||
prefs_dir::attach_color_settings(&mut h.session);
|
||||
prefs_dir::attach_plugin_store(&mut h.session);
|
||||
|
||||
@@ -478,6 +478,7 @@ fn main() -> std::process::ExitCode {
|
||||
prefs_dir::attach_plugin_store(&mut session);
|
||||
// File › Scripts lists the scripts of the Scripts folder next to the preferences.
|
||||
prefs_dir::attach_scripts_dir(&mut session);
|
||||
prefs_dir::attach_new_doc_profiles(&mut session);
|
||||
let mut app = VectorcraftApp::new(session, services(cc.winit_window().cloned(), cc.egui_ctx.clone()));
|
||||
app.set_startup_update_check(true);
|
||||
load_prefs(&mut app, saved);
|
||||
|
||||
@@ -65,6 +65,14 @@ pub fn attach_scripts_dir(session: &mut vectorcraft_engine::Session) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Have File › New start each category's documents from its profile in the `New Document
|
||||
/// Profiles` folder, which `file.newProfiles.edit` makes.
|
||||
pub fn attach_new_doc_profiles(session: &mut vectorcraft_engine::Session) {
|
||||
if let Some(dir) = store_dir("New Document Profiles") {
|
||||
session.attach_new_doc_profiles(dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// Take the saved PDF presets from the `PDF Presets` folder (#2168), and keep the ones saved from
|
||||
/// now on there. After the preferences were restored: the folder is the one place the presets live.
|
||||
pub fn attach_pdf_preset_store(session: &mut vectorcraft_engine::Session) {
|
||||
|
||||
+78
-13
@@ -6,8 +6,9 @@
|
||||
//! * **CMYK spaces:** [`GENERIC_CMYK`] is our own documented parametric press model
|
||||
//! ([`generic`]); [`DEVICE_CMYK`] is the old profile-free formula (kept for exact legacy numbers);
|
||||
//! any CMYK `.icc` the user loads goes through `moxcms` (LUT-based profiles).
|
||||
//! * The **active settings** (Edit → Color Settings) are process-wide; [`crate::Color::to_rgb`]
|
||||
//! uses them. Code that needs deterministic numbers builds its own [`Cms`] with [`Cms::new`].
|
||||
//! * The **active settings** are process-wide: the working settings (Edit → Color Settings), with
|
||||
//! the profiles [`use_document_profiles`] last put in place of the working spaces;
|
||||
//! [`crate::Color::to_rgb`] uses them. Code that needs deterministic numbers builds its own [`Cms`] with [`Cms::new`].
|
||||
|
||||
pub mod generic;
|
||||
pub mod icc;
|
||||
@@ -320,6 +321,9 @@ pub struct Cms {
|
||||
settings: ColorSettings,
|
||||
rgb: RgbSpace,
|
||||
cmyk: CmykSpace,
|
||||
/// The working settings, when a document's assigned profiles take the place of their spaces
|
||||
/// ([`use_document_profiles`]): a [`ProofTarget::WorkingCmyk`] proof simulates their CMYK space.
|
||||
working: Option<Arc<Cms>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Cms {
|
||||
@@ -330,7 +334,7 @@ impl std::fmt::Debug for Cms {
|
||||
|
||||
impl Default for Cms {
|
||||
fn default() -> Self {
|
||||
Self { settings: ColorSettings::default(), rgb: RgbSpace::Srgb, cmyk: CmykSpace::Generic(generic()) }
|
||||
Self { settings: ColorSettings::default(), rgb: RgbSpace::Srgb, cmyk: CmykSpace::Generic(generic()), working: None }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,7 +342,7 @@ impl Cms {
|
||||
/// Profiles named by a legacy name are stored under their current name.
|
||||
pub fn new(settings: &ColorSettings) -> Result<Self, CmsError> {
|
||||
let settings = ColorSettings { rgb: canonical_name(&settings.rgb).into(), cmyk: canonical_name(&settings.cmyk).into(), ..settings.clone() };
|
||||
Ok(Self { rgb: rgb_space(&settings.rgb)?, cmyk: cmyk_space(&settings.cmyk)?, settings })
|
||||
Ok(Self { rgb: rgb_space(&settings.rgb)?, cmyk: cmyk_space(&settings.cmyk)?, settings, working: None })
|
||||
}
|
||||
pub fn settings(&self) -> &ColorSettings {
|
||||
&self.settings
|
||||
@@ -542,8 +546,9 @@ impl Cms {
|
||||
ProofTarget::Deuteranopia => simulate_cvd(srgb, &DEUTERANOPIA),
|
||||
ProofTarget::Tritanopia => TRITANOPIA.simulate(srgb),
|
||||
ProofTarget::WorkingCmyk => {
|
||||
let cmyk = self.srgb_to_cmyk(srgb, proof.intent);
|
||||
self.cmyk_to_srgb(cmyk, proof.simulate_paper)
|
||||
let working = self.working_cms();
|
||||
let cmyk = working.srgb_to_cmyk(srgb, proof.intent);
|
||||
working.cmyk_to_srgb(cmyk, proof.simulate_paper)
|
||||
}
|
||||
ProofTarget::Cmyk(name) => match cmyk_space(name) {
|
||||
Ok(sp) => {
|
||||
@@ -555,20 +560,29 @@ impl Cms {
|
||||
}
|
||||
}
|
||||
|
||||
/// Display sRGB of CMYK inks in the proof's CMYK space (the working space for RGB targets).
|
||||
/// Display sRGB of CMYK inks in the proof's CMYK space (this context's CMYK space for RGB
|
||||
/// targets).
|
||||
pub fn proof_cmyk_to_srgb(&self, cmyk: [f32; 4], proof: &ProofSetup) -> [f32; 3] {
|
||||
match &proof.target {
|
||||
ProofTarget::Cmyk(name) => match cmyk_space(name) {
|
||||
Ok(sp) => Self::cmyk_to_srgb_in(&sp, cmyk, proof.simulate_paper, self.settings.bpc),
|
||||
Err(_) => self.cmyk_to_srgb(cmyk, proof.simulate_paper),
|
||||
},
|
||||
ProofTarget::WorkingCmyk => self.working_cms().cmyk_to_srgb(cmyk, proof.simulate_paper),
|
||||
_ => self.cmyk_to_srgb(cmyk, proof.simulate_paper),
|
||||
}
|
||||
}
|
||||
|
||||
/// The context of the working settings: the one [`use_document_profiles`] put a document's
|
||||
/// profiles in place of, else this one.
|
||||
fn working_cms(&self) -> &Cms {
|
||||
self.working.as_deref().unwrap_or(self)
|
||||
}
|
||||
|
||||
/// A cached 3-D lookup table implementing [`Cms::proof_srgb`] for 8-bit pixels.
|
||||
pub fn proof_lut(&self, proof: &ProofSetup) -> Arc<ProofLut> {
|
||||
let key = format!("{:?}|{:?}|{:?}|{}", self.settings, proof.target, proof.intent, proof.simulate_paper);
|
||||
let working = self.working.as_ref().map(|w| &w.settings);
|
||||
let key = format!("{:?}|{working:?}|{:?}|{:?}|{}", self.settings, proof.target, proof.intent, proof.simulate_paper);
|
||||
let mut cache = PROOF_CACHE.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some((_, l)) = cache.iter().find(|(k, _)| *k == key) {
|
||||
return l.clone();
|
||||
@@ -890,10 +904,12 @@ impl CmykLut {
|
||||
// ---------- the active (process-wide) settings ----------
|
||||
|
||||
static ACTIVE: RwLock<Option<Arc<Cms>>> = RwLock::new(None);
|
||||
static WORKING: RwLock<Option<Arc<Cms>>> = RwLock::new(None);
|
||||
static RGB_IS_SRGB: AtomicBool = AtomicBool::new(true);
|
||||
static CMYK_IS_DEVICE: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// The active colour-management context (Edit → Color Settings).
|
||||
/// The active color-management context: the working settings, with the profiles
|
||||
/// [`use_document_profiles`] last put in place of the working spaces.
|
||||
pub fn active() -> Arc<Cms> {
|
||||
if let Some(c) = ACTIVE.read().unwrap_or_else(|e| e.into_inner()).as_ref() {
|
||||
return c.clone();
|
||||
@@ -906,13 +922,62 @@ pub fn active_settings() -> ColorSettings {
|
||||
active().settings.clone()
|
||||
}
|
||||
|
||||
/// Replace the active settings (fails, leaving them unchanged, if a profile is unknown).
|
||||
/// The color-management context of Edit → Color Settings (the working spaces, intent and black
|
||||
/// point compensation), as [`set_active`] last set them, whatever profiles a document is assigned.
|
||||
pub fn working() -> Arc<Cms> {
|
||||
if let Some(c) = WORKING.read().unwrap_or_else(|e| e.into_inner()).as_ref() {
|
||||
return c.clone();
|
||||
}
|
||||
let mut w = WORKING.write().unwrap_or_else(|e| e.into_inner());
|
||||
w.get_or_insert_with(|| Arc::new(Cms::default())).clone()
|
||||
}
|
||||
|
||||
/// The settings of [`working`].
|
||||
pub fn working_settings() -> ColorSettings {
|
||||
working().settings.clone()
|
||||
}
|
||||
|
||||
/// Replace the working settings (Edit → Color Settings) and make them the active settings (fails,
|
||||
/// leaving both unchanged, if a profile is unknown).
|
||||
pub fn set_active(settings: &ColorSettings) -> Result<(), CmsError> {
|
||||
let cms = Cms::new(settings)?;
|
||||
let cms = Arc::new(Cms::new(settings)?);
|
||||
*WORKING.write().unwrap_or_else(|e| e.into_inner()) = Some(cms.clone());
|
||||
install(cms);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Make the working settings the active settings, with the profiles a document is assigned
|
||||
/// (`None`: none) in place of the working spaces. An assigned profile that is not available, or
|
||||
/// is not of its model, leaves the working space. The working settings stay as they are. True when
|
||||
/// the active settings changed.
|
||||
pub fn use_document_profiles(rgb: Option<&str>, cmyk: Option<&str>) -> bool {
|
||||
let working = working();
|
||||
let mut st = working.settings.clone();
|
||||
if let Some(p) = rgb.and_then(profile).filter(|p| p.kind == ProfileKind::Rgb) {
|
||||
st.rgb = p.name;
|
||||
}
|
||||
if let Some(p) = cmyk.and_then(profile).filter(|p| p.kind == ProfileKind::Cmyk) {
|
||||
st.cmyk = p.name;
|
||||
}
|
||||
if st == active().settings {
|
||||
return false;
|
||||
}
|
||||
if st == working.settings {
|
||||
install(working);
|
||||
return true;
|
||||
}
|
||||
// The working settings were checked when they were set; a working profile removed since then
|
||||
// leaves the active settings as they are.
|
||||
let Ok(mut cms) = Cms::new(&st) else { return false };
|
||||
cms.working = Some(working);
|
||||
install(Arc::new(cms));
|
||||
true
|
||||
}
|
||||
|
||||
fn install(cms: Arc<Cms>) {
|
||||
RGB_IS_SRGB.store(cms.rgb_is_srgb(), Ordering::Relaxed);
|
||||
CMYK_IS_DEVICE.store(matches!(cms.cmyk, CmykSpace::Device), Ordering::Relaxed);
|
||||
*ACTIVE.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(cms));
|
||||
Ok(())
|
||||
*ACTIVE.write().unwrap_or_else(|e| e.into_inner()) = Some(cms);
|
||||
}
|
||||
|
||||
/// Fast path for [`Color::to_rgb`]: RGB colours need no conversion while the working space is sRGB.
|
||||
|
||||
@@ -440,6 +440,8 @@ fn leaf_base(n: &Node) -> Option<(PathData, FillRule, GeomContext<'_>, Node)> {
|
||||
if carry.moves(&m.appearance) {
|
||||
carry.apply(&mut m.appearance);
|
||||
}
|
||||
// The raster effects of its fills and strokes follow its Transform effects, as on the canvas.
|
||||
crate::raster::follow_items(&mut m);
|
||||
m.appearance.effects = crate::raster::without_geometry(&m.appearance.effects);
|
||||
Some((g, rule, ctx, m))
|
||||
}
|
||||
|
||||
@@ -267,7 +267,7 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"distort.transform",
|
||||
"Transform…",
|
||||
DT,
|
||||
"{scaleH: % (100), scaleV: % (100), moveH: pt (0; right = +), moveV: pt (0; down = +), rotate: deg (0; counter-clockwise; when the effect turns, reflects or scales the object unevenly, a gradient fitted to the object is fixed to its fit and turns with the object and with each copy), copies: 0..1000 (0; each copy transforms the last again and is painted below it, the object on top; a group's copies each hold all its members, and a group with a member that carries a geometry, Pathfinder or 3D effect copies each member in its place; placed gradients transform with the object and with each copy), reflectX: bool (false; flips left to right), reflectY: bool (false; flips top to bottom), reference: 0..8 (4; the point of the bounds' 9-point grid it scales, rotates and reflects about: 0 top left, 4 centre, 8 bottom right), random: bool (false; each scale goes a random share of the way from 100 % to its value, each move and the angle a random share of theirs, differently for each object and the same on every redraw), objects: bool (true; Transform Objects: off, the geometry stays where it is and makes no copies, and only the two options below apply, once; on, without copies or random, the raster effects listed before this effect (Rasterize and SVG filters excepted) transform with the art whatever strokes says: their sizes scale by the mean scale and a Drop Shadow's offset turns, reflects and scales, while the angles and light directions of pixel filters stay as they are), patterns: bool (false; Transform Patterns: pattern fills and strokes transform along; with copies the original keeps its pattern and copy k takes the transformation k times), strokes: bool (false; Scale Strokes & Effects: stroke weights and dashes, and the distances of the effects on the fills and strokes, scale by the mean scale √|scaleH·scaleV|, copy k by its k-th power; on a fill or stroke, the effects listed after this effect in that fill's or stroke's own list keep their distances and apply to the transformed art, and with objects off so do the effects before it)}",
|
||||
"{scaleH: % (100), scaleV: % (100), moveH: pt (0; right = +), moveV: pt (0; down = +), rotate: deg (0; counter-clockwise; when the effect turns, reflects or scales the object unevenly, a gradient fitted to the object is fixed to its fit and turns with the object and with each copy), copies: 0..1000 (0; each copy transforms the last again and is painted below it, the object on top; a group's copies each hold all its members, and a group with a member that carries a geometry, Pathfinder or 3D effect copies each member in its place; placed gradients transform with the object and with each copy), reflectX: bool (false; flips left to right), reflectY: bool (false; flips top to bottom), reference: 0..8 (4; the point of the bounds' 9-point grid it scales, rotates and reflects about: 0 top left, 4 centre, 8 bottom right), random: bool (false; each scale goes a random share of the way from 100 % to its value, each move and the angle a random share of theirs, differently for each object and the same on every redraw), objects: bool (true; Transform Objects: off, the geometry stays where it is and makes no copies, and only the two options below apply, once; on, without copies or random, the raster effects listed before this effect (Rasterize and SVG filters excepted) transform with the art whatever strokes says: their sizes scale by the mean scale and a Drop Shadow's offset turns, reflects and scales, while the angles and light directions of pixel filters stay as they are; so do the raster effects of the object's fills and strokes and, on a group, those of its members, of their fills and strokes and of their linked opacity masks' art, after their own Transform effects, including those that a member's own Transform effect with copies or random holds back), patterns: bool (false; Transform Patterns: pattern fills and strokes transform along; with copies the original keeps its pattern and copy k takes the transformation k times), strokes: bool (false; Scale Strokes & Effects: stroke weights and dashes, and the distances of the effects on the fills and strokes, scale by the mean scale √|scaleH·scaleV|, copy k by its k-th power; raster effects that transform with the art are not scaled again; on a fill or stroke, the effects listed after this effect in that fill's or stroke's own list keep their distances and apply to the transformed art, and with objects off so do the effects before it)}",
|
||||
json!({"scaleH": 100.0, "scaleV": 100.0, "moveH": 0.0, "moveV": 0.0, "rotate": 0.0, "copies": 0, "reflectX": false, "reflectY": false, "reference": 4, "random": false, "objects": true, "patterns": false, "strokes": false}),
|
||||
),
|
||||
g(
|
||||
|
||||
@@ -257,6 +257,9 @@ fn reshaped(src: &Node, n: &Node, doc: Option<&Document>) -> Node {
|
||||
if carry.moves(&m.appearance) {
|
||||
carry.apply(&mut m.appearance);
|
||||
}
|
||||
// So do the raster effects of its fills and strokes, by `n`'s Transform effects
|
||||
// ([`crate::follow_items`]).
|
||||
crate::raster::follow_items_of(&mut m, &n.appearance.effects);
|
||||
}
|
||||
without_geometry_effects(&mut m);
|
||||
m
|
||||
|
||||
@@ -1281,6 +1281,23 @@ fn compound_shape(mode: ShapeMode) -> Node {
|
||||
g
|
||||
}
|
||||
|
||||
/// A compound shape's Transform effect turns its fill's drop shadow with the area it turns.
|
||||
#[test]
|
||||
fn a_compound_shapes_transform_turns_its_fills_shadow() {
|
||||
let mut shape = compound_shape(ShapeMode::Add);
|
||||
if let Some(item) = shape.appearance.items.first_mut() {
|
||||
*item.effects_mut() =
|
||||
vec![Effect { id: "stylize.dropShadow".into(), params: serde_json::json!({"x": 10, "y": 0, "blur": 0}), visible: true }];
|
||||
}
|
||||
shape.appearance.effects.push(Effect { id: "distort.transform".into(), params: serde_json::json!({"rotate": 90}), visible: true });
|
||||
let art = shape_art(None, &shape, None).unwrap();
|
||||
let item = art.appearance.items.first().unwrap();
|
||||
match crate::raster_effects(item.effects()).first() {
|
||||
Some(crate::RasterFx::DropShadow { dx, dy, .. }) => assert!(dx.abs() < 1e-9 && (dy + 10.0).abs() < 1e-9, "{dx} {dy}"),
|
||||
other => panic!("expected a drop shadow, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A compound shape draws the area its members' modes make, one unpainted path that its own
|
||||
/// appearance paints; evaluated, it is plain art painted with that appearance.
|
||||
#[test]
|
||||
|
||||
@@ -78,7 +78,7 @@ pub use lighting_presets::{LIGHTING_PRESETS, LightingPreset, lighting_preset_par
|
||||
pub use live::{expand_live, expand_live_deep, expanded_live_group, outline_object, text_outliner};
|
||||
pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks};
|
||||
pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace};
|
||||
pub use raster::{RASTERIZE, RasterFx, Rasterize, color_param, outset, raster_effects, without_geometry};
|
||||
pub use raster::{RASTERIZE, RasterFx, Rasterize, color_param, follow_items, item_effects_in, outset, raster_effects, without_geometry};
|
||||
pub use reshape::{
|
||||
SYMBOL_ART_MAX_NODES, expand_outlined, gives_image_shape, has_gradient_copies, has_item_geometry, has_painted_effects, has_transform_art,
|
||||
has_transform_copies, has_transform_options, image_paint, needs_outline, outline_art, outline_text, reshape,
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::borrow::Cow;
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_doc::color::{BlendMode, Color};
|
||||
use vectorcraft_doc::rastersettings::MAX_ADD_AROUND;
|
||||
use vectorcraft_doc::{Effect, RasterColorModel, RasterEffectsSettings};
|
||||
use vectorcraft_doc::{AppearanceItem, Effect, Node, RasterColorModel, RasterEffectsSettings};
|
||||
|
||||
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
|
||||
|
||||
@@ -478,6 +478,104 @@ pub(crate) fn follow_one(effects: &mut [Effect], t: &Effect) -> bool {
|
||||
changed
|
||||
}
|
||||
|
||||
/// The linear part of the visible Transform effects of `owner`, an object's or a container's own
|
||||
/// effects, in order: what they do to the raster effects of everything the object paints (its
|
||||
/// fills and strokes, and a container's members and theirs), which those effects transform with
|
||||
/// the rest of the art. `None` when they leave them as they are: no Transform effect moves the
|
||||
/// art, or one of them makes copies or moves at random ([`followed`]).
|
||||
pub(crate) fn contents_step(owner: &[Effect]) -> Option<Affine> {
|
||||
let a = owner.iter().filter(|e| e.visible && e.id == "distort.transform").try_fold(Affine::IDENTITY, |a, e| transform_step(e).map(|s| s * a))?;
|
||||
(a != Affine::IDENTITY).then_some(a)
|
||||
}
|
||||
|
||||
/// Make the raster effects of `effects`, the own effects of something inside an object (a fill's
|
||||
/// or stroke's, or a member's), follow `a`, the linear part of the object's Transform effects
|
||||
/// ([`contents_step`]), which apply after all of them. `outer` is what the Transform effects
|
||||
/// between them and `a` do to them (those of the fill's or stroke's object and of the groups that
|
||||
/// hold it inside the object). A raster effect that the Transform effects after it in `effects`
|
||||
/// move ([`followed`]) is set so that it draws as those effects, then `outer` and then `a`
|
||||
/// transform it, whichever of them is applied first. One that a Transform effect after it in
|
||||
/// `effects` holds back keeps its value through those effects, so it is set to draw as `outer` and
|
||||
/// then `a` transform it. `false` when nothing changed.
|
||||
pub(crate) fn follow_contents(effects: &mut [Effect], a: Affine, outer: Affine) -> bool {
|
||||
if a == Affine::IDENTITY || !effects.iter().any(|e| follows(&e.id)) {
|
||||
return false;
|
||||
}
|
||||
let mut changed = false;
|
||||
// The linear part of the Transform effects after the effect at hand, as `followed` reads it;
|
||||
// `None` once one of them holds the effect back, which they then leave as it is.
|
||||
let mut after = Some(Affine::IDENTITY);
|
||||
for e in effects.iter_mut().rev() {
|
||||
if e.id == "distort.transform" {
|
||||
after = after.and_then(|b| transform_step(e).map(|s| b * s));
|
||||
continue;
|
||||
}
|
||||
if !follows(&e.id) {
|
||||
continue;
|
||||
}
|
||||
let b = outer * after.unwrap_or(Affine::IDENTITY);
|
||||
// The effect is still to be mapped by `b` when it is drawn, so it takes b⁻¹·a·b now:
|
||||
// drawn, it is a·b of what it was. A `b` that flattens the art takes `a` alone.
|
||||
let det = b.determinant();
|
||||
let c = if b == Affine::IDENTITY || !(det.is_finite() && det.abs() > 1e-12) { a } else { b.inverse() * a * b };
|
||||
if c.is_finite() {
|
||||
follow_transform(e, c);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Make the raster effects of the fills and strokes of `n` follow the Transform effects of its own
|
||||
/// effect list ([`contents_step`]), as they do while the object is drawn with them: for art that
|
||||
/// applies those effects and keeps the fills and strokes. `false` when nothing changed.
|
||||
pub fn follow_items(n: &mut Node) -> bool {
|
||||
let Some(a) = contents_step(&n.appearance.effects) else { return false };
|
||||
follow_items_by(n, a)
|
||||
}
|
||||
|
||||
/// [`follow_items`] for `n` holding the fills and strokes of an object whose own effects are
|
||||
/// `owner` (the area of a compound shape, which carries none of the shape's effects).
|
||||
pub(crate) fn follow_items_of(n: &mut Node, owner: &[Effect]) -> bool {
|
||||
let Some(a) = contents_step(owner) else { return false };
|
||||
follow_items_by(n, a)
|
||||
}
|
||||
|
||||
/// Make the raster effects of the fills and strokes of `n` follow `a` ([`follow_contents`]).
|
||||
fn follow_items_by(n: &mut Node, a: Affine) -> bool {
|
||||
if !n.appearance.items.iter().any(|i| i.effects().iter().any(|e| follows(&e.id))) {
|
||||
return false;
|
||||
}
|
||||
let mut changed = false;
|
||||
for item in &mut n.appearance.items {
|
||||
changed |= follow_contents(item.effects_mut(), a, Affine::IDENTITY);
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Make the raster effects of `items`, the fills and strokes of an object whose own effects are
|
||||
/// `owner`, follow the Transform effects of `owner` ([`follow_contents`]): for art that applies
|
||||
/// those effects to the object's path and draws each fill and stroke on it as a node of its own.
|
||||
pub(crate) fn follow_paints(items: &mut [AppearanceItem], owner: &[Effect]) {
|
||||
let Some(a) = contents_step(owner) else { return };
|
||||
for item in items {
|
||||
follow_contents(item.effects_mut(), a, Affine::IDENTITY);
|
||||
}
|
||||
}
|
||||
|
||||
/// `effects`, those of a fill or stroke of an object whose own effects are `owner`, with each
|
||||
/// raster effect following the Transform effects of `owner` as well ([`follow_contents`]): the
|
||||
/// list to draw that fill's or stroke's raster effects from ([`raster_effects`]). Borrowed when
|
||||
/// nothing changes.
|
||||
pub fn item_effects_in<'a>(owner: &[Effect], effects: &'a [Effect]) -> Cow<'a, [Effect]> {
|
||||
let Some(a) = contents_step(owner) else { return Cow::Borrowed(effects) };
|
||||
if !effects.iter().any(|e| follows(&e.id)) {
|
||||
return Cow::Borrowed(effects);
|
||||
}
|
||||
let mut out = effects.to_vec();
|
||||
if follow_contents(&mut out, a, Affine::IDENTITY) { Cow::Owned(out) } else { Cow::Borrowed(effects) }
|
||||
}
|
||||
|
||||
/// How many of the effects of `effects` that [`without_geometry`] keeps no longer follow the
|
||||
/// Transform effects listed after `effects` ([`followed`]): those before the last visible Transform
|
||||
/// effect among them that makes copies or moves at random.
|
||||
@@ -511,7 +609,7 @@ fn transform_step(e: &Effect) -> Option<Affine> {
|
||||
/// Does raster effect `id` follow the Transform effects listed after it ([`followed`])? An SVG
|
||||
/// filter does not, and neither does Rasterize, whose image keeps its own pixel grid and room
|
||||
/// around the art.
|
||||
fn follows(id: &str) -> bool {
|
||||
pub(crate) fn follows(id: &str) -> bool {
|
||||
crate::is_raster(id) && id != crate::SVG_FILTER && id != RASTERIZE
|
||||
}
|
||||
|
||||
|
||||
+113
-11
@@ -740,12 +740,16 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget
|
||||
wholes.entry(id).or_insert_with(|| copies_whole(m));
|
||||
});
|
||||
let mut warped = WarpedImages::new();
|
||||
// The raster effects of what the art holds follow each Transform effect that moves it once,
|
||||
// whatever its options, unless one of the effects makes copies or moves at random
|
||||
// ([`crate::raster::contents_step`], [`follow_art`]).
|
||||
let held = listed.iter().any(|e| crate::raster::holds_back(e));
|
||||
for e in fx {
|
||||
let Some(bounds) = warped.bounds(art) else { break };
|
||||
let params = merged_params(&e.id, &e.params);
|
||||
// Where a live-effect plug-in sits, which it is listed by when it is deferred.
|
||||
let effect = owner.iter().position(|x| std::ptr::eq(x, *e)).or_else(|| listed.iter().position(|x| std::ptr::eq(*x, *e)));
|
||||
let step = Step { e, params: ¶ms, bounds, seed, effect, apart, doc, wholes: &wholes };
|
||||
let step = Step { e, params: ¶ms, bounds, seed, effect, apart, doc, wholes: &wholes, held };
|
||||
// Art with linked masks keeps its shape under an effect that a mask in it could not take
|
||||
// as the art it masks did, so no mask is left apart from its art.
|
||||
let before = masked.then(|| (art.clone(), used));
|
||||
@@ -768,8 +772,10 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget
|
||||
/// One geometry effect of an object run over its art ([`reshape_leaves_apart`]): the effect, its
|
||||
/// merged params, the reference box and seed of the object's art, where a deferred live-effect
|
||||
/// plug-in is listed, whether each Transform copy becomes an object, the document that the
|
||||
/// swatches and patterns of painted pieces are looked up in, and [`copies_whole`] of the art of
|
||||
/// each linked mask before the first effect, by the id of the object it masks.
|
||||
/// swatches and patterns of painted pieces are looked up in, [`copies_whole`] of the art of
|
||||
/// each linked mask before the first effect, by the id of the object it masks, and whether one of
|
||||
/// the object's effects holds the raster effects in its art back from its Transform effects
|
||||
/// ([`follow_art`]).
|
||||
struct Step<'a> {
|
||||
e: &'a Effect,
|
||||
params: &'a serde_json::Value,
|
||||
@@ -779,6 +785,7 @@ struct Step<'a> {
|
||||
apart: bool,
|
||||
doc: Option<&'a Document>,
|
||||
wholes: &'a HashMap<NodeId, bool>,
|
||||
held: bool,
|
||||
}
|
||||
|
||||
/// What [`Step::run`] did to a piece of art.
|
||||
@@ -885,6 +892,8 @@ impl Step<'_> {
|
||||
let options = crate::distort::TransformOptions::of(params);
|
||||
(m, copies, options, crate::distort::transform_affine(bounds, params, seed))
|
||||
});
|
||||
// What the effect does to the raster effects in the art, when they follow it.
|
||||
let raster_map = if self.held { None } else { crate::raster::contents_step(std::slice::from_ref(e)) };
|
||||
// In a mask's art, images and symbol instances take only a Transform effect
|
||||
// ([`transform_placed`]): a mask's art that holds one can't take another effect as the art
|
||||
// it masks does.
|
||||
@@ -944,7 +953,7 @@ impl Step<'_> {
|
||||
// The copies of a path replace it by a group of them, the path included.
|
||||
let copied = |leaf: &Node| leaf.children().map_or(0, Vec::len);
|
||||
if options.reach_appearance() {
|
||||
let (made, moved) = transform_leaf(leaf, &path, map, copies, options, room, None);
|
||||
let (made, moved) = transform_leaf(leaf, &path, map, copies, options, room, raster_map.is_some(), None);
|
||||
*used = used.minus(before).plus(made);
|
||||
if moved {
|
||||
// With Transform Objects off the path stays, and its mask takes the
|
||||
@@ -1014,12 +1023,22 @@ impl Step<'_> {
|
||||
*art = Node::group(id, vec![Arc::new(one)]);
|
||||
}
|
||||
let placed = if watch { masked_placed(art, own) } else { 0 };
|
||||
transform_placed(art, m, copies, copy_scaling(options));
|
||||
// Scale Strokes & Effects leaves the raster effects that follow the effect to
|
||||
// [`follow_art`].
|
||||
let mut scaling = copy_scaling(options);
|
||||
if raster_map.is_some() {
|
||||
scaling.effects = options.strokes.then_some(scale_unfollowed as fn(&mut Effect, f64));
|
||||
}
|
||||
transform_placed(art, m, copies, scaling);
|
||||
short |= watch && masked_placed(art, own) < placed.saturating_mul(copies.saturating_add(1));
|
||||
if root && let Some([one]) = art.children().map(Vec::as_slice) {
|
||||
*art = (**one).clone();
|
||||
}
|
||||
}
|
||||
// A mask's art takes them as a group inside the art: its own raster effects follow too.
|
||||
if let Some(a) = raster_map {
|
||||
follow_art(art, a, &kept_ids, Some(&moved_ids), own.then_some(Affine::IDENTITY), depth);
|
||||
}
|
||||
if outlines.iter().any(Option::is_some) {
|
||||
rebase_type_outlines(art, &mut outlines.into_iter());
|
||||
}
|
||||
@@ -1239,15 +1258,94 @@ fn copy_scaling(options: crate::distort::TransformOptions) -> vectorcraft_doc::S
|
||||
}
|
||||
}
|
||||
|
||||
/// [`crate::scale_effect`] for the effects that do not follow a Transform effect as raster effects
|
||||
/// do ([`crate::raster::follows`]): Scale Strokes & Effects scales them, and [`follow_art`] moves
|
||||
/// the others.
|
||||
fn scale_unfollowed(e: &mut Effect, s: f64) {
|
||||
if !crate::raster::follows(&e.id) {
|
||||
crate::scale_effect(e, s);
|
||||
}
|
||||
}
|
||||
|
||||
/// Make the raster effects under `art` follow `a`, the linear part of a Transform effect that moved
|
||||
/// it ([`crate::raster::follow_contents`]): those of every object in it (its own and its fills' and
|
||||
/// strokes'), the art's own effects excepted (`up` is `None` for the art itself). A path that kept
|
||||
/// its shape (in `kept`), and a group with such a path under it, keep their own and their fills'
|
||||
/// and strokes'. `up` is what the Transform effects of the groups between an object and the art do
|
||||
/// to the object's effects. A Transform effect of an object's own that makes copies or moves at
|
||||
/// random leaves the effects it holds back as they are, so those follow `a` and the groups' maps
|
||||
/// alone. The linked opacity mask of an object that took the effect as an object (in `moved`, and
|
||||
/// every image and symbol instance) moved with it, so the raster effects in the mask's art follow
|
||||
/// `a` as those of a mask that takes the effect as art do ([`Step::reshape_masks`]); `moved` is
|
||||
/// `None` inside such a mask's art, where every linked mask moved. The walk reaches the groups and
|
||||
/// layers that [`for_each_leaf`] made the art's own, so it copies nothing. Returns whether a path in
|
||||
/// `kept` is under `n`.
|
||||
fn follow_art(n: &mut Node, a: Affine, kept: &HashSet<NodeId>, moved: Option<&HashSet<NodeId>>, up: Option<Affine>, depth: usize) -> bool {
|
||||
if n.is_guide() {
|
||||
return false;
|
||||
}
|
||||
if moved.is_none_or(|moved| moved.contains(&n.id)) || is_placed(n) {
|
||||
follow_moved_mask(n, a, depth);
|
||||
}
|
||||
// What the object's own Transform effects then do to its fills' and strokes' effects and its
|
||||
// members' (nothing when one of them holds those back); the art's own effects are the ones
|
||||
// being applied.
|
||||
let below = up.map_or(Affine::IDENTITY, |up| crate::raster::contents_step(&n.appearance.effects).map_or(up, |s| up * s));
|
||||
let keeps = match &mut n.kind {
|
||||
NodeKind::Path { .. } | NodeKind::Compound { .. } => kept.contains(&n.id),
|
||||
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => {
|
||||
let mut keeps = false;
|
||||
for c in children.iter_mut() {
|
||||
keeps |= follow_art(Arc::make_mut(c), a, kept, moved, Some(below), depth);
|
||||
}
|
||||
keeps
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if !keeps {
|
||||
if let Some(up) = up
|
||||
&& contents_raster(&n.appearance.effects)
|
||||
{
|
||||
crate::raster::follow_contents(&mut n.appearance.effects, a, up);
|
||||
}
|
||||
if n.appearance.items.iter().any(|i| contents_raster(i.effects())) {
|
||||
for item in &mut n.appearance.items {
|
||||
crate::raster::follow_contents(item.effects_mut(), a, below);
|
||||
}
|
||||
}
|
||||
}
|
||||
keeps
|
||||
}
|
||||
|
||||
/// Make the raster effects in the art of the linked opacity mask of `n`, which moved with `n`,
|
||||
/// follow `a` ([`follow_art`]), with those of the linked masks inside that art, which moved too. A
|
||||
/// mask past [`vectorcraft_doc::depth::MAX_DEPTH`] masks inside masks stays as it is.
|
||||
fn follow_moved_mask(n: &mut Node, a: Affine, depth: usize) {
|
||||
if depth >= vectorcraft_doc::depth::MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
let Some(m) = n.extras_if_any().and_then(|x| x.mask.as_mut()).filter(|m| m.linked) else { return };
|
||||
follow_art(Arc::make_mut(&mut m.art), a, &HashSet::new(), None, Some(Affine::IDENTITY), depth + 1);
|
||||
}
|
||||
|
||||
/// Does any of `effects` follow a Transform effect as a raster effect ([`crate::raster::follows`])?
|
||||
fn contents_raster(effects: &[Effect]) -> bool {
|
||||
effects.iter().any(|e| crate::raster::follows(&e.id))
|
||||
}
|
||||
|
||||
/// A Transform effect whose Transform Patterns or Scale Strokes & Effects is on (`options`), with
|
||||
/// map `m` and `copies`, applied to `leaf` (a path or compound path with geometry `path`) as Object
|
||||
/// › Transform with those options transforms an object (#885): the strokes' weights and dashes
|
||||
/// and the distances of the effects on its fills and strokes scale by `m`'s mean scale, and its
|
||||
/// pattern paints transform by `m`. With copies, the original keeps them and copy `k` takes them
|
||||
/// `k` times, each copy an object of its own ([`copy_leaf`], within `budget`). With Transform
|
||||
/// Objects off the geometry stays and the options apply once. The effects `kept` names keep their
|
||||
/// values on the leaf and on each copy ([`keep_listed`]). Returns what the leaf costs after, and
|
||||
/// `false` when the path kept its shape because the moved path would pass `budget`.
|
||||
/// Objects off the geometry stays and the options apply once. With `follows`, Scale Strokes &
|
||||
/// Effects leaves the raster effects that follow the Transform effect ([`follow_art`]) to the
|
||||
/// caller. The effects `kept` names keep their values on the leaf and on each copy
|
||||
/// ([`keep_listed`]), whether Scale Strokes & Effects scaled them or left them to the caller.
|
||||
/// Returns what the leaf costs after, and `false` when the path kept its shape because the moved
|
||||
/// path would pass `budget`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn transform_leaf(
|
||||
leaf: &mut Node,
|
||||
path: &PathData,
|
||||
@@ -1255,9 +1353,13 @@ fn transform_leaf(
|
||||
copies: usize,
|
||||
options: crate::distort::TransformOptions,
|
||||
budget: Budget,
|
||||
follows: bool,
|
||||
kept: Kept,
|
||||
) -> (Budget, bool) {
|
||||
let scaling = copy_scaling(options);
|
||||
let mut scaling = copy_scaling(options);
|
||||
if follows && copies == 0 {
|
||||
scaling.effects = options.strokes.then_some(scale_unfollowed as fn(&mut Effect, f64));
|
||||
}
|
||||
if !options.objects {
|
||||
if options.strokes
|
||||
&& let Some(k) = vectorcraft_doc::Scaling::factor(m).filter(|k| k.is_finite() && *k > 0.0)
|
||||
@@ -1716,7 +1818,7 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt
|
||||
set_geometry(&mut p, g.clone(), rule);
|
||||
let after = kept_before.len();
|
||||
let keep = |k: usize| k >= after || !options.objects;
|
||||
let (made, moved) = transform_leaf(&mut p, &g, map, copies, options, room, Some((&rest, &keep)));
|
||||
let (made, moved) = transform_leaf(&mut p, &g, map, copies, options, room, false, Some((&rest, &keep)));
|
||||
*used = used.plus(made);
|
||||
let from = crate::raster::held_back(before);
|
||||
if moved && !held_later {
|
||||
@@ -1795,7 +1897,7 @@ fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), h
|
||||
let options = crate::distort::TransformOptions::of(¶ms);
|
||||
let map = crate::distort::transform_affine(bounds, ¶ms, seed);
|
||||
let keep = |k: usize| k > j || (k < j && !options.objects);
|
||||
let (made, moved) = transform_leaf(leaf, &path, map, copies, options, room, Some((item.effects(), &keep)));
|
||||
let (made, moved) = transform_leaf(leaf, &path, map, copies, options, room, false, Some((item.effects(), &keep)));
|
||||
*used = used.minus(before).plus(made);
|
||||
if moved {
|
||||
follow_piece(leaf, was, from, &e);
|
||||
|
||||
@@ -619,9 +619,13 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option<Node> {
|
||||
} else {
|
||||
let path = geometry(&path, &n.appearance.effects, &ctx);
|
||||
remaining = crate::without_geometry(&remaining);
|
||||
// The object's Transform effects, applied to the path above, transform the items' raster
|
||||
// effects too.
|
||||
let mut items: Vec<AppearanceItem> = n.appearance.items.iter().filter(|i| crate::paints(i)).cloned().collect();
|
||||
crate::raster::follow_paints(&mut items, &n.appearance.effects);
|
||||
// Every item's shadow goes behind every item's art.
|
||||
let mut shadows = vec![];
|
||||
for item in n.appearance.items.iter().filter(|i| crate::paints(i)) {
|
||||
for item in &items {
|
||||
if let Some(index) = item.effects().iter().position(|e| e.visible && is_revolve(&e.id)) {
|
||||
let fx = item.effects().get(index)?;
|
||||
let p = geometry(&path, item.effects().get(..index)?, &ctx.item(item));
|
||||
|
||||
@@ -985,6 +985,10 @@ pub fn solid_art_in(doc: Option<&Document>, n: &Node) -> Option<Node> {
|
||||
} else {
|
||||
let path = geometry(&path, &n.appearance.effects, &ctx);
|
||||
remaining = crate::without_geometry(&n.appearance.effects);
|
||||
// The object's Transform effects, applied to the path above, transform the items' raster
|
||||
// effects too.
|
||||
let mut paints = paints;
|
||||
crate::raster::follow_paints(&mut paints, &n.appearance.effects);
|
||||
// Every item's shadow goes behind every item's art.
|
||||
let mut shadows = vec![];
|
||||
for item in &paints {
|
||||
|
||||
@@ -325,6 +325,23 @@ fn a_shadow_before_a_transform_follows_it_into_3d_art() {
|
||||
}
|
||||
}
|
||||
|
||||
/// An object's Transform effect transforms the raster effects of its fills when only a fill has a
|
||||
/// 3D effect: the shadow after that fill's 3D effect and another fill's shadow, as it does without
|
||||
/// the 3D effect.
|
||||
#[test]
|
||||
fn an_objects_transform_moves_its_fills_shadows_beside_a_fills_3d_effect() {
|
||||
for three_d in [crate::EXTRUDE, crate::REVOLVE] {
|
||||
let art = |n: &Node| if three_d == crate::EXTRUDE { crate::solid_art(n) } else { crate::revolve_art(n) };
|
||||
let mut n = fill_square(18, vec![fx(three_d, json!({})), shadow(10.0, 10.0, 0.0)]);
|
||||
let mut other = n.appearance.items[0].clone();
|
||||
*other.effects_mut() = vec![shadow(10.0, 10.0, 0.0)];
|
||||
n.appearance.items.push(other);
|
||||
n.appearance.effects = vec![transform(json!({"scaleH": 200, "scaleV": 100}))];
|
||||
let drawn = shadows_in(&art(&n).unwrap());
|
||||
assert!(drawn.len() > 1 && near(&drawn, &vec![(20.0, 10.0); drawn.len()]), "{three_d}: {drawn:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glows_blurs_and_feathers_scale_with_the_transform_after_them() {
|
||||
let effects = [
|
||||
@@ -419,3 +436,217 @@ fn expanded_and_exported_objects_keep_the_shadow_the_canvas_draws() {
|
||||
assert!(close(drawn_shadow(&b.appearance.effects), (20.0, 20.0, 8.0)), "{id}: {:?}", b.appearance.effects);
|
||||
}
|
||||
}
|
||||
|
||||
/// A group of `members` with `effects` of its own.
|
||||
fn group(id: u64, members: Vec<Node>, effects: Vec<Effect>) -> Node {
|
||||
let mut g = Node::group(NodeId(id), members.into_iter().map(std::sync::Arc::new).collect());
|
||||
g.appearance.effects = effects;
|
||||
g
|
||||
}
|
||||
|
||||
/// The node with `id` under `n`.
|
||||
fn node_in(n: &Node, id: u64) -> Node {
|
||||
let mut found = None;
|
||||
n.walk(&mut |m| {
|
||||
if m.id == NodeId(id) && found.is_none() {
|
||||
found = Some(m.clone());
|
||||
}
|
||||
});
|
||||
found.unwrap_or_else(|| panic!("no node {id}"))
|
||||
}
|
||||
|
||||
/// The offset of the Drop Shadow that the first fill of `n` draws, its own effects and `n`'s
|
||||
/// Transform effects applied ([`crate::item_effects_in`]).
|
||||
fn fill_shadow(n: &Node) -> (f64, f64) {
|
||||
let item = n.appearance.items.first().unwrap();
|
||||
match raster_effects(&crate::item_effects_in(&n.appearance.effects, item.effects())).first() {
|
||||
Some(RasterFx::DropShadow { dx, dy, .. }) => (*dx, *dy),
|
||||
other => panic!("expected a drop shadow, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A group's Transform effect transforms the raster effects of what the group holds with the art,
|
||||
/// whatever its options: a member's own shadow, a member's fill's shadow and a nested group's own
|
||||
/// shadow, drawn (the group's art) and baked.
|
||||
#[test]
|
||||
fn a_groups_transform_moves_its_members_shadows() {
|
||||
let cases = [
|
||||
(json!({"scaleH": 200, "scaleV": 200}), (20.0, 20.0)),
|
||||
(json!({"scaleH": 200, "scaleV": 100, "strokes": true}), (20.0, 10.0)),
|
||||
(json!({"rotate": 90, "patterns": true}), (10.0, -10.0)),
|
||||
(json!({"rotate": 90, "strokes": true}), (10.0, -10.0)),
|
||||
];
|
||||
for (params, want) in cases {
|
||||
let members = vec![
|
||||
square(21, vec![shadow(10.0, 10.0, 0.0)]),
|
||||
fill_square(22, vec![shadow(10.0, 10.0, 0.0)]),
|
||||
group(23, vec![square(24, vec![])], vec![shadow(10.0, 10.0, 0.0)]),
|
||||
];
|
||||
let g = group(20, members, vec![transform(params.clone())]);
|
||||
let art = crate::evaluate_container(None, &g).unwrap();
|
||||
assert!(near(&shadows_in(&art), &[want; 3]), "{params}: drawn {:?}", shadows_in(&art));
|
||||
let mut d = Document::new(800.0, 800.0);
|
||||
let layer = d.layers[0].id;
|
||||
d.insert(Some(layer), 0, g).unwrap();
|
||||
let baked = bake_document(&d).unwrap();
|
||||
for id in [21, 22, 23] {
|
||||
let b = baked.node(NodeId(id)).unwrap();
|
||||
assert!(near(&shadows_in(b), &[want]), "{params}: {id} baked {:?}", shadows_in(b));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A group's Transform effect that makes copies or moves at random leaves its members' shadows as
|
||||
/// they are, as it leaves the shadows listed before it in its own effects.
|
||||
#[test]
|
||||
fn a_groups_copying_transform_leaves_its_members_shadows() {
|
||||
for params in [json!({"scaleH": 200, "scaleV": 200, "copies": 1}), json!({"scaleH": 200, "scaleV": 200, "random": true})] {
|
||||
let g = group(25, vec![square(26, vec![shadow(10.0, 10.0, 0.0)]), square(27, vec![])], vec![transform(params.clone())]);
|
||||
let art = crate::evaluate_container(None, &g).unwrap();
|
||||
let drawn = shadows_in(&art);
|
||||
assert!(!drawn.is_empty() && near(&drawn, &vec![(10.0, 10.0); drawn.len()]), "{params}: {drawn:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The Transform effects inside a group apply before the group's: a member's own turn of 90°
|
||||
/// applies to its shadow before the group's uneven scale does, for the member's own shadow, its
|
||||
/// fill's shadow, and the shadow of a member of a nested group that has the turn.
|
||||
#[test]
|
||||
fn a_groups_transform_applies_after_its_members_own() {
|
||||
let turn = || transform(json!({"rotate": 90}));
|
||||
let uneven = || transform(json!({"scaleH": 200, "scaleV": 100}));
|
||||
// Turned first, (10, 0) goes to (0, -10), which the scale leaves; scaled first, it would be (0, -20).
|
||||
let want = (0.0, -10.0);
|
||||
let own = group(30, vec![square(31, vec![shadow(10.0, 0.0, 0.0), turn()])], vec![uneven()]);
|
||||
let art = crate::evaluate_container(None, &own).unwrap();
|
||||
assert!(near(&shadows_in(&art), &[want]), "own: {:?}", shadows_in(&art));
|
||||
let mut on_fill = fill_square(33, vec![shadow(10.0, 0.0, 0.0)]);
|
||||
on_fill.appearance.effects = vec![turn()];
|
||||
let art = crate::evaluate_container(None, &group(32, vec![on_fill], vec![uneven()])).unwrap();
|
||||
assert!(near(&[fill_shadow(&node_in(&art, 33))], &[want]), "fill: {:?}", fill_shadow(&node_in(&art, 33)));
|
||||
let nested = group(34, vec![group(35, vec![square(36, vec![shadow(10.0, 0.0, 0.0)])], vec![turn()])], vec![uneven()]);
|
||||
let art = crate::evaluate_container(None, &nested).unwrap();
|
||||
let inner = crate::evaluate_container(None, &node_in(&art, 35)).unwrap();
|
||||
assert!(near(&shadows_in(&inner), &[want]), "nested: {:?}", shadows_in(&inner));
|
||||
}
|
||||
|
||||
/// An object's Transform effect transforms the raster effects of its fills and strokes with the
|
||||
/// art, whatever its options: drawn, drawn through the art an option makes, and baked. One that
|
||||
/// makes copies leaves them.
|
||||
#[test]
|
||||
fn an_objects_transform_moves_its_fills_shadows() {
|
||||
let cases = [
|
||||
(json!({"scaleH": 200, "scaleV": 100}), (20.0, 10.0)),
|
||||
(json!({"scaleH": 200, "scaleV": 100, "strokes": true}), (20.0, 10.0)),
|
||||
(json!({"rotate": 90, "patterns": true}), (10.0, -10.0)),
|
||||
(json!({"rotate": 90, "strokes": true}), (10.0, -10.0)),
|
||||
(json!({"scaleH": 200, "scaleV": 200, "copies": 1}), (10.0, 10.0)),
|
||||
];
|
||||
for (params, want) in cases {
|
||||
let mut n = fill_square(40, vec![shadow(10.0, 10.0, 0.0)]);
|
||||
n.appearance.effects = vec![transform(params.clone())];
|
||||
if crate::has_transform_art(&n) {
|
||||
let art = crate::reshape(None, &n, None).unwrap();
|
||||
let drawn = shadows_in(&art);
|
||||
assert!(!drawn.is_empty() && near(&drawn, &vec![want; drawn.len()]), "{params}: drawn {drawn:?}");
|
||||
} else {
|
||||
assert!(near(&[fill_shadow(&n)], &[want]), "{params}: drawn {:?}", fill_shadow(&n));
|
||||
}
|
||||
let mut d = Document::new(800.0, 800.0);
|
||||
let layer = d.layers[0].id;
|
||||
d.insert(Some(layer), 0, n).unwrap();
|
||||
let baked = bake_document(&d).unwrap();
|
||||
let drawn = shadows_in(baked.node(NodeId(40)).unwrap());
|
||||
assert!(!drawn.is_empty() && near(&drawn, &vec![want; drawn.len()]), "{params}: baked {drawn:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A 10 pt image at the origin with `effects`.
|
||||
fn image(id: u64, effects: Vec<Effect>) -> Node {
|
||||
let mut n = Node::new(
|
||||
NodeId(id),
|
||||
vectorcraft_doc::NodeKind::Image(Box::new(vectorcraft_doc::ImageObject {
|
||||
key: "k".into(),
|
||||
width: 10,
|
||||
height: 10,
|
||||
xf: vectorcraft_geom::Affine::IDENTITY,
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
})),
|
||||
);
|
||||
n.appearance.effects = effects;
|
||||
n
|
||||
}
|
||||
|
||||
/// A group's Transform effect transforms, with Scale Strokes & Effects off or on, the raster
|
||||
/// effects that a member's own Transform effect with copies holds back: they keep their value
|
||||
/// through the member's effect and transform with the group's. That holds for a shadow listed
|
||||
/// before the member's copying effect, one listed after it, a fill's shadow under it, the shadow
|
||||
/// of a member of a nested group with such an effect, and an image's shadow.
|
||||
#[test]
|
||||
fn a_groups_transform_moves_the_shadows_its_members_copies_hold_back() {
|
||||
let copies = || transform(json!({"moveH": 60, "copies": 1}));
|
||||
// Halved and turned 90° counterclockwise on screen, (10, 10) goes to (5, -5).
|
||||
let want = (5.0, -5.0);
|
||||
for strokes in [false, true] {
|
||||
let mut on_fill = fill_square(73, vec![shadow(10.0, 10.0, 0.0)]);
|
||||
on_fill.appearance.effects = vec![copies()];
|
||||
let members = vec![
|
||||
square(71, vec![shadow(10.0, 10.0, 0.0), copies()]),
|
||||
square(72, vec![copies(), shadow(10.0, 10.0, 0.0)]),
|
||||
on_fill,
|
||||
group(74, vec![square(75, vec![shadow(10.0, 10.0, 0.0)])], vec![copies()]),
|
||||
image(76, vec![shadow(10.0, 10.0, 0.0)]),
|
||||
];
|
||||
let g = group(70, members, vec![transform(json!({"scaleH": 50, "scaleV": 50, "rotate": 90, "strokes": strokes}))]);
|
||||
let art = crate::evaluate_container(None, &g).unwrap();
|
||||
for id in [71, 72, 73, 75, 76] {
|
||||
let drawn = shadows_in(&node_in(&art, id));
|
||||
assert!(near(&drawn, &[want]), "Scale Strokes & Effects {strokes}: {id} {drawn:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A group's Transform effect transforms the raster effects in the art of a member's linked opacity
|
||||
/// mask with the member, a path's or an image's, with Scale Strokes & Effects off or on.
|
||||
#[test]
|
||||
fn a_groups_transform_moves_the_shadow_in_a_members_mask() {
|
||||
let masked = |mut n: Node, id: u64| {
|
||||
n.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(square(id, vec![shadow(10.0, 10.0, 0.0)]), true)));
|
||||
n
|
||||
};
|
||||
for strokes in [false, true] {
|
||||
let params = json!({"scaleH": 50, "scaleV": 50, "rotate": 90, "strokes": strokes});
|
||||
let g = group(80, vec![masked(square(81, vec![]), 82), masked(image(83, vec![]), 84)], vec![transform(params)]);
|
||||
let art = crate::evaluate_container(None, &g).unwrap();
|
||||
for id in [81, 83] {
|
||||
let member = node_in(&art, id);
|
||||
let mask = member.extras().mask.as_deref().unwrap();
|
||||
assert!(near(&shadows_in(&mask.art), &[(5.0, -5.0)]), "Scale Strokes & Effects {strokes}: {id} {:?}", shadows_in(&mask.art));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A group's Transform effect transforms the shadows of its members' fills that carry a Transform
|
||||
/// effect of their own with Scale Strokes & Effects on, with the group's Scale Strokes & Effects off
|
||||
/// or on: a shadow listed after the fill's Transform effect keeps its value through that effect and
|
||||
/// transforms with the group's, and one listed before it follows the fill's Transform effect and
|
||||
/// then the group's. Drawn through each member's art.
|
||||
#[test]
|
||||
fn a_groups_transform_moves_its_members_fills_shadows_beside_their_own_transform() {
|
||||
let scaled = || transform(json!({"scaleH": 200, "scaleV": 200, "strokes": true}));
|
||||
// Halved and turned 90° counterclockwise on screen, (10, 10) goes to (5, -5); doubled first by
|
||||
// the fill's own Transform effect, it goes to (10, -10).
|
||||
let cases = [(91, vec![scaled(), shadow(10.0, 10.0, 0.0)], (5.0, -5.0)), (92, vec![shadow(10.0, 10.0, 0.0), scaled()], (10.0, -10.0))];
|
||||
for strokes in [false, true] {
|
||||
let members = cases.iter().map(|(id, effects, _)| fill_square(*id, effects.clone())).collect();
|
||||
let g = group(90, members, vec![transform(json!({"scaleH": 50, "scaleV": 50, "rotate": 90, "strokes": strokes}))]);
|
||||
let art = crate::evaluate_container(None, &g).unwrap();
|
||||
for (id, _, want) in &cases {
|
||||
let member = node_in(&art, *id);
|
||||
let drawn = shadows_in(&crate::reshape(None, &member, None).unwrap());
|
||||
assert!(!drawn.is_empty() && near(&drawn, &vec![*want; drawn.len()]), "Scale Strokes & Effects {strokes}: {id} {drawn:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ mod addons;
|
||||
mod apiref;
|
||||
pub(crate) mod appearance;
|
||||
pub mod arrowheads;
|
||||
mod artboardalign;
|
||||
mod assets;
|
||||
mod attributes;
|
||||
pub mod brushlib;
|
||||
@@ -338,6 +339,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(variablecmds::specs());
|
||||
v.extend(docinfo::specs());
|
||||
v.extend(panelcmds::specs());
|
||||
v.extend(artboardalign::specs());
|
||||
v.extend(buildcmds::specs());
|
||||
v.extend(shaper::specs());
|
||||
v.extend(brushsym::specs());
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
//! Align and Distribute for artboards: what the Align panel's buttons run while the Artboard tool
|
||||
//! is chosen.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::Scaling;
|
||||
use vectorcraft_geom::{Rect, Vec2};
|
||||
|
||||
use super::object::choice;
|
||||
use super::panelcmds::{artboard_index, artboards_art, locked_and_hidden, named_or_selected, note_artboards};
|
||||
use super::{Affine, CommandSpec, NodeId, Result, Session, bad, check_switches, cmd, has_doc, switch_or};
|
||||
use crate::EngineError;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"artboard.align",
|
||||
"Align",
|
||||
[],
|
||||
None,
|
||||
"{index? | indices?: [n…], selectedArtboards?: [n…] (as artboard.duplicate; default: the artboards the Artboard tool has selected, else its active one), horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\" (one of the two is needed), to?: \"selection\"|\"artboard\" (default \"selection\": the bounds of the artboards together; \"artboard\": artboard `artboard`, which stays where it is), artboard?: n (0-based; the artboard to align to with to: \"artboard\"; default: the Artboard tool's active one; it need not be one of the artboards aligned), activeArtboard?: n (the journal records that default here, for a replay; `artboard` goes ahead of it), moveArt?: bool (the art fully inside each artboard moves with it; default: the Artboard tool's Move Artwork with Artboard option), patterns?: bool, lockedAndHidden?: bool (as artboard.move)} (Align panel › Align Objects with the Artboard tool chosen) each artboard moves until its edge or center is on that edge or center of what it aligns to, with its guides, in one undo step; a locked artboard stays where it is and still counts in the bounds, and the call fails with `the artboard is locked` when every artboard it names is locked; when no artboard moves, no undo step is recorded → {moved: [index…]}",
|
||||
has_doc,
|
||||
align
|
||||
),
|
||||
cmd!(
|
||||
"artboard.distribute",
|
||||
"Distribute",
|
||||
[],
|
||||
None,
|
||||
"{index? | indices?, selectedArtboards? (as artboard.align), horizontal?: \"left\"|\"center\"|\"right\" | vertical?: \"top\"|\"center\"|\"bottom\" (one is needed; horizontal goes first), to?: \"selection\"|\"artboard\", artboard?: n, activeArtboard?: n (as artboard.align; with `spacing` the artboard must be one of those distributed), spacing?: pt (used with to: \"artboard\"), moveArt?, patterns?, lockedAndHidden? (as artboard.align)} (Align panel › Distribute Objects with the Artboard tool chosen) the named edges or centers of the artboards are spaced evenly between the first and the last along the axis, which stay where they are (three or more artboards); with to: \"artboard\", the artboards are spaced the same way and then moved together so that artboard `artboard`, when it is one of them, stays where it is; with to: \"artboard\" and `spacing`, that artboard stays and the edges or centers are placed `spacing` apart, outward from it in their order (two or more); a locked artboard stays where it is and keeps its place in the order, and the call fails with `the artboard is locked` when every artboard it names is locked; a spacing that would move an artboard past the canvas is an error; with fewer artboards, or when none moves, no undo step is recorded → {moved: [index…]}",
|
||||
has_doc,
|
||||
distribute
|
||||
),
|
||||
cmd!(
|
||||
"artboard.distributeSpacing",
|
||||
"Distribute Spacing",
|
||||
[],
|
||||
None,
|
||||
"{index? | indices?, selectedArtboards? (as artboard.align), axis?: \"horizontal\"|\"vertical\" (default horizontal), to?: \"selection\"|\"artboard\", artboard?: n, activeArtboard?: n (as artboard.distribute), spacing?: pt (used with to: \"artboard\"), moveArt?, patterns?, lockedAndHidden? (as artboard.align)} (Align panel › Distribute Spacing with the Artboard tool chosen) the gaps between the artboards along the axis become equal, the first and the last staying where they are (three or more artboards); with to: \"artboard\", the gaps become equal the same way and the artboards then move together so that artboard `artboard`, when it is one of them, stays where it is; with to: \"artboard\" and `spacing`, that artboard stays and the others are placed with a gap of `spacing` between neighbors (two or more; a negative spacing overlaps them); a locked artboard stays where it is and keeps its place in the order, and the call fails with `the artboard is locked` when every artboard it names is locked; a spacing that would move an artboard past the canvas is an error; with fewer artboards, or when none moves, no undo step is recorded → {moved: [index…]}",
|
||||
has_doc,
|
||||
distribute_spacing
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// What an artboard Align or Distribute command acts on, read from its params.
|
||||
struct Boards {
|
||||
/// The artboards named, in artboard order, each with its rectangle.
|
||||
items: Vec<(usize, Rect)>,
|
||||
/// With `to: "artboard"`, the artboard to align to and its rectangle.
|
||||
reference: Option<(usize, Rect)>,
|
||||
/// Whether the art inside each artboard moves with it.
|
||||
move_art: bool,
|
||||
/// Whether locked and hidden art moves too.
|
||||
locked_art: bool,
|
||||
}
|
||||
|
||||
/// Reads the params that `artboard.align`, `artboard.distribute` and `artboard.distributeSpacing`
|
||||
/// (command `cmd`) share, and adds the artboards and the art choice to the journal entry. The
|
||||
/// artboard to align to is `artboard`, else `activeArtboard` (a replay of the journal), else the
|
||||
/// Artboard tool's active one, which the journal entry then records as `activeArtboard`. It fails
|
||||
/// when every artboard named is locked.
|
||||
fn boards(s: &mut Session, p: &Value, cmd: &str) -> Result<Boards> {
|
||||
check_switches(p, &["patterns"], cmd)?;
|
||||
let to_artboard = choice(p, cmd, "to", &["selection", "artboard"])? == Some("artboard");
|
||||
let indices = named_or_selected(s, p, cmd)?;
|
||||
let given = |key: &str| -> Result<Option<usize>> {
|
||||
match p.get(key).filter(|v| !v.is_null()) {
|
||||
Some(v) => artboard_index(&s.doc()?.doc, &json!({ "index": v }), cmd).map(Some),
|
||||
None => Ok(None),
|
||||
}
|
||||
};
|
||||
let (named, recorded) = (given("artboard")?, given("activeArtboard")?);
|
||||
let move_art = switch_or(p, "moveArt", s.tool_options_of("artboard")["moveArt"].as_bool().unwrap_or(true), cmd)?;
|
||||
let locked_art = locked_and_hidden(s, p, move_art, cmd)?;
|
||||
note_artboards(s, p, &indices);
|
||||
s.note_journal("moveArt", json!(move_art));
|
||||
let reference = match (to_artboard, named.or(recorded)) {
|
||||
(false, _) => None,
|
||||
(true, Some(i)) => Some(i),
|
||||
(true, None) => {
|
||||
let active = s.tool_options_of("artboard")["active"].clone();
|
||||
Some(artboard_index(&s.doc()?.doc, &json!({ "index": active }), cmd)?)
|
||||
}
|
||||
};
|
||||
if let Some(i) = reference.filter(|_| named.is_none()) {
|
||||
s.note_journal("activeArtboard", json!(i));
|
||||
}
|
||||
let d = &s.doc()?.doc;
|
||||
let rect = |i: usize| d.artboards.get(i).map(|a| (i, a.rect)).ok_or_else(|| EngineError::Other("no such artboard".into()));
|
||||
let items = indices.iter().map(|i| rect(*i)).collect::<Result<Vec<_>>>()?;
|
||||
if indices.iter().all(|i| d.artboards.get(*i).is_some_and(|a| a.locked)) {
|
||||
return Err(EngineError::Other("the artboard is locked".into()));
|
||||
}
|
||||
Ok(Boards { items, reference: reference.map(rect).transpose()?, move_art, locked_art })
|
||||
}
|
||||
|
||||
/// `spacing` of the Distribute commands: a finite number, or `None` when it is missing or null.
|
||||
fn spacing(p: &Value, cmd: &str) -> Result<Option<f64>> {
|
||||
p.get("spacing")
|
||||
.filter(|v| !v.is_null())
|
||||
.map(|v| v.as_f64().filter(|g| g.is_finite()).ok_or_else(|| bad(cmd, "`spacing` must be a number")))
|
||||
.transpose()
|
||||
}
|
||||
|
||||
/// The place in `items` of the artboard `b` aligns to, which the Distribute commands keep where it
|
||||
/// is. `None` without one, or when it is not one of `items`; with a `gap` that is an error
|
||||
/// (`no such artboard`), since the gap is measured from it.
|
||||
fn kept(b: &Boards, items: &[(usize, Rect)], gap: Option<f64>) -> Result<Option<usize>> {
|
||||
let Some((reference, _)) = b.reference else { return Ok(None) };
|
||||
match items.iter().position(|(i, _)| *i == reference) {
|
||||
None if gap.is_some() => Err(EngineError::Other("no such artboard".into())),
|
||||
at => Ok(at),
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves each artboard of `moves` by its offset as undo step `label`, with its guides and, when
|
||||
/// `b` says so, the art inside it. Locked artboards stay where they are. When no artboard moves
|
||||
/// more than 1e-9 pt on either axis, the document and its undo history stay as they are. The edit
|
||||
/// fails with nothing changed when an artboard would end up past the canvas, as every edit does.
|
||||
fn apply(s: &mut Session, label: &str, p: &Value, b: &Boards, moves: &[(usize, Vec2)]) -> Result<Value> {
|
||||
let d = &s.doc()?.doc;
|
||||
let moves: Vec<(usize, Vec2)> =
|
||||
moves.iter().copied().filter(|(i, dv)| (dv.x.abs() > 1e-9 || dv.y.abs() > 1e-9) && d.artboards.get(*i).is_some_and(|a| !a.locked)).collect();
|
||||
let moved: Vec<usize> = moves.iter().map(|(i, _)| *i).collect();
|
||||
if moves.is_empty() {
|
||||
return Ok(json!({ "moved": moved }));
|
||||
}
|
||||
let arts = artboards_art(s, &moved, b.move_art, b.locked_art, true)?;
|
||||
let ids: Vec<NodeId> = arts.iter().flat_map(|(_, a)| a.iter().copied()).collect();
|
||||
let sc = Scaling { patterns: super::object::transform_patterns(s, p, &ids)?, ..Scaling::default() };
|
||||
s.edit(label, |d, _| {
|
||||
for ((i, dv), (_, art)) in moves.iter().zip(&arts) {
|
||||
let a = d.artboards.get_mut(*i).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
|
||||
a.rect = a.rect + *dv;
|
||||
let id = a.id;
|
||||
d.move_artboard_guides(id, *dv);
|
||||
for id in art {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
n.transform(Affine::translate(*dv), sc);
|
||||
super::object::keep_pixel_aligned(n, sc);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "moved": moved }))
|
||||
}
|
||||
|
||||
fn align(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "artboard.align";
|
||||
let h = choice(p, C, "horizontal", &["left", "center", "right"])?;
|
||||
let v = choice(p, C, "vertical", &["top", "center", "bottom"])?;
|
||||
if h.is_none() && v.is_none() {
|
||||
return Err(bad(C, "give horizontal or vertical"));
|
||||
}
|
||||
let b = boards(s, p, C)?;
|
||||
let Some(r) = b.reference.map(|(_, r)| r).or_else(|| b.items.iter().map(|(_, r)| *r).reduce(|a, b| a.union(b))) else {
|
||||
return Ok(json!({ "moved": [] }));
|
||||
};
|
||||
let moves: Vec<(usize, Vec2)> = b
|
||||
.items
|
||||
.iter()
|
||||
.map(|(i, a)| {
|
||||
let dx = match h {
|
||||
Some("left") => r.x0 - a.x0,
|
||||
Some("center") => r.center().x - a.center().x,
|
||||
Some("right") => r.x1 - a.x1,
|
||||
_ => 0.0,
|
||||
};
|
||||
let dy = match v {
|
||||
Some("top") => r.y0 - a.y0,
|
||||
Some("center") => r.center().y - a.center().y,
|
||||
Some("bottom") => r.y1 - a.y1,
|
||||
_ => 0.0,
|
||||
};
|
||||
(*i, Vec2::new(dx, dy))
|
||||
})
|
||||
.collect();
|
||||
apply(s, "Align", p, &b, &moves)
|
||||
}
|
||||
|
||||
fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "artboard.distribute";
|
||||
let h = choice(p, C, "horizontal", &["left", "center", "right"])?;
|
||||
let v = choice(p, C, "vertical", &["top", "center", "bottom"])?;
|
||||
let (horiz, line): (bool, fn(&Rect) -> f64) = match (h, v) {
|
||||
(Some("left"), _) => (true, |r| r.x0),
|
||||
(Some("right"), _) => (true, |r| r.x1),
|
||||
(Some(_), _) => (true, |r| r.center().x),
|
||||
(None, Some("top")) => (false, |r| r.y0),
|
||||
(None, Some("bottom")) => (false, |r| r.y1),
|
||||
(None, Some(_)) => (false, |r| r.center().y),
|
||||
(None, None) => return Err(bad(C, "give horizontal or vertical")),
|
||||
};
|
||||
let gap = spacing(p, C)?;
|
||||
let b = boards(s, p, C)?;
|
||||
let mut items = b.items.clone();
|
||||
items.sort_by(|x, y| line(&x.1).total_cmp(&line(&y.1)));
|
||||
let kept = kept(&b, &items, gap)?;
|
||||
// With a spacing: the place in the order and the line of the artboard it is measured from.
|
||||
let from = kept.zip(gap).and_then(|(at, gap)| items.get(at).map(|(_, r)| (at, gap, line(r))));
|
||||
let n = items.len();
|
||||
// Two artboards are the ends already; with a spacing the second is placed from the first.
|
||||
if n < if from.is_some() { 2 } else { 3 } {
|
||||
return Ok(json!({ "moved": [] }));
|
||||
}
|
||||
let (Some((_, first)), Some((_, last))) = (items.first(), items.last()) else { return Ok(json!({ "moved": [] })) };
|
||||
let (first, last) = (line(first), line(last));
|
||||
let deltas: Vec<f64> = items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(k, (_, r))| {
|
||||
let target = match from {
|
||||
Some((at, gap, origin)) => origin + gap * (k as f64 - at as f64),
|
||||
None => first + (last - first) * k as f64 / (n - 1) as f64,
|
||||
};
|
||||
target - line(r)
|
||||
})
|
||||
.collect();
|
||||
// The artboard aligned to stays where it is, and the others keep their places from it.
|
||||
let hold = kept.and_then(|at| deltas.get(at)).copied().unwrap_or(0.0);
|
||||
let moves: Vec<(usize, Vec2)> = items.iter().zip(&deltas).map(|((i, _), d)| (*i, along(horiz, d - hold))).collect();
|
||||
apply(s, "Distribute", p, &b, &moves)
|
||||
}
|
||||
|
||||
fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "artboard.distributeSpacing";
|
||||
let horiz = choice(p, C, "axis", &["horizontal", "vertical"])? != Some("vertical");
|
||||
let gap = spacing(p, C)?;
|
||||
let b = boards(s, p, C)?;
|
||||
let start = |r: &Rect| if horiz { r.x0 } else { r.y0 };
|
||||
let size = |r: &Rect| if horiz { r.width() } else { r.height() };
|
||||
let mut items = b.items.clone();
|
||||
items.sort_by(|x, y| start(&x.1).total_cmp(&start(&y.1)));
|
||||
let kept = kept(&b, &items, gap)?;
|
||||
let gap = gap.filter(|_| kept.is_some());
|
||||
let n = items.len();
|
||||
if n < if gap.is_some() { 2 } else { 3 } {
|
||||
return Ok(json!({ "moved": [] }));
|
||||
}
|
||||
let (Some((_, first)), Some((_, last))) = (items.first(), items.last()) else { return Ok(json!({ "moved": [] })) };
|
||||
let (lo, hi) = (start(first), start(last) + size(last));
|
||||
let gap = gap.unwrap_or_else(|| (hi - lo - items.iter().map(|(_, r)| size(r)).sum::<f64>()) / (n - 1) as f64);
|
||||
let mut pos = lo;
|
||||
let mut deltas = Vec::with_capacity(n);
|
||||
for (i, r) in &items {
|
||||
deltas.push((*i, pos - start(r)));
|
||||
pos += size(r) + gap;
|
||||
}
|
||||
// The artboard aligned to stays where it is, and the others keep their places from it.
|
||||
let hold = kept.and_then(|at| deltas.get(at)).map_or(0.0, |d| d.1);
|
||||
let moves: Vec<(usize, Vec2)> = deltas.into_iter().map(|(i, d)| (i, along(horiz, d - hold))).collect();
|
||||
apply(s, "Distribute Spacing", p, &b, &moves)
|
||||
}
|
||||
|
||||
/// An offset of `d` along the axis: x when `horiz`, else y.
|
||||
fn along(horiz: bool, d: f64) -> Vec2 {
|
||||
if horiz { Vec2::new(d, 0.0) } else { Vec2::new(0.0, d) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,277 @@
|
||||
//! Align and Distribute for artboards.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::NodeId;
|
||||
|
||||
use crate::Session;
|
||||
use crate::tooling::ViewInfo;
|
||||
|
||||
/// A document with three artboards, each with a 10 pt square 5 pt inside its top-left corner:
|
||||
/// 0 at (0, 0) 100 × 100, 1 at (150, 40) 60 × 80 and 2 at (400, 10) 100 × 50 → the squares' ids.
|
||||
fn three_boards() -> (Session, [u64; 3]) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
s.execute("artboard.new", &json!({"x": 150, "y": 40, "width": 60, "height": 80})).unwrap();
|
||||
s.execute("artboard.new", &json!({"x": 400, "y": 10, "width": 100, "height": 50})).unwrap();
|
||||
let mut sq = |x: u64, y: u64| s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
|
||||
let ids = [sq(5, 5), sq(155, 45), sq(405, 15)];
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
(s, ids)
|
||||
}
|
||||
|
||||
/// The top-left corner of each artboard.
|
||||
fn corners(s: &Session) -> Vec<(f64, f64)> {
|
||||
s.doc().unwrap().doc.artboards.iter().map(|a| (a.rect.x0, a.rect.y0)).collect()
|
||||
}
|
||||
|
||||
/// The top-left corner of object `id`.
|
||||
fn corner(s: &Session, id: u64) -> (f64, f64) {
|
||||
let b = s.doc().unwrap().doc.node(NodeId(id)).unwrap().geometric_bounds().unwrap();
|
||||
(b.x0, b.y0)
|
||||
}
|
||||
|
||||
fn undo_steps(s: &Session) -> usize {
|
||||
s.doc().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
const ALL: [u64; 3] = [0, 1, 2];
|
||||
|
||||
/// Align Objects lines the artboards up on the bounds of all of them, each with the art inside
|
||||
/// it, in one undo step.
|
||||
#[test]
|
||||
fn align_moves_the_artboards_to_their_common_edge_with_their_art() {
|
||||
let (mut s, [a, b, c]) = three_boards();
|
||||
let before = corners(&s);
|
||||
let steps = undo_steps(&s);
|
||||
let out = s.execute("artboard.align", &json!({"indices": ALL, "vertical": "top"})).unwrap();
|
||||
assert_eq!(out, json!({"moved": [1, 2]}));
|
||||
assert_eq!(corners(&s), [(0.0, 0.0), (150.0, 0.0), (400.0, 0.0)]);
|
||||
assert_eq!([corner(&s, a), corner(&s, b), corner(&s, c)], [(5.0, 5.0), (155.0, 5.0), (405.0, 5.0)], "each square stays on its artboard");
|
||||
assert_eq!(undo_steps(&s), steps + 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!((corners(&s), corner(&s, b)), (before, (155.0, 45.0)), "one undo step");
|
||||
s.execute("artboard.align", &json!({"indices": ALL, "horizontal": "right", "vertical": "bottom"})).unwrap();
|
||||
assert_eq!(corners(&s), [(400.0, 20.0), (440.0, 40.0), (400.0, 70.0)], "the right edges at 500 and the bottom edges at 120");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("artboard.align", &json!({"indices": ALL, "horizontal": "center", "moveArt": false})).unwrap();
|
||||
assert_eq!(corners(&s), [(200.0, 0.0), (220.0, 40.0), (200.0, 10.0)], "the centers at 250");
|
||||
assert_eq!(corner(&s, a), (5.0, 5.0), "moveArt false leaves the art");
|
||||
}
|
||||
|
||||
/// Align to an artboard: that artboard stays where it is and the others line up on it. It need not
|
||||
/// be one of the artboards aligned.
|
||||
#[test]
|
||||
fn align_to_an_artboard_keeps_that_artboard_in_place() {
|
||||
let (mut s, _) = three_boards();
|
||||
let out = s.execute("artboard.align", &json!({"indices": ALL, "horizontal": "left", "to": "artboard", "artboard": 2})).unwrap();
|
||||
assert_eq!(out, json!({"moved": [0, 1]}));
|
||||
assert_eq!(corners(&s), [(400.0, 0.0), (400.0, 40.0), (400.0, 10.0)]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("artboard.align", &json!({"index": 0, "vertical": "center", "to": "artboard", "artboard": 1})).unwrap();
|
||||
assert_eq!(corners(&s), [(0.0, 30.0), (150.0, 40.0), (400.0, 10.0)], "artboard 1's center is at y 80");
|
||||
}
|
||||
|
||||
/// With no artboard named, the commands act on the artboards the Artboard tool has selected and
|
||||
/// align to its active one; the journal records both for a replay, and an action leaves them out.
|
||||
#[test]
|
||||
fn the_artboard_tools_selection_is_the_default_and_is_recorded() {
|
||||
let (mut s, [_, _, c]) = three_boards();
|
||||
s.select_tool("artboard", ViewInfo::default()).unwrap();
|
||||
s.set_tool_option("selected", &json!([1, 2]));
|
||||
let start = s.journal.len();
|
||||
assert_eq!(s.execute("artboard.align", &json!({"horizontal": "left"})).unwrap(), json!({"moved": [2]}));
|
||||
assert_eq!(corners(&s), [(0.0, 0.0), (150.0, 40.0), (150.0, 10.0)]);
|
||||
assert_eq!(corner(&s, c), (155.0, 15.0), "the tool's Move Artwork with Artboard option is on");
|
||||
let recorded = json!({"horizontal": "left", "selectedArtboards": [1, 2], "moveArt": true, "lockedAndHidden": false});
|
||||
assert_eq!(s.journal.last().unwrap(), &("artboard.align".to_string(), recorded));
|
||||
assert_eq!(
|
||||
s.journal_for_action(start),
|
||||
[("artboard.align".to_string(), json!({"horizontal": "left", "moveArt": true, "lockedAndHidden": false}))]
|
||||
);
|
||||
s.execute("artboard.align", &json!({"vertical": "top", "to": "artboard"})).unwrap();
|
||||
assert_eq!(corners(&s)[2], (150.0, 40.0), "artboard 2 lines up on the active artboard, 1");
|
||||
assert_eq!(s.journal.last().unwrap().1["activeArtboard"], json!(1));
|
||||
assert_eq!(s.journal_for_action(start)[1].1.get("activeArtboard"), None, "an action aligns to the artboard active when it plays");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("artboard.align", &json!({"vertical": "top", "to": "artboard", "activeArtboard": 0})).unwrap();
|
||||
assert_eq!(corners(&s)[1..], [(150.0, 0.0), (150.0, 0.0)], "a replay aligns to the artboard it recorded, 0");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("artboard.align", &json!({"vertical": "bottom", "to": "artboard", "artboard": 0, "activeArtboard": 2})).unwrap();
|
||||
assert_eq!(corners(&s)[1..], [(150.0, 20.0), (150.0, 50.0)], "`artboard` goes ahead of the recorded one");
|
||||
let action = s.journal_for_action(start);
|
||||
let entry = &action.last().unwrap().1;
|
||||
assert_eq!((entry.get("artboard"), entry.get("activeArtboard")), (Some(&json!(0)), None), "an action keeps the artboard a call named");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("artboard.align", &json!({"vertical": "top", "to": "artboard"})).unwrap();
|
||||
s.set_tool_option("moveArt", &json!(false));
|
||||
s.execute("artboard.align", &json!({"horizontal": "right"})).unwrap();
|
||||
assert_eq!((corners(&s)[1], corner(&s, c)), ((190.0, 40.0), (155.0, 45.0)), "with the option off the art stays");
|
||||
}
|
||||
|
||||
/// A command that moves no artboard leaves the document and its undo history as they are.
|
||||
#[test]
|
||||
fn nothing_to_move_records_no_undo_step() {
|
||||
let (mut s, _) = three_boards();
|
||||
s.execute("artboard.align", &json!({"indices": ALL, "vertical": "top"})).unwrap();
|
||||
let (steps, before) = (undo_steps(&s), corners(&s));
|
||||
for (id, p) in [
|
||||
("artboard.align", json!({"indices": ALL, "vertical": "top"})),
|
||||
("artboard.align", json!({"index": 1, "horizontal": "left", "vertical": "bottom"})),
|
||||
("artboard.align", json!({"index": 1, "horizontal": "center", "to": "artboard", "artboard": 1})),
|
||||
("artboard.distribute", json!({"indices": [0, 2], "horizontal": "center"})),
|
||||
("artboard.distribute", json!({"index": 2, "vertical": "top", "to": "artboard", "artboard": 2, "spacing": 30})),
|
||||
("artboard.distributeSpacing", json!({"indices": [0, 1], "axis": "horizontal"})),
|
||||
("artboard.distributeSpacing", json!({"indices": [1], "axis": "vertical"})),
|
||||
] {
|
||||
assert_eq!(s.execute(id, &p).unwrap(), json!({"moved": []}), "{id} {p}");
|
||||
assert_eq!((undo_steps(&s), corners(&s)), (steps, before.clone()), "{id} {p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A locked artboard stays where it is and still counts in the bounds and in the order. When every
|
||||
/// artboard named is locked, the command fails.
|
||||
#[test]
|
||||
fn a_locked_artboard_stays_where_it_is() {
|
||||
let (mut s, _) = three_boards();
|
||||
s.execute("artboard.setProps", &json!({"index": 1, "locked": true})).unwrap();
|
||||
assert_eq!(s.execute("artboard.align", &json!({"indices": ALL, "vertical": "bottom"})).unwrap(), json!({"moved": [0, 2]}));
|
||||
assert_eq!(corners(&s), [(0.0, 20.0), (150.0, 40.0), (400.0, 70.0)], "the bottom edges at 120, the locked artboard's");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.execute("artboard.distributeSpacing", &json!({"indices": ALL})).unwrap(), json!({"moved": []}), "the middle artboard is locked");
|
||||
let out =
|
||||
s.execute("artboard.distribute", &json!({"indices": ALL, "horizontal": "left", "to": "artboard", "artboard": 0, "spacing": 100})).unwrap();
|
||||
assert_eq!((out, corners(&s)), (json!({"moved": [2]}), vec![(0.0, 0.0), (150.0, 40.0), (200.0, 10.0)]), "the third left edge goes to 200");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let (steps, before) = (undo_steps(&s), corners(&s));
|
||||
for (id, p) in [
|
||||
("artboard.align", json!({"index": 1, "horizontal": "left", "to": "artboard", "artboard": 0})),
|
||||
("artboard.distribute", json!({"index": 1, "horizontal": "left"})),
|
||||
("artboard.distributeSpacing", json!({"indices": [1]})),
|
||||
] {
|
||||
assert_eq!(s.execute(id, &p).unwrap_err().to_string(), "the artboard is locked", "{id}");
|
||||
assert_eq!((undo_steps(&s), corners(&s)), (steps, before.clone()), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Distribute Objects spaces the named edges or centers evenly between the outermost artboards;
|
||||
/// with a spacing and an artboard to align to, it places them that far apart from that artboard.
|
||||
#[test]
|
||||
fn distribute_spaces_the_edges_or_centers() {
|
||||
let (mut s, [_, b, _]) = three_boards();
|
||||
assert_eq!(s.execute("artboard.distribute", &json!({"indices": ALL, "horizontal": "center"})).unwrap(), json!({"moved": [1]}));
|
||||
assert_eq!(corners(&s), [(0.0, 0.0), (220.0, 40.0), (400.0, 10.0)], "the centers at 50, 250 and 450");
|
||||
assert_eq!(corner(&s, b), (225.0, 45.0));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("artboard.distribute", &json!({"indices": ALL, "vertical": "bottom"})).unwrap();
|
||||
assert_eq!(corners(&s), [(0.0, -10.0), (150.0, 40.0), (400.0, 10.0)], "the bottom edges at 60, 90 and 120");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let p = json!({"indices": ALL, "horizontal": "left", "to": "artboard", "artboard": 1, "spacing": 100});
|
||||
assert_eq!(s.execute("artboard.distribute", &p).unwrap(), json!({"moved": [0, 2]}));
|
||||
assert_eq!(corners(&s), [(50.0, 0.0), (150.0, 40.0), (250.0, 10.0)], "the left edges 100 apart, artboard 1 in place");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let two = json!({"indices": [0, 2], "horizontal": "right", "to": "artboard", "artboard": 2, "spacing": 150});
|
||||
assert_eq!(s.execute("artboard.distribute", &two).unwrap(), json!({"moved": [0]}));
|
||||
assert_eq!(corners(&s)[0], (250.0, 0.0), "two artboards are enough with a spacing: the right edges at 350 and 500");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let held = json!({"indices": ALL, "horizontal": "center", "to": "artboard", "artboard": 1});
|
||||
assert_eq!(s.execute("artboard.distribute", &held).unwrap(), json!({"moved": [0, 2]}));
|
||||
assert_eq!(corners(&s), [(-70.0, 0.0), (150.0, 40.0), (330.0, 10.0)], "the centers 200 apart, artboard 1 in place");
|
||||
}
|
||||
|
||||
/// Distribute Spacing makes the gaps between the artboards equal; with a spacing and an artboard
|
||||
/// to align to, the gaps are that wide and that artboard stays.
|
||||
#[test]
|
||||
fn distribute_spacing_makes_the_gaps_equal() {
|
||||
let (mut s, _) = three_boards();
|
||||
assert_eq!(s.execute("artboard.distributeSpacing", &json!({"indices": ALL})).unwrap(), json!({"moved": [1]}));
|
||||
assert_eq!(corners(&s), [(0.0, 0.0), (220.0, 40.0), (400.0, 10.0)], "gaps of 120 between 0 and 500");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("artboard.distributeSpacing", &json!({"indices": ALL, "axis": "vertical"})).unwrap();
|
||||
assert_eq!(corners(&s), [(0.0, 0.0), (150.0, 40.0), (400.0, 45.0)], "top to bottom: artboards 0, 2 and 1, overlapping by 55");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let p = json!({"indices": ALL, "axis": "horizontal", "to": "artboard", "artboard": 1, "spacing": 20});
|
||||
assert_eq!(s.execute("artboard.distributeSpacing", &p).unwrap(), json!({"moved": [0, 2]}));
|
||||
assert_eq!(corners(&s), [(30.0, 0.0), (150.0, 40.0), (230.0, 10.0)], "gaps of 20 on both sides of artboard 1");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("artboard.distributeSpacing", &json!({"indices": [0, 2], "to": "artboard", "artboard": 0, "spacing": -25})).unwrap();
|
||||
assert_eq!(corners(&s)[2], (75.0, 10.0), "a negative spacing overlaps them");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let held = json!({"indices": ALL, "to": "artboard", "artboard": 1});
|
||||
assert_eq!(s.execute("artboard.distributeSpacing", &held).unwrap(), json!({"moved": [0, 2]}));
|
||||
assert_eq!(corners(&s), [(-70.0, 0.0), (150.0, 40.0), (330.0, 10.0)], "gaps of 120, artboard 1 in place");
|
||||
}
|
||||
|
||||
/// A value outside what a param takes is an error, and nothing changes.
|
||||
#[test]
|
||||
fn bad_params_are_errors() {
|
||||
let (mut s, _) = three_boards();
|
||||
let (steps, before) = (undo_steps(&s), corners(&s));
|
||||
let with = |extra: Value| {
|
||||
let mut p = json!({"indices": ALL, "horizontal": "left"});
|
||||
for (k, v) in extra.as_object().unwrap() {
|
||||
p[k] = v.clone();
|
||||
}
|
||||
p
|
||||
};
|
||||
let shared = [
|
||||
json!({"horizontal": "middle"}),
|
||||
json!({"horizontal": 1}),
|
||||
json!({"to": "key"}),
|
||||
json!({"to": "artboard", "artboard": 9}),
|
||||
json!({"artboard": "1"}),
|
||||
json!({"artboard": -1}),
|
||||
json!({"moveArt": "yes"}),
|
||||
json!({"patterns": 1}),
|
||||
json!({"lockedAndHidden": "no"}),
|
||||
json!({"indices": [9]}),
|
||||
json!({"indices": []}),
|
||||
json!({"indices": "0"}),
|
||||
];
|
||||
for id in ["artboard.align", "artboard.distribute", "artboard.distributeSpacing"] {
|
||||
let axis = if id == "artboard.distributeSpacing" { json!({"horizontal": null, "axis": "horizontal"}) } else { json!({}) };
|
||||
for extra in &shared {
|
||||
let mut p = with(axis.clone());
|
||||
for (k, v) in extra.as_object().unwrap() {
|
||||
p[k] = v.clone();
|
||||
}
|
||||
if id == "artboard.distributeSpacing" && extra.get("horizontal").is_some() {
|
||||
p = json!({"indices": ALL, "axis": extra["horizontal"]});
|
||||
}
|
||||
assert!(s.execute(id, &p).is_err(), "{id} {p}");
|
||||
}
|
||||
}
|
||||
for (id, p) in [
|
||||
("artboard.align", json!({"indices": ALL})),
|
||||
("artboard.align", json!({"indices": ALL, "vertical": "middle"})),
|
||||
("artboard.distribute", json!({"indices": ALL})),
|
||||
("artboard.distribute", with(json!({"spacing": "10"}))),
|
||||
("artboard.distribute", with(json!({"spacing": true}))),
|
||||
("artboard.distribute", json!({"indices": [0, 1], "horizontal": "left", "to": "artboard", "artboard": 2, "spacing": 10})),
|
||||
("artboard.distributeSpacing", json!({"indices": ALL, "spacing": [10]})),
|
||||
("artboard.distributeSpacing", json!({"indices": [0, 1], "to": "artboard", "artboard": 2, "spacing": 10})),
|
||||
] {
|
||||
assert!(s.execute(id, &p).is_err(), "{id} {p}");
|
||||
}
|
||||
// Spacings that would put an artboard past the canvas, or overflow to infinity.
|
||||
for (id, p) in [
|
||||
("artboard.distribute", with(json!({"to": "artboard", "artboard": 0, "spacing": 1e9}))),
|
||||
("artboard.distribute", with(json!({"to": "artboard", "artboard": 1, "spacing": 1e308}))),
|
||||
("artboard.distributeSpacing", json!({"indices": ALL, "to": "artboard", "artboard": 0, "spacing": 1e9})),
|
||||
("artboard.distributeSpacing", json!({"indices": ALL, "to": "artboard", "artboard": 1, "spacing": 1e308})),
|
||||
] {
|
||||
let e = s.execute(id, &p).unwrap_err().to_string();
|
||||
assert!(e.contains("exceed the canvas"), "{id} {p}: {e}");
|
||||
}
|
||||
assert_eq!((undo_steps(&s), corners(&s)), (steps, before));
|
||||
}
|
||||
|
||||
/// An artboard's own guides move with it.
|
||||
#[test]
|
||||
fn an_artboards_guides_move_with_it() {
|
||||
let (mut s, _) = three_boards();
|
||||
s.execute("guide.add", &json!({"vertical": true, "pos": 160, "artboard": 1})).unwrap();
|
||||
s.execute("artboard.align", &json!({"indices": [0, 1], "horizontal": "left"})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let positions: Vec<f64> = d.ruler_guides().iter().map(|g| g.pos).collect();
|
||||
assert_eq!(positions, [10.0], "the guide stays 10 pt inside artboard 1");
|
||||
}
|
||||
@@ -5,7 +5,9 @@
|
||||
//! Colour settings and the proof view are process-wide (`vectorcraft_color::cms::active`,
|
||||
//! `vectorcraft_render::proof::view`). A document's assigned profiles are stored in
|
||||
//! `Document::color_profiles` (files before format v3: `Document.unknown["colorProfiles"]`) and
|
||||
//! become active when assigned.
|
||||
//! take the place of the working spaces in the active settings when assigned, and when the working
|
||||
//! settings change while the document is in front ([`use_front_profiles`]); the working spaces stay
|
||||
//! as Edit → Color Settings set them.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
@@ -59,7 +61,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Assign Profile…",
|
||||
["Edit"],
|
||||
None,
|
||||
"{rgb?: profile|null, cmyk?: profile|null} tag the document with profiles (colour numbers are kept) → {rgb, cmyk}",
|
||||
"{rgb?: profile|null, cmyk?: profile|null} tag the document with profiles (color numbers are kept; null: the working space). Colors then show and convert through the profiles assigned; the working spaces stay as edit.colorSettings set them → {rgb, cmyk}",
|
||||
has_doc,
|
||||
assign_profile
|
||||
),
|
||||
@@ -176,9 +178,19 @@ fn touch_all(s: &mut Session) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Make the active settings the working settings with the profiles the front document is assigned
|
||||
/// in place of the working spaces (none, or no document open: the working spaces). The open
|
||||
/// documents redraw when the active settings change.
|
||||
fn use_front_profiles(s: &mut Session) {
|
||||
let (rgb, cmyk) = s.active().map(|d| doc_profiles(&d.doc)).unwrap_or_default();
|
||||
if cms::use_document_profiles(rgb.as_deref(), cmyk.as_deref()) {
|
||||
touch_all(s);
|
||||
}
|
||||
}
|
||||
|
||||
fn color_settings(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "edit.colorSettings";
|
||||
let mut st = cms::active_settings();
|
||||
let mut st = cms::working_settings();
|
||||
let before = st.clone();
|
||||
if let Some(v) = text_param(p, "rgb", C)? {
|
||||
st.rgb = cms::canonical_name(v).to_string();
|
||||
@@ -194,6 +206,7 @@ fn color_settings(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
cms::set_active(&st).map_err(|e| bad(C, e.to_string()))?;
|
||||
// Saved for the next start when the app attached a settings file (#6335).
|
||||
s.store_color_settings(&st);
|
||||
use_front_profiles(s);
|
||||
touch_all(s);
|
||||
}
|
||||
Ok(settings_json(&st))
|
||||
@@ -208,14 +221,18 @@ fn load_profile(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let info = cms::register_icc(&bytes, None).map_err(|e| bad(C, e.to_string()))?;
|
||||
// The bytes are kept, not the path: the file may move or go away.
|
||||
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();
|
||||
let is_working = working.rgb == info.name || working.cmyk == info.name;
|
||||
if is_working {
|
||||
// A new file of a working space or of an assigned profile in use: the active settings still
|
||||
// hold the old one. A profile the front document is assigned, loaded after it was removed, is
|
||||
// in use again.
|
||||
let (working, active) = (cms::working_settings(), cms::active_settings());
|
||||
let (rgb, cmyk) = s.active().map(|d| doc_profiles(&d.doc)).unwrap_or_default();
|
||||
let front = [rgb, cmyk].iter().any(|n| n.as_deref() == Some(info.name.as_str()));
|
||||
if front || [&working.rgb, &working.cmyk, &active.rgb, &active.cmyk].contains(&&info.name) {
|
||||
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) {
|
||||
use_front_profiles(s);
|
||||
touch_all(s);
|
||||
} else if proof_targets(&info.name) {
|
||||
// A new file of the CMYK profile View → Proof Setup targets: redraw.
|
||||
touch_all(s);
|
||||
}
|
||||
Ok(json!({"name": info.name, "kind": info.kind, "remembered": remembered}))
|
||||
@@ -229,14 +246,21 @@ fn remove_profile(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if info.builtin {
|
||||
return Err(bad(C, format!("`{name}` is a built-in profile and stays")));
|
||||
}
|
||||
let working = cms::active_settings();
|
||||
let working = cms::working_settings();
|
||||
if working.rgb == name || working.cmyk == name {
|
||||
return Err(bad(C, format!("`{name}` is a working space: choose another with edit.colorSettings first")));
|
||||
}
|
||||
let active = cms::active_settings();
|
||||
if !cms::unregister_icc(&info.name) {
|
||||
return Err(bad(C, format!("no profile named `{name}`")));
|
||||
}
|
||||
s.unstore_profile(&info.name);
|
||||
// An assigned profile in use: the working space takes its place, as for any document assigned
|
||||
// a profile that is not available.
|
||||
if active.rgb == name || active.cmyk == name {
|
||||
use_front_profiles(s);
|
||||
}
|
||||
// The CMYK profile View → Proof Setup targets: redraw.
|
||||
if proof_targets(&info.name) {
|
||||
touch_all(s);
|
||||
}
|
||||
@@ -284,18 +308,9 @@ fn assign_profile(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
d.color_profiles = profiles;
|
||||
Ok(())
|
||||
})?;
|
||||
let mut st = cms::active_settings();
|
||||
let before = st.clone();
|
||||
if let Some(r) = &rgb {
|
||||
st.rgb = r.clone();
|
||||
}
|
||||
if let Some(c) = &cmyk {
|
||||
st.cmyk = c.clone();
|
||||
}
|
||||
if st != before {
|
||||
cms::set_active(&st).map_err(|e| bad(C, e.to_string()))?;
|
||||
touch_all(s);
|
||||
}
|
||||
// The document draws through its profiles now, and a Document Color Mode later in the same
|
||||
// batch converts through them. The working spaces stay as they are.
|
||||
use_front_profiles(s);
|
||||
}
|
||||
Ok(json!({"rgb": rgb, "cmyk": cmyk}))
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"New…",
|
||||
["File"],
|
||||
None,
|
||||
"{preset?: a name from file.newPresets (start from it; the other params change it), name?|title?: document name (default Untitled-N), width?: pt=612|\"210 mm\", height?: pt=792 (each more than 0 and at most 3,999,856 pt, as artboard.new takes them), units?: \"Pixels\"|\"Points\"|\"Picas\"|\"Inches\"|\"Millimeters\"|\"Centimeters\"|\"Feet\"|\"Yards\"|\"Meters\"|\"Feet & Inches\" (default: prefs unitsGeneral, as print presets; screen presets: Pixels), orientation?: \"portrait\"|\"landscape\" (swaps width and height to match), artboards?: n (1–1000), artboardLayout?: {layout?: \"gridByRow\"|\"gridByColumn\"|\"row\"|\"column\", columns?: n (default: all in one row), spacing?: pt=20, rightToLeft?: bool}, bleed?: pt|[top, bottom, left, right]|{top?, …} (0–72 pt), backgroundContents?: \"transparent\"|\"white\" (a white artboard background, not an object), colorMode?: \"rgb\"|\"cmyk\" (a CMYK document starts with CMYK swatches and stores the colours applied to it as CMYK), rasterEffectsPpi?: 72|150|300 (1–2400), previewMode?: \"default\"|\"pixel\"|\"overprint\" (overprint turns Overprint Preview on, pixel Pixel Preview in the app; default leaves both), alignNewObjectsToPixelGrid?: bool (Advanced Options › Align New Objects to Pixel Grid: paths drawn in the document are pixel-aligned; default on for the Mobile and Web presets, else off; document.setup changes it later), created?: Unix seconds|null (File Info's created date; default now, recorded in the journal so a replay matches), size?: a preset's size label (a preset file.newPresets lists can be passed back; ignored)} → {index, previewMode}; the size is remembered in file.newPresets' Recent",
|
||||
"{preset?: a name from file.newPresets (start from it; the other params change it), name?|title?: document name (default Untitled-N), width?: pt=612|\"210 mm\", height?: pt=792 (each more than 0 and at most 3,999,856 pt, as artboard.new takes them), units?: \"Pixels\"|\"Points\"|\"Picas\"|\"Inches\"|\"Millimeters\"|\"Centimeters\"|\"Feet\"|\"Yards\"|\"Meters\"|\"Feet & Inches\" (default: prefs unitsGeneral, as print presets; screen presets: Pixels), orientation?: \"portrait\"|\"landscape\" (swaps width and height to match), artboards?: n (1–1000), artboardLayout?: {layout?: \"gridByRow\"|\"gridByColumn\"|\"row\"|\"column\", columns?: n (default: all in one row), spacing?: pt=20, rightToLeft?: bool}, bleed?: pt|[top, bottom, left, right]|{top?, …} (0–72 pt), backgroundContents?: \"transparent\"|\"white\" (a white artboard background, not an object), colorMode?: \"rgb\"|\"cmyk\" (a CMYK document starts with CMYK swatches and stores the colours applied to it as CMYK), rasterEffectsPpi?: 72|150|300 (1–2400), previewMode?: \"default\"|\"pixel\"|\"overprint\" (overprint turns Overprint Preview on, pixel Pixel Preview in the app; default leaves both), alignNewObjectsToPixelGrid?: bool (Advanced Options › Align New Objects to Pixel Grid: paths drawn in the document are pixel-aligned; default on for the Mobile and Web presets, else off; document.setup changes it later), profile?: a category of file.newPresets|null (the New Document profile the document starts from, see file.newProfiles; default: a built-in preset's category, kept by the saved and Recent presets made from it; null or no preset: the defaults), created?: Unix seconds|null (File Info's created date; default now, recorded in the journal so a replay matches), size?: a preset's size label (a preset file.newPresets lists can be passed back; ignored)} → {index, previewMode, profile?: the profile applied, profileError?: {profile, reason} (its file could not be read, was larger than 64 MiB or is not a native document, or the request may not read the profiles folder: the document has the defaults)}; the size is remembered in file.newPresets' Recent",
|
||||
always,
|
||||
super::newdoc::file_new
|
||||
),
|
||||
|
||||
@@ -588,7 +588,9 @@ 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, with each raster effect
|
||||
/// following the Transform effects after it
|
||||
/// ([`without_geometry`](vectorcraft_render::effects::without_geometry)). `None` when it has no
|
||||
/// ([`without_geometry`](vectorcraft_render::effects::without_geometry)) and those of its fills and
|
||||
/// strokes following its Transform effects
|
||||
/// ([`follow_items`](vectorcraft_render::effects::follow_items)). `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.
|
||||
@@ -604,6 +606,8 @@ pub(crate) fn reshaped(doc: &Document, l: &Node) -> Option<Node> {
|
||||
if carry.moves(&m.appearance) {
|
||||
carry.apply(&mut m.appearance);
|
||||
}
|
||||
// So do the raster effects of its fills and strokes.
|
||||
vectorcraft_render::effects::follow_items(&mut m);
|
||||
m.appearance.effects = without_geometry(&m.appearance.effects);
|
||||
Some(m)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
//! File → New: the settings `file.new` takes, the New Document presets (generated in code, by
|
||||
//! category), the user's saved presets and the recently used sizes (both kept in the
|
||||
//! preferences), and the artboard grid layout shared with Rearrange All Artboards.
|
||||
//!
|
||||
//! New Document profiles: each built-in category has a profile, a native document in the
|
||||
//! `New Document Profiles` folder next to the preferences ([`Session::attach_new_doc_profiles`]).
|
||||
//! `file.new` starts a document of that category with the profile's swatches, graphic styles,
|
||||
//! brushes, symbols, character and paragraph styles, rulers, document setup and print settings
|
||||
//! ([`DocSettings::document_from_profile`]). The grid stays that of Preferences › Guides & Grid,
|
||||
//! which applies to every open document. A category without a profile file starts with the
|
||||
//! defaults, and a profile that can't be read leaves them in place and is reported in `file.new`'s
|
||||
//! result.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Artboard, Background, ColorMode, Document, Unit};
|
||||
use vectorcraft_doc::{Artboard, Background, ColorMode, Document, LayerColor, Node, Unit};
|
||||
use vectorcraft_geom::{Point, Rect};
|
||||
|
||||
use super::{CommandSpec, Result, Session, always, bad, clock_date, cmd, date_param, str_param, switch_or};
|
||||
@@ -38,6 +49,33 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|s| if s.prefs.new_doc_presets.is_empty() { Err("no saved presets".into()) } else { Ok(()) },
|
||||
delete
|
||||
),
|
||||
cmd!(
|
||||
query "file.newProfiles",
|
||||
"New Document Profiles",
|
||||
[],
|
||||
None,
|
||||
"{} the New Document profiles, one per built-in category of file.newPresets: native documents in the New Document Profiles folder next to the preferences, whose swatches, swatch groups, graphic styles, brushes, symbols, patterns, character and paragraph styles, composite fonts, SVG filters, graph designs, ruler origin and mode, transparency grid, document setup (other than bleed, background and Align New Objects to Pixel Grid), assigned color profiles, Document Raster Effects Settings (other than the resolution) and print settings a new document of that category starts with → {profiles: [{name, customized?: a profile file exists (else the shipped profile, today's defaults, applies), path?; both left out for a request that may not read the folder}], folder?: the folder (none in runs without preferences and on the web)}",
|
||||
always,
|
||||
list_profiles
|
||||
),
|
||||
cmd!(
|
||||
"file.newProfiles.edit",
|
||||
"Edit Profile",
|
||||
[],
|
||||
None,
|
||||
"{name: a category of file.newPresets} open the category's New Document profile as a document to edit (File › Save keeps the changes for the next file.new); a category without a profile file gets the shipped profile written first → document.open's result",
|
||||
always,
|
||||
edit_profile
|
||||
),
|
||||
cmd!(
|
||||
"file.newProfiles.reset",
|
||||
"Reset Profile",
|
||||
[],
|
||||
None,
|
||||
"{name: a category of file.newPresets} delete the category's profile file, so the shipped profile (today's defaults) applies again; an open copy of it stays open → {name, reset: whether a file was deleted}",
|
||||
always,
|
||||
reset_profile
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -148,6 +186,11 @@ pub struct DocSettings {
|
||||
/// on in the Mobile and Web presets, off in the others and in a saved or Recent preset from
|
||||
/// before it.
|
||||
pub align_new_objects_to_pixel_grid: bool,
|
||||
/// The New Document profile the document starts from (a built-in category's name): the
|
||||
/// category of a built-in preset, kept by the saved and Recent presets made from it; `None`:
|
||||
/// the defaults.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub profile: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for DocSettings {
|
||||
@@ -168,6 +211,7 @@ impl Default for DocSettings {
|
||||
raster_effects_ppi: 72.0,
|
||||
preview_mode: PreviewMode::Default,
|
||||
align_new_objects_to_pixel_grid: false,
|
||||
profile: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -217,6 +261,7 @@ impl DocSettings {
|
||||
"rasterEffectsPpi": self.raster_effects_ppi,
|
||||
"previewMode": self.preview_mode.id(),
|
||||
"alignNewObjectsToPixelGrid": self.align_new_objects_to_pixel_grid,
|
||||
"profile": self.profile,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -280,6 +325,11 @@ impl DocSettings {
|
||||
self.preview_mode = m.as_str().and_then(PreviewMode::parse).ok_or_else(|| bad(cmd, "previewMode must be default, pixel or overprint"))?;
|
||||
}
|
||||
self.align_new_objects_to_pixel_grid = switch_or(p, "alignNewObjectsToPixelGrid", self.align_new_objects_to_pixel_grid, cmd)?;
|
||||
match p.get("profile") {
|
||||
None => {}
|
||||
Some(Value::Null) => self.profile = None,
|
||||
Some(v) => self.profile = Some(profile_name(v, cmd)?.to_string()),
|
||||
}
|
||||
let extent = self.boards().iter().fold(Rect::ZERO, |a, r| a.union(*r));
|
||||
if extent.width().max(extent.height()) + self.bleed.iter().fold(0.0f64, |a, b| a.max(*b)) * 2.0 > crate::MAX_COORD {
|
||||
return Err(bad(cmd, "the artboards don't fit on the canvas: fewer artboards, less spacing or a smaller size"));
|
||||
@@ -357,6 +407,101 @@ impl DocSettings {
|
||||
.collect();
|
||||
d
|
||||
}
|
||||
|
||||
/// The new document started from New Document profile `profile`: [`Self::document`] with the
|
||||
/// profile's swatches, swatch groups, graphic styles, brushes, symbols, patterns and the images
|
||||
/// they use, character and paragraph styles, composite fonts, SVG filters, graph designs, ruler
|
||||
/// origin and mode, transparency grid, document setup (bleed, background and Align New Objects
|
||||
/// to Pixel Grid stay these settings'), assigned color profiles, raster effects settings (the
|
||||
/// resolution stays these settings') and print settings, in these settings' color mode (the
|
||||
/// profile's colors converted as Document Color Mode converts them). The profile's art,
|
||||
/// layers and artboards are left out: the document has one empty layer.
|
||||
pub fn document_from_profile(&self, mut profile: Document) -> Document {
|
||||
if profile.color_mode != self.color_mode {
|
||||
super::colormgmt::set_color_mode(&mut profile, self.color_mode, true, None, super::colormgmt::Grays::Profile);
|
||||
}
|
||||
let profile_next_id = profile.peek_next_id();
|
||||
let mut d = self.document();
|
||||
d.swatches = profile.swatches;
|
||||
d.swatch_groups = profile.swatch_groups;
|
||||
d.graphic_styles = profile.graphic_styles;
|
||||
d.char_styles = profile.char_styles;
|
||||
d.para_styles = profile.para_styles;
|
||||
d.composite_fonts = profile.composite_fonts;
|
||||
d.symbols = profile.symbols;
|
||||
d.graph_designs = profile.graph_designs;
|
||||
d.patterns = profile.patterns;
|
||||
d.images = profile.images;
|
||||
d.svg_filters = profile.svg_filters;
|
||||
d.spot_use_lab = profile.spot_use_lab;
|
||||
d.ruler_origin = profile.ruler_origin;
|
||||
d.rulers = profile.rulers;
|
||||
d.artboard_ruler_origins = profile.artboard_ruler_origins.into_iter().filter(|(id, _)| d.artboards.iter().any(|a| a.id == *id)).collect();
|
||||
d.transparency_grid = profile.transparency_grid;
|
||||
d.print_setup = profile.print_setup;
|
||||
d.color_profiles = profile.color_profiles;
|
||||
// The resolution stays the preset's (raster_effects_ppi); the other raster effects settings
|
||||
// are the profile's.
|
||||
d.raster_effects = profile.raster_effects;
|
||||
let setup = std::mem::replace(&mut d.setup, profile.setup);
|
||||
d.setup.bleed = setup.bleed;
|
||||
d.setup.background = setup.background;
|
||||
d.setup.align_new_objects_to_pixel_grid = setup.align_new_objects_to_pixel_grid;
|
||||
if let Some(brushes) = profile.unknown.get("brushes") {
|
||||
d.unknown.insert("brushes".into(), brushes.clone());
|
||||
}
|
||||
// Symbol, pattern and graph design art keeps the profile's ids, which a damaged file can
|
||||
// have above its counter: the counter goes past all of them, as it does for a loaded
|
||||
// document's layers, before the new layer takes an id.
|
||||
d.raise_next_id(profile_next_id);
|
||||
d.layers = d
|
||||
.symbols
|
||||
.iter()
|
||||
.map(|s| s.art.clone())
|
||||
.chain(d.graph_designs.iter().map(|g| g.art.clone()))
|
||||
.chain(d.patterns.iter().flat_map(|p| p.art.iter().cloned()))
|
||||
.collect();
|
||||
d.fix_next_id();
|
||||
let layer = d.alloc_id();
|
||||
d.layers = vec![Arc::new(Node::layer(layer, "Layer 1", LayerColor::Preset(0)))];
|
||||
// Images only the profile's art used stay behind: an image is kept when an object of the new
|
||||
// document names it in its `key`, as a native save keeps only the images its objects name.
|
||||
let images = std::mem::take(&mut d.images);
|
||||
if !images.is_empty() {
|
||||
d.images = match serde_json::to_value(&d) {
|
||||
Ok(v) => {
|
||||
let used = image_keys(&v);
|
||||
images.into_iter().filter(|(k, _)| used.contains(k.as_str())).collect()
|
||||
}
|
||||
Err(_) => images,
|
||||
};
|
||||
}
|
||||
d
|
||||
}
|
||||
}
|
||||
|
||||
/// The string values of every `key` member at any depth of `v`, which name the images a document's
|
||||
/// objects use, collected in one pass.
|
||||
fn image_keys(v: &Value) -> std::collections::HashSet<&str> {
|
||||
let mut keys = std::collections::HashSet::new();
|
||||
let mut stack = vec![v];
|
||||
while let Some(v) = stack.pop() {
|
||||
match v {
|
||||
Value::Object(m) => {
|
||||
for (k, v) in m {
|
||||
match v.as_str() {
|
||||
Some(s) if k == "key" => {
|
||||
keys.insert(s);
|
||||
}
|
||||
_ => stack.push(v),
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Array(a) => stack.extend(a),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
keys
|
||||
}
|
||||
|
||||
/// Largest artboard side `file.new` and `artboard.new` accept, points: the span of the canvas
|
||||
@@ -594,6 +739,7 @@ fn builtin(c: &Category, general: Unit) -> impl Iterator<Item = DocSettings> + '
|
||||
color_mode,
|
||||
raster_effects_ppi: ppi,
|
||||
align_new_objects_to_pixel_grid: align,
|
||||
profile: Some(category.into()),
|
||||
..DocSettings::default()
|
||||
})
|
||||
}
|
||||
@@ -644,7 +790,18 @@ pub(crate) fn file_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if size != Some(sorted(&settings)) {
|
||||
settings.name = "Custom".into();
|
||||
}
|
||||
let mut d = settings.document();
|
||||
let (profile, profile_error) = match settings.profile.as_deref() {
|
||||
Some(name) => match s.read_profile(name) {
|
||||
Ok(p) => (p, None),
|
||||
Err(e) => (None, Some(e)),
|
||||
},
|
||||
None => (None, None),
|
||||
};
|
||||
let applied = profile.is_some();
|
||||
let mut d = match profile {
|
||||
Some(p) => settings.document_from_profile(p),
|
||||
None => settings.document(),
|
||||
};
|
||||
d.grid = s.prefs.grid();
|
||||
d.title = match str_param(p, "name").or(str_param(p, "title")).map(str::trim).filter(|t| !t.is_empty()) {
|
||||
// New Document offers the next untitled name: taking it uses it up.
|
||||
@@ -660,7 +817,14 @@ pub(crate) fn file_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.execute("view.overprintPreview", &json!({ "on": true }))?;
|
||||
}
|
||||
remember(s, &settings);
|
||||
Ok(json!({ "index": i, "previewMode": settings.preview_mode.id() }))
|
||||
let mut out = json!({ "index": i, "previewMode": settings.preview_mode.id() });
|
||||
if applied {
|
||||
out["profile"] = json!(settings.profile);
|
||||
}
|
||||
if let Some(e) = profile_error {
|
||||
out["profileError"] = json!({ "profile": settings.profile, "reason": e });
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Put `settings` first in Recent (one entry per size and units).
|
||||
@@ -714,3 +878,136 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = saved.iter().position(|q| q.name.eq_ignore_ascii_case(name)).ok_or_else(|| bad(C, format!("no saved preset called `{name}`")))?;
|
||||
Ok(json!({ "deleted": saved.remove(i).name }))
|
||||
}
|
||||
|
||||
/// The largest New Document profile file `file.new` reads, bytes (VectorCraft's own limit); a
|
||||
/// larger one is reported and the document starts with the defaults.
|
||||
pub const MAX_PROFILE_BYTES: u64 = 64 << 20;
|
||||
|
||||
/// The New Document profiles' names: the built-in categories, in tab order.
|
||||
pub fn profile_names() -> impl Iterator<Item = &'static str> {
|
||||
CATEGORIES.iter().map(|c| c.0)
|
||||
}
|
||||
|
||||
/// The profile `v` names (a built-in category, any case).
|
||||
fn profile_name(v: &Value, cmd: &str) -> Result<&'static str> {
|
||||
let name = v.as_str().map(str::trim).unwrap_or_default();
|
||||
profile_names().find(|c| c.eq_ignore_ascii_case(name)).ok_or_else(|| {
|
||||
let shown = v.as_str().map_or_else(|| v.to_string(), str::to_string);
|
||||
bad(cmd, format!("no New Document profile called `{shown}` ({})", profile_names().collect::<Vec<_>>().join(", ")))
|
||||
})
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Keep the New Document profiles in the folder `dir` (`New Document Profiles` next to the
|
||||
/// preferences). The folder is made when a profile is first edited.
|
||||
pub fn attach_new_doc_profiles(&mut self, dir: std::path::PathBuf) {
|
||||
self.new_doc_profile_dir = Some(dir);
|
||||
}
|
||||
|
||||
/// The New Document profiles' folder ([`Session::attach_new_doc_profiles`]); none on the web.
|
||||
pub fn new_doc_profile_dir(&self) -> Option<&std::path::Path> {
|
||||
self.new_doc_profile_dir.as_deref().filter(|_| !cfg!(target_arch = "wasm32"))
|
||||
}
|
||||
|
||||
/// The file of profile `name`, whether or not it exists.
|
||||
fn profile_path(&self, name: &str) -> Option<std::path::PathBuf> {
|
||||
Some(self.new_doc_profile_dir()?.join(format!("{name}.{}", vectorcraft_format::EXTENSION)))
|
||||
}
|
||||
|
||||
/// Whether profile `name` has a file, which `file.newProfiles.reset` deletes.
|
||||
pub fn new_doc_profile_customized(&self, name: &str) -> bool {
|
||||
self.profile_path(name).is_some_and(|p| p.is_file())
|
||||
}
|
||||
|
||||
/// Profile `name`'s document: `None` when it has no file (the shipped profile, the defaults),
|
||||
/// the reason when the request being served may not read it, or its file can't be read, is
|
||||
/// larger than [`MAX_PROFILE_BYTES`] or isn't a native document VectorCraft reads.
|
||||
fn read_profile(&self, name: &str) -> std::result::Result<Option<Document>, String> {
|
||||
let Some(path) = self.profile_path(name) else { return Ok(None) };
|
||||
// Checked before the file is looked for, so a request that may not read the folder learns
|
||||
// nothing of its files.
|
||||
crate::file_access::check_read(&path.to_string_lossy())?;
|
||||
if std::fs::symlink_metadata(&path).is_err_and(|e| e.kind() == std::io::ErrorKind::NotFound) {
|
||||
return Ok(None);
|
||||
}
|
||||
crate::file_access::check_read(&path.to_string_lossy())?;
|
||||
let bytes = crate::store::read_capped(&path, MAX_PROFILE_BYTES)?;
|
||||
let mut doc = vectorcraft_format::load(&bytes).map_err(|e| e.to_string())?;
|
||||
// Linked images in its symbols, patterns and brushes are read from their files, as placing
|
||||
// a document reads them.
|
||||
super::links::resolve(&mut doc, super::links::LinkBase::file(Some(&path.to_string_lossy())), false, false);
|
||||
Ok(Some(doc))
|
||||
}
|
||||
}
|
||||
|
||||
/// The shipped profile of category `name`: the defaults, as a document of the category's first
|
||||
/// preset.
|
||||
fn shipped_profile(s: &Session, name: &str) -> Document {
|
||||
let first = CATEGORIES.iter().find(|c| c.0 == name).and_then(|c| builtin(c, s.default_units()).next());
|
||||
let mut d = first.unwrap_or_default().document();
|
||||
d.title = name.to_string();
|
||||
d
|
||||
}
|
||||
|
||||
/// `path` when the request being served may read it.
|
||||
fn shown(path: &std::path::Path) -> Option<String> {
|
||||
let path = path.to_string_lossy().to_string();
|
||||
crate::file_access::check_read(&path).is_ok().then_some(path)
|
||||
}
|
||||
|
||||
fn list_profiles(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let profiles: Vec<Value> = profile_names()
|
||||
.map(|name| {
|
||||
let mut v = json!({ "name": name });
|
||||
match s.profile_path(name) {
|
||||
// A request that may not read the folder learns nothing of its files.
|
||||
Some(path) => {
|
||||
if let Some(p) = shown(&path) {
|
||||
v["customized"] = json!(path.is_file());
|
||||
v["path"] = json!(p);
|
||||
}
|
||||
}
|
||||
None => v["customized"] = json!(false),
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({ "profiles": profiles, "folder": s.new_doc_profile_dir().and_then(shown) }))
|
||||
}
|
||||
|
||||
/// The file of profile `name` for `cmd` to change, checked against the request's write access.
|
||||
fn writable_profile(s: &Session, name: &str, cmd: &str) -> Result<std::path::PathBuf> {
|
||||
let path = s.profile_path(name).ok_or_else(|| bad(cmd, "New Document profiles need the preferences folder, which this run doesn't have"))?;
|
||||
crate::file_access::check_write(&path.to_string_lossy()).map_err(super::EngineError::Other)?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn edit_profile(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "file.newProfiles.edit";
|
||||
let name = profile_name(p.get("name").unwrap_or(&Value::Null), C)?;
|
||||
let file = writable_profile(s, name, C)?;
|
||||
let path = file.to_string_lossy().to_string();
|
||||
// document.open reads it next: a profile it can't open is not written either.
|
||||
crate::file_access::check_read(&path).map_err(super::EngineError::Other)?;
|
||||
if !file.exists() {
|
||||
let bytes = vectorcraft_format::save(&shipped_profile(s, name), false);
|
||||
file.parent()
|
||||
.map_or(Ok(()), std::fs::create_dir_all)
|
||||
.and_then(|()| std::fs::write(&file, bytes))
|
||||
.map_err(|e| super::EngineError::Other(format!("{path}: {e}")))?;
|
||||
}
|
||||
s.execute("document.open", &json!({ "path": path }))
|
||||
}
|
||||
|
||||
fn reset_profile(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "file.newProfiles.reset";
|
||||
let name = profile_name(p.get("name").unwrap_or(&Value::Null), C)?;
|
||||
let file = writable_profile(s, name, C)?;
|
||||
let path = file.to_string_lossy().to_string();
|
||||
let reset = match std::fs::remove_file(&file) {
|
||||
Ok(()) => true,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
|
||||
Err(e) => return Err(super::EngineError::Other(format!("{path}: {e}"))),
|
||||
};
|
||||
Ok(json!({ "name": name, "reset": reset }))
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@ pub(crate) fn boards_and_art_noted(s: &mut Session, p: &Value, cmd: &str) -> Res
|
||||
/// Add `selectedArtboards` to the running command's journal entry when `p` names no artboard, so a
|
||||
/// replay acts on the artboards the Artboard tool had selected. An action leaves it out
|
||||
/// (`REPLAY_ONLY`) and keeps an `index` or `indices` the call gave.
|
||||
fn note_artboards(s: &mut Session, p: &Value, indices: &[usize]) {
|
||||
pub(crate) fn note_artboards(s: &mut Session, p: &Value, indices: &[usize]) {
|
||||
if p.get("index").is_none() && p.get("indices").is_none() {
|
||||
s.note_journal("selectedArtboards", json!(indices));
|
||||
}
|
||||
@@ -130,7 +130,7 @@ fn note_artboards(s: &mut Session, p: &Value, indices: &[usize]) {
|
||||
|
||||
/// The artboards `cmd` acts on: `index` or `indices`, else `selectedArtboards` (a replay of the
|
||||
/// journal), else those the Artboard tool has selected, else its active one.
|
||||
fn named_or_selected(s: &Session, p: &Value, cmd: &str) -> Result<Vec<usize>> {
|
||||
pub(crate) fn named_or_selected(s: &Session, p: &Value, cmd: &str) -> Result<Vec<usize>> {
|
||||
let t = s.tool_options_of("artboard");
|
||||
let d = &s.doc()?.doc;
|
||||
if p.get("index").is_some() || p.get("indices").is_some() {
|
||||
|
||||
@@ -86,9 +86,12 @@ pub(super) const ALLOWED: &[&str] = &[
|
||||
"appearance.showAllHidden",
|
||||
"appearance.targetContents",
|
||||
"appearance.transfer",
|
||||
"artboard.align",
|
||||
"artboard.convertToArtboards",
|
||||
"artboard.delete",
|
||||
"artboard.deleteEmpty",
|
||||
"artboard.distribute",
|
||||
"artboard.distributeSpacing",
|
||||
"artboard.duplicate",
|
||||
"artboard.fitToArt",
|
||||
"artboard.fitToSelection",
|
||||
|
||||
@@ -28,7 +28,7 @@ pub const FIT_ACTIVE_ARTBOARD: [&str; 2] = ["artboard.fitToArt", "artboard.fitTo
|
||||
/// Artboard tool had selected for its commands and for Paste in Place, in Front and in Back; the artboards and art Cut and Copy took with that
|
||||
/// tool chosen; the view's center the app passes to Edit Symbol); an action leaves them out
|
||||
/// ([`Session::journal_for_action`]).
|
||||
const REPLAY_ONLY: [(&str, &str); 29] = [
|
||||
const REPLAY_ONLY: [(&str, &str); 35] = [
|
||||
("file.new", "created"),
|
||||
("edit.newDocumentFromState", "created"),
|
||||
("document.save", "modified"),
|
||||
@@ -50,6 +50,12 @@ const REPLAY_ONLY: [(&str, &str); 29] = [
|
||||
("artboard.cut", "selectedArtboards"),
|
||||
("artboard.rename", "selectedArtboards"),
|
||||
("artboard.switchOrientation", "selectedArtboards"),
|
||||
("artboard.align", "selectedArtboards"),
|
||||
("artboard.align", "activeArtboard"),
|
||||
("artboard.distribute", "selectedArtboards"),
|
||||
("artboard.distribute", "activeArtboard"),
|
||||
("artboard.distributeSpacing", "selectedArtboards"),
|
||||
("artboard.distributeSpacing", "activeArtboard"),
|
||||
("edit.copy", "selectedArtboards"),
|
||||
("edit.copy", "art"),
|
||||
("edit.cut", "selectedArtboards"),
|
||||
|
||||
@@ -176,6 +176,9 @@ pub struct Session {
|
||||
/// The Scripts folder, whose scripts File › Scripts lists; `None`: no folder
|
||||
/// ([`Session::attach_scripts_dir`]).
|
||||
scripts_dir: Option<std::path::PathBuf>,
|
||||
/// The folder of the New Document profiles `file.new` starts documents from; `None`: none,
|
||||
/// every document starts with the defaults ([`Session::attach_new_doc_profiles`]).
|
||||
new_doc_profile_dir: Option<std::path::PathBuf>,
|
||||
/// A filter's output that a run made in the background handed in for `plugin.run` to apply
|
||||
/// ([`cmd::plugin::FilterRun::hand_in`]); the next `plugin.run` takes it.
|
||||
plugin_output: Option<cmd::plugin::HandedIn>,
|
||||
|
||||
@@ -89,6 +89,7 @@ impl Session {
|
||||
color_settings_file: None,
|
||||
plugin_dir: None,
|
||||
scripts_dir: None,
|
||||
new_doc_profile_dir: None,
|
||||
plugin_output: None,
|
||||
plugin_install_approval: None,
|
||||
plugin_grants_from_requests: false,
|
||||
|
||||
@@ -512,3 +512,173 @@ fn rearrange_that_moves_no_artboard_records_no_undo_step() {
|
||||
assert_eq!(s.execute("artboard.rearrange", &p).unwrap(), json!({"artboards": 0, "rows": 1, "columns": 1}));
|
||||
assert_eq!(history(&s), before);
|
||||
}
|
||||
|
||||
/// A session whose New Document profiles are in a fresh folder for test `name` (not made yet).
|
||||
fn with_profiles(name: &str) -> (Session, std::path::PathBuf) {
|
||||
let dir = std::env::temp_dir().join(format!("vc-newdoc-profiles-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let mut s = Session::new();
|
||||
s.attach_new_doc_profiles(dir.clone());
|
||||
(s, dir)
|
||||
}
|
||||
|
||||
fn swatch_names(s: &Session) -> Vec<String> {
|
||||
s.doc().unwrap().doc.swatches.iter().map(|w| w.name.clone()).collect()
|
||||
}
|
||||
|
||||
/// Edit the Print profile: Brand Red, the Brand Box style, the Badge and Logo symbols (Logo's art an
|
||||
/// embedded image) and an image left on its layer, saved.
|
||||
fn edit_print_profile(s: &mut Session, dir: &std::path::Path) {
|
||||
// Edit writes the shipped profile and opens it.
|
||||
s.execute("file.newProfiles.edit", &json!({"name": "print"})).unwrap();
|
||||
assert!(dir.join("Print.vectorcraft").is_file());
|
||||
assert_eq!(s.doc().unwrap().doc.color_mode, ColorMode::Cmyk);
|
||||
s.execute("swatch.new", &json!({"name": "Brand Red", "color": "#cc2200"})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap();
|
||||
s.execute("graphicStyle.new", &json!({"name": "Brand Box"})).unwrap();
|
||||
s.execute("symbol.new", &json!({"name": "Badge"})).unwrap();
|
||||
// One embedded image becomes a symbol's art, another stays on the profile's layer.
|
||||
let png = |rgb: [u8; 3]| {
|
||||
let mut out = vec![];
|
||||
image::RgbaImage::from_pixel(4, 4, image::Rgba([rgb[0], rgb[1], rgb[2], 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
vectorcraft_format::base64_encode(&out)
|
||||
};
|
||||
s.execute("file.place", &json!({"name": "red.png", "dataBase64": png([255, 0, 0]), "link": false})).unwrap();
|
||||
s.execute("symbol.new", &json!({"name": "Logo"})).unwrap();
|
||||
s.execute("file.place", &json!({"name": "blue.png", "dataBase64": png([0, 0, 255]), "link": false})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.images.len(), 2);
|
||||
s.execute("document.save", &json!({})).unwrap();
|
||||
assert_eq!(s.execute("file.newProfiles", &json!({})).unwrap()["profiles"][2]["customized"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edited_profile_starts_the_documents_of_its_category() {
|
||||
let (mut s, dir) = with_profiles("edited");
|
||||
let listed = s.execute("file.newProfiles", &json!({})).unwrap();
|
||||
let names: Vec<&str> = listed["profiles"].as_array().unwrap().iter().map(|p| p["name"].as_str().unwrap()).collect();
|
||||
assert_eq!(names, ["Mobile", "Web", "Print", "Film & Video", "Art & Illustration", "Branding", "Social"]);
|
||||
assert!(listed["profiles"].as_array().unwrap().iter().all(|p| p["customized"] == false), "{listed}");
|
||||
edit_print_profile(&mut s, &dir);
|
||||
|
||||
let r = s.execute("file.new", &json!({"preset": "A4"})).unwrap();
|
||||
assert_eq!(r["profile"], "Print", "{r}");
|
||||
assert!(swatch_names(&s).contains(&"Brand Red".to_string()));
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert!(d.graphic_styles.iter().any(|g| g.name == "Brand Box"));
|
||||
let badge = d.symbols.iter().find(|y| y.name == "Badge").expect("the profile's symbol");
|
||||
assert!(d.symbols.iter().any(|y| y.name == "Logo"));
|
||||
assert_eq!(d.images.len(), 1, "only the symbol's image comes along");
|
||||
// The profile's art stays behind: one empty layer, with an id the symbol's art doesn't use.
|
||||
assert_eq!(d.layers.len(), 1);
|
||||
assert!(d.layers[0].children().is_none_or(Vec::is_empty));
|
||||
let mut symbol_ids = vec![];
|
||||
badge.art.walk(&mut |n| symbol_ids.push(n.id));
|
||||
assert!(!symbol_ids.contains(&d.layers[0].id), "{symbol_ids:?}");
|
||||
assert_eq!(d.artboards[0].rect.width().round(), 595.0);
|
||||
let red = d.swatches.iter().find(|w| w.name == "Brand Red").cloned().unwrap();
|
||||
assert!(matches!(red.paint, vectorcraft_color::Paint::Solid { color: vectorcraft_color::Color::Cmyk { .. }, .. }), "{:?}", red.paint);
|
||||
// In RGB the profile's colors are converted, as Document Color Mode converts them.
|
||||
s.execute("file.new", &json!({"preset": "A4", "colorMode": "rgb"})).unwrap();
|
||||
let red = s.doc().unwrap().doc.swatches.iter().find(|w| w.name == "Brand Red").cloned().expect("converted, not dropped");
|
||||
assert!(matches!(red.paint, vectorcraft_color::Paint::Solid { color: vectorcraft_color::Color::Rgb { .. }, .. }), "{:?}", red.paint);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_profile_follows_the_preset_access_and_reset() {
|
||||
let (mut s, dir) = with_profiles("choice");
|
||||
edit_print_profile(&mut s, &dir);
|
||||
// A request that may not read the folder gets the defaults, and the reason; edit is refused
|
||||
// before anything is written.
|
||||
let elsewhere = dir.with_extension("elsewhere");
|
||||
std::fs::create_dir_all(&elsewhere).unwrap();
|
||||
let roots = crate::file_access::AutomationRoots::new(&[elsewhere.as_path()], &[dir.as_path()]).unwrap().unwrap();
|
||||
let r = crate::file_access::confine(Some(&roots), || s.execute("file.new", &json!({"preset": "A4"}))).unwrap();
|
||||
assert!(r.get("profile").is_none() && r["profileError"]["profile"] == "Print", "{r}");
|
||||
assert!(!swatch_names(&s).contains(&"Brand Red".to_string()));
|
||||
// Nothing tells such a request which profiles have files.
|
||||
let r = crate::file_access::confine(Some(&roots), || s.execute("file.new", &json!({"preset": "Web 1920×1080"}))).unwrap();
|
||||
assert_eq!(r["profileError"]["profile"], "Web", "{r}");
|
||||
let listed = crate::file_access::confine(Some(&roots), || s.execute("file.newProfiles", &json!({}))).unwrap();
|
||||
assert!(listed["profiles"].as_array().unwrap().iter().all(|p| p.get("customized").is_none() && p.get("path").is_none()), "{listed}");
|
||||
assert!(crate::file_access::confine(Some(&roots), || s.execute("file.newProfiles.edit", &json!({"name": "Web"}))).is_err());
|
||||
assert!(!dir.join("Web.vectorcraft").exists());
|
||||
let _ = std::fs::remove_dir_all(&elsewhere);
|
||||
// A saved preset made from a Print preset keeps the profile.
|
||||
s.execute("file.newPresets.save", &json!({"name": "Zine", "preset": "A5"})).unwrap();
|
||||
assert_eq!(presets(&mut s, "Saved")[0]["profile"], "Print");
|
||||
assert_eq!(s.execute("file.new", &json!({"preset": "Zine"})).unwrap()["profile"], "Print");
|
||||
|
||||
// Other categories, no preset and `profile: null` start with the defaults.
|
||||
for p in [json!({"preset": "Web 1920×1080"}), json!({}), json!({"preset": "A4", "profile": null})] {
|
||||
let r = s.execute("file.new", &p).unwrap();
|
||||
assert!(r.get("profile").is_none() && !swatch_names(&s).contains(&"Brand Red".to_string()), "{p}: {r}");
|
||||
}
|
||||
// `profile` picks one for any settings.
|
||||
assert_eq!(s.execute("file.new", &json!({"width": 300, "height": 300, "profile": "PRINT"})).unwrap()["profile"], "Print");
|
||||
|
||||
// Reset deletes the file, once; the shipped profile applies again.
|
||||
assert_eq!(s.execute("file.newProfiles.reset", &json!({"name": "Print"})).unwrap(), json!({"name": "Print", "reset": true}));
|
||||
assert_eq!(s.execute("file.newProfiles.reset", &json!({"name": "Print"})).unwrap(), json!({"name": "Print", "reset": false}));
|
||||
let r = s.execute("file.new", &json!({"preset": "A4"})).unwrap();
|
||||
assert!(r.get("profile").is_none() && !swatch_names(&s).contains(&"Brand Red".to_string()), "{r}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_damaged_or_oversized_profile_leaves_the_defaults_and_is_reported() {
|
||||
let (mut s, dir) = with_profiles("damaged");
|
||||
s.execute("file.new", &json!({"preset": "Letter", "profile": null})).unwrap();
|
||||
let defaults = swatch_names(&s);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("Print.vectorcraft"), b"not a document").unwrap();
|
||||
let r = s.execute("file.new", &json!({"preset": "Letter"})).unwrap();
|
||||
assert_eq!(r["profileError"]["profile"], "Print", "{r}");
|
||||
assert!(!r["profileError"]["reason"].as_str().unwrap_or("").is_empty(), "{r}");
|
||||
assert!(r.get("profile").is_none());
|
||||
assert_eq!(swatch_names(&s), defaults);
|
||||
// Past the limit the file is not read at all (a sparse file of that length).
|
||||
let big = std::fs::File::create(dir.join("Web.vectorcraft")).unwrap();
|
||||
big.set_len(crate::cmd::newdoc::MAX_PROFILE_BYTES + 1).unwrap();
|
||||
drop(big);
|
||||
let r = s.execute("file.new", &json!({"preset": "Web 1920×1080"})).unwrap();
|
||||
assert!(r["profileError"]["reason"].as_str().unwrap_or("").contains("larger than"), "{r}");
|
||||
assert_eq!(s.doc().unwrap().doc.artboards[0].rect.width(), 1920.0);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_names_are_checked_and_editing_needs_the_folder() {
|
||||
let mut s = Session::new();
|
||||
for p in [json!({"profile": "Posters"}), json!({"profile": 5}), json!({"profile": ""})] {
|
||||
let e = s.execute("file.new", &p).unwrap_err().to_string();
|
||||
assert!(e.contains("no New Document profile called") && e.contains("Film & Video"), "{p}: {e}");
|
||||
}
|
||||
assert!(s.execute("file.newProfiles.edit", &json!({"name": "Posters"})).is_err());
|
||||
for cmd in ["file.newProfiles.edit", "file.newProfiles.reset"] {
|
||||
let e = s.execute(cmd, &json!({"name": "Print"})).unwrap_err().to_string();
|
||||
assert!(e.contains("preferences folder"), "{cmd}: {e}");
|
||||
}
|
||||
// Without a folder every profile is the shipped one and documents start with the defaults.
|
||||
let r = s.execute("file.newProfiles", &json!({})).unwrap();
|
||||
assert!(r["folder"].is_null() && r["profiles"][0].get("path").is_none(), "{r}");
|
||||
let r = s.execute("file.new", &json!({"preset": "A4"})).unwrap();
|
||||
assert!(r.get("profile").is_none() && r.get("profileError").is_none(), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_new_layer_takes_an_id_no_profile_art_has() {
|
||||
// A damaged profile whose symbol art has the id the new layer would otherwise take.
|
||||
let settings = crate::cmd::newdoc::DocSettings::default();
|
||||
let mut profile = settings.document();
|
||||
let next = settings.document().peek_next_id().max(profile.peek_next_id());
|
||||
let art = std::sync::Arc::new(vectorcraft_doc::Node::group(vectorcraft_doc::NodeId(next), vec![]));
|
||||
profile.symbols.push(vectorcraft_doc::Symbol { name: "Badge".into(), art, dynamic: false });
|
||||
let d = settings.document_from_profile(profile);
|
||||
assert_eq!(d.layers.len(), 1);
|
||||
assert_ne!(d.layers[0].id, vectorcraft_doc::NodeId(next));
|
||||
// Ids given out later skip the symbol's art too.
|
||||
assert!(d.peek_next_id() > d.layers[0].id.0 && d.peek_next_id() > next);
|
||||
}
|
||||
|
||||
@@ -212,6 +212,51 @@ fn a_shadow_before_a_transform_expands_as_the_canvas_draws_it() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The object's Transform effect scales and turns the drop shadow of its gradient fill on the
|
||||
/// canvas; Object › Expand of the fill keeps the shadow the canvas drew.
|
||||
#[test]
|
||||
fn a_fills_shadow_under_the_objects_transform_expands_as_the_canvas_draws_it() {
|
||||
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, item) in [
|
||||
("stylize.dropShadow", json!({"x": 10, "y": 0, "blur": 0, "opacity": 100}), json!(0)),
|
||||
("distort.transform", json!({"scaleH": 200, "scaleV": 100, "rotate": 90}), json!(null)),
|
||||
] {
|
||||
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, "{diff} pixels differ");
|
||||
}
|
||||
|
||||
/// A group's Transform effect scales and turns its member's drop shadow on the canvas, and an
|
||||
/// object's scales and turns its fill's, with Scale Strokes & Effects off or on; Expand Appearance
|
||||
/// keeps the shadows the canvas drew.
|
||||
#[test]
|
||||
fn shadows_under_a_groups_or_an_objects_transform_expand_appearance_as_the_canvas_draws_them() {
|
||||
let shadow = json!({"x": 10, "y": 0, "blur": 0, "opacity": 100});
|
||||
for strokes in [false, true] {
|
||||
let transform = json!({"scaleH": 200, "scaleV": 100, "rotate": 90, "strokes": strokes});
|
||||
for group in [false, true] {
|
||||
let mut s = session();
|
||||
let id = gradient_rect(&mut s, Rect::new(80.0, 80.0, 120.0, 120.0), white_black(), false);
|
||||
let item = if group { json!(null) } else { json!(0) };
|
||||
run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "params": shadow, "ids": [id.0], "item": item}));
|
||||
if group {
|
||||
run(&mut s, "object.group", json!({}));
|
||||
}
|
||||
run(&mut s, "effect.apply", json!({"effect": "distort.transform", "params": transform}));
|
||||
let before = render(&s);
|
||||
run(&mut s, "effect.expandAppearance", json!({}));
|
||||
let after = render(&s);
|
||||
let diff = differing(&before, &after, 64);
|
||||
assert_eq!(diff, 0, "group {group}, Scale Strokes & Effects {strokes}: {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).
|
||||
|
||||
@@ -332,6 +332,30 @@ fn a_shadow_before_a_transform_outlines_as_the_canvas_draws_it() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The object's Transform effect turns and scales the drop shadow of its stroke (brushed or not)
|
||||
/// on the canvas; outlining the stroke keeps the shadow the canvas drew.
|
||||
#[test]
|
||||
fn a_strokes_shadow_under_the_objects_transform_outlines_as_the_canvas_draws_it() {
|
||||
for brush in [false, true] {
|
||||
for (command, p) in outlining().into_iter().filter(|(command, _)| !(brush && *command == "object.flattenTransparency")) {
|
||||
let mut s = session();
|
||||
square_with(
|
||||
&mut s,
|
||||
json!({"color": "#ff0000"}),
|
||||
json!(1),
|
||||
&[("stylize.dropShadow", json!({"x": 10, "y": 0, "blur": 0, "opacity": 100}))],
|
||||
);
|
||||
s.execute("effect.apply", &json!({"effect": "distort.transform", "params": {"scaleH": 50, "scaleV": 100, "rotate": 90}})).unwrap();
|
||||
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}, 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() {
|
||||
|
||||
@@ -75,6 +75,140 @@ fn assigning_another_profile_replaces_the_active_color_settings() {
|
||||
cms::set_active(&before).unwrap();
|
||||
}
|
||||
|
||||
/// Assign Profile tags the document and leaves the working spaces: Color Settings shows the
|
||||
/// working spaces, a later change to it saves them and not the assigned profile, and taking the
|
||||
/// profile off the document brings the working spaces back.
|
||||
#[test]
|
||||
fn assign_profile_leaves_the_working_spaces() {
|
||||
let _g = settings();
|
||||
let dir = fresh_dir("assign-profile");
|
||||
let _reset = ColorSettingsReset { profile: None, paths: vec![dir.clone()] };
|
||||
let file = dir.join("Color Settings.json");
|
||||
let mut s = session();
|
||||
s.attach_color_settings(file.clone());
|
||||
let working = cms::working_settings();
|
||||
assert_ne!(working.cmyk, cms::DEVICE_CMYK);
|
||||
exec(&mut s, "edit.assignProfile", json!({"cmyk": cms::DEVICE_CMYK}));
|
||||
assert_eq!(cms::active_settings().cmyk, cms::DEVICE_CMYK, "the document draws through its profile");
|
||||
assert_eq!(cms::working_settings(), working);
|
||||
assert!(!file.exists(), "Assign Profile saves no Color Settings");
|
||||
let r = exec(&mut s, "edit.colorSettings", json!({}));
|
||||
assert_eq!(r["cmyk"], working.cmyk.as_str(), "Color Settings shows the working CMYK space");
|
||||
exec(&mut s, "edit.colorSettings", json!({"intent": "perceptual"}));
|
||||
let saved: cms::ColorSettings = serde_json::from_slice(&std::fs::read(&file).unwrap()).unwrap();
|
||||
assert_eq!((saved.cmyk.as_str(), saved.intent), (working.cmyk.as_str(), cms::Intent::Perceptual));
|
||||
assert_eq!(cms::active_settings().cmyk, cms::DEVICE_CMYK, "the document still draws through its profile");
|
||||
exec(&mut s, "edit.assignProfile", json!({"cmyk": null}));
|
||||
assert_eq!(cms::active_settings(), cms::working_settings(), "no profile: the working spaces");
|
||||
}
|
||||
|
||||
/// A loaded profile that is assigned but is not a working space can be removed: the working space
|
||||
/// takes its place in the active settings, and the working spaces stay as they are.
|
||||
#[test]
|
||||
fn removing_an_assigned_profile_puts_the_working_space_back() {
|
||||
const NAME: &str = "Assigned CMYK";
|
||||
let _g = settings();
|
||||
let _reset = ColorSettingsReset { profile: Some(NAME), paths: vec![] };
|
||||
cms::register_icc(&cms::icc_bytes(cms::GENERIC_CMYK).unwrap(), Some(NAME.into())).unwrap();
|
||||
let mut s = session();
|
||||
let working = cms::working_settings();
|
||||
exec(&mut s, "edit.assignProfile", json!({"cmyk": NAME}));
|
||||
assert_eq!(cms::active_settings().cmyk, NAME);
|
||||
let revision = s.doc().unwrap().revision;
|
||||
let r = exec(&mut s, "color.removeProfile", json!({"name": NAME}));
|
||||
assert_eq!(r["removed"], NAME);
|
||||
assert_eq!(cms::active_settings(), working, "the working CMYK space is in use again");
|
||||
assert_eq!(cms::working_settings(), working);
|
||||
assert!(s.doc().unwrap().revision > revision, "the canvas redraws");
|
||||
}
|
||||
|
||||
/// Loading the file of an assigned profile in use again keeps it in use, loading it after it was
|
||||
/// removed puts it back in use, and the working spaces stay as they are.
|
||||
#[test]
|
||||
fn loading_an_assigned_profile_again_keeps_it_in_use() {
|
||||
const NAME: &str = "Assigned P";
|
||||
let _g = settings();
|
||||
let dir = fresh_dir("reload-assigned");
|
||||
let _reset = ColorSettingsReset { profile: Some(NAME), paths: vec![dir.clone()] };
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("assigned.icc");
|
||||
std::fs::write(&path, icc_named(NAME)).unwrap();
|
||||
let mut s = session();
|
||||
let working = cms::working_settings();
|
||||
let load = |s: &mut Session| assert_eq!(exec(s, "color.loadProfile", json!({"path": path.to_str().unwrap()}))["name"], NAME);
|
||||
load(&mut s);
|
||||
exec(&mut s, "edit.assignProfile", json!({"rgb": NAME}));
|
||||
let before = cms::active();
|
||||
load(&mut s);
|
||||
assert_eq!(cms::active_settings().rgb, NAME, "the assigned profile stays in use");
|
||||
assert!(!std::sync::Arc::ptr_eq(&before, &cms::active()), "the new file is in use");
|
||||
exec(&mut s, "color.removeProfile", json!({"name": NAME}));
|
||||
assert_eq!(cms::active_settings(), working);
|
||||
load(&mut s);
|
||||
assert_eq!(cms::active_settings().rgb, NAME, "loaded again, the assigned profile is in use");
|
||||
assert_eq!(cms::working_settings(), working);
|
||||
}
|
||||
|
||||
/// A batch that assigns a profile and then changes the color mode, as Document Setup's OK does,
|
||||
/// converts the colors through the profile it assigns.
|
||||
#[test]
|
||||
fn a_batch_converts_through_the_profile_it_assigns() {
|
||||
let _g = settings();
|
||||
let _reset = ColorSettingsReset { profile: None, paths: vec![] };
|
||||
let blue = |s: &mut Session| {
|
||||
let id = rect(s, 10.0, 10.0, 50.0, 50.0);
|
||||
exec(s, "paint.setFill", json!({"ids": [id.0], "color": "#3366cc"}));
|
||||
id
|
||||
};
|
||||
let fill = |s: &Session, id| s.doc().unwrap().doc.node(id).unwrap().appearance.fill_paint().color();
|
||||
let mut working = session();
|
||||
let w = blue(&mut working);
|
||||
exec(&mut working, "file.documentColorMode", json!({"mode": "cmyk"}));
|
||||
let mut tagged = session();
|
||||
let a = blue(&mut tagged);
|
||||
let steps = json!([
|
||||
{"command": "edit.assignProfile", "params": {"cmyk": cms::DEVICE_CMYK}},
|
||||
{"command": "file.documentColorMode", "params": {"mode": "cmyk"}},
|
||||
]);
|
||||
exec(&mut tagged, "command.batch", json!({"commands": steps}));
|
||||
let converted = fill(&tagged, a);
|
||||
assert_ne!(converted, fill(&working, w), "converted through the working CMYK space");
|
||||
let mut device = session();
|
||||
let b = blue(&mut device);
|
||||
exec(&mut device, "edit.colorSettings", json!({"cmyk": cms::DEVICE_CMYK}));
|
||||
exec(&mut device, "file.documentColorMode", json!({"mode": "cmyk"}));
|
||||
assert_eq!(converted, fill(&device, b));
|
||||
}
|
||||
|
||||
/// View › Proof Setup › Working CMYK simulates the working CMYK space of Color Settings in a
|
||||
/// document assigned another CMYK profile, in Proof Colors and in Separations Preview, and a change
|
||||
/// of the working CMYK space shows in the next proof while the document keeps its profile.
|
||||
#[test]
|
||||
fn working_cmyk_proofs_a_tagged_document_through_the_working_space() {
|
||||
const NAME: &str = "Tagged CMYK";
|
||||
let _g = settings();
|
||||
let _reset = ColorSettingsReset { profile: Some(NAME), paths: vec![] };
|
||||
cms::register_icc(&cms::icc_bytes(cms::GENERIC_CMYK).unwrap(), Some(NAME.into())).unwrap();
|
||||
let proof = cms::ProofSetup::default();
|
||||
assert_eq!(proof.target, cms::ProofTarget::WorkingCmyk);
|
||||
let (blue, inks) = ([0.2, 0.4, 0.8], [0.6, 0.3, 0.0, 0.1]);
|
||||
let mut s = session();
|
||||
exec(&mut s, "edit.assignProfile", json!({"cmyk": NAME}));
|
||||
let (active, working) = (cms::active(), cms::working());
|
||||
assert_eq!(active.settings().cmyk, NAME);
|
||||
assert_eq!(active.proof_srgb(blue, &proof), working.proof_srgb(blue, &proof), "Proof Colors");
|
||||
assert_eq!(active.proof_cmyk_to_srgb(inks, &proof), working.proof_cmyk_to_srgb(inks, &proof), "Separations Preview");
|
||||
let _ = active.proof_lut(&proof);
|
||||
exec(&mut s, "edit.colorSettings", json!({"cmyk": cms::DEVICE_CMYK}));
|
||||
let (active, working) = (cms::active(), cms::working());
|
||||
assert_eq!(active.settings().cmyk, NAME, "the document keeps its profile");
|
||||
let lut = active.proof_lut(&proof);
|
||||
let fresh = cms::ProofLut::build(|rgb| working.proof_srgb(rgb, &proof));
|
||||
for px in [[51, 102, 204], [230, 30, 30], [20, 160, 40], [128, 128, 128]] {
|
||||
assert_eq!(lut.apply8(px), fresh.apply8(px), "the proof of {px:?} through the new working CMYK space");
|
||||
}
|
||||
}
|
||||
|
||||
/// Separations Preview follows the Color Settings and the profiles loaded: a renderer draws a gray
|
||||
/// square on the Black plate through the new working CMYK profile, and the four process plates
|
||||
/// through a proof profile loaded again under the same name, as a new renderer draws them.
|
||||
|
||||
@@ -7126,6 +7126,81 @@ fn a_linked_object_past_the_object_limit_warns_that_its_link_is_left_out() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A link to a graphic style with Twist is kept where the reader reads Twist: on a curve, a curved
|
||||
/// compound path or a group with nothing stroked up to a quarter turn, and on a path with straight
|
||||
/// segments at any angle. A stroked curve, a group with a stroked member or a guide, or a curve,
|
||||
/// compound path or group twisted further, is written without the link and warns.
|
||||
#[test]
|
||||
fn a_link_to_a_twist_style_is_kept_where_the_reader_reads_it() {
|
||||
// A document with the object `make` builds from its appearance, `ap` with Twist at `angle`,
|
||||
// linked to a graphic style of that appearance.
|
||||
fn twisted(angle: f64, ap: Appearance, make: impl FnOnce(&mut Document, Appearance) -> Node) -> Document {
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let ap = Appearance { effects: vec![fx("distort.twist", serde_json::json!({"angle": angle}))], ..ap };
|
||||
let mut g = vectorcraft_doc::GraphicStyle::new("Twisted", ap.clone());
|
||||
g.id = 9;
|
||||
doc.graphic_styles.push(g);
|
||||
let mut n = make(&mut doc, ap);
|
||||
n.extras_mut().graphic_style = Some(9);
|
||||
put(&mut doc, n);
|
||||
doc
|
||||
}
|
||||
let oval = || shapes::ellipse(Rect::new(10.0, 10.0, 90.0, 60.0));
|
||||
let square = || shapes::rectangle(Rect::new(10.0, 10.0, 90.0, 60.0));
|
||||
let red = solid(Color::rgb(1.0, 0.0, 0.0));
|
||||
let filled = || Appearance::basic(black_fill(), Paint::None, 0.0);
|
||||
let path = |p: PathData| move |doc: &mut Document, ap: Appearance| Node::path(doc.alloc_id(), p, ap);
|
||||
let ring = |doc: &mut Document, ap: Appearance| {
|
||||
let outer = Node::path(doc.alloc_id(), oval(), Appearance::default());
|
||||
let inner = Node::path(doc.alloc_id(), shapes::ellipse(Rect::new(30.0, 20.0, 70.0, 50.0)), Appearance::default());
|
||||
let mut n = Node::new(doc.alloc_id(), NodeKind::Compound { children: vec![Arc::new(outer), Arc::new(inner)], rule: FillRule::NonZero });
|
||||
n.appearance = ap.into();
|
||||
n
|
||||
};
|
||||
// A group of a filled square and `second`.
|
||||
let group = |second: fn(&mut Document) -> Node| {
|
||||
move |doc: &mut Document, ap: Appearance| {
|
||||
let first = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 40.0, 40.0)), filled());
|
||||
let second = second(doc);
|
||||
let mut n = Node::group(doc.alloc_id(), vec![Arc::new(first), Arc::new(second)]);
|
||||
n.appearance = ap.into();
|
||||
n
|
||||
}
|
||||
};
|
||||
let curve = |doc: &mut Document| {
|
||||
Node::path(doc.alloc_id(), shapes::ellipse(Rect::new(50.0, 10.0, 90.0, 60.0)), Appearance::basic(black_fill(), Paint::None, 0.0))
|
||||
};
|
||||
let stroked_curve = |doc: &mut Document| {
|
||||
let ap = Appearance::basic(black_fill(), solid(Color::rgb(1.0, 0.0, 0.0)), 2.0);
|
||||
Node::path(doc.alloc_id(), shapes::ellipse(Rect::new(50.0, 10.0, 90.0, 60.0)), ap)
|
||||
};
|
||||
let guide = |doc: &mut Document| {
|
||||
let path = shapes::rectangle(Rect::new(50.0, 10.0, 90.0, 60.0));
|
||||
Node::new(doc.alloc_id(), NodeKind::Path { path, rule: FillRule::NonZero, live: None, clipping: false, guide: true })
|
||||
};
|
||||
for (what, doc, kept) in [
|
||||
("a twisted curve", twisted(45.0, filled(), path(oval())), true),
|
||||
("a curve twisted a quarter turn back", twisted(-90.0, filled(), path(oval())), true),
|
||||
("a square twisted three quarters of a turn", twisted(270.0, filled(), path(square())), true),
|
||||
("a curve twisted past a quarter turn", twisted(120.0, filled(), path(oval())), false),
|
||||
("a stroked curve", twisted(45.0, Appearance::basic(black_fill(), red.clone(), 2.0), path(oval())), false),
|
||||
("a curved compound path", twisted(-60.0, filled(), ring), true),
|
||||
("a curved compound path twisted past a quarter turn", twisted(120.0, filled(), ring), false),
|
||||
("a twisted group", twisted(90.0, Appearance::default(), group(curve)), true),
|
||||
("a group twisted past a quarter turn", twisted(135.0, Appearance::default(), group(curve)), false),
|
||||
("a group with a stroked member", twisted(45.0, Appearance::default(), group(stroked_curve)), false),
|
||||
("a group with a guide", twisted(45.0, Appearance::default(), group(guide)), false),
|
||||
] {
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert_eq!(out.warnings.iter().any(|w| w == writer::STYLE_LINKS), !kept, "{what}: {:?}", out.warnings);
|
||||
let back = read_back(&doc);
|
||||
let n = &back.layers[0].children().unwrap()[0];
|
||||
let style = n.extras().graphic_style.and_then(|id| back.graphic_style_by_id(id)).map(|g| g.name.as_str());
|
||||
assert_eq!(style, kept.then_some("Twisted"), "{what}: {n:?}");
|
||||
assert_eq!(n.appearance.effects.iter().any(|e| e.id == "distort.twist"), kept, "{what}: {n:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Twist and Transform on an object that isn't linked to a graphic style are not written as a style
|
||||
/// of its own: the object reads back without live effects.
|
||||
#[test]
|
||||
|
||||
@@ -1067,7 +1067,8 @@ fn strokes(n: &Node, depth: usize) -> bool {
|
||||
|| n.children().into_iter().flatten().any(|c| strokes(c, depth + 1))
|
||||
}
|
||||
|
||||
/// Are all of path or compound path `n`'s segments straight, each anchor's handles on it?
|
||||
/// Are all of path or compound path `n`'s segments straight, each anchor's handles on it? Any other
|
||||
/// object, a group included, counts as no.
|
||||
fn straight(n: &Node) -> bool {
|
||||
match &n.kind {
|
||||
NodeKind::Path { path, .. } => path.subpaths.iter().flat_map(|s| &s.anchors).all(|a| a.h_in == a.p && a.h_out == a.p),
|
||||
@@ -1076,10 +1077,21 @@ fn straight(n: &Node) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Is neither `n` nor anything in it a guide? Deeper than [`MAX_DEPTH`] counts as no.
|
||||
fn no_guides(n: &Node, depth: usize) -> bool {
|
||||
depth <= MAX_DEPTH && !n.is_guide() && n.children().into_iter().flatten().all(|c| no_guides(c, depth + 1))
|
||||
}
|
||||
|
||||
/// The largest Twist angle, in degrees either way, read as a live effect on an object that isn't a
|
||||
/// path or compound path with straight segments: VectorCraft's own limit. An object with a larger
|
||||
/// Twist keeps the file's drawn look.
|
||||
const MAX_TWIST: f64 = 90.0;
|
||||
|
||||
/// Is `object` one that style `st`, whose effect moves the outline, is read on? It and everything
|
||||
/// in it [`moves_alike`], and the style paints no gradient. Twist is read only on an unstroked path
|
||||
/// or compound path with straight segments, and Transform that scales, turns or reflects only when
|
||||
/// nothing is stroked (its reference point lies on the bounds without strokes).
|
||||
/// in it [`moves_alike`], and the style paints no gradient. Twist is read only when nothing is
|
||||
/// stroked: on a path or compound path with straight segments at any angle, or up to [`MAX_TWIST`]
|
||||
/// degrees either way on another object with no guide in it. Transform that scales, turns or reflects
|
||||
/// is read only when nothing is stroked (its reference point lies on the bounds without strokes).
|
||||
fn moves_with(object: &Node, st: &crate::import::ai::style::ArtStyle) -> bool {
|
||||
use crate::import::ai::style::StyleItem;
|
||||
let stroked = st.items.iter().any(|i| matches!(i, StyleItem::Stroke { paint, .. } if !paint.is_none()))
|
||||
@@ -1088,7 +1100,9 @@ fn moves_with(object: &Node, st: &crate::import::ai::style::ArtStyle) -> bool {
|
||||
&& outlines_only(object)
|
||||
&& object.children().into_iter().flatten().all(|c| moves_alike(c, 1))
|
||||
&& st.effects.iter().all(|e| match e.id.as_str() {
|
||||
"distort.twist" => !stroked && matches!(object.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) && straight(object),
|
||||
"distort.twist" => {
|
||||
!stroked && (straight(object) || (no_guides(object, 0) && e.params["angle"].as_f64().is_some_and(|a| a.abs() <= MAX_TWIST)))
|
||||
}
|
||||
"distort.transform" => {
|
||||
let p = &e.params;
|
||||
!stroked
|
||||
|
||||
@@ -7092,13 +7092,12 @@ fn with_red_stroke(styles: &str) -> String {
|
||||
|
||||
#[test]
|
||||
fn twist_and_a_scaling_transform_are_read_only_where_their_outline_is_known() {
|
||||
// GUARDS: Twist was read on curves, strokes and groups, and Transform scaling a stroked object,
|
||||
// where the outline VectorCraft draws hasn't been checked.
|
||||
// GUARDS: Twist was read on strokes, and Transform scaling a stroked object, where the outline
|
||||
// VectorCraft draws hasn't been checked.
|
||||
let object = |hidden: &str| {
|
||||
format!("0 Ae\nu\n{RED}{}f\nU\n%_Bb\n%_{}f\n%_0 BB\n1 (Bent) XW\n", rect_path(16.0, 16.0, 64.0, 54.0), hidden.replace('\n', "\n%_"))
|
||||
};
|
||||
let square = object(&rect_path(20.0, 20.0, 60.0, 50.0));
|
||||
let curved = object("20 35 m\n20 45 60 45 60 35 c\n60 25 20 25 20 35 c\n");
|
||||
let twist = distort_styles("Adobe Twirl", "45 /Real (angle) ,\n", BLUE_FILL);
|
||||
let scale = "0.5 /Real (scaleH_Factor) ,\n0 /Bool (scaleLines) ,\n0 /Bool (transformPatterns) ,\n";
|
||||
let corner = distort_styles("Adobe Transform", &format!("0 /Int (pinPoint) ,\n{scale}"), BLUE_FILL);
|
||||
@@ -7106,9 +7105,7 @@ fn twist_and_a_scaling_transform_are_read_only_where_their_outline_is_known() {
|
||||
let moved = distort_styles("Adobe Transform", "0 /Int (pinPoint) ,\n5 /Real (moveH_Pts) ,\n0 /Bool (transformPatterns) ,\n", BLUE_FILL);
|
||||
let read = |styles: &str, art_: &str| read_ok(&format!("{styles}{}", layer(art_)));
|
||||
for (what, r) in [
|
||||
("a twisted curve", read(&twist, &curved)),
|
||||
("a twisted stroke", read(&with_red_stroke(&twist), &square)),
|
||||
("a twisted group", read(&distort_styles("Adobe Twirl", "45 /Real (angle) ,\n", None), &distort_group())),
|
||||
("a stroke scaled from a corner", read(&with_red_stroke(&corner), &square)),
|
||||
("a stroke scaled from the center", read(&with_red_stroke(¢er), &square)),
|
||||
] {
|
||||
@@ -7133,6 +7130,67 @@ fn twist_and_a_scaling_transform_are_read_only_where_their_outline_is_known() {
|
||||
assert_eq!((e.id.as_str(), e.params["angle"].as_f64()), ("distort.twist", Some(45.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn twist_is_read_on_curves_compound_paths_and_groups_with_nothing_stroked_up_to_a_quarter_turn() {
|
||||
// GUARDS: Twist was read only on paths and compound paths with straight segments.
|
||||
// The object, `hidden` painted with its style, under its drawn look.
|
||||
let object = |hidden: &str| {
|
||||
format!("0 Ae\nu\n{RED}{}f\nU\n%_Bb\n%_{}0 BB\n1 (Bent) XW\n", rect_path(16.0, 16.0, 64.0, 54.0), hidden.replace('\n', "\n%_"))
|
||||
};
|
||||
let square = object(&format!("{}f\n", rect_path(20.0, 20.0, 60.0, 50.0)));
|
||||
let oval = "20 35 m\n20 45 60 45 60 35 c\n60 25 20 25 20 35 c\n";
|
||||
let curved = object(&format!("{oval}f\n"));
|
||||
let ring = object(&format!("*u\n{oval}f\n30 35 m\n30 40 50 40 50 35 c\n50 30 30 30 30 35 c\nf\n*U\n"));
|
||||
let twist = |angle: f64, paint| distort_styles("Adobe Twirl", &format!("{angle} /Real (angle) ,\n"), paint);
|
||||
let read = |styles: &str, art_: &str| read_ok(&format!("{styles}{}", layer(art_)));
|
||||
// The first member of a group, and a second one.
|
||||
let group = |second: &str| {
|
||||
let member = |s: String| s.replace('\n', "\n%_");
|
||||
format!(
|
||||
"0 Ae\nu\n{RED}{}f\nU\n%_0 Ae\n%_u\n%_{}{}U\n1 (Bent) XW\n",
|
||||
rect_path(-1.0, -1.0, 16.0, 11.0),
|
||||
member(format!("{RED}{}f\n", rect_path(0.0, 0.0, 10.0, 10.0))),
|
||||
member(second.to_string())
|
||||
)
|
||||
};
|
||||
let hidden_member = group(&format!("1 Xw\n{RED}{}f\n0 Xw\n", rect_path(5.0, 0.0, 15.0, 10.0)));
|
||||
// A compound path, its first subpath `first` and a guide.
|
||||
let with_guide = |first: &str| object(&format!("*u\n{first}f\n5 0 m\n15 10 L\n(N) *\n*U\n"));
|
||||
for (what, r, members) in [
|
||||
("a twisted curve", read(&twist(45.0, BLUE_FILL), &curved), 0),
|
||||
("a curved compound path", read(&twist(-60.0, BLUE_FILL), &ring), 2),
|
||||
("a twisted group", read(&twist(90.0, None), &distort_group()), 2),
|
||||
("a group with a hidden member", read(&twist(45.0, None), &hidden_member), 2),
|
||||
("a curve twisted a quarter turn back", read(&twist(-90.0, BLUE_FILL), &curved), 0),
|
||||
("a square twisted three quarters of a turn, as before", read(&twist(270.0, BLUE_FILL), &square), 0),
|
||||
("a straight compound path with a guide, as before", read(&twist(45.0, BLUE_FILL), &with_guide(&rect_path(20.0, 20.0, 60.0, 50.0))), 2),
|
||||
] {
|
||||
assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{what}: {:?}", r.warnings);
|
||||
let a = art(&r.doc);
|
||||
assert_eq!(a.len(), 1, "{what}: {a:#?}");
|
||||
assert_eq!(a[0].children().map_or(0, |c| c.len()), members, "{what}: the object, not its look: {a:#?}");
|
||||
let [e] = a[0].appearance.effects.as_slice() else { panic!("{what}: {:?}", a[0].appearance) };
|
||||
assert_eq!(e.id, "distort.twist", "{what}");
|
||||
}
|
||||
// A group with a stroked member or a guide, a curved compound path with a guide, and curves and
|
||||
// groups twisted past a quarter turn, keep the drawn look.
|
||||
let stroked_member = group(&format!("0 1 1 0 1 0 0 XA\n2 w\n{}S\n", rect_path(5.0, 0.0, 15.0, 10.0)));
|
||||
let guide_member = group("5 0 m\n15 10 L\n(N) *\n");
|
||||
for (what, r) in [
|
||||
("a group with a stroked member", read(&twist(45.0, None), &stroked_member)),
|
||||
("a group with a guide", read(&twist(45.0, None), &guide_member)),
|
||||
("a curved compound path with a guide", read(&twist(45.0, BLUE_FILL), &with_guide(oval))),
|
||||
("a curve twisted past a quarter turn", read(&twist(91.0, BLUE_FILL), &curved)),
|
||||
("a curve twisted half a turn back", read(&twist(-180.0, BLUE_FILL), &curved)),
|
||||
("a group twisted past a quarter turn", read(&twist(135.0, None), &distort_group())),
|
||||
] {
|
||||
let a = art(&r.doc);
|
||||
assert_eq!(a.len(), 1, "{what}: {a:#?}");
|
||||
assert!(a[0].appearance.effects.is_empty(), "{what}: the drawn look: {a:#?}");
|
||||
assert!(r.warnings.iter().any(|w| w.contains("drawn look")), "{what}: {:?}", r.warnings);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bare_comment_line_in_a_prolog_leaves_its_end_alone() {
|
||||
// GUARDS: a bare `%` line's empty marker matched the empty begin marker of a section that has none.
|
||||
|
||||
@@ -261,91 +261,91 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
"align.horizontalLeft",
|
||||
"Horizontal Align Left",
|
||||
"",
|
||||
"{} the Align panel's Horizontal Align Left button: object.align {horizontal: \"left\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice",
|
||||
"{} the Align panel's Horizontal Align Left button: object.align {horizontal: \"left\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice; with the Artboard tool chosen, artboard.align with the same direction on the artboards the tool has selected, to: \"artboard\" (the active artboard) while Align to Artboard is chosen, else \"selection\"",
|
||||
),
|
||||
(
|
||||
"align.horizontalCenter",
|
||||
"Horizontal Align Center",
|
||||
"",
|
||||
"{} the Align panel's Horizontal Align Center button: object.align {horizontal: \"center\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice",
|
||||
"{} the Align panel's Horizontal Align Center button: object.align {horizontal: \"center\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice; with the Artboard tool chosen, artboard.align with the same direction on the artboards the tool has selected, to: \"artboard\" (the active artboard) while Align to Artboard is chosen, else \"selection\"",
|
||||
),
|
||||
(
|
||||
"align.horizontalRight",
|
||||
"Horizontal Align Right",
|
||||
"",
|
||||
"{} the Align panel's Horizontal Align Right button: object.align {horizontal: \"right\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice",
|
||||
"{} the Align panel's Horizontal Align Right button: object.align {horizontal: \"right\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice; with the Artboard tool chosen, artboard.align with the same direction on the artboards the tool has selected, to: \"artboard\" (the active artboard) while Align to Artboard is chosen, else \"selection\"",
|
||||
),
|
||||
(
|
||||
"align.horizontalVerticalCenter",
|
||||
"Horizontal & Vertical Align Center",
|
||||
"",
|
||||
"{} the Align panel's Horizontal & Vertical Align Center button: object.align {horizontal: \"center\", vertical: \"center\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice",
|
||||
"{} the Align panel's Horizontal & Vertical Align Center button: object.align {horizontal: \"center\", vertical: \"center\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice; with the Artboard tool chosen, artboard.align with the same direction on the artboards the tool has selected, to: \"artboard\" (the active artboard) while Align to Artboard is chosen, else \"selection\"",
|
||||
),
|
||||
(
|
||||
"align.verticalTop",
|
||||
"Vertical Align Top",
|
||||
"",
|
||||
"{} the Align panel's Vertical Align Top button: object.align {vertical: \"top\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice",
|
||||
"{} the Align panel's Vertical Align Top button: object.align {vertical: \"top\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice; with the Artboard tool chosen, artboard.align with the same direction on the artboards the tool has selected, to: \"artboard\" (the active artboard) while Align to Artboard is chosen, else \"selection\"",
|
||||
),
|
||||
(
|
||||
"align.verticalCenter",
|
||||
"Vertical Align Center",
|
||||
"",
|
||||
"{} the Align panel's Vertical Align Center button: object.align {vertical: \"center\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice",
|
||||
"{} the Align panel's Vertical Align Center button: object.align {vertical: \"center\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice; with the Artboard tool chosen, artboard.align with the same direction on the artboards the tool has selected, to: \"artboard\" (the active artboard) while Align to Artboard is chosen, else \"selection\"",
|
||||
),
|
||||
(
|
||||
"align.verticalBottom",
|
||||
"Vertical Align Bottom",
|
||||
"",
|
||||
"{} the Align panel's Vertical Align Bottom button: object.align {vertical: \"bottom\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice",
|
||||
"{} the Align panel's Vertical Align Bottom button: object.align {vertical: \"bottom\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice; with the Artboard tool chosen, artboard.align with the same direction on the artboards the tool has selected, to: \"artboard\" (the active artboard) while Align to Artboard is chosen, else \"selection\"",
|
||||
),
|
||||
(
|
||||
"align.distributeVerticalTop",
|
||||
"Vertical Distribute Top",
|
||||
"",
|
||||
"{} the Align panel's Vertical Distribute Top button: object.distribute {vertical: \"top\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key",
|
||||
"{} the Align panel's Vertical Distribute Top button: object.distribute {vertical: \"top\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key; with the Artboard tool chosen, the artboard command of the same name (artboard.distribute, artboard.distributeSpacing) with the same direction on the artboards the tool has selected, to: \"artboard\" (the active artboard) and spacing: the Distribute Spacing value while Align to Artboard is chosen, else \"selection\"",
|
||||
),
|
||||
(
|
||||
"align.distributeVerticalCenter",
|
||||
"Vertical Distribute Center",
|
||||
"",
|
||||
"{} the Align panel's Vertical Distribute Center button: object.distribute {vertical: \"center\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key",
|
||||
"{} the Align panel's Vertical Distribute Center button: object.distribute {vertical: \"center\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key; with the Artboard tool chosen, the artboard command of the same name (artboard.distribute, artboard.distributeSpacing) with the same direction on the artboards the tool has selected, to: \"artboard\" (the active artboard) and spacing: the Distribute Spacing value while Align to Artboard is chosen, else \"selection\"",
|
||||
),
|
||||
(
|
||||
"align.distributeVerticalBottom",
|
||||
"Vertical Distribute Bottom",
|
||||
"",
|
||||
"{} the Align panel's Vertical Distribute Bottom button: object.distribute {vertical: \"bottom\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key",
|
||||
"{} the Align panel's Vertical Distribute Bottom button: object.distribute {vertical: \"bottom\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key; with the Artboard tool chosen, the artboard command of the same name (artboard.distribute, artboard.distributeSpacing) with the same direction on the artboards the tool has selected, to: \"artboard\" (the active artboard) and spacing: the Distribute Spacing value while Align to Artboard is chosen, else \"selection\"",
|
||||
),
|
||||
(
|
||||
"align.distributeHorizontalLeft",
|
||||
"Horizontal Distribute Left",
|
||||
"",
|
||||
"{} the Align panel's Horizontal Distribute Left button: object.distribute {horizontal: \"left\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key",
|
||||
"{} the Align panel's Horizontal Distribute Left button: object.distribute {horizontal: \"left\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key; with the Artboard tool chosen, the artboard command of the same name (artboard.distribute, artboard.distributeSpacing) with the same direction on the artboards the tool has selected, to: \"artboard\" (the active artboard) and spacing: the Distribute Spacing value while Align to Artboard is chosen, else \"selection\"",
|
||||
),
|
||||
(
|
||||
"align.distributeHorizontalCenter",
|
||||
"Horizontal Distribute Center",
|
||||
"",
|
||||
"{} the Align panel's Horizontal Distribute Center button: object.distribute {horizontal: \"center\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key",
|
||||
"{} the Align panel's Horizontal Distribute Center button: object.distribute {horizontal: \"center\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key; with the Artboard tool chosen, the artboard command of the same name (artboard.distribute, artboard.distributeSpacing) with the same direction on the artboards the tool has selected, to: \"artboard\" (the active artboard) and spacing: the Distribute Spacing value while Align to Artboard is chosen, else \"selection\"",
|
||||
),
|
||||
(
|
||||
"align.distributeHorizontalRight",
|
||||
"Horizontal Distribute Right",
|
||||
"",
|
||||
"{} the Align panel's Horizontal Distribute Right button: object.distribute {horizontal: \"right\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key",
|
||||
"{} the Align panel's Horizontal Distribute Right button: object.distribute {horizontal: \"right\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key; with the Artboard tool chosen, the artboard command of the same name (artboard.distribute, artboard.distributeSpacing) with the same direction on the artboards the tool has selected, to: \"artboard\" (the active artboard) and spacing: the Distribute Spacing value while Align to Artboard is chosen, else \"selection\"",
|
||||
),
|
||||
(
|
||||
"align.distributeVerticalSpace",
|
||||
"Vertical Distribute Space",
|
||||
"",
|
||||
"{} the Align panel's Vertical Distribute Space button: object.distributeSpacing {axis: \"vertical\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key",
|
||||
"{} the Align panel's Vertical Distribute Space button: object.distributeSpacing {axis: \"vertical\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key; with the Artboard tool chosen, the artboard command of the same name (artboard.distribute, artboard.distributeSpacing) with the same direction on the artboards the tool has selected, to: \"artboard\" (the active artboard) and spacing: the Distribute Spacing value while Align to Artboard is chosen, else \"selection\"",
|
||||
),
|
||||
(
|
||||
"align.distributeHorizontalSpace",
|
||||
"Horizontal Distribute Space",
|
||||
"",
|
||||
"{} the Align panel's Horizontal Distribute Space button: object.distributeSpacing {axis: \"horizontal\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key",
|
||||
"{} the Align panel's Horizontal Distribute Space button: object.distributeSpacing {axis: \"horizontal\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key; with the Artboard tool chosen, the artboard command of the same name (artboard.distribute, artboard.distributeSpacing) with the same direction on the artboards the tool has selected, to: \"artboard\" (the active artboard) and spacing: the Distribute Spacing value while Align to Artboard is chosen, else \"selection\"",
|
||||
),
|
||||
(
|
||||
"type.bold",
|
||||
|
||||
@@ -126,21 +126,49 @@ fn path_is_finite(path: &PathData) -> bool {
|
||||
/// Fallible [`offset_path`] that returns an error when the input is non-finite or exceeds
|
||||
/// the complexity bound ([`MAX_OFFSET_SEGMENT_PAIRS`]), or when planar arrangement fails.
|
||||
pub fn try_offset_path(path: &PathData, delta: f64, join: Join, miter_limit: f64) -> Result<PathData, PathOpsError> {
|
||||
let src = std::cell::LazyCell::new(|| SegmentTree::new(boxed_segments(&path.to_bezpath())));
|
||||
offset_with_check(path, delta, join, miter_limit, |p, r| any_within(&src, p, r))
|
||||
offset_with_check(path, delta, join, miter_limit, |part| {
|
||||
let bp = part.to_bezpath();
|
||||
let src = std::cell::LazyCell::new(move || SegmentTree::new(boxed_segments(&bp)));
|
||||
move |p, r| any_within(&src, p, r)
|
||||
})
|
||||
}
|
||||
|
||||
/// [`try_offset_path`] with the test of whether some segment of `path` lies closer than a radius to a point.
|
||||
fn offset_with_check(
|
||||
/// [`try_offset_path`] with `within_of(part)`, the test of whether some segment of `part` lies
|
||||
/// closer than a radius to a point.
|
||||
fn offset_with_check<W: Fn(kurbo::Point, f64) -> bool>(
|
||||
path: &PathData,
|
||||
delta: f64,
|
||||
join: Join,
|
||||
miter_limit: f64,
|
||||
within_of: impl Fn(&PathData) -> W,
|
||||
) -> Result<PathData, PathOpsError> {
|
||||
if !delta.is_finite() || !path_is_finite(path) {
|
||||
return Err(PathOpsError::NonFinite);
|
||||
}
|
||||
// An inset keeps only faces inside the closed fill, so it would drop the outline of every
|
||||
// open subpath (#6427). Inset the closed subpaths, outline the open ones as a positive offset
|
||||
// does, and unite the two.
|
||||
if delta < 0.0 && path.subpaths.iter().any(|s| s.anchors.len() > 1 && !close_if_ends_meet(s).closed) {
|
||||
let (closed, open): (Vec<SubPath>, Vec<SubPath>) = path.subpaths.iter().cloned().partition(|s| close_if_ends_meet(s).closed);
|
||||
let (closed, open) = (PathData::new(closed), PathData::new(open));
|
||||
let inset = offset_region(&closed, delta, join, miter_limit, within_of(&closed))?;
|
||||
let outline = offset_region(&open, -delta, join, miter_limit, within_of(&open))?;
|
||||
if inset.is_empty() {
|
||||
return Ok(outline);
|
||||
}
|
||||
return crate::boolean::try_unite_all(&[(&inset, FillRule::NonZero), (&outline, FillRule::NonZero)]);
|
||||
}
|
||||
offset_region(path, delta, join, miter_limit, within_of(path))
|
||||
}
|
||||
|
||||
/// [`offset_with_check`] of a finite `path` with the `within` test of its own segments.
|
||||
fn offset_region(
|
||||
path: &PathData,
|
||||
delta: f64,
|
||||
join: Join,
|
||||
miter_limit: f64,
|
||||
within: impl Fn(kurbo::Point, f64) -> bool,
|
||||
) -> Result<PathData, PathOpsError> {
|
||||
if !delta.is_finite() || !path_is_finite(path) {
|
||||
return Err(PathOpsError::NonFinite);
|
||||
}
|
||||
if path.is_empty() {
|
||||
return Ok(PathData::default());
|
||||
}
|
||||
@@ -630,14 +658,17 @@ mod tests {
|
||||
fn offsets_with_the_tree_are_identical_to_offsets_with_the_scan() {
|
||||
let (mut checks, mut hits, mut misses) = (0, 0, 0);
|
||||
for path in offset_inputs() {
|
||||
let segs = boxed_segments(&path.to_bezpath());
|
||||
for delta in [0.0, 0.05, 1.0, 5.0, 60.0, -0.05, -1.0, -5.0, -60.0] {
|
||||
for join in [Join::Round, Join::Miter, Join::Bevel] {
|
||||
let found = std::cell::Cell::new((0, 0));
|
||||
let by_scan = offset_with_check(&path, delta, join, 4.0, |p, r| {
|
||||
let v = any_within_by_scan(&segs, p, r);
|
||||
found.set((found.get().0 + usize::from(v), found.get().1 + usize::from(!v)));
|
||||
v
|
||||
let by_scan = offset_with_check(&path, delta, join, 4.0, |part| {
|
||||
let segs = boxed_segments(&part.to_bezpath());
|
||||
let found = &found;
|
||||
move |p, r| {
|
||||
let v = any_within_by_scan(&segs, p, r);
|
||||
found.set((found.get().0 + usize::from(v), found.get().1 + usize::from(!v)));
|
||||
v
|
||||
}
|
||||
});
|
||||
checks += 1;
|
||||
hits += found.get().0;
|
||||
|
||||
@@ -316,6 +316,24 @@ fn offset_of_an_open_path_whose_ends_meet_is_the_closed_shape_offset() {
|
||||
assert_eq!(o.subpaths.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_negative_offset_outlines_an_open_path_as_a_positive_one_does() {
|
||||
// Issue #6427 (A4): an inset of an open path was empty. Open subpaths are outlined with a
|
||||
// stroke of width 2|delta| whichever way the offset goes.
|
||||
let line = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0)], false));
|
||||
for delta in [2.0, -2.0] {
|
||||
let o = offset_path(&line, delta, Join::Miter, 4.0);
|
||||
assert!(close(ar(&o), 400.0, 1e-6), "delta {delta}: {}", ar(&o));
|
||||
assert_eq!(o.subpaths.len(), 1, "delta {delta}");
|
||||
}
|
||||
// With a closed shape beside it, the shape is inset and the line still outlined.
|
||||
let mut both = rect(0.0, 0.0, 20.0, 10.0);
|
||||
both.subpaths.push(SubPath::polyline(&[Point::new(100.0, 0.0), Point::new(150.0, 0.0)], false));
|
||||
let o = offset_path(&both, -2.0, Join::Miter, 4.0);
|
||||
assert!(close(ar(&o), 16.0 * 6.0 + 50.0 * 4.0, 1e-6), "{}", ar(&o));
|
||||
assert_eq!(o.subpaths.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_stroke_of_line() {
|
||||
let l = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(30.0, 40.0)], false));
|
||||
|
||||
@@ -54,6 +54,12 @@ pub(super) fn item_effects(item: &AppearanceItem) -> &[Effect] {
|
||||
}
|
||||
}
|
||||
|
||||
/// The effects of `item`, a fill or stroke of `n`, that its raster effects are drawn from: each
|
||||
/// one following `n`'s Transform effects as well ([`effects::item_effects_in`]).
|
||||
pub(super) fn item_raster<'a>(n: &Node, item: &'a AppearanceItem) -> std::borrow::Cow<'a, [Effect]> {
|
||||
effects::item_effects_in(&n.appearance.effects, item_effects(item))
|
||||
}
|
||||
|
||||
/// Visual bounds including geometry effects, stroke outsets and shadows/glows (symbol instances
|
||||
/// measured by their art from `symbols`).
|
||||
pub(crate) fn visual_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
|
||||
@@ -81,7 +87,7 @@ pub(crate) fn visual_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
|
||||
/// `r`, the art of `n` with its strokes, grown by how far the raster effects of `n`'s fills and
|
||||
/// strokes and then `n`'s own paint past it.
|
||||
fn with_raster_reach(symbols: &SymbolBoxes, n: &Node, r: Rect) -> Rect {
|
||||
let r = n.appearance.items.iter().filter(|i| paints(i)).map(|i| spread(symbols, item_effects(i), r)).fold(r, |acc, b| acc.union(b));
|
||||
let r = n.appearance.items.iter().filter(|i| paints(i)).map(|i| spread(symbols, &item_raster(n, i), r)).fold(r, |acc, b| acc.union(b));
|
||||
let o = outset(symbols, &n.appearance.effects, r);
|
||||
r.inflate(o, o)
|
||||
}
|
||||
@@ -109,7 +115,7 @@ pub(crate) fn drawn_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
|
||||
}
|
||||
let b = art_bounds(symbols, n)?;
|
||||
let items = n.appearance.items.iter().filter(|i| paints(i) && !effects::raster_effects(item_effects(i)).is_empty());
|
||||
Some(items.map(|i| spread(symbols, item_effects(i), item_bounds(symbols.document(), n, i).unwrap_or(b))).fold(b, |acc, r| acc.union(r)))
|
||||
Some(items.map(|i| spread(symbols, &item_raster(n, i), item_bounds(symbols.document(), n, i).unwrap_or(b))).fold(b, |acc, r| acc.union(r)))
|
||||
}
|
||||
|
||||
/// The bounds of what `item`, a fill or stroke of the path or compound path `n`, paints: the path
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::bounds::{apply_carrying, item_effects};
|
||||
use super::bounds::{apply_carrying, item_effects, item_raster};
|
||||
use super::entries::{Content, DocInputs, Drawn, FX_RASTER_BUDGET, Inputs, ItemGeom, KeptImage, Made, Reads, feather_outline};
|
||||
#[cfg(test)]
|
||||
use super::raster::RASTERIZED;
|
||||
@@ -677,7 +677,7 @@ impl Renderer {
|
||||
});
|
||||
let item = carried.as_ref().and_then(Option::as_ref).unwrap_or(item);
|
||||
let ib = if effects::has_geometry(item_effects(item)) && !ig.elements().is_empty() { ig.bounding_box() } else { bounds };
|
||||
let rfx = effects::raster_effects(item_effects(item));
|
||||
let rfx = effects::raster_effects(&item_raster(n, item));
|
||||
if rfx.is_empty() {
|
||||
self.paint_item(ctx, f, n, item, ig, rule, ib);
|
||||
continue;
|
||||
|
||||
@@ -337,6 +337,45 @@ fn a_transform_after_a_drop_shadow_scales_the_shadow_with_the_art() {
|
||||
}
|
||||
}
|
||||
|
||||
/// An object's or a group's Transform effect transforms the raster effects of what it holds with
|
||||
/// the art: halving a 40 pt square about its center halves the 10 pt offset of its fill's shadow,
|
||||
/// and of a group member's own shadow, with Scale Strokes & Effects off or on.
|
||||
#[test]
|
||||
fn a_transform_scales_the_shadows_of_fills_and_members_with_the_art() {
|
||||
let r = Rect::new(20.0, 20.0, 60.0, 60.0);
|
||||
let shadow =
|
||||
Effect { id: "stylize.dropShadow".into(), params: json!({"x": 10, "y": 10, "blur": 0, "opacity": 100, "mode": "normal"}), visible: true };
|
||||
for strokes in [false, true] {
|
||||
let transform = ("distort.transform", json!({"scaleH": 50, "scaleV": 50, "strokes": strokes}));
|
||||
let mut fill = doc_with(r, Color::rgb(1.0, 0.0, 0.0), vec![transform.clone()]);
|
||||
let l = fill.layers[0].id;
|
||||
let mut square = (*fill.layers[0].children().and_then(|c| c.first()).cloned().unwrap()).clone();
|
||||
fill.remove(square.id).unwrap();
|
||||
if let Some(item) = square.appearance.items.first_mut() {
|
||||
*item.effects_mut() = vec![shadow.clone()];
|
||||
}
|
||||
fill.insert(Some(l), 0, square.clone()).unwrap();
|
||||
let mut member = doc_with(r, Color::rgb(1.0, 0.0, 0.0), vec![]);
|
||||
let l = member.layers[0].id;
|
||||
let mut square = (*member.layers[0].children().and_then(|c| c.first()).cloned().unwrap()).clone();
|
||||
member.remove(square.id).unwrap();
|
||||
square.appearance.effects = vec![shadow.clone()];
|
||||
let mut g = Node::group(member.alloc_id(), vec![Arc::new(square)]);
|
||||
g.appearance.effects = [transform].into_iter().map(|(id, p)| Effect { id: id.into(), params: p, visible: true }).collect();
|
||||
member.insert(Some(l), 0, g).unwrap();
|
||||
for (what, d) in [("fill", fill), ("member", member)] {
|
||||
let img = render(&d);
|
||||
// The square now covers 30..50 and its shadow 35..55.
|
||||
assert_eq!(img.pixel(40, 40), [255, 0, 0, 255], "{what}, Scale Strokes & Effects {strokes}");
|
||||
assert!(lum(img.pixel(53, 37)) < 100, "{what}, {strokes}: the shadow reaches up to 35: {:?}", img.pixel(53, 37));
|
||||
assert_eq!(lum(img.pixel(57, 57)), WHITE, "{what}, {strokes}: the shadow ends at 55");
|
||||
// The area redrawn for it takes in the 5 pt shadow around the 30..50 square.
|
||||
let b = super::cull_bounds(&d.symbol_boxes(), &d.layers[0].children().unwrap()[0]).unwrap();
|
||||
assert!((b.x0 - 25.0).abs() < 1e-9 && (b.x1 - 55.0).abs() < 1e-9, "{what}, {strokes}: {b:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A red square's drop shadow at Darkness `darkness` (%), opaque and sharp, 10 pt down and
|
||||
/// right; `shade` "color" or "darkness".
|
||||
fn shaded_doc(mode: vectorcraft_doc::ColorMode, fill: Color, shade: &str, darkness: f64) -> Document {
|
||||
|
||||
@@ -282,6 +282,26 @@ fn groups_before_transform(app: &mut VectorcraftApp, ui: &mut Ui, objects: usize
|
||||
transform
|
||||
}
|
||||
|
||||
/// Draws the Control bar's Align group after the artboard controls, with the Artboard tool in use:
|
||||
/// the Align buttons, the Align To menu and, for two or more selected artboards, the Distribute
|
||||
/// buttons. They align and distribute the selected artboards
|
||||
/// ([`crate::panels::align::run_button`]). Each shows only while the bar has room for it, and none
|
||||
/// while the panel menu has the group off.
|
||||
fn artboard_align_group(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
if !group_on(app, "alignDistribute") || app.session.active().is_none() || ui.available_width() < separator_width(ui) + buttons_width(ui, 7) {
|
||||
return;
|
||||
}
|
||||
separator(ui);
|
||||
crate::panels::align::align_buttons(app, ui, BUTTON);
|
||||
if ui.available_width() >= crate::panels::align::align_to_menu_width(BUTTON) + ui.spacing().item_spacing.x {
|
||||
crate::panels::align::align_to_menu(app, ui, BUTTON);
|
||||
if crate::panels::align::artboards_selected(app) > 1 && ui.available_width() >= GROUP_GAP + buttons_width(ui, 6) {
|
||||
ui.add_space(GROUP_GAP);
|
||||
crate::panels::align::distribute_buttons(app, ui, BUTTON);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The edges of the window the Control bar docks to (`window.control.dock {edge}`): the top, then
|
||||
/// the bottom.
|
||||
pub const CONTROL_BAR_EDGES: [&str; 2] = ["top", "bottom"];
|
||||
@@ -516,6 +536,7 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
// of the object controls.
|
||||
let (artboard_options, transform) = (group_on(app, "artboardOptions"), group_on(app, "transform"));
|
||||
if crate::panels::artboards::control_bar(app, ui, artboard_options, transform) {
|
||||
artboard_align_group(app, ui);
|
||||
return;
|
||||
}
|
||||
if anchors != AnchorControls::None && group_on(app, "anchors") {
|
||||
|
||||
@@ -2669,6 +2669,49 @@ fn menus_stop_above_a_control_bar_docked_at_the_bottom() {
|
||||
assert!(f < bar_top - 1.0, "the status and hint bars show above the Control bar: menus stop above them, {f}");
|
||||
}
|
||||
|
||||
/// With the Artboard tool in use, the Control bar shows the Align buttons after the artboard
|
||||
/// controls while it has room, and the Distribute buttons for two or more selected artboards. A
|
||||
/// click aligns the selected artboards as the Align panel's button does. The group is left out
|
||||
/// while the panel menu has it off.
|
||||
#[test]
|
||||
fn the_control_bar_aligns_the_selected_artboards_with_the_artboard_tool() {
|
||||
crate::i18n::set_current(crate::i18n::Lang::EN);
|
||||
let mut app = crate::VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
|
||||
app.run("artboard.new", json!({"x": 150, "y": 40, "width": 60, "height": 80})).unwrap();
|
||||
app.select_tool("artboard");
|
||||
let ctx = egui::Context::default();
|
||||
ctx.enable_accesskit();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(2400.0, 300.0));
|
||||
let frame = |app: &mut crate::VectorcraftApp, events: Vec<egui::Event>| {
|
||||
let mut out = ctx.run_ui(egui::RawInput { events, screen_rect: Some(screen), ..Default::default() }, |ui| super::control_bar(app, ui));
|
||||
out.textures_delta.clear();
|
||||
out.platform_output.accesskit_update.map(|u| u.nodes).unwrap_or_default()
|
||||
};
|
||||
let button = |app: &mut crate::VectorcraftApp, name: &str| {
|
||||
frame(app, vec![]);
|
||||
let nodes = frame(app, vec![]);
|
||||
let n = nodes.iter().find(|(_, n)| n.label() == Some(name)).map(|(_, n)| n)?;
|
||||
n.bounds().map(|b| egui::Rect::from_min_max(egui::pos2(b.x0 as f32, b.y0 as f32), egui::pos2(b.x1 as f32, b.y1 as f32)))
|
||||
};
|
||||
app.session.set_tool_option("selected", &json!([1]));
|
||||
assert!(button(&mut app, "Vertical Align Top").is_some(), "one artboard: the Align buttons");
|
||||
assert!(button(&mut app, "Horizontal Distribute Left").is_none(), "one artboard: no Distribute buttons");
|
||||
app.session.set_tool_option("selected", &json!([1, 0]));
|
||||
assert!(button(&mut app, "Horizontal Distribute Left").is_some(), "two artboards: the Distribute buttons");
|
||||
let r = button(&mut app, "Vertical Align Top").expect("Vertical Align Top");
|
||||
let press =
|
||||
|pressed| egui::Event::PointerButton { pos: r.center(), button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
for events in [vec![egui::Event::PointerMoved(r.center()), press(true)], vec![press(false)]] {
|
||||
frame(&mut app, events);
|
||||
}
|
||||
let tops: Vec<f64> = app.session.active().unwrap().doc.artboards.iter().map(|a| a.rect.y0).collect();
|
||||
assert_eq!(tops, [0.0, 0.0], "{}", app.ui.status);
|
||||
app.run("window.control.options", json!({"group": "alignDistribute", "visible": false})).unwrap();
|
||||
assert!(button(&mut app, "Vertical Align Top").is_none(), "the group is off");
|
||||
}
|
||||
|
||||
/// Two or more selected objects show all six Distribute Objects buttons in the Control bar;
|
||||
/// a single object keeps the bar as it was. A click distributes as the Align panel's button does.
|
||||
#[test]
|
||||
|
||||
@@ -652,7 +652,10 @@ fn occurrences(text: &str, needle: &str, fold: bool) -> Vec<usize> {
|
||||
/// changed.
|
||||
fn replace_text(s: &str, r: &Rewrite) -> Option<String> {
|
||||
let decoded = if has_percent_escape(s) { decoded_with_spans(s).filter(|(decoded, _)| decoded != s) } else { None };
|
||||
let mut found = Found::default();
|
||||
let mut found = Found { kept: readable_ids_in(s, None, r), ..Found::default() };
|
||||
if let Some((text, spans)) = &decoded {
|
||||
found.kept.extend(readable_ids_in(text, Some(spans.as_slice()), r));
|
||||
}
|
||||
for group in &r.pairs {
|
||||
for fold in [false, true].into_iter().take(if r.fold { 2 } else { 1 }) {
|
||||
for (from, to) in group {
|
||||
@@ -679,24 +682,28 @@ fn replace_text(s: &str, r: &Rewrite) -> Option<String> {
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The spans of a text that [`replace_text`] replaces, each with its replacement, and every span it has
|
||||
/// taken: the ones it replaces and the loaded library ids it keeps.
|
||||
/// The spans of a text that [`replace_text`] replaces, each with its replacement, every span it has
|
||||
/// taken (the ones it replaces and the loaded library ids it keeps), and the whole readable library ids
|
||||
/// the text holds ([`readable_ids_in`]).
|
||||
#[derive(Default)]
|
||||
struct Found<'a> {
|
||||
cuts: Vec<(usize, usize, &'a str)>,
|
||||
taken: Vec<(usize, usize)>,
|
||||
kept: Vec<(usize, usize)>,
|
||||
}
|
||||
|
||||
impl<'a> Found<'a> {
|
||||
/// Takes each occurrence of `from` in `text` that overlaps no span taken: as a span to write as `to`,
|
||||
/// or, where it begins a loaded library's id ([`begins_known_id`]), that id's span, kept. `spans`
|
||||
/// gives the range of the original text each byte of a decoded `text` was read from.
|
||||
/// Takes each occurrence of `from` in `text` that overlaps no span taken and lies inside no whole
|
||||
/// readable library id: as a span to write as `to`, or, where it begins a loaded library's id
|
||||
/// ([`begins_known_id`]), that id's span, kept. `spans` gives the range of the original text each
|
||||
/// byte of a decoded `text` was read from.
|
||||
fn claim(&mut self, text: &str, spans: Option<&[(usize, usize)]>, from: &str, to: &'a str, fold: bool, r: &Rewrite) {
|
||||
let span = |at: usize, end: usize| spans.map_or((at, end), |s| (s[at].0, s[end - 1].1));
|
||||
for at in occurrences(text, from, fold) {
|
||||
let end = at + from.len();
|
||||
let (start, stop) = span(at, end);
|
||||
if self.taken.iter().any(|&(a, b)| start < b && a < stop) || ends_a_longer_path(text, at, from) {
|
||||
let inside_readable = self.kept.iter().any(|&(a, b)| a <= start && stop <= b);
|
||||
if inside_readable || self.taken.iter().any(|&(a, b)| start < b && a < stop) || ends_a_longer_path(text, at, from) {
|
||||
continue;
|
||||
}
|
||||
match begins_known_id(text, at, end, r) {
|
||||
@@ -825,6 +832,47 @@ fn begins_known_id(text: &str, at: usize, end: usize, r: &Rewrite) -> Option<usi
|
||||
})
|
||||
}
|
||||
|
||||
/// The spans of `text` that hold a whole loaded library id ([`Hiding::readable`]), written with or
|
||||
/// without its `loaded/` and with no `.` or `..` part: text that does not continue a path follows it
|
||||
/// ([`continues_path`]), and it begins the text or follows a space, a quote, `,`, `;`, an opening
|
||||
/// bracket or, written with its `loaded/`, `=`, unless a `file:` URL holds it ([`url_start`]). Any
|
||||
/// other character before it, such as `+` or `@`, can be part of a longer path's name, and so can `=`
|
||||
/// before a path. The URL must name no host or `localhost`
|
||||
/// ([`in_local_file_url`]).
|
||||
/// A hidden id or path found inside such a span stays, wherever it begins. `spans` gives the range of
|
||||
/// the original text each byte of a decoded `text` was read from.
|
||||
fn readable_ids_in(text: &str, spans: Option<&[(usize, usize)]>, r: &Rewrite) -> Vec<(usize, usize)> {
|
||||
let span = |at: usize, end: usize| spans.map_or((at, end), |s| (s[at].0, s[end - 1].1));
|
||||
let dotted = |id: &str| id.split(['/', '\\']).any(|part| part == "." || part == "..");
|
||||
let mut kept = Vec::new();
|
||||
for known in r.hiding.readable.iter().filter(|known| !dotted(known)) {
|
||||
let bare = known.strip_prefix("loaded/").filter(|bare| !bare.is_empty());
|
||||
for id in std::iter::once(known.as_str()).chain(bare) {
|
||||
let prefixed = id.starts_with("loaded/");
|
||||
for at in occurrences(text, id, r.fold) {
|
||||
let end = at + id.len();
|
||||
let after_delimiter = text[..at]
|
||||
.chars()
|
||||
.next_back()
|
||||
.is_none_or(|c| c.is_whitespace() || matches!(c, '"' | '\'' | '`' | ',' | ';' | '(' | '[' | '{' | '<') || (prefixed && c == '='));
|
||||
if !continues_path(&text[end..]) && (after_delimiter || in_local_file_url(text, at, id)) {
|
||||
kept.push(span(at, end));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
kept
|
||||
}
|
||||
|
||||
/// Is `path`, an absolute path at `text[at..]`, held by a `file:` URL ([`url_start`]) that names no host
|
||||
/// or `localhost`, in any case of its letters?
|
||||
fn in_local_file_url(text: &str, at: usize, path: &str) -> bool {
|
||||
let url = url_start(text, at, path);
|
||||
let Some(between) = text.get(url + "file:".len()..at).filter(|_| url < at) else { return false };
|
||||
let host = between.strip_prefix("//").unwrap_or(between).trim_end_matches('/');
|
||||
host.is_empty() || host.eq_ignore_ascii_case("localhost")
|
||||
}
|
||||
|
||||
/// Text that a person or a script wrote, by its key: a command palette query, a script's report and a
|
||||
/// workspace's name. [`swap_library_ids`] leaves it as written.
|
||||
const TEXT_FIELDS: [&str; 3] = ["palette_query", "scriptReport", "name"];
|
||||
|
||||
@@ -94,6 +94,44 @@ fn a_reply_keeps_a_hidden_path_only_inside_a_known_library_id() {
|
||||
assert_eq!(reply["error"], "x loaded//art/in work/a.svg, /art/in work/a.svg and in work; in/../ok/c.svg", "{reply}");
|
||||
}
|
||||
|
||||
/// A readable library's id that holds a hidden absolute path after its start stays whole: with and
|
||||
/// without its `loaded/`, in a `file:` URL and with `%XX` escapes, in any case with matching in any
|
||||
/// case. The hidden path on its own, and followed by other path text, is still hidden.
|
||||
#[test]
|
||||
fn a_reply_keeps_a_readable_id_that_holds_a_hidden_path_inside() {
|
||||
let hidden = hiding(&[("loaded//libs/Kit.gpl", "loaded/Kit.gpl")], &["loaded//project/libs/Kit.gpl"]);
|
||||
let text = "loaded//project/libs/Kit.gpl, /project/libs/Kit.gpl, file:///project/libs/Kit.gpl and file:///project%2Flibs/Kit.gpl; /libs/Kit.gpl and /libs/Kit.gpl.bak";
|
||||
for fold in [false, true] {
|
||||
let mut reply = json!({"error": text});
|
||||
super::replace_in_value(&mut reply, &super::Rewrite { pairs: super::hidden_text(&hidden), hiding: &hidden, fold });
|
||||
let kept = "loaded//project/libs/Kit.gpl, /project/libs/Kit.gpl, file:///project/libs/Kit.gpl and file:///project%2Flibs/Kit.gpl";
|
||||
assert_eq!(reply["error"], json!(format!("{kept}; Kit.gpl and Kit.gpl.bak")), "fold {fold}: {reply}");
|
||||
}
|
||||
let mut reply = json!({"error": "LOADED//PROJECT/LIBS/KIT.GPL and file://LocalHost/project/libs/Kit.gpl"});
|
||||
super::replace_in_value(&mut reply, &super::Rewrite { pairs: super::hidden_text(&hidden), hiding: &hidden, fold: true });
|
||||
assert_eq!(reply["error"], "LOADED//PROJECT/LIBS/KIT.GPL and file://LocalHost/project/libs/Kit.gpl", "{reply}");
|
||||
// The readable id's path after a drive or in a URL that names another host is another path.
|
||||
for text in ["C:/project/libs/Kit.gpl", "file://remote/project/libs/Kit.gpl"] {
|
||||
let mut reply = json!({"error": text});
|
||||
super::replace_in_value(&mut reply, &super::Rewrite { pairs: super::hidden_text(&hidden), hiding: &hidden, fold: false });
|
||||
assert!(!reply["error"].as_str().unwrap_or_default().contains("/libs/Kit.gpl"), "{text}: {reply}");
|
||||
}
|
||||
// The readable id's path after a character that can continue a path name ends another path.
|
||||
for text in
|
||||
["/a+/project/libs/Kit.gpl", "/a@/project/libs/Kit.gpl", "a=/project/libs/Kit.gpl", "/a!/project/libs/Kit.gpl", "/a%2B/project/libs/Kit.gpl"]
|
||||
{
|
||||
for fold in [false, true] {
|
||||
let mut reply = json!({"error": text});
|
||||
super::replace_in_value(&mut reply, &super::Rewrite { pairs: super::hidden_text(&hidden), hiding: &hidden, fold });
|
||||
assert!(!reply["error"].as_str().unwrap_or_default().contains("/libs/Kit.gpl"), "{text}, fold {fold}: {reply}");
|
||||
}
|
||||
}
|
||||
// Written with its `loaded/`, the readable id stays after `=` too.
|
||||
let mut reply = json!({"error": "key=loaded//project/libs/Kit.gpl"});
|
||||
super::replace_in_value(&mut reply, &super::Rewrite { pairs: super::hidden_text(&hidden), hiding: &hidden, fold: false });
|
||||
assert_eq!(reply["error"], "key=loaded//project/libs/Kit.gpl", "{reply}");
|
||||
}
|
||||
|
||||
/// With matching in any case (as on Windows), a hidden path written in other letter cases is hidden
|
||||
/// too; a path in a `file:` URL with `%XX` escapes is hidden on every platform, the whole URL shown as
|
||||
/// the file name.
|
||||
|
||||
@@ -368,6 +368,7 @@ registry! {
|
||||
JavaScriptFiles: [javascript_files::KIND] => javascript_files::SPEC,
|
||||
SvgFilter: [svg_filter::KIND] => svg_filter::SPEC,
|
||||
NewDocumentMore: [new_document::MORE] => new_document::MORE_SPEC,
|
||||
NewDocumentProfiles: [new_document::PROFILES] => new_document::MORE_SPEC,
|
||||
Place: [place::KIND] => place::SPEC,
|
||||
RasterOptions: ["pngOptions", "jpgOptions", "webpOptions", "gifOptions", "png8Options"] => png_options::SPEC,
|
||||
ExportAs: ["exportAs"] => export_as::SPEC,
|
||||
|
||||
@@ -4,11 +4,13 @@
|
||||
//! Close and Create. More Settings opens a pop-up over the dialog with the artboard layout, spacing
|
||||
//! and columns, editing the same fields. Save Preset turns the top of Preset Details into the
|
||||
//! preset's name, Cancel and Save, and hides Close and Create until the preset is saved or canceled;
|
||||
//! the rows under it stay editable and are what the preset stores.
|
||||
//! the rows under it stay editable and are what the preset stores. Manage Profiles opens a pop-up
|
||||
//! listing the New Document profiles (`file.newProfiles`), each with Edit, which closes the dialog
|
||||
//! and opens the profile as a document, and Reset, which brings back the shipped profile.
|
||||
//!
|
||||
//! Fields: `file.new`'s params (`preset`, `name`, `width`, `height` (pt), `units`, `artboards`,
|
||||
//! `artboardLayout`, `bleed`, `backgroundContents`, `colorMode`, `rasterEffectsPpi`,
|
||||
//! `previewMode`, `alignNewObjectsToPixelGrid`), plus `category` (the tab), `advanced` (Advanced Options open), `bleedLinked` and
|
||||
//! `previewMode`, `alignNewObjectsToPixelGrid`, `profile`), plus `category` (the tab), `advanced` (Advanced Options open), `bleedLinked` and
|
||||
//! `presetName` (Save Preset's name). Setting `preset` fills the fields from that preset; changing
|
||||
//! any of them afterwards makes the settings custom.
|
||||
|
||||
@@ -24,12 +26,14 @@ use crate::{VectorcraftApp, widgets};
|
||||
pub(super) const KIND: &str = "newDocument";
|
||||
/// New Document with its More Settings pop-up open.
|
||||
pub(super) const MORE: &str = "newDocumentMore";
|
||||
/// New Document with its Manage Profiles pop-up open.
|
||||
pub(super) const PROFILES: &str = "newDocumentProfiles";
|
||||
|
||||
pub(super) const SPEC: DialogSpec = DialogSpec { inputs: |_| &["presetName"], ..DialogSpec::window(show, confirm) };
|
||||
pub(super) const MORE_SPEC: DialogSpec = SPEC;
|
||||
|
||||
/// The fields `file.new` takes.
|
||||
const PARAMS: [&str; 13] = [
|
||||
const PARAMS: [&str; 14] = [
|
||||
"preset",
|
||||
"name",
|
||||
"width",
|
||||
@@ -43,6 +47,7 @@ const PARAMS: [&str; 13] = [
|
||||
"rasterEffectsPpi",
|
||||
"previewMode",
|
||||
"alignNewObjectsToPixelGrid",
|
||||
"profile",
|
||||
];
|
||||
/// The preset the fields were last filled from, as `apply` set them; a Recent or Saved card keeps
|
||||
/// its own units here, which the units chosen in Preset Details don't change.
|
||||
@@ -139,12 +144,21 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
}
|
||||
let p = params(app, d);
|
||||
let r = app.run("file.new", Value::Object(p));
|
||||
if r.is_ok() {
|
||||
if let Ok(r) = &r {
|
||||
app.ui.dialog = None;
|
||||
report_profile(app, r);
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// Say in the status bar when `file.new`'s result `r` reports a profile it could not read.
|
||||
fn report_profile(app: &mut VectorcraftApp, r: &Value) {
|
||||
if let Some(e) = r.get("profileError") {
|
||||
let (name, reason) = (e["profile"].as_str().unwrap_or_default(), e["reason"].as_str().unwrap_or_default());
|
||||
app.status(format!("The {name} profile could not be read, so the new document has the default settings: {reason}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// New Without Dialog (`file.newWithoutDialog`, Cmd+Alt+N): create the document [`open`] would
|
||||
/// offer, as its Create would, without showing the dialog. A dialog already open stays open.
|
||||
pub fn create_without_dialog(app: &mut VectorcraftApp) -> Result<Value, String> {
|
||||
@@ -153,7 +167,11 @@ pub fn create_without_dialog(app: &mut VectorcraftApp) -> Result<Value, String>
|
||||
let offered = app.ui.dialog.take();
|
||||
app.ui.dialog = shown;
|
||||
let p = offered.map(|d| params(app, &d)).unwrap_or_default();
|
||||
app.run("file.new", Value::Object(p))
|
||||
let r = app.run("file.new", Value::Object(p));
|
||||
if let Ok(r) = &r {
|
||||
report_profile(app, r);
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// The `file.new` params of the fields.
|
||||
@@ -194,15 +212,15 @@ fn window(ctx: &egui::Context, id: &str, margin: i8, add: impl FnOnce(&mut egui:
|
||||
super::modal::show(ctx, id, egui::Id::unique(("dialog", id)), -20.0, margin, add);
|
||||
}
|
||||
|
||||
/// Whether More Settings' pop-up is open over New Document.
|
||||
/// Whether More Settings' or Manage Profiles' pop-up is open over New Document.
|
||||
pub(crate) fn popup_open(d: &Dialog) -> bool {
|
||||
d.kind == MORE
|
||||
d.kind == MORE || d.kind == PROFILES
|
||||
}
|
||||
|
||||
/// Close New Document's More Settings pop-up, or else leave its Save Preset, keeping the fields.
|
||||
/// False when neither is showing, or `d` is another dialog.
|
||||
/// Close New Document's More Settings or Manage Profiles pop-up, or else leave its Save Preset,
|
||||
/// keeping the fields. False when none is showing, or `d` is another dialog.
|
||||
pub(super) fn close_popup(d: &mut Dialog) -> bool {
|
||||
if d.kind != KIND && d.kind != MORE {
|
||||
if d.kind != KIND && !popup_open(d) {
|
||||
return false;
|
||||
}
|
||||
if popup_open(d) {
|
||||
@@ -461,12 +479,14 @@ fn details(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, b: &mut
|
||||
if saving {
|
||||
return;
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
b.close |= manage_profiles(app, ui, d);
|
||||
// Close and Create at the bottom right.
|
||||
ui.add_space((HEIGHT - (ui.cursor().top() - top) - 28.0).max(12.0));
|
||||
ui.allocate_ui_with_layout(egui::vec2(ui.available_width(), 28.0), egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
b.create |= widgets::primary_button(ui, tl!("Create")).clicked();
|
||||
ui.add_space(8.0);
|
||||
b.close = widgets::secondary_button(ui, tl!("Close")).clicked();
|
||||
b.close |= widgets::secondary_button(ui, tl!("Close")).clicked();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -563,7 +583,60 @@ fn more_settings(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
.align(egui::RectAlign::TOP_END)
|
||||
.accessible_name(tl!("More Settings"))
|
||||
.show(|ui| done = more_settings_popup(ui, opening, d));
|
||||
d.kind = if open && !done { MORE } else { KIND }.into();
|
||||
if open && !done {
|
||||
d.kind = MORE.into();
|
||||
} else if d.kind == MORE {
|
||||
d.kind = KIND.into();
|
||||
}
|
||||
}
|
||||
|
||||
/// The Manage Profiles button and its pop-up: a row per New Document profile with Edit, which opens
|
||||
/// the profile as a document and closes New Document (true then), and Reset, which deletes an edited
|
||||
/// profile's file so the shipped profile applies again. A click outside it and Escape close it.
|
||||
fn manage_profiles(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
let button = widgets::flat_button(ui, tl!("Manage Profiles"), DETAILS);
|
||||
let mut open = (d.kind == PROFILES) != button.clicked();
|
||||
let mut chosen: Option<(&str, &str)> = None;
|
||||
let has_folder = app.session.new_doc_profile_dir().is_some();
|
||||
egui::Popup::from_response(&button)
|
||||
.open_bool(&mut open)
|
||||
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
|
||||
.align(egui::RectAlign::TOP_END)
|
||||
.accessible_name(tl!("Manage Profiles"))
|
||||
.show(|ui| {
|
||||
ui.set_width(MORE_WIDTH);
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.label(egui::RichText::new(tl!("NEW DOCUMENT PROFILES")).font(theme::semibold(11.0)).color(t.text_dim));
|
||||
ui.add_space(6.0);
|
||||
for name in newdoc::profile_names() {
|
||||
let customized = app.session.new_doc_profile_customized(name);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tl!(name));
|
||||
ui.allocate_ui_with_layout(egui::vec2(ui.available_width(), 24.0), egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.add_enabled_ui(customized, |ui| widgets::secondary_button(ui, tl!("Reset"))).inner.clicked() {
|
||||
chosen = Some(("file.newProfiles.reset", name));
|
||||
}
|
||||
// Without a profiles folder (the web, runs without preferences) there is nothing to edit.
|
||||
if ui.add_enabled_ui(has_folder, |ui| widgets::secondary_button(ui, tl!("Edit"))).inner.clicked() {
|
||||
chosen = Some(("file.newProfiles.edit", name));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
if open {
|
||||
d.kind = PROFILES.into();
|
||||
} else if d.kind == PROFILES {
|
||||
d.kind = KIND.into();
|
||||
}
|
||||
let Some((cmd, name)) = chosen else { return false };
|
||||
match app.run(cmd, json!({ "name": name })) {
|
||||
Ok(_) => cmd == "file.newProfiles.edit",
|
||||
Err(e) => {
|
||||
app.status(e);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// More Settings' pop-up. True when Done (or Enter) finished with it.
|
||||
@@ -1518,4 +1591,45 @@ mod tests {
|
||||
assert!(screen.contains_rect(h.get_by_label(label).rect()), "{label} is off screen");
|
||||
}
|
||||
}
|
||||
|
||||
/// Manage Profiles lists a row per profile; Reset is off until a profile has been edited, Escape
|
||||
/// closes only the pop-up, and Edit opens the profile as a document and closes New Document.
|
||||
#[test]
|
||||
fn manage_profiles_edits_and_resets_the_profiles() {
|
||||
let dir = std::env::temp_dir().join(format!("vc-ui-newdoc-profiles-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let mut h = big();
|
||||
// Without a profiles folder Edit is off too.
|
||||
h.get_by_label("Manage Profiles").click();
|
||||
h.run_steps(4);
|
||||
assert!(h.get_all_by_label("Edit").all(|b| b.accesskit_node().is_disabled()));
|
||||
h.key_press(egui::Key::Escape);
|
||||
h.run_steps(3);
|
||||
h.state_mut().session.attach_new_doc_profiles(dir.clone());
|
||||
h.get_by_label("Manage Profiles").click();
|
||||
h.run_steps(4);
|
||||
assert_eq!(kind(&h), super::PROFILES);
|
||||
assert_eq!(h.get_all_by_label("Edit").count(), 7);
|
||||
assert!(h.get_all_by_label("Reset").all(|b| b.accesskit_node().is_disabled()));
|
||||
h.key_press(egui::Key::Escape);
|
||||
h.run_steps(3);
|
||||
assert_eq!(kind(&h), super::KIND, "Escape closes only the pop-up");
|
||||
// An edited profile's Reset deletes its file.
|
||||
h.state_mut().run("file.newProfiles.edit", json!({"name": "Web"})).unwrap();
|
||||
h.state_mut().run("file.newDialog", json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Manage Profiles").click();
|
||||
h.run_steps(4);
|
||||
assert_eq!(h.get_all_by_label("Reset").filter(|b| !b.accesskit_node().is_disabled()).count(), 1);
|
||||
h.get_all_by_label("Reset").find(|b| !b.accesskit_node().is_disabled()).unwrap().click();
|
||||
h.run_steps(3);
|
||||
assert!(!dir.join("Web.vectorcraft").exists());
|
||||
// Edit on the third row (Print).
|
||||
h.get_all_by_label("Edit").nth(2).unwrap().click();
|
||||
h.run_steps(3);
|
||||
assert!(h.state().ui.dialog.is_none(), "{:?}", h.state().ui.status);
|
||||
assert_eq!(h.state().session.active().unwrap().doc.title, "Print");
|
||||
assert!(dir.join("Print.vectorcraft").is_file());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,7 +225,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool {
|
||||
}
|
||||
id if crate::panels::paragraph::ALIGN_COMMANDS.iter().any(|c| c.0 == id) => crate::panels::character::text_style(app).is_some(),
|
||||
"type.applyBullets" | "type.applyNumbers" | "type.convertListToText" => crate::panels::character::text_style(app).is_some(),
|
||||
id if let Some(b) = crate::panels::align::button(id) => enabled(app, b.command()),
|
||||
id if let Some(b) = crate::panels::align::button(id) => enabled(app, b.command_now(app)),
|
||||
"file.reveal" => app.services.reveal.is_some() && app.session.active().is_some_and(|d| d.path.is_some()),
|
||||
"file.cancelExport" | "file.exportInBackground" => app.background.jobs.iter().any(|j| j.progress.is_some() && !j.cancelled()),
|
||||
"file.cancelOpen" => !app.opening.pending.is_empty(),
|
||||
|
||||
@@ -262,91 +262,91 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
"align.horizontalLeft",
|
||||
"Horizontal Align Left",
|
||||
"",
|
||||
"{} the Align panel's Horizontal Align Left button: object.align {horizontal: \"left\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice",
|
||||
"{} the Align panel's Horizontal Align Left button: object.align {horizontal: \"left\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice; with the Artboard tool chosen, artboard.align with the same direction on the artboards the tool has selected, to: \"artboard\" (the active artboard) while Align to Artboard is chosen, else \"selection\"",
|
||||
),
|
||||
(
|
||||
"align.horizontalCenter",
|
||||
"Horizontal Align Center",
|
||||
"",
|
||||
"{} the Align panel's Horizontal Align Center button: object.align {horizontal: \"center\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice",
|
||||
"{} the Align panel's Horizontal Align Center button: object.align {horizontal: \"center\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice; with the Artboard tool chosen, artboard.align with the same direction on the artboards the tool has selected, to: \"artboard\" (the active artboard) while Align to Artboard is chosen, else \"selection\"",
|
||||
),
|
||||
(
|
||||
"align.horizontalRight",
|
||||
"Horizontal Align Right",
|
||||
"",
|
||||
"{} the Align panel's Horizontal Align Right button: object.align {horizontal: \"right\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice",
|
||||
"{} the Align panel's Horizontal Align Right button: object.align {horizontal: \"right\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice; with the Artboard tool chosen, artboard.align with the same direction on the artboards the tool has selected, to: \"artboard\" (the active artboard) while Align to Artboard is chosen, else \"selection\"",
|
||||
),
|
||||
(
|
||||
"align.horizontalVerticalCenter",
|
||||
"Horizontal & Vertical Align Center",
|
||||
"",
|
||||
"{} the Align panel's Horizontal & Vertical Align Center button: object.align {horizontal: \"center\", vertical: \"center\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice",
|
||||
"{} the Align panel's Horizontal & Vertical Align Center button: object.align {horizontal: \"center\", vertical: \"center\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice; with the Artboard tool chosen, artboard.align with the same direction on the artboards the tool has selected, to: \"artboard\" (the active artboard) while Align to Artboard is chosen, else \"selection\"",
|
||||
),
|
||||
(
|
||||
"align.verticalTop",
|
||||
"Vertical Align Top",
|
||||
"",
|
||||
"{} the Align panel's Vertical Align Top button: object.align {vertical: \"top\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice",
|
||||
"{} the Align panel's Vertical Align Top button: object.align {vertical: \"top\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice; with the Artboard tool chosen, artboard.align with the same direction on the artboards the tool has selected, to: \"artboard\" (the active artboard) while Align to Artboard is chosen, else \"selection\"",
|
||||
),
|
||||
(
|
||||
"align.verticalCenter",
|
||||
"Vertical Align Center",
|
||||
"",
|
||||
"{} the Align panel's Vertical Align Center button: object.align {vertical: \"center\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice",
|
||||
"{} the Align panel's Vertical Align Center button: object.align {vertical: \"center\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice; with the Artboard tool chosen, artboard.align with the same direction on the artboards the tool has selected, to: \"artboard\" (the active artboard) while Align to Artboard is chosen, else \"selection\"",
|
||||
),
|
||||
(
|
||||
"align.verticalBottom",
|
||||
"Vertical Align Bottom",
|
||||
"",
|
||||
"{} the Align panel's Vertical Align Bottom button: object.align {vertical: \"bottom\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice",
|
||||
"{} the Align panel's Vertical Align Bottom button: object.align {vertical: \"bottom\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice; with the Artboard tool chosen, artboard.align with the same direction on the artboards the tool has selected, to: \"artboard\" (the active artboard) while Align to Artboard is chosen, else \"selection\"",
|
||||
),
|
||||
(
|
||||
"align.distributeVerticalTop",
|
||||
"Vertical Distribute Top",
|
||||
"",
|
||||
"{} the Align panel's Vertical Distribute Top button: object.distribute {vertical: \"top\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key",
|
||||
"{} the Align panel's Vertical Distribute Top button: object.distribute {vertical: \"top\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key; with the Artboard tool chosen, the artboard command of the same name (artboard.distribute, artboard.distributeSpacing) with the same direction on the artboards the tool has selected, to: \"artboard\" (the active artboard) and spacing: the Distribute Spacing value while Align to Artboard is chosen, else \"selection\"",
|
||||
),
|
||||
(
|
||||
"align.distributeVerticalCenter",
|
||||
"Vertical Distribute Center",
|
||||
"",
|
||||
"{} the Align panel's Vertical Distribute Center button: object.distribute {vertical: \"center\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key",
|
||||
"{} the Align panel's Vertical Distribute Center button: object.distribute {vertical: \"center\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key; with the Artboard tool chosen, the artboard command of the same name (artboard.distribute, artboard.distributeSpacing) with the same direction on the artboards the tool has selected, to: \"artboard\" (the active artboard) and spacing: the Distribute Spacing value while Align to Artboard is chosen, else \"selection\"",
|
||||
),
|
||||
(
|
||||
"align.distributeVerticalBottom",
|
||||
"Vertical Distribute Bottom",
|
||||
"",
|
||||
"{} the Align panel's Vertical Distribute Bottom button: object.distribute {vertical: \"bottom\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key",
|
||||
"{} the Align panel's Vertical Distribute Bottom button: object.distribute {vertical: \"bottom\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key; with the Artboard tool chosen, the artboard command of the same name (artboard.distribute, artboard.distributeSpacing) with the same direction on the artboards the tool has selected, to: \"artboard\" (the active artboard) and spacing: the Distribute Spacing value while Align to Artboard is chosen, else \"selection\"",
|
||||
),
|
||||
(
|
||||
"align.distributeHorizontalLeft",
|
||||
"Horizontal Distribute Left",
|
||||
"",
|
||||
"{} the Align panel's Horizontal Distribute Left button: object.distribute {horizontal: \"left\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key",
|
||||
"{} the Align panel's Horizontal Distribute Left button: object.distribute {horizontal: \"left\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key; with the Artboard tool chosen, the artboard command of the same name (artboard.distribute, artboard.distributeSpacing) with the same direction on the artboards the tool has selected, to: \"artboard\" (the active artboard) and spacing: the Distribute Spacing value while Align to Artboard is chosen, else \"selection\"",
|
||||
),
|
||||
(
|
||||
"align.distributeHorizontalCenter",
|
||||
"Horizontal Distribute Center",
|
||||
"",
|
||||
"{} the Align panel's Horizontal Distribute Center button: object.distribute {horizontal: \"center\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key",
|
||||
"{} the Align panel's Horizontal Distribute Center button: object.distribute {horizontal: \"center\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key; with the Artboard tool chosen, the artboard command of the same name (artboard.distribute, artboard.distributeSpacing) with the same direction on the artboards the tool has selected, to: \"artboard\" (the active artboard) and spacing: the Distribute Spacing value while Align to Artboard is chosen, else \"selection\"",
|
||||
),
|
||||
(
|
||||
"align.distributeHorizontalRight",
|
||||
"Horizontal Distribute Right",
|
||||
"",
|
||||
"{} the Align panel's Horizontal Distribute Right button: object.distribute {horizontal: \"right\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key",
|
||||
"{} the Align panel's Horizontal Distribute Right button: object.distribute {horizontal: \"right\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key; with the Artboard tool chosen, the artboard command of the same name (artboard.distribute, artboard.distributeSpacing) with the same direction on the artboards the tool has selected, to: \"artboard\" (the active artboard) and spacing: the Distribute Spacing value while Align to Artboard is chosen, else \"selection\"",
|
||||
),
|
||||
(
|
||||
"align.distributeVerticalSpace",
|
||||
"Vertical Distribute Space",
|
||||
"",
|
||||
"{} the Align panel's Vertical Distribute Space button: object.distributeSpacing {axis: \"vertical\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key",
|
||||
"{} the Align panel's Vertical Distribute Space button: object.distributeSpacing {axis: \"vertical\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key; with the Artboard tool chosen, the artboard command of the same name (artboard.distribute, artboard.distributeSpacing) with the same direction on the artboards the tool has selected, to: \"artboard\" (the active artboard) and spacing: the Distribute Spacing value while Align to Artboard is chosen, else \"selection\"",
|
||||
),
|
||||
(
|
||||
"align.distributeHorizontalSpace",
|
||||
"Horizontal Distribute Space",
|
||||
"",
|
||||
"{} the Align panel's Horizontal Distribute Space button: object.distributeSpacing {axis: \"horizontal\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key",
|
||||
"{} the Align panel's Horizontal Distribute Space button: object.distributeSpacing {axis: \"horizontal\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key; with the Artboard tool chosen, the artboard command of the same name (artboard.distribute, artboard.distributeSpacing) with the same direction on the artboards the tool has selected, to: \"artboard\" (the active artboard) and spacing: the Distribute Spacing value while Align to Artboard is chosen, else \"selection\"",
|
||||
),
|
||||
(
|
||||
"type.bold",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Align panel: Align Objects, Distribute Objects and Distribute Spacing (both with the spacing
|
||||
//! value when aligning to a key object or a key guide) and Align To (selection / key object /
|
||||
//! artboard).
|
||||
//! artboard). With the Artboard tool chosen, the buttons align and distribute the artboards the
|
||||
//! tool has selected.
|
||||
|
||||
use egui::Ui;
|
||||
use serde::Serialize;
|
||||
@@ -104,7 +105,7 @@ pub enum Button {
|
||||
}
|
||||
|
||||
impl Button {
|
||||
/// The engine command the button runs.
|
||||
/// The engine command the button runs on the selected objects.
|
||||
pub(crate) fn command(self) -> &'static str {
|
||||
match self {
|
||||
Button::Align(..) | Button::AlignCenters => "object.align",
|
||||
@@ -112,6 +113,51 @@ impl Button {
|
||||
Button::Spacing(_) => "object.distributeSpacing",
|
||||
}
|
||||
}
|
||||
|
||||
/// The engine command the button runs on the selected artboards ([`on_artboards`]).
|
||||
fn artboard_command(self) -> &'static str {
|
||||
match self {
|
||||
Button::Align(..) | Button::AlignCenters => "artboard.align",
|
||||
Button::Distribute(..) => "artboard.distribute",
|
||||
Button::Spacing(_) => "artboard.distributeSpacing",
|
||||
}
|
||||
}
|
||||
|
||||
/// The engine command a click on the button runs now: the artboard command with the Artboard
|
||||
/// tool chosen, else the object command.
|
||||
pub(crate) fn command_now(self, app: &VectorcraftApp) -> &'static str {
|
||||
if on_artboards(app) { self.artboard_command() } else { self.command() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the Align panel's buttons, and the Control bar's, align and distribute artboards: the
|
||||
/// Artboard tool is chosen. They then act on the artboards the tool has selected.
|
||||
pub(crate) fn on_artboards(app: &VectorcraftApp) -> bool {
|
||||
app.session.tool_id() == "artboard"
|
||||
}
|
||||
|
||||
/// The number of artboards the Artboard tool has selected ([`super::artboards::picked`]).
|
||||
pub(crate) fn artboards_selected(app: &VectorcraftApp) -> usize {
|
||||
let n = app.session.active().map_or(0, |d| d.doc.artboards.len());
|
||||
if n == 0 { 0 } else { super::artboards::picked(app, n).len() }
|
||||
}
|
||||
|
||||
/// Runs what a click on button `b` runs with the Artboard tool chosen: the artboard command on
|
||||
/// the artboards the tool has selected. With Align to Artboard chosen, it sends `to: "artboard"`,
|
||||
/// which keeps the tool's active artboard in place, and the Distribute buttons send the
|
||||
/// Distribute Spacing value. With Align to Selection chosen, it sends `to: "selection"`.
|
||||
fn run_on_artboards(app: &mut VectorcraftApp, b: Button) -> Result<Value, String> {
|
||||
let to_artboard = app.ui.align_to == AlignTo::Artboard;
|
||||
let mut p = match b {
|
||||
Button::Align(axis, v) | Button::Distribute(axis, v) => json!({axis: v}),
|
||||
Button::AlignCenters => json!({"horizontal": "center", "vertical": "center"}),
|
||||
Button::Spacing(axis) => json!({"axis": axis}),
|
||||
};
|
||||
p["to"] = json!(if to_artboard { "artboard" } else { "selection" });
|
||||
if to_artboard && matches!(b, Button::Distribute(..) | Button::Spacing(_)) {
|
||||
p["spacing"] = json!(app.ui.align_spacing);
|
||||
}
|
||||
app.run(b.artboard_command(), p)
|
||||
}
|
||||
|
||||
/// The Align panel's buttons as UI commands ([`crate::menus::UI_COMMANDS`]), in the panel's order:
|
||||
@@ -143,8 +189,12 @@ pub(crate) fn button(id: &str) -> Option<Button> {
|
||||
|
||||
/// Runs what a click on button `b` runs. An Align Objects button sends the `to` of
|
||||
/// [`with_align_to`]. A Distribute Objects or Distribute Spacing button sends the key while there
|
||||
/// is one, else the Align To choice, and the Distribute Spacing value while there is a key.
|
||||
/// is one, else the Align To choice, and the Distribute Spacing value while there is a key. With
|
||||
/// the Artboard tool chosen, the button acts on the selected artboards ([`run_on_artboards`]).
|
||||
pub fn run_button(app: &mut VectorcraftApp, b: Button) -> Result<Value, String> {
|
||||
if on_artboards(app) {
|
||||
return run_on_artboards(app, b);
|
||||
}
|
||||
let to = align_to(app).param();
|
||||
let p = match b {
|
||||
Button::Align(axis, v) => with_align_to(app, json!({axis: v})),
|
||||
@@ -163,14 +213,15 @@ pub fn run_button(app: &mut VectorcraftApp, b: Button) -> Result<Value, String>
|
||||
|
||||
/// The Distribute Spacing value ([`crate::UiState::align_spacing`]) while there is a key object
|
||||
/// or a key guide (#2143), which the Distribute Objects and Distribute Spacing buttons then send;
|
||||
/// else none.
|
||||
/// else none. With the Artboard tool chosen, the buttons send it while Align to Artboard is chosen.
|
||||
fn spacing_sent(app: &VectorcraftApp) -> Option<f64> {
|
||||
(align_to(app) == AlignTo::Key).then_some(app.ui.align_spacing)
|
||||
let sent = if on_artboards(app) { app.ui.align_to == AlignTo::Artboard } else { align_to(app) == AlignTo::Key };
|
||||
sent.then_some(app.ui.align_spacing)
|
||||
}
|
||||
|
||||
/// The six align buttons of the Control bar and the Properties panel, `size` points square.
|
||||
/// They align to what the Align panel's Align To says (the key object while there is one), as
|
||||
/// its own buttons do (#789).
|
||||
/// its own buttons do (#789), and the selected artboards with the Artboard tool chosen.
|
||||
pub fn align_buttons(app: &mut VectorcraftApp, ui: &mut Ui, size: f32) {
|
||||
for (i, (icon, tip, axis, v)) in [
|
||||
("align-start-vertical", "Horizontal Align Left", "horizontal", "left"),
|
||||
@@ -184,10 +235,10 @@ pub fn align_buttons(app: &mut VectorcraftApp, ui: &mut Ui, size: f32) {
|
||||
.enumerate()
|
||||
{
|
||||
if i == 3 && widgets::icon_button(ui, "dc-al-center", tl!("Horizontal & Vertical Align Center"), false, size).clicked() {
|
||||
app.run("object.align", with_align_to(app, json!({"horizontal": "center", "vertical": "center"}))).ok();
|
||||
run_button(app, Button::AlignCenters).ok();
|
||||
}
|
||||
if widgets::icon_button(ui, icon, tl!(tip), false, size).clicked() {
|
||||
app.run("object.align", with_align_to(app, json!({axis: v}))).ok();
|
||||
run_button(app, Button::Align(axis, v)).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -246,15 +297,23 @@ fn align_to_icon_name(v: AlignTo) -> (&'static str, &'static str) {
|
||||
}
|
||||
|
||||
/// The Align To choice that the Align panel and the Control bar show chosen: Artboard while one
|
||||
/// object is selected (the buttons then align it to the artboard), else [`align_to`].
|
||||
/// object is selected (the buttons then align it to the artboard), else [`align_to`]. With the
|
||||
/// Artboard tool chosen, it is the stored choice, Selection or Artboard.
|
||||
fn shown_align_to(app: &VectorcraftApp) -> AlignTo {
|
||||
if on_artboards(app) {
|
||||
return app.ui.align_to;
|
||||
}
|
||||
aligns_to(align_to(app), objects_aligned(app))
|
||||
}
|
||||
|
||||
/// Whether Align To choice `v` can be chosen. Align to Key Object needs a key object or a key
|
||||
/// guide, or something to make one of: two or more selected objects, or a guide selected with
|
||||
/// objects.
|
||||
/// objects. With the Artboard tool chosen it is off: Align to Artboard keeps the active artboard
|
||||
/// in place.
|
||||
fn can_choose(app: &VectorcraftApp, v: AlignTo) -> bool {
|
||||
if on_artboards(app) {
|
||||
return v != AlignTo::Key;
|
||||
}
|
||||
v != AlignTo::Key || selection_len(app) >= 2 || has_key(app) || guide_for_key(app).is_some()
|
||||
}
|
||||
|
||||
@@ -262,11 +321,16 @@ fn can_choose(app: &VectorcraftApp, v: AlignTo) -> bool {
|
||||
/// do. Align to Key Object makes the topmost selected object the key object, unless there is one
|
||||
/// already; with one object, a guide selected with it becomes the key. The stored choice stays as
|
||||
/// it was, and Align To shows it again once the key is cleared. Align to Selection and Align to
|
||||
/// Artboard store the choice and let go of the key object.
|
||||
/// Artboard store the choice and let go of the key object. With the Artboard tool chosen, the
|
||||
/// choice is stored and the selected objects keep their key.
|
||||
pub(crate) fn choose_align_to(app: &mut VectorcraftApp, v: AlignTo) {
|
||||
if !can_choose(app, v) {
|
||||
return;
|
||||
}
|
||||
if on_artboards(app) {
|
||||
app.ui.align_to = v;
|
||||
return;
|
||||
}
|
||||
if v == AlignTo::Key {
|
||||
if !has_key(app) {
|
||||
match (topmost(app).filter(|_| selection_len(app) >= 2), guide_for_key(app)) {
|
||||
@@ -332,13 +396,17 @@ pub(crate) fn align_to_menu(app: &mut VectorcraftApp, ui: &mut Ui, size: f32) {
|
||||
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let n = selection_len(app);
|
||||
let guides = selected_guides(app);
|
||||
let can_align = n + guides >= 1;
|
||||
// With the Artboard tool chosen, the buttons act on the artboards it has selected.
|
||||
let boards = on_artboards(app).then(|| artboards_selected(app));
|
||||
let can_align = boards.map_or(n + guides >= 1, |b| b >= 1);
|
||||
// `spacing` is the Distribute Spacing value. The Distribute Objects and Distribute Spacing
|
||||
// buttons send it while there is a key object or a key guide (#2143).
|
||||
// buttons send it while there is a key object or a key guide (#2143), or for artboards while
|
||||
// Align to Artboard is chosen.
|
||||
let spacing = app.ui.align_spacing;
|
||||
let key = spacing_sent(app).is_some();
|
||||
// Two or more objects and ruler guides (#1597), or anchor points, which may all be on one path.
|
||||
let distributes = n + selected_guides(app) >= 2 || has_anchors(app);
|
||||
// Two or more objects and ruler guides (#1597), or anchor points, which may all be on one
|
||||
// path; or two or more artboards.
|
||||
let distributes = boards.map_or(n + selected_guides(app) >= 2 || has_anchors(app), |b| b >= 2);
|
||||
widgets::subheader(ui, tl!("Align Objects:"));
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 5.0;
|
||||
|
||||
@@ -721,3 +721,96 @@ fn align_to_glyph_bounds_toggles_point_and_area_text() {
|
||||
choose(&mut app, "Point Text");
|
||||
assert_eq!((prefs(&app), checked(&mut app)), ((false, true), vec!["Area Text".to_string()]));
|
||||
}
|
||||
|
||||
/// Three artboards, 0 at (0, 0) 100 × 100, 1 at (150, 40) 60 × 80 and 2 at (400, 10) 100 × 50, with
|
||||
/// the Artboard tool chosen and all three selected, artboard 1 active.
|
||||
fn selected_artboards() -> VectorcraftApp {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
|
||||
app.run("artboard.new", json!({"x": 150, "y": 40, "width": 60, "height": 80})).unwrap();
|
||||
app.run("artboard.new", json!({"x": 400, "y": 10, "width": 100, "height": 50})).unwrap();
|
||||
app.select_tool("artboard");
|
||||
app.session.set_tool_option("selected", &json!([1, 0, 2]));
|
||||
app
|
||||
}
|
||||
|
||||
/// The top-left corner of each artboard.
|
||||
fn artboard_corners(app: &VectorcraftApp) -> Vec<(f64, f64)> {
|
||||
app.session.active().unwrap().doc.artboards.iter().map(|a| (a.rect.x0, a.rect.y0)).collect()
|
||||
}
|
||||
|
||||
/// The command the app ran last, with its `to` and `spacing`.
|
||||
fn last_sent(app: &VectorcraftApp) -> (String, Value, Option<Value>) {
|
||||
let (id, p) = app.session.journal.last().unwrap();
|
||||
(id.clone(), p["to"].clone(), p.get("spacing").cloned())
|
||||
}
|
||||
|
||||
/// With the Artboard tool chosen, the Align Objects buttons align the selected artboards: to
|
||||
/// their bounds with Align to Selection, and to the active artboard, which stays in place, with
|
||||
/// Align to Artboard. Align to Key Object cannot be chosen.
|
||||
#[test]
|
||||
fn the_align_buttons_align_the_selected_artboards() {
|
||||
let mut app = selected_artboards();
|
||||
let ctx = egui::Context::default();
|
||||
let steps = app.session.active().unwrap().history.undo.len();
|
||||
let ([_, key, artboard], left) = panel_buttons(&mut app, &ctx);
|
||||
click(&ctx, &mut app, left.center(), show);
|
||||
assert_eq!(artboard_corners(&app), [(0.0, 0.0), (0.0, 40.0), (0.0, 10.0)], "{}", app.ui.status);
|
||||
assert_eq!(last_sent(&app), ("artboard.align".to_string(), json!("selection"), None));
|
||||
assert_eq!(app.session.active().unwrap().history.undo.len(), steps + 1, "one undo step");
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
click(&ctx, &mut app, key.center(), show);
|
||||
assert!(app.ui.align_to == AlignTo::Selection && !has_key(&app), "Align to Key Object is off");
|
||||
click(&ctx, &mut app, artboard.center(), show);
|
||||
assert_eq!(app.ui.align_to, AlignTo::Artboard);
|
||||
click(&ctx, &mut app, left.center(), show);
|
||||
assert_eq!(artboard_corners(&app), [(150.0, 0.0), (150.0, 40.0), (150.0, 10.0)], "artboard 1 stays: {}", app.ui.status);
|
||||
assert_eq!(last_sent(&app), ("artboard.align".to_string(), json!("artboard"), None));
|
||||
}
|
||||
|
||||
/// With the Artboard tool chosen, the Distribute Objects and Distribute Spacing buttons space the
|
||||
/// selected artboards. With Align to Artboard they send the Distribute Spacing value and the
|
||||
/// active artboard stays in place. They are off for one artboard.
|
||||
#[test]
|
||||
fn the_distribute_buttons_space_the_selected_artboards() {
|
||||
let mut app = selected_artboards();
|
||||
let ctx = egui::Context::default();
|
||||
let (left, space) = distribute_buttons(&mut app, &ctx);
|
||||
click(&ctx, &mut app, left.center(), show);
|
||||
assert_eq!(artboard_corners(&app), [(0.0, 0.0), (200.0, 40.0), (400.0, 10.0)], "{}", app.ui.status);
|
||||
assert_eq!(last_sent(&app), ("artboard.distribute".to_string(), json!("selection"), None));
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
click(&ctx, &mut app, space.center(), show);
|
||||
assert_eq!(artboard_corners(&app), [(0.0, 0.0), (220.0, 40.0), (400.0, 10.0)], "gaps of 120: {}", app.ui.status);
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
app.ui.align_to = AlignTo::Artboard;
|
||||
app.ui.align_spacing = 20.0;
|
||||
click(&ctx, &mut app, space.center(), show);
|
||||
assert_eq!(artboard_corners(&app), [(30.0, 0.0), (150.0, 40.0), (230.0, 10.0)], "gaps of 20: {}", app.ui.status);
|
||||
assert_eq!(last_sent(&app), ("artboard.distributeSpacing".to_string(), json!("artboard"), Some(json!(20.0))));
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
click(&ctx, &mut app, left.center(), show);
|
||||
assert_eq!(artboard_corners(&app), [(130.0, 0.0), (150.0, 40.0), (170.0, 10.0)], "left edges 20 apart: {}", app.ui.status);
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
app.session.set_tool_option("selected", &json!([1]));
|
||||
let ran = app.session.journal.len();
|
||||
for button in [left, space] {
|
||||
click(&ctx, &mut app, button.center(), show);
|
||||
}
|
||||
assert_eq!(app.session.journal.len(), ran, "one artboard: the Distribute buttons are off");
|
||||
}
|
||||
|
||||
/// A button's shortcut entry is enabled by, and runs, the artboard command with the Artboard tool
|
||||
/// chosen and the object command with another tool.
|
||||
#[test]
|
||||
fn a_button_runs_the_artboard_command_only_with_the_artboard_tool() {
|
||||
let mut app = selected_artboards();
|
||||
let b = Button::Spacing("horizontal");
|
||||
assert_eq!(b.command_now(&app), "artboard.distributeSpacing");
|
||||
assert_eq!(artboards_selected(&app), 3);
|
||||
app.select_tool("selection");
|
||||
assert_eq!(b.command_now(&app), "object.distributeSpacing");
|
||||
let before = artboard_corners(&app);
|
||||
run_button(&mut app, Button::Align("horizontal", "left")).ok();
|
||||
assert_eq!(artboard_corners(&app), before, "with the Selection tool the buttons leave the artboards alone");
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ fn settings_section(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
/// The section's rows: the RGB and CMYK working spaces, intent, black point compensation, Load
|
||||
/// Profile… and Gamut Check, and the profile the active document is assigned.
|
||||
fn settings_rows(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let st = cms::active_settings();
|
||||
let st = cms::working_settings();
|
||||
widgets::section_header(ui, tl!("Color Settings"));
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tl!("RGB:"));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Swatches panel: proxy, Recent Colors, find field, thumbnail grid (15.5 pt tiles on a 17 pt pitch)
|
||||
//! or list, colour groups as folders, None/Registration first, bottom bar and panel menu.
|
||||
//! Swatches panel: proxy, Recent Colors (hidden by the panel menu's Show Recent Colors), find
|
||||
//! field, thumbnail grid (15.5 pt tiles on a 17 pt pitch) or list, colour groups as folders,
|
||||
//! None/Registration first, bottom bar and panel menu.
|
||||
//!
|
||||
//! Swatches drag to reorder, into and out of colour groups (`swatch.move`) and onto art (the active
|
||||
//! proxy's paint command with the object's id) and onto the Gradient panel's ramp; a Fill/Stroke
|
||||
|
||||
@@ -182,6 +182,10 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
if menu_item(ui, tl!("Show Find Field"), true, find) {
|
||||
set_pstate(ui.ctx(), "swatch-show-find", !find);
|
||||
}
|
||||
let recent = app.ui.swatches_show_recent_colors;
|
||||
if menu_item(ui, tl!("Show Recent Colors"), true, recent) {
|
||||
app.ui.swatches_show_recent_colors = !recent;
|
||||
}
|
||||
ui.separator();
|
||||
if menu_item(ui, tl!("Swatch Options…"), opts.is_some(), false)
|
||||
&& let Some(n) = opts
|
||||
|
||||
@@ -31,12 +31,14 @@ pub(super) fn body(app: &mut VectorcraftApp, ui: &mut Ui, salt: &'static str) {
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
// A recent colour paints the active proxy (Alt: the inactive one), as a swatch does, and
|
||||
// becomes the Color Guide's base color.
|
||||
if let Some(c) = crate::panels::recent_colors_row(app, ui) {
|
||||
crate::panels::color_guide::choose_base(app, c);
|
||||
crate::panels::apply_click(app, ui, json!({"color": crate::panels::color_json(&c)}));
|
||||
// becomes the Color Guide's base color. The panel menu's Show Recent Colors hides the row.
|
||||
if app.ui.swatches_show_recent_colors {
|
||||
if let Some(c) = crate::panels::recent_colors_row(app, ui) {
|
||||
crate::panels::color_guide::choose_base(app, c);
|
||||
crate::panels::apply_click(app, ui, json!({"color": crate::panels::color_json(&c)}));
|
||||
}
|
||||
widgets::divider(ui);
|
||||
}
|
||||
widgets::divider(ui);
|
||||
widgets::subheader(ui, tl!("Swatch Tiles"));
|
||||
let query = if pstate(ui.ctx(), "swatch-show-find") { widgets::search_field(ui, find_id(), tl!("Find")) } else { String::new() };
|
||||
let items = entries(app, kind, &query);
|
||||
|
||||
@@ -901,3 +901,61 @@ fn lab_spot_tiles_show_the_colour_the_spot_colors_options_pick() {
|
||||
assert_eq!(tile(&app), app.session.active().unwrap().doc.global_color("Ink").unwrap());
|
||||
assert!(matches!(tile(&app), Color::Cmyk { .. }));
|
||||
}
|
||||
|
||||
/// One headless frame of the panel menu; returns its painted labels.
|
||||
fn menu_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<Event>) -> Vec<(String, Rect)> {
|
||||
let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(236.0, 700.0))), events, ..Default::default() };
|
||||
let mut out = ctx.run_ui(raw, |ui| menu(app, ui));
|
||||
out.textures_delta.clear();
|
||||
crate::tests_removeanchors::shapes_text(&out.shapes.iter().map(|c| c.shape.clone()).collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
/// Click the panel menu's item `label`.
|
||||
fn click_menu_item(app: &mut VectorcraftApp, ctx: &egui::Context, label: &str) {
|
||||
let labels = menu_frame(app, ctx, vec![]);
|
||||
let at = labels.iter().find(|(t, _)| t.trim() == label).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("no {label} in the menu"));
|
||||
let button = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
|
||||
menu_frame(app, ctx, vec![Event::PointerMoved(at), button(true)]);
|
||||
menu_frame(app, ctx, vec![button(false)]);
|
||||
}
|
||||
|
||||
/// The panel menu's Show Recent Colors hides and shows the panel's Recent Colors row (#6617):
|
||||
/// checked by default, applied colors still join the list while the row is hidden, and the Color
|
||||
/// panel keeps its own row and setting.
|
||||
#[test]
|
||||
fn show_recent_colors_hides_and_shows_the_swatches_row() {
|
||||
let mut app = app();
|
||||
app.run("paint.setFill", json!({"color": "#ff0000"})).unwrap();
|
||||
let ctx = context();
|
||||
let menu_ctx = context();
|
||||
// The first recent chip, if the panel draws one (the second frame's answer counts, since egui
|
||||
// also answers for a widget the frame before drew).
|
||||
let chip = |app: &mut VectorcraftApp| {
|
||||
let mut chip = None;
|
||||
for _ in 0..2 {
|
||||
let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(280.0, 700.0)));
|
||||
let mut out = ctx.run_ui(egui::RawInput { screen_rect, ..Default::default() }, |ui| {
|
||||
show(app, ui);
|
||||
chip = ctx.read_response(ui.scope_id().with(("recent", 0usize))).map(|r| r.rect);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
chip
|
||||
};
|
||||
assert!(chip(&mut app).is_some(), "the row shows by default");
|
||||
click_menu_item(&mut app, &menu_ctx, "✓ Show Recent Colors");
|
||||
assert!(!app.ui.swatches_show_recent_colors);
|
||||
assert!(chip(&mut app).is_none(), "unchecked, the row is hidden");
|
||||
app.run("paint.setFill", json!({"color": "#00ff00"})).unwrap();
|
||||
assert_eq!(app.session.recent_colors.first().map(|c| c.to_hex()).as_deref(), Some("#00ff00"), "colors still join the list");
|
||||
assert_eq!(app.run("ui.colorPanel", json!({})).unwrap()["recentColors"], true, "the Color panel keeps its setting");
|
||||
click_menu_item(&mut app, &menu_ctx, "Show Recent Colors");
|
||||
assert!(chip(&mut app).is_some(), "checking the item shows the row again");
|
||||
// The app keeps it with the preferences; a saved value that isn't true or false reads as on.
|
||||
let saved = |v: Value| {
|
||||
let mut prefs = serde_json::to_value(crate::UiState::default()).unwrap();
|
||||
prefs["swatches_show_recent_colors"] = v;
|
||||
serde_json::from_value::<crate::UiState>(prefs).unwrap().swatches_show_recent_colors
|
||||
};
|
||||
assert_eq!((saved(json!(false)), saved(json!(true)), saved(json!("no"))), (false, true, true));
|
||||
}
|
||||
|
||||
@@ -699,6 +699,10 @@ pub struct UiState {
|
||||
/// recent colors keeps filling while the row is hidden.
|
||||
#[serde(default = "yes", deserialize_with = "crate::panels::color::deserialize_show_recent_colors")]
|
||||
pub color_show_recent_colors: bool,
|
||||
/// Swatches panel: Show Recent Colors in its panel menu, which draws the panel's Recent Colors
|
||||
/// row (#6617; read as [`Self::color_show_recent_colors`] is). Separate from the Color panel's.
|
||||
#[serde(default = "yes", deserialize_with = "crate::panels::color::deserialize_show_recent_colors")]
|
||||
pub swatches_show_recent_colors: bool,
|
||||
/// The SVG Options chosen last (`svg` object of the export/save commands; null: never used).
|
||||
#[serde(default)]
|
||||
pub svg_options: Value,
|
||||
@@ -1148,6 +1152,7 @@ impl Default for UiState {
|
||||
color_mode: None,
|
||||
color_hide_options: false,
|
||||
color_show_recent_colors: true,
|
||||
swatches_show_recent_colors: true,
|
||||
svg_options: Value::Null,
|
||||
place_link: true,
|
||||
dialog_file: None,
|
||||
|
||||
File diff suppressed because one or more lines are too long
+93
-3
@@ -178,8 +178,8 @@ the suite) keep the agent to the folders you give it:
|
||||
(`ui.palette_query`), a script's report (`scriptReport`), a screenshot's reply and the error from an internal
|
||||
fault included, for the libraries hidden before the request ran and after it. The path is also matched with `%XX` escapes as a `file:`
|
||||
URL writes it, and on Windows in any case of its letters; a `file:` URL of an absolute path is shown as the file
|
||||
name alone, without `file:` or its host. A hidden id or path stays only where it begins
|
||||
another loaded library's whole id with no `.` or `..` part, so a readable library's id that begins with a hidden one stays. A reply also shows a path outside the
|
||||
name alone, without `file:` or its host. A hidden id or path stays only where it lies inside
|
||||
another loaded library's whole id with no `.` or `..` part, so a readable library's id that begins with or holds a hidden one stays. A reply also shows a path outside the
|
||||
read roots that the request sent by its file name, whether or not a library was loaded from it and whether the
|
||||
request succeeded or failed, so that the reply reads the same either way. The one exception is the `path`
|
||||
and `files` entries of a reply's result, a `command.batch` step's result included, that name the file the
|
||||
@@ -1418,6 +1418,9 @@ preferences (the folder the desktop app uses, so both see the same ones; `VECTOR
|
||||
compensation go into `Color Settings.json` when they change, and are the active settings again at the next
|
||||
start, after the profiles above are registered (#6335). Saved settings that name a profile that is no longer
|
||||
there, or a damaged file, leave the defaults (logged), and the next change writes over the file.
|
||||
`edit.assignProfile` tags the document only: colors show and convert through the profiles it assigns, and the
|
||||
working spaces and the file stay as they are. The `workingCmyk` target of `view.proofSetup` simulates the working
|
||||
CMYK space in such a document too.
|
||||
|
||||
Not remembered: the document, anything else. Nothing is read or written when
|
||||
`VECTORCRAFT_NO_PREFS` is set, and a server confined with `--automation-read-root` or
|
||||
@@ -5030,6 +5033,12 @@ the Align To choice, and while there is a key object or a key guide they send th
|
||||
`spacing`. Edit › Keyboard Shortcuts lists them under Align, with no default shortcut, and
|
||||
`shortcuts.set {id, shortcut}` gives one a shortcut.
|
||||
|
||||
With the Artboard tool chosen, the same buttons and UI commands, and the Control bar's Align and Distribute buttons,
|
||||
run `artboard.align`, `artboard.distribute` and `artboard.distributeSpacing` on the artboards the tool has selected.
|
||||
Align To then offers Align to Selection and Align to Artboard. Align to Artboard sends `to: "artboard"`, which keeps the
|
||||
tool's active artboard in place, and the Distribute buttons then send the Distribute Spacing value as `spacing`. Align
|
||||
to Selection sends `to: "selection"`. The Distribute buttons need two or more selected artboards.
|
||||
|
||||
Edit › Keyboard Shortcuts and `shortcuts.list` leave out a command with no default shortcut when a key press cannot
|
||||
run it: no item of the menu bar runs it, it opens no dialog when run without params, and it is one of these:
|
||||
|
||||
@@ -5781,6 +5790,46 @@ every group as it is. The groups and their contents are VectorCraft's own.
|
||||
{"name":"run_command","arguments":{"command":"file.new","params":{"preset":"A4","orientation":"landscape","artboards":4,"artboardLayout":{"columns":2},"bleed":9}}}
|
||||
```
|
||||
|
||||
### New Document profiles
|
||||
|
||||
Each built-in category (Mobile, Web, Print, Film & Video, Art & Illustration, Branding, Social) has a New Document
|
||||
profile: a native document that holds the starting assets and settings of the documents made from that category's
|
||||
presets ([Create a custom new document profile](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/start-a-new-file/create-a-custom-new-document-profile.html)).
|
||||
`file.new` copies the profile's swatches, swatch groups, graphic styles, brushes, symbols, patterns, character and
|
||||
paragraph styles, composite fonts, SVG filters, graph designs, ruler origin and ruler mode, transparency grid, Document
|
||||
Setup options, assigned color profiles, Document Raster Effects Settings other than the resolution, and print settings
|
||||
into the new document. The preset's size, units, artboards, bleed, background, color mode, raster effects resolution
|
||||
and Align New Objects to Pixel Grid still apply. The profile's art, layers and artboards are not copied: the document
|
||||
starts with one empty layer. The grid comes from Preferences › Guides & Grid, as it does for every document. When the
|
||||
preset's color mode differs from the profile's, the profile's colors are converted as `file.documentColorMode`
|
||||
converts them.
|
||||
|
||||
A built-in preset names its category's profile in `file.newPresets` (`profile`). Saved and Recent presets keep the
|
||||
profile of the preset they were made from. `file.new {profile: "Print"}` picks a profile for any settings, and
|
||||
`profile: null` starts with the defaults. A document made without a preset starts with the defaults.
|
||||
|
||||
The profiles are `.vectorcraft` files named after their category in the `New Document Profiles` folder next to the
|
||||
preferences (VectorCraft's own place and form). A category without a file has the shipped profile, which holds the
|
||||
defaults. Runs without preferences (`VECTORCRAFT_NO_PREFS`) and the web build have no folder, so every document starts
|
||||
with the defaults.
|
||||
|
||||
- `file.newProfiles` lists `{profiles: [{name, customized?, path?}], folder?}`. `customized` is true when the category
|
||||
has a file. A request whose read roots leave out the folder gets neither `customized` nor `path`.
|
||||
- `file.newProfiles.edit {name}` opens the profile as a document and returns `document.open`'s result. A category
|
||||
without a file gets the shipped profile written first. File › Save keeps the changes, and the next `file.new` reads
|
||||
them.
|
||||
- `file.newProfiles.reset {name}` deletes the category's file, so the shipped profile applies again, and returns
|
||||
`{name, reset}`. `reset` is false when there was no file. A copy of the profile that is open stays open.
|
||||
|
||||
The profile is read on every `file.new`, so a replayed journal or a recorded action uses the profile as it is when it
|
||||
runs, as `document.open` reads its file when it runs. A file larger than 64 MiB (VectorCraft's own limit) is not read.
|
||||
When the file cannot be read, is too large or is not a native document, or the request's read roots leave out the
|
||||
folder (whether or not the file exists), the document starts with the defaults and
|
||||
`file.new`'s result carries `profileError: {profile, reason}`; the desktop app shows the reason in the status bar. In
|
||||
the New Document dialog, Manage Profiles lists the profiles with Edit and Reset buttons. Edit is available only when
|
||||
there is a profiles folder, and Reset only for a profile that has a file. An unknown profile name is an error that
|
||||
lists the names.
|
||||
|
||||
## Place
|
||||
|
||||
`file.place` puts another file's art into the active document as one undo step without touching the clipboard:
|
||||
@@ -5952,7 +6001,48 @@ artboard it names is locked. When no artboard changes, the command records no un
|
||||
`scaleArt` scales the art with each artboard and defaults to the Artboard tool's Scale Artwork with Artboard option.
|
||||
`patterns` and `lockedAndHidden` work as they do on `artboard.setProps`.
|
||||
|
||||
When a call to `artboard.duplicate`, `artboard.copy`, `artboard.cut`, `artboard.rename` or `artboard.switchOrientation`
|
||||
Align and distribute artboards ([Align and distribute artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/align-and-distribute-artboards.html)):
|
||||
three commands move whole artboards, by default those the Artboard tool has selected, else its active one. Each takes
|
||||
`index? | indices?`, `to?: "selection"|"artboard"`, `artboard?: n`, `moveArt?`, `patterns?` and `lockedAndHidden?`, and
|
||||
returns `{moved: [index…]}`.
|
||||
|
||||
- `artboard.align {horizontal?: "left"|"center"|"right", vertical?: "top"|"center"|"bottom"}` moves each artboard until
|
||||
the named edge or center is on that edge or center of the bounds of all the artboards named. One of the two
|
||||
directions is needed, and both can be given.
|
||||
- `artboard.distribute {horizontal? | vertical?, spacing?}` spaces the named edges or centers evenly between the first
|
||||
and the last artboard along the axis, which stay where they are. It needs three or more artboards.
|
||||
- `artboard.distributeSpacing {axis?: "horizontal"|"vertical", spacing?}` makes the gaps between the artboards along
|
||||
the axis equal. The first and the last artboard stay where they are. It needs three or more artboards, and `axis`
|
||||
defaults to `"horizontal"`.
|
||||
|
||||
`to: "artboard"` aligns to artboard `artboard`, which stays where it is. `artboard` defaults to the Artboard tool's
|
||||
active artboard. For `artboard.align` it can be an artboard that is not being aligned. The two Distribute commands
|
||||
space the artboards as they do without it and then move them together so that artboard `artboard`, when it is one of
|
||||
them, stays where it is. With `to: "artboard"` and `spacing`, `artboard.distribute` places the edges or centers
|
||||
`spacing` points apart, outward from that artboard in their order, and `artboard.distributeSpacing` leaves a gap of
|
||||
`spacing` points between neighbors; two artboards are enough, a negative `spacing` overlaps them, and an `artboard`
|
||||
that is not one of the artboards distributed fails with `no such artboard`. Without `to: "artboard"`, `spacing` is not
|
||||
used. These rules for `to`, `artboard` and `spacing` are VectorCraft's own.
|
||||
|
||||
When a call with `to: "artboard"` names no `artboard`, its journal entry records the active artboard it used as
|
||||
`activeArtboard`. A replay of the journal aligns to that artboard, and a recorded action leaves the value out and
|
||||
aligns to the artboard that is active when it plays. An `artboard` the call names stays in the action.
|
||||
|
||||
`moveArt` moves the art fully inside each artboard with it and defaults to the Artboard tool's Move Artwork with
|
||||
Artboard option. `patterns` and `lockedAndHidden` work as they do on `artboard.move`. An artboard's own guides move
|
||||
with it. A locked artboard stays where it is: it still counts in the bounds and keeps its place in the order, and a
|
||||
call fails with `the artboard is locked` when every artboard it names is locked. That rule is VectorCraft's own. Each
|
||||
command is one undo step, and records none when no artboard moves (fewer artboards than it needs included). A direction,
|
||||
`to`, `axis`, `artboard`, `spacing` or switch with a value outside the ones listed is an error, and nothing changes.
|
||||
A `spacing` that would move an artboard past the edge of the canvas fails with
|
||||
`result would exceed the canvas (coordinates out of range)`, and nothing changes.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"artboard.distributeSpacing","params":{"indices":[0,1,2],"to":"artboard","artboard":0,"spacing":20}}}
|
||||
```
|
||||
|
||||
When a call to `artboard.duplicate`, `artboard.copy`, `artboard.cut`, `artboard.rename`, `artboard.switchOrientation`,
|
||||
`artboard.align`, `artboard.distribute` or `artboard.distributeSpacing`
|
||||
names no artboard, its journal entry records the artboards it acted on as `selectedArtboards`. A replay of the journal
|
||||
acts on those artboards, and a recorded action leaves the value out and acts on the selection when it plays.
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -198,6 +198,7 @@ tools, never checked side by side) isn't captured row by row.
|
||||
| Distribute ruler guides: the Distribute Objects and Distribute Spacing buttons space the selected guides across the axis with the selected objects, as items with no size; two or more guides selected alone are enough | done | #1597 in part; guides alone: select them, choose Align to Artboard and click Horizontal (Vertical) Distribute Center for equal columns (rows) ([public forum answer, 2017](https://community.adobe.com/questions-652/illustrator-equal-guides-822924)); with objects: a guide on each artboard edge, included in the selection, keeps the outermost lines there ([public forum answer, 2025](https://www.signs101.com/threads/is-there-a-way-to-distribute-shapes-like-this-in-illustrator.179556/)); a guide has no width, so the three options of an axis are the same for it; guides along the axis stay |
|
||||
| Ruler guides align to a key object: a click on an object selected with guides makes it the key (one object is enough); Align moves the selected guides to its edge or centre (vertical guides to left, centre or right, horizontal ones to top, centre or bottom) and the key stays put | done | #2143; aligning to the selection or the artboard leaves guides alone |
|
||||
| Align panel menu › Align to Glyph Bounds › Point Text and Area Text (checked while on): Align and Distribute measure point type or area type by the ink of its glyphs instead of its em box or frame | done | #4231; `alignGlyphBoundsPoint`, `alignGlyphBoundsArea` ([Align text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/align-text.html)); `tests::glyphbounds`, `panels::align::tests::align_to_glyph_bounds_toggles_point_and_area_text`; the Character and Properties panels' Align to Glyph Bounds controls are not done |
|
||||
| Align and distribute artboards: with the Artboard tool chosen, the Align panel's Align Objects, Distribute Objects and Distribute Spacing buttons, and the Control bar's, move the selected artboards with the art inside them (Move Artwork with Artboard) | done | `artboard.align`, `artboard.distribute`, `artboard.distributeSpacing` ([Align and distribute artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/align-and-distribute-artboards.html)); VectorCraft's own rules: Align to Artboard keeps the active artboard in place and sends the Distribute Spacing value, Align to Key Object is off, a locked artboard stays where it is; `cmd::artboardalign::tests`, `panels::align::tests::the_align_buttons_align_the_selected_artboards` |
|
||||
| A marquee selects the objects whose paths it touches (anchors or segments): drawn inside a filled shape, over a background, it leaves that shape (Preview and Outline) | done | #1103 |
|
||||
| Click a segment independently of its endpoint anchors; only its direction handles show | done | #1278: hollow endpoint squares, selected edge emphasized; headless egui checked |
|
||||
| Drag a curved segment to bend it, a straight one to move its anchors | done | |
|
||||
|
||||
Reference in New Issue
Block a user