mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-10 18:10:01 -06:00
Merge main into feat/native-save-precision
This commit is contained in:
@@ -10,6 +10,7 @@ pub mod clipboard;
|
||||
mod colorcmds;
|
||||
pub mod colormgmt;
|
||||
pub mod colortheme;
|
||||
mod compoundshape;
|
||||
pub(crate) mod create;
|
||||
mod css;
|
||||
mod cut;
|
||||
@@ -249,6 +250,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(xform::specs());
|
||||
v.extend(effectcmd::specs());
|
||||
v.extend(pathops::specs());
|
||||
v.extend(compoundshape::specs());
|
||||
v.extend(typecmd::specs());
|
||||
v.extend(menucmds::specs());
|
||||
v.extend(live::specs());
|
||||
@@ -378,6 +380,15 @@ pub(crate) fn check_switches(p: &Value, keys: &[&str], cmd: &str) -> Result<()>
|
||||
pub(crate) fn str_param<'a>(p: &'a Value, key: &str) -> Option<&'a str> {
|
||||
p.get(key).and_then(Value::as_str)
|
||||
}
|
||||
/// The string `p` gives for `key`, or `None` when `p` leaves it out or gives null. Any other
|
||||
/// value, such as `5`, is an error: "`{key}` must be a string".
|
||||
pub(crate) fn text_param<'a>(p: &'a Value, key: &str, cmd: &str) -> Result<Option<&'a str>> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::String(v)) => Ok(Some(v)),
|
||||
Some(_) => Err(bad(cmd, format!("`{key}` must be a string"))),
|
||||
}
|
||||
}
|
||||
pub(crate) fn id_param(p: &Value, key: &str) -> Option<NodeId> {
|
||||
p.get(key).and_then(Value::as_u64).map(NodeId)
|
||||
}
|
||||
@@ -514,26 +525,30 @@ pub(crate) fn ok() -> Result<Value> {
|
||||
}
|
||||
|
||||
/// Leaves whose appearance a paint command changes: groups and layers expand to their contents,
|
||||
/// and compound paths own their children's appearance, so a member stands for its compound path
|
||||
/// (#3692), which is listed once.
|
||||
/// and compound paths and compound shapes own their members' appearance, so a member stands for
|
||||
/// its compound path (#3692) or compound shape, which is listed once.
|
||||
pub(crate) fn leaf_targets(s: &Session, ids: &[NodeId]) -> Result<Vec<NodeId>> {
|
||||
use vectorcraft_doc::NodeKind;
|
||||
let d = &s.doc()?.doc;
|
||||
let compound = |id: NodeId| d.parent_of(id).filter(|p| matches!(d.node(*p).map(|n| &n.kind), Some(NodeKind::Compound { .. })));
|
||||
let owns = |n: &vectorcraft_doc::Node| matches!(n.kind, NodeKind::Compound { .. }) || n.is_compound_shape();
|
||||
let compound = |id: NodeId| d.parent_of(id).filter(|p| d.node(*p).is_some_and(owns));
|
||||
let mut out = vec![];
|
||||
for id in ids {
|
||||
let Some(n) = d.node(*id) else { continue };
|
||||
match &n.kind {
|
||||
NodeKind::Group { .. } | NodeKind::Layer { .. } => n.walk(&mut |c| {
|
||||
if !c.is_container() || matches!(c.kind, NodeKind::Compound { .. }) {
|
||||
NodeKind::Group { .. } | NodeKind::Layer { .. } if !n.is_compound_shape() => n.walk(&mut |c| {
|
||||
if !c.is_container() || owns(c) {
|
||||
out.push(c.id)
|
||||
}
|
||||
}),
|
||||
_ => out.push(compound(*id).unwrap_or(*id)),
|
||||
}
|
||||
}
|
||||
let comp: Vec<NodeId> = out.iter().filter(|id| matches!(d.node(**id).map(|n| &n.kind), Some(NodeKind::Compound { .. }))).copied().collect();
|
||||
out.retain(|id| !comp.iter().any(|c| d.parent_of(*id) == Some(*c)));
|
||||
let comp: std::collections::HashSet<NodeId> = out.iter().filter(|id| d.node(**id).is_some_and(owns)).copied().collect();
|
||||
if !comp.is_empty() {
|
||||
// A compound shape's members lie at any depth in it.
|
||||
out.retain(|id| !d.ancestry(*id).is_some_and(|up| up.iter().rev().skip(1).any(|a| comp.contains(a))));
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
out.retain(|id| seen.insert(*id));
|
||||
Ok(out)
|
||||
@@ -581,6 +596,9 @@ pub use opacitymask::TransparencyInfo;
|
||||
/// Eyedropper Options (`Prefs::eyedropper`).
|
||||
pub use xform::{AppearanceAttrs, EyedropperAttrs, EyedropperOptions, FillAttrs, StrokeAttrs};
|
||||
|
||||
/// The shape modes of the selected compound-shape members (the Pathfinder panel lights theirs).
|
||||
pub use compoundshape::selected_member_modes;
|
||||
|
||||
/// Pathfinder Options (`Prefs::pathfinder`).
|
||||
pub use pathops::{PATHFINDER_PRECISION_RANGE, PathfinderOptions};
|
||||
|
||||
@@ -596,6 +614,9 @@ pub use flatten::FlattenerPreset;
|
||||
/// A saved Image Trace preset ([`crate::Prefs::trace_presets`]), and a change to the saved presets.
|
||||
pub use buildcmds::{TracePreset, TracePresetChange};
|
||||
|
||||
/// An Image Trace made off the UI thread, and what it traced (#3098).
|
||||
pub use buildcmds::{TraceRun, Traced};
|
||||
|
||||
/// The saved Image Trace presets' revision and latest changes ([`crate::Session`] keeps them).
|
||||
pub(crate) use buildcmds::TracePresetChanges;
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ pub use live_paint::live_paint_gaps;
|
||||
use live_paint::{lp_expand, lp_fill, lp_gap_options, lp_info, lp_make, lp_merge, lp_release, lp_stroke_edge};
|
||||
use shape_builder::{sb_merge, sb_regions};
|
||||
pub(crate) use trace::TracePresetChanges;
|
||||
pub use trace::{TracePreset, TracePresetChange};
|
||||
pub use trace::{TracePreset, TracePresetChange, TraceRun, Traced};
|
||||
use trace::{trace_expand, trace_make, trace_presets_delete, trace_presets_save, trace_release, trace_set_view};
|
||||
|
||||
/// Name of an Image Trace group.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use vectorcraft_color::Swatch;
|
||||
use vectorcraft_doc::ImageObject;
|
||||
|
||||
use super::{
|
||||
Appearance, AppearanceItem, Arc, Beside, Color, Document, EngineError, LineCap, LineJoin, LiveShape, Node, NodeId, NodeKind, Paint, Point,
|
||||
@@ -369,8 +370,34 @@ fn grouped(d: &mut Document, nodes: Vec<Node>, parents: &[Option<usize>]) -> Vec
|
||||
roots.iter().filter_map(|&r| built.get_mut(r)?.take()).chain(ring).collect()
|
||||
}
|
||||
|
||||
pub(super) fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
|
||||
let c = if expand { "imageTrace.makeAndExpand" } else { "imageTrace.make" };
|
||||
/// What a trace depends on: the object traced (an image, or an Image Trace object traced again),
|
||||
/// its image, and the settings it is traced with. The tracer makes the same paths of the same
|
||||
/// inputs, so a trace made away from the command ([`TraceRun`]) stands in for the command's own.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct TraceInputs {
|
||||
target: NodeId,
|
||||
image: Box<ImageObject>,
|
||||
/// The settings traced with, as JSON.
|
||||
params: Value,
|
||||
/// Document Library: the library's colours, which the JSON leaves out
|
||||
/// ([`tr::TraceParams::swatches`]).
|
||||
swatches: Vec<[u8; 3]>,
|
||||
}
|
||||
|
||||
/// What `imageTrace.make` and `imageTrace.makeAndExpand` take from the session before they trace.
|
||||
struct Prepared {
|
||||
inputs: TraceInputs,
|
||||
trace: tr::TraceParams,
|
||||
view: Option<TraceView>,
|
||||
/// The settings asked for, which the Image Trace object keeps.
|
||||
params_json: Value,
|
||||
preset_label: String,
|
||||
library: Vec<([u8; 3], Paint)>,
|
||||
bytes: Arc<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// What command `c` with params `p` traces, and how, from the session: everything but the trace.
|
||||
fn prepare(s: &Session, c: &str, p: &Value) -> Result<Prepared> {
|
||||
let mut params = trace_params(s, c, p)?;
|
||||
let view = view_param(c, p)?;
|
||||
let params_json = serde_json::to_value(¶ms).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
@@ -384,15 +411,9 @@ pub(super) fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Val
|
||||
None if is(tr::preset(named)) || is(tr::enhanced_preset(named)) => named.to_string(),
|
||||
None => "Custom".to_string(),
|
||||
};
|
||||
// A saved preset's settings join the journal entry, so a replay traces as this run did after
|
||||
// the preset is changed, renamed or deleted.
|
||||
if let Some(q) = s.trace_preset(named).filter(|_| tr::preset(named).is_none()) {
|
||||
let recorded = serde_json::to_value(&q.params).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
s.note_journal("savedPreset", recorded);
|
||||
}
|
||||
let roots = root_ids(s, p)?;
|
||||
// The image (inside a trace group, or selected directly).
|
||||
let (target, img) = {
|
||||
let (target, image) = {
|
||||
let d = &s.doc()?.doc;
|
||||
roots
|
||||
.iter()
|
||||
@@ -409,15 +430,128 @@ pub(super) fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Val
|
||||
})
|
||||
.ok_or_else(|| bad(c, "select an image (or an Image Trace group)"))?
|
||||
};
|
||||
let blob = s.doc()?.doc.images.get(&img.key).ok_or_else(|| EngineError::Other(format!("image data `{}` is missing", img.key)))?;
|
||||
// An image over the tracer's size is traced scaled down (the paths are scaled back below).
|
||||
let raster = tr::Raster::decode_to_fit(&blob.bytes).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let blob = s.doc()?.doc.images.get(&image.key).ok_or_else(|| EngineError::Other(format!("image data `{}` is missing", image.key)))?;
|
||||
let bytes = Arc::clone(&blob.bytes);
|
||||
let library = trace_library(c, s, &mut params)?;
|
||||
let res = tr::trace_within(&raster, ¶ms, MAX_TRACE_ANCHORS).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let warnings = trace_warnings((img.width, img.height), (raster.width, raster.height), res.worked_at);
|
||||
let traced_with = serde_json::to_value(¶ms).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let inputs = TraceInputs { target, image, params: traced_with, swatches: params.swatches.clone() };
|
||||
Ok(Prepared { inputs, trace: params, view, params_json, preset_label, library, bytes })
|
||||
}
|
||||
|
||||
/// What a trace made of an image: the size in pixels it was decoded at, and the paths.
|
||||
pub struct Traced {
|
||||
inputs: TraceInputs,
|
||||
raster: (u32, u32),
|
||||
res: tr::TraceResult,
|
||||
}
|
||||
|
||||
impl Traced {
|
||||
/// Hand it to the next run of its command with the same inputs ([`TraceRun::params`]), which
|
||||
/// applies it as one undo step instead of tracing again. Any other trace drops it.
|
||||
pub fn hand_in(self, s: &mut Session) {
|
||||
s.trace_hand_off = Some(self);
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode the image `bytes` and trace it with `params`, telling `steps` how far it has got
|
||||
/// ([`tr::trace_watched`]); [`EngineError::Cancelled`] once `steps` answers `false`.
|
||||
fn trace_now(inputs: &TraceInputs, params: &tr::TraceParams, bytes: &[u8], steps: &(dyn Fn(u64, u64) -> bool + Sync)) -> Result<Traced> {
|
||||
// An image over the tracer's size is traced scaled down (the paths are scaled back).
|
||||
let raster = tr::Raster::decode_to_fit(bytes).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
if !steps(0, 0) {
|
||||
return Err(EngineError::Cancelled);
|
||||
}
|
||||
let res = tr::trace_watched(&raster, params, MAX_TRACE_ANCHORS, steps).map_err(|e| match e {
|
||||
tr::TraceError::Cancelled => EngineError::Cancelled,
|
||||
e => EngineError::Other(e.to_string()),
|
||||
})?;
|
||||
Ok(Traced { inputs: inputs.clone(), raster: (raster.width, raster.height), res })
|
||||
}
|
||||
|
||||
/// An Image Trace made away from its command, off the UI thread (#3098). [`TraceRun::prepare`]
|
||||
/// takes what `imageTrace.make` or `imageTrace.makeAndExpand` traces from the session, checked as
|
||||
/// the command checks it; [`TraceRun::run`] decodes and traces the image without the session, on
|
||||
/// any thread; [`Traced::hand_in`] gives the result to the command.
|
||||
pub struct TraceRun {
|
||||
command: &'static str,
|
||||
params: Value,
|
||||
prepared: Prepared,
|
||||
}
|
||||
|
||||
impl TraceRun {
|
||||
/// The trace `command` (`imageTrace.make` or `imageTrace.makeAndExpand`) with params `p` makes
|
||||
/// in the active document.
|
||||
pub fn prepare(s: &Session, command: &str, p: &Value) -> Result<TraceRun> {
|
||||
let command = match command {
|
||||
"imageTrace.make" => "imageTrace.make",
|
||||
"imageTrace.makeAndExpand" => "imageTrace.makeAndExpand",
|
||||
other => return Err(EngineError::Other(format!("`{other}` doesn't trace an image"))),
|
||||
};
|
||||
let prepared = prepare(s, command, p)?;
|
||||
// The object traced is named, so the command applies the trace to it whatever is
|
||||
// selected by then.
|
||||
let mut params = if p.is_object() { p.clone() } else { json!({}) };
|
||||
if let Some(o) = params.as_object_mut() {
|
||||
o.remove("ids");
|
||||
o.insert("id".into(), json!(prepared.inputs.target.0));
|
||||
}
|
||||
Ok(TraceRun { command, params, prepared })
|
||||
}
|
||||
|
||||
/// `imageTrace.make` or `imageTrace.makeAndExpand`.
|
||||
pub fn command(&self) -> &'static str {
|
||||
self.command
|
||||
}
|
||||
|
||||
/// The command's params that apply this trace: those it was prepared with, the object traced
|
||||
/// in `id`.
|
||||
pub fn params(&self) -> &Value {
|
||||
&self.params
|
||||
}
|
||||
|
||||
/// The object it traces: an image, or an Image Trace object traced again.
|
||||
pub fn target(&self) -> NodeId {
|
||||
self.prepared.inputs.target
|
||||
}
|
||||
|
||||
/// The size in pixels of the image it traces.
|
||||
pub fn image_size(&self) -> (u32, u32) {
|
||||
(self.prepared.inputs.image.width, self.prepared.inputs.image.height)
|
||||
}
|
||||
|
||||
/// Does it still apply: would the command trace the same object, image and settings now?
|
||||
pub fn current(&self, s: &Session) -> bool {
|
||||
prepare(s, self.command, &self.params).is_ok_and(|now| now.inputs == self.prepared.inputs)
|
||||
}
|
||||
|
||||
/// Decode and trace the image, telling `steps` how far it has got → what it traced, or why it
|
||||
/// failed or stopped ([`EngineError::Cancelled`] once `steps` answers `false`). It touches no
|
||||
/// document, so it can run on any thread.
|
||||
pub fn run(&self, steps: &(dyn Fn(u64, u64) -> bool + Sync)) -> Result<Traced> {
|
||||
trace_now(&self.prepared.inputs, &self.prepared.trace, &self.prepared.bytes, steps)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
|
||||
let c = if expand { "imageTrace.makeAndExpand" } else { "imageTrace.make" };
|
||||
let Prepared { inputs, trace, view, params_json, preset_label, library, bytes } = prepare(s, c, p)?;
|
||||
// A saved preset's settings join the journal entry, so a replay traces as this run did after
|
||||
// the preset is changed, renamed or deleted.
|
||||
let named = str_param(p, "preset").unwrap_or("Default");
|
||||
if let Some(q) = s.trace_preset(named).filter(|_| tr::preset(named).is_none()) {
|
||||
let recorded = serde_json::to_value(&q.params).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
s.note_journal("savedPreset", recorded);
|
||||
}
|
||||
// A trace handed in for these very inputs ([`TraceRun`]), else one made now.
|
||||
let Traced { raster, res, .. } = match s.trace_hand_off.take() {
|
||||
Some(traced) if traced.inputs == inputs => traced,
|
||||
_ => trace_now(&inputs, &trace, &bytes, &|_, _| true)?,
|
||||
};
|
||||
let TraceInputs { target, image: img, .. } = inputs;
|
||||
let warnings = trace_warnings((img.width, img.height), raster, res.worked_at);
|
||||
// Pixel space of the decoded raster → the image object's pixel space → document.
|
||||
let sx = img.width.max(1) as f64 / raster.width.max(1) as f64;
|
||||
let sy = img.height.max(1) as f64 / raster.height.max(1) as f64;
|
||||
let sx = img.width.max(1) as f64 / raster.0.max(1) as f64;
|
||||
let sy = img.height.max(1) as f64 / raster.1.max(1) as f64;
|
||||
let xf = img.xf * vectorcraft_geom::Affine::scale_non_uniform(sx, sy);
|
||||
// Document units per raster pixel (stroke widths).
|
||||
let unit = xf.determinant().abs().sqrt();
|
||||
|
||||
@@ -645,6 +645,7 @@ fn set_spot(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
if sw.spot != spot {
|
||||
let n = name.clone();
|
||||
let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok();
|
||||
s.edit("Swatch Options", |d, _| {
|
||||
if let Some(w) = d.swatch_mut(&n) {
|
||||
w.spot = spot;
|
||||
@@ -652,7 +653,7 @@ fn set_spot(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
w.global = true;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(C, e))
|
||||
})?;
|
||||
}
|
||||
Ok(json!({"name": name, "spot": spot}))
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
//! Pathfinder panel › compound shapes (`object.compoundShape.*`): Make Compound Shape (what
|
||||
//! Alt-clicking a Shape Mode button does), a member's shape mode, Release and Expand Compound
|
||||
//! Shape. A compound shape is a group marked so ([`Node::is_compound_shape`]) whose members each
|
||||
//! have a [`ShapeMode`]; `vectorcraft-effects` draws it as the area they make
|
||||
//! ([`effects::compound_shape_area`]).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, Node, NodeId, NodeKind, ShapeMode};
|
||||
use vectorcraft_render::effects;
|
||||
|
||||
use super::edit::{roots_of, selected_roots};
|
||||
use super::pathops::{pf_leaves, shape_node, style_of};
|
||||
use super::{CommandSpec, EngineError, Result, Session, bad, cmd, has_multi};
|
||||
|
||||
const MODE_NAMES: [&str; 4] = ["add", "subtract", "intersect", "exclude"];
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"object.compoundShape.make",
|
||||
"Make Compound Shape",
|
||||
["Window", "Pathfinder"],
|
||||
None,
|
||||
"{ids?: [id…] (selected first; default: the selection), mode?: \"add\"|\"subtract\"|\"intersect\"|\"exclude\" (default add; Alt-clicking a Shape Mode button gives its mode)} the two or more selected objects (paths, compound paths, groups, compound shapes, type) become the members of one compound shape, each with the shape mode, in their stacking order where the frontmost was: it draws as the area they make from the backmost up (open paths closed), painted with the appearance and transparency of the topmost member in Add, Intersect or Exclude mode (with every member in Subtract, the backmost), and the members stay editable (Direct Selection, Layers panel, isolation mode) → {id}",
|
||||
has_multi,
|
||||
make
|
||||
),
|
||||
cmd!(
|
||||
"object.compoundShape.setMode",
|
||||
"Shape Mode",
|
||||
["Window", "Pathfinder"],
|
||||
None,
|
||||
"{mode: \"add\"|\"subtract\"|\"intersect\"|\"exclude\", ids?: [id…] (selected first; default: the selection)} the members of compound shapes that the selected objects are or are in take the shape mode (what clicking a Shape Mode button does with members selected); the backmost member's mode doesn't change the shape → {ids}",
|
||||
has_member,
|
||||
set_mode
|
||||
),
|
||||
cmd!(
|
||||
"object.compoundShape.release",
|
||||
"Release Compound Shape",
|
||||
["Window", "Pathfinder"],
|
||||
None,
|
||||
"{ids?: [id…] (selected first; default: the selection)} the selected compound shapes (and those holding the selected objects) become their members again, as they were before Make Compound Shape, selected → {ids}",
|
||||
has_compound_shape,
|
||||
release
|
||||
),
|
||||
cmd!(
|
||||
"object.compoundShape.expand",
|
||||
"Expand Compound Shape",
|
||||
["Window", "Pathfinder"],
|
||||
None,
|
||||
"{ids?: [id…] (selected first; default: the selection)} the selected compound shapes (and those holding the selected objects) become the path, or compound path, of the area they draw, painted as they are, selected → {ids}",
|
||||
has_compound_shape,
|
||||
expand
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// The innermost compound shape holding `id` (not `id` itself), with the member of it that `id` is
|
||||
/// or is in.
|
||||
fn holder(d: &Document, id: NodeId) -> Option<(NodeId, NodeId)> {
|
||||
let up = d.ancestry(id)?;
|
||||
up.windows(2).rev().find_map(|w| match w {
|
||||
[shape, member] if d.node(*shape).is_some_and(Node::is_compound_shape) => Some((*shape, *member)),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The compound shape `id` is or is in (the innermost).
|
||||
fn shape_of(d: &Document, id: NodeId) -> Option<NodeId> {
|
||||
if d.node(id)?.is_compound_shape() { Some(id) } else { holder(d, id).map(|(shape, _)| shape) }
|
||||
}
|
||||
|
||||
/// The compound shapes the selection is or is in, outermost only, in paint order.
|
||||
fn selected_shapes(s: &Session) -> Result<Vec<NodeId>> {
|
||||
let st = s.doc()?;
|
||||
let mut shapes: Vec<NodeId> = vec![];
|
||||
for id in st.selection.in_paint_order(&st.doc) {
|
||||
if let Some(shape) = shape_of(&st.doc, id).filter(|shape| !shapes.contains(shape)) {
|
||||
shapes.push(shape);
|
||||
}
|
||||
}
|
||||
Ok(roots_of(&st.doc, shapes))
|
||||
}
|
||||
|
||||
/// The members of compound shapes that the selected objects are or are in.
|
||||
fn selected_members(s: &Session) -> Result<Vec<NodeId>> {
|
||||
let st = s.doc()?;
|
||||
let mut members: Vec<NodeId> = vec![];
|
||||
for id in &st.selection.objects {
|
||||
if let Some((_, member)) = holder(&st.doc, *id).filter(|(_, m)| !members.contains(m)) {
|
||||
members.push(member);
|
||||
}
|
||||
}
|
||||
Ok(members)
|
||||
}
|
||||
|
||||
/// The shape modes of the compound-shape members the selection is or is in (none when it is in
|
||||
/// none): the Pathfinder panel lights the Shape Mode button they share.
|
||||
pub fn selected_member_modes(s: &Session) -> Vec<ShapeMode> {
|
||||
let Some(st) = s.active() else { return vec![] };
|
||||
let members = selected_members(s).unwrap_or_default();
|
||||
members.iter().filter_map(|id| st.doc.node(*id)).map(|n| n.extras().shape_mode).collect()
|
||||
}
|
||||
|
||||
/// The selection is or is in a compound shape.
|
||||
fn has_compound_shape(s: &Session) -> std::result::Result<(), String> {
|
||||
let st = s.active().ok_or("no document open")?;
|
||||
if st.selection.objects.iter().any(|id| shape_of(&st.doc, *id).is_some()) { Ok(()) } else { Err("select a compound shape".into()) }
|
||||
}
|
||||
|
||||
/// The selection is or is in a member of a compound shape.
|
||||
fn has_member(s: &Session) -> std::result::Result<(), String> {
|
||||
let st = s.active().ok_or("no document open")?;
|
||||
if st.selection.objects.iter().any(|id| holder(&st.doc, *id).is_some()) { Ok(()) } else { Err("select members of a compound shape".into()) }
|
||||
}
|
||||
|
||||
/// The `mode` param: one of [`MODE_NAMES`], or `default` when it is missing (`None`: required).
|
||||
fn mode_param(p: &Value, cmd: &str, default: Option<ShapeMode>) -> Result<ShapeMode> {
|
||||
match super::object::choice(p, cmd, "mode", &MODE_NAMES)? {
|
||||
Some(name) => ShapeMode::parse(name),
|
||||
None => default,
|
||||
}
|
||||
.ok_or_else(|| bad(cmd, format!("`mode` must be one of {}", MODE_NAMES.join(", "))))
|
||||
}
|
||||
|
||||
fn ids_json(ids: &[NodeId]) -> Value {
|
||||
json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() })
|
||||
}
|
||||
|
||||
fn make(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.compoundShape.make";
|
||||
let mode = mode_param(p, C, Some(ShapeMode::Add))?;
|
||||
let roots = selected_roots(s)?;
|
||||
if roots.len() < 2 {
|
||||
return Err(bad(C, "select at least two objects"));
|
||||
}
|
||||
let d = &s.doc()?.doc;
|
||||
let mut members = Vec::with_capacity(roots.len());
|
||||
for id in &roots {
|
||||
let n = d.node(*id).ok_or(EngineError::NoNode(*id))?;
|
||||
if !matches!(n.kind, NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } | NodeKind::Group { .. } | NodeKind::Text(_)) {
|
||||
return Err(bad(C, "compound shapes take paths, compound paths, groups, compound shapes and type"));
|
||||
}
|
||||
members.push(n);
|
||||
}
|
||||
// The paint and transparency of the topmost member in Add, Intersect or Exclude mode: as they
|
||||
// all have `mode`, the topmost, or the backmost when they all subtract.
|
||||
let order: Vec<&Node> = if mode == ShapeMode::Subtract { members } else { members.into_iter().rev().collect() };
|
||||
let style = order.into_iter().find_map(|n| {
|
||||
let mut lv = vec![];
|
||||
pf_leaves(n, &mut lv);
|
||||
lv.last().map(style_of)
|
||||
});
|
||||
let id = s.edit("Make Compound Shape", |d, sel| {
|
||||
let gid = d.alloc_id();
|
||||
let id = d.wrap(&roots, |children| {
|
||||
let children = children
|
||||
.into_iter()
|
||||
.map(|c| {
|
||||
let mut c = Arc::unwrap_or_clone(c);
|
||||
c.extras_mut().shape_mode = mode;
|
||||
Arc::new(c)
|
||||
})
|
||||
.collect();
|
||||
let mut g = Node::group(gid, children);
|
||||
g.extras_mut().compound_shape = true;
|
||||
if let Some(style) = &style {
|
||||
style.apply(&mut g);
|
||||
}
|
||||
g
|
||||
})?;
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
})?;
|
||||
Ok(json!({ "id": id.0 }))
|
||||
}
|
||||
|
||||
fn set_mode(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.compoundShape.setMode";
|
||||
let mode = mode_param(p, C, None)?;
|
||||
let members = selected_members(s)?;
|
||||
if members.is_empty() {
|
||||
return Err(bad(C, "select members of a compound shape"));
|
||||
}
|
||||
// Members already in the mode leave the document and its undo history as they are.
|
||||
let d = &s.doc()?.doc;
|
||||
if members.iter().all(|id| d.node(*id).is_some_and(|n| n.extras().shape_mode == mode)) {
|
||||
return Ok(ids_json(&members));
|
||||
}
|
||||
s.edit("Shape Mode", |d, _| {
|
||||
for id in &members {
|
||||
d.node_mut(*id).ok_or(EngineError::NoNode(*id))?.extras_mut().shape_mode = mode;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(ids_json(&members))
|
||||
}
|
||||
|
||||
fn release(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let shapes = selected_shapes(s)?;
|
||||
if shapes.is_empty() {
|
||||
return Err(bad("object.compoundShape.release", "select a compound shape"));
|
||||
}
|
||||
let ids = s.edit("Release Compound Shape", |d, sel| {
|
||||
let (mut items, mut released) = (vec![], vec![]);
|
||||
for id in &shapes {
|
||||
let members = d.node(*id).and_then(Node::children).ok_or(EngineError::NoNode(*id))?;
|
||||
let out: Vec<Node> = members
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let mut c = (**c).clone();
|
||||
c.extras_mut().shape_mode = ShapeMode::Add;
|
||||
released.push(c.id);
|
||||
c
|
||||
})
|
||||
.collect();
|
||||
items.push((*id, out));
|
||||
}
|
||||
d.insert_beside(items, true)?;
|
||||
sel.set(released.iter().copied());
|
||||
Ok(released)
|
||||
})?;
|
||||
Ok(ids_json(&ids))
|
||||
}
|
||||
|
||||
fn expand(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
const LABEL: &str = "Expand Compound Shape";
|
||||
let shapes = selected_shapes(s)?;
|
||||
if shapes.is_empty() {
|
||||
return Err(bad("object.compoundShape.expand", "select a compound shape"));
|
||||
}
|
||||
let ids = s.edit(LABEL, |d, sel| {
|
||||
let (mut items, mut made) = (vec![], vec![]);
|
||||
for id in &shapes {
|
||||
let shape = d.node(*id).cloned().ok_or(EngineError::NoNode(*id))?;
|
||||
let area = effects::compound_shape_area(&shape, effects::text_outliner())
|
||||
.ok_or_else(|| EngineError::Other("the compound shape is too complex to expand".into()))?;
|
||||
if area.is_empty() {
|
||||
return Err(EngineError::Other(format!("{LABEL} produced an empty result")));
|
||||
}
|
||||
let mut n = shape_node(d, area, None);
|
||||
let mut appearance = (*shape.appearance).clone();
|
||||
appearance.contents_index = None;
|
||||
n.appearance = appearance.into();
|
||||
(n.opacity, n.blend, n.name) = (shape.opacity, shape.blend, shape.name.clone());
|
||||
made.push(n.id);
|
||||
items.push((*id, vec![n]));
|
||||
}
|
||||
d.insert_beside(items, true)?;
|
||||
sel.set(made.iter().copied());
|
||||
Ok(made)
|
||||
})?;
|
||||
Ok(ids_json(&ids))
|
||||
}
|
||||
@@ -36,6 +36,7 @@ pub(crate) use load::{MAX_RASTER_ALLOC, size_note};
|
||||
mod metafile;
|
||||
mod naming;
|
||||
mod native;
|
||||
mod pcxread;
|
||||
pub mod pdf;
|
||||
mod pdfimport;
|
||||
pub mod pngtext;
|
||||
@@ -192,6 +193,9 @@ mod tests_psd;
|
||||
#[cfg(test)]
|
||||
mod tests_psdread;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_pcxread;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_epsimport;
|
||||
|
||||
|
||||
@@ -489,6 +489,8 @@ pub const FORMATS: &[Format] = &[
|
||||
reader("psb", "PSB", &["psb"], "image/x-psb", true),
|
||||
// PostScript programs, read as EPS files are; Print to PostScript writes them, exports don't.
|
||||
reader("ps", "PostScript", &["ps"], "application/postscript", false),
|
||||
// ZSoft PCX images, read by `pcxread` and stored as PNG; exports don't write them.
|
||||
reader("pcx", "PCX", &["pcx"], "image/x-pcx", true),
|
||||
];
|
||||
|
||||
/// Every extension `document.open` reads (the "All readable files" filter of open dialogs).
|
||||
@@ -512,6 +514,7 @@ pub const OPEN_EXTS: &[&str] = &[
|
||||
"vda",
|
||||
"icb",
|
||||
"vst",
|
||||
"pcx",
|
||||
"psd",
|
||||
"psb",
|
||||
"vctemplate",
|
||||
@@ -528,10 +531,10 @@ pub const OPEN_EXTS: &[&str] = &[
|
||||
|
||||
/// Extensions in [`OPEN_EXTS`] the packaging declares no type for: Photoshop documents belong to
|
||||
/// raster editors, so Finder and file managers don't offer a vector app for them, and there is no
|
||||
/// `.epsf`, `.ps` or Targa type (File › Open and Place still read them all). On Linux the desktop
|
||||
/// entry's EPS type (shared-mime-info's) also covers `.epsf`, so file managers offer VectorCraft
|
||||
/// for it.
|
||||
pub const UNASSOCIATED_EXTS: &[&str] = &["psd", "psb", "epsf", "ps", "tga", "vda", "icb", "vst"];
|
||||
/// `.epsf`, `.ps`, Targa or PCX type (File › Open and Place still read them all). On Linux the
|
||||
/// desktop entry's EPS type (shared-mime-info's) also covers `.epsf`, so file managers offer
|
||||
/// VectorCraft for it.
|
||||
pub const UNASSOCIATED_EXTS: &[&str] = &["psd", "psb", "epsf", "ps", "tga", "vda", "icb", "vst", "pcx"];
|
||||
|
||||
/// The extension that picks each writable format when exporting (the format's first; PNG-8 shares
|
||||
/// `.png` with PNG, so `.png` comes once), in [`FORMATS`] order.
|
||||
@@ -577,6 +580,7 @@ pub const PLACE_EXTS: &[&str] = &[
|
||||
"vda",
|
||||
"icb",
|
||||
"vst",
|
||||
"pcx",
|
||||
"psd",
|
||||
"psb",
|
||||
"vctemplate",
|
||||
|
||||
@@ -561,6 +561,8 @@ pub(crate) fn size_note(bytes: &[u8]) -> Option<String> {
|
||||
/// too ([`vectorcraft_doc::cmyk::group3_tiff_rgba`]).
|
||||
/// Grayscale TIFFs with an alpha channel or other extra channels, which the decoder can't read
|
||||
/// either, become RGBA too, whether they store their channels together or one plane per channel.
|
||||
/// A PCX file is recognized by its header ([`super::pcxread::is_pcx`]) and read by
|
||||
/// [`super::pcxread::decode`], which refuses an image past [`MAX_RASTER_ALLOC`] before decoding.
|
||||
/// A Targa file, which starts with no signature, is recognized by its header
|
||||
/// ([`super::tgaread::is_targa`]) and becomes RGBA with the transparency it stores
|
||||
/// ([`super::tgaread::fix_alpha`]). An image stored as PNG whose decoded samples, or whose pixels
|
||||
@@ -580,6 +582,9 @@ pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
|
||||
if reader.format().is_none() && super::tgaread::is_targa(bytes) {
|
||||
reader.set_format(image::ImageFormat::Tga);
|
||||
}
|
||||
if reader.format().is_none() && super::pcxread::is_pcx(bytes) {
|
||||
return as_png(super::pcxread::decode(bytes, MAX_RASTER_ALLOC).map_err(err)?, ppi);
|
||||
}
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_alloc = Some(MAX_RASTER_ALLOC);
|
||||
reader.limits(limits);
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
//! Reading PCX files (`.pcx`) for File › Open and File › Place, from the ZSoft PCX Technical
|
||||
//! Reference Manual (release 5). [`is_pcx`] recognizes a file by the first fields of its 128-byte
|
||||
//! header, and [`decode`] reads its run-length encoded scan lines into RGBA pixels: 1-bit with two
|
||||
//! colors; 1-bit in two to four planes and 4-bit with up to 16 colors; 2-bit CGA pictures in the
|
||||
//! four colors their CGA palette settings pick; 8-bit with the
|
||||
//! 256-color palette after the image data (gray levels without one); and 24-bit color in three
|
||||
//! 8-bit planes. The file is untrusted: every read is checked.
|
||||
|
||||
/// The size of the header, after which the image data starts.
|
||||
const HEADER: usize = 128;
|
||||
|
||||
/// The message for a file whose header or image data ends early.
|
||||
const TRUNCATED: &str = "the PCX file is cut short";
|
||||
|
||||
/// The message for a header whose window or line length can't hold its image.
|
||||
const DAMAGED: &str = "the PCX file's header is damaged";
|
||||
|
||||
/// The 16 colors of the EGA palette, which files of versions 0 and 3 use instead of the header's.
|
||||
const EGA: [[u8; 3]; 16] = [
|
||||
[0x00, 0x00, 0x00],
|
||||
[0x00, 0x00, 0xaa],
|
||||
[0x00, 0xaa, 0x00],
|
||||
[0x00, 0xaa, 0xaa],
|
||||
[0xaa, 0x00, 0x00],
|
||||
[0xaa, 0x00, 0xaa],
|
||||
[0xaa, 0x55, 0x00],
|
||||
[0xaa, 0xaa, 0xaa],
|
||||
[0x55, 0x55, 0x55],
|
||||
[0x55, 0x55, 0xff],
|
||||
[0x55, 0xff, 0x55],
|
||||
[0x55, 0xff, 0xff],
|
||||
[0xff, 0x55, 0x55],
|
||||
[0xff, 0x55, 0xff],
|
||||
[0xff, 0xff, 0x55],
|
||||
[0xff, 0xff, 0xff],
|
||||
];
|
||||
|
||||
/// Does `bytes` start with a PCX header? The manufacturer byte is `0x0A`, the version 0, 2, 3, 4
|
||||
/// or 5, the encoding 1 (run-length) and the bits per pixel of each plane 1, 2, 4 or 8.
|
||||
pub(super) fn is_pcx(bytes: &[u8]) -> bool {
|
||||
matches!(bytes, [0x0a, 0 | 2 | 3 | 4 | 5, 1, 1 | 2 | 4 | 8, ..])
|
||||
}
|
||||
|
||||
/// The resolution in the header's HDpi and VDpi fields; `None` when either is 0 or the header is
|
||||
/// cut short.
|
||||
pub(super) fn resolution(bytes: &[u8]) -> Option<(f64, f64)> {
|
||||
let le = |at: usize| Some(u16::from_le_bytes([*bytes.get(at)?, *bytes.get(at + 1)?]));
|
||||
let (x, y) = (le(12)?, le(14)?);
|
||||
(x > 0 && y > 0).then(|| (f64::from(x), f64::from(y)))
|
||||
}
|
||||
|
||||
/// The pixels of PCX file `bytes`, which [`is_pcx`] accepts, refused when they or the decoded
|
||||
/// scan lines take more than `max_alloc` bytes (the pixels counted as RGBA) before anything is
|
||||
/// decoded.
|
||||
pub(super) fn decode(bytes: &[u8], max_alloc: u64) -> Result<image::RgbaImage, String> {
|
||||
let head = bytes.get(..HEADER).ok_or(TRUNCATED)?;
|
||||
let le = |at: usize| usize::from(u16::from_le_bytes([head[at], head[at + 1]]));
|
||||
let (version, bits, planes) = (head[1], head[3], head[65]);
|
||||
let (x_min, y_min, x_max, y_max) = (le(4), le(6), le(8), le(10));
|
||||
let line = le(66);
|
||||
if !matches!((bits, planes), (1, 1..=4) | (2 | 4, 1) | (8, 1 | 3)) {
|
||||
return Err(format!("PCX files with {bits}-bit pixels in {planes} planes can't be read"));
|
||||
}
|
||||
if x_max < x_min || y_max < y_min {
|
||||
return Err(DAMAGED.into());
|
||||
}
|
||||
let (w, h) = (x_max - x_min + 1, y_max - y_min + 1);
|
||||
// Each plane's line holds the row's pixels, padded to an even number of bytes.
|
||||
if line * 8 < w * usize::from(bits) {
|
||||
return Err(DAMAGED.into());
|
||||
}
|
||||
let scan = line * usize::from(planes);
|
||||
let rgba = w as u64 * h as u64 * 4;
|
||||
if rgba > max_alloc || scan as u64 * h as u64 > max_alloc {
|
||||
return Err(format!("an image of {w} × {h} pixels is too large to draw"));
|
||||
}
|
||||
// An 8-bit image's 256-color palette is its last 769 bytes, `0x0C` and then the colors; the
|
||||
// image data ends before it. A `0x0C` there is image data instead when the image data runs to
|
||||
// the end of the file.
|
||||
let candidate = bytes.len().checked_sub(769).filter(|at| *at >= HEADER && (bits, planes) == (8, 1) && bytes.get(*at) == Some(&0x0c));
|
||||
let body = bytes.get(HEADER..).unwrap_or_default();
|
||||
let (tail, decoded) = match candidate {
|
||||
Some(at) => match unpack(body, scan * h) {
|
||||
Ok((data, used)) if used == body.len() => (None, Ok((data, used))),
|
||||
_ => (Some(at), unpack(bytes.get(HEADER..at).unwrap_or_default(), scan * h)),
|
||||
},
|
||||
None => (None, unpack(body, scan * h)),
|
||||
};
|
||||
let (data, used) = decoded?;
|
||||
// A palette marker right after the image data with fewer than 768 colors after it is a palette
|
||||
// cut short.
|
||||
let after = bytes.get(HEADER + used..).unwrap_or_default();
|
||||
if tail.is_none() && (bits, planes) == (8, 1) && after.first() == Some(&0x0c) && after.len() < 769 {
|
||||
return Err(TRUNCATED.into());
|
||||
}
|
||||
let palette: Vec<[u8; 3]> = match (bits, planes) {
|
||||
(8, 1) => match tail.and_then(|at| bytes.get(at + 1..)) {
|
||||
Some(colors) => colors.as_chunks::<3>().0.to_vec(),
|
||||
None => (0..=255).map(|v| [v; 3]).collect(),
|
||||
},
|
||||
// A 1-bit image takes the header palette's first two colors; versions without a palette,
|
||||
// and headers whose two colors are the same (left unset), give black and white.
|
||||
(1, 1) if matches!(version, 0 | 3) || head[16..19] == head[19..22] => vec![[0; 3], [255; 3]],
|
||||
// A 2-bit image is a CGA picture, whose header holds CGA palette settings instead of colors.
|
||||
(2, 1) => cga(head[16], head[19]),
|
||||
_ if matches!(version, 0 | 3) => EGA.to_vec(),
|
||||
_ => head[16..64].as_chunks::<3>().0.to_vec(),
|
||||
};
|
||||
let mut img = image::RgbaImage::new(w as u32, h as u32);
|
||||
for (y, row) in data.chunks_exact(scan).enumerate() {
|
||||
let plane = |p: usize| row.get(p * line..(p + 1) * line).unwrap_or_default();
|
||||
for x in 0..w {
|
||||
let rgb = if bits == 8 && planes == 3 {
|
||||
[0, 1, 2].map(|p| plane(p).get(x).copied().unwrap_or(0))
|
||||
} else {
|
||||
// Plane p gives bits p × bits and up of the color index.
|
||||
let index = (0..usize::from(planes)).fold(0, |index, p| {
|
||||
let at = x * usize::from(bits);
|
||||
let byte = plane(p).get(at / 8).copied().unwrap_or(0);
|
||||
let value = (byte >> (8 - usize::from(bits) - at % 8)) & (0xff >> (8 - bits));
|
||||
index | usize::from(value) << (p * usize::from(bits))
|
||||
});
|
||||
palette.get(index).copied().unwrap_or([0; 3])
|
||||
};
|
||||
img.put_pixel(x as u32, y as u32, image::Rgba([rgb[0], rgb[1], rgb[2], 255]));
|
||||
}
|
||||
}
|
||||
Ok(img)
|
||||
}
|
||||
|
||||
/// The four colors of a CGA picture from header bytes 16 and 19. The top four bits of `background`
|
||||
/// are the EGA color of index 0. The top three bits of `settings` are the color burst, palette and
|
||||
/// intensity bits (C, P, I), which pick the other three: with color burst on (C = 0), P = 0 gives
|
||||
/// green, red and brown and P = 1 gives cyan, magenta and light gray; with it off (C = 1) they are
|
||||
/// cyan, red and light gray. I = 1 gives the bright version of each (brown becomes yellow).
|
||||
fn cga(background: u8, settings: u8) -> Vec<[u8; 3]> {
|
||||
let foreground: [usize; 3] = match settings >> 6 {
|
||||
0 => [2, 4, 6],
|
||||
1 => [3, 5, 7],
|
||||
_ => [3, 4, 7],
|
||||
};
|
||||
let bright = if settings & 0x20 != 0 { 8 } else { 0 };
|
||||
let ega = |i: usize| EGA.get(i).copied().unwrap_or([0; 3]);
|
||||
let mut colors = vec![ega(usize::from(background >> 4))];
|
||||
colors.extend(foreground.map(|i| ega(i + bright)));
|
||||
colors
|
||||
}
|
||||
|
||||
/// `len` bytes of run-length encoded image data: a byte with its top two bits set repeats the
|
||||
/// next byte as many times as its other six bits count, and any other byte stands for itself.
|
||||
/// Runs may cross the end of a scan line. Also returns how many bytes of `data` were read.
|
||||
fn unpack(data: &[u8], len: usize) -> Result<(Vec<u8>, usize), String> {
|
||||
let mut out = Vec::with_capacity(len);
|
||||
let mut bytes = data.iter();
|
||||
while out.len() < len {
|
||||
let &b = bytes.next().ok_or(TRUNCATED)?;
|
||||
if b & 0xc0 == 0xc0 {
|
||||
let &v = bytes.next().ok_or(TRUNCATED)?;
|
||||
let n = usize::from(b & 0x3f).min(len - out.len());
|
||||
out.resize(out.len() + n, v);
|
||||
} else {
|
||||
out.push(b);
|
||||
}
|
||||
}
|
||||
Ok((out, data.len() - bytes.len()))
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Image resolution metadata (pixels per inch): PNG `pHYs`, JPEG JFIF density or EXIF, TIFF and
|
||||
//! WebP EXIF resolution tags, BMP pixels per metre, a Photoshop document's ResolutionInfo. Place
|
||||
//! WebP EXIF resolution tags, BMP pixels per metre, a Photoshop document's ResolutionInfo, a PCX
|
||||
//! header's HDpi and VDpi. Place
|
||||
//! sizes an image by it (72 ppi when the file has none); images converted to PNG on import keep it
|
||||
//! as a `pHYs` chunk.
|
||||
|
||||
@@ -21,6 +22,8 @@ pub fn resolution(bytes: &[u8]) -> Option<(f64, f64)> {
|
||||
bmp(bytes)
|
||||
} else if super::psdread::is_psd(bytes) {
|
||||
super::psdread::resolution(bytes)
|
||||
} else if super::pcxread::is_pcx(bytes) {
|
||||
super::pcxread::resolution(bytes)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
//! Reading PCX files: every depth and plane layout decodes to the pixels it stores, the files open,
|
||||
//! place and paste at the resolution their header gives, and damaged files are refused with a
|
||||
//! message.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use super::pcxread::{decode, is_pcx};
|
||||
use super::*;
|
||||
|
||||
/// A PCX file of `version`, `bits` per pixel in `planes` planes, `w` × `h` pixels from (`x0`, 0),
|
||||
/// `line` bytes per plane line, `dpi` both ways, the header palette `colors` (up to 16 entries),
|
||||
/// then the encoded image data `data`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn pcx(version: u8, bits: u8, planes: u8, x0: u16, (w, h): (u16, u16), line: u16, dpi: u16, colors: &[[u8; 3]], data: &[u8]) -> Vec<u8> {
|
||||
let mut f = vec![0x0a, version, 1, bits];
|
||||
for v in [x0, 0, x0.wrapping_add(w).wrapping_sub(1), h.wrapping_sub(1), dpi, dpi] {
|
||||
f.extend(v.to_le_bytes());
|
||||
}
|
||||
let mut palette = [0; 48];
|
||||
for (i, c) in colors.iter().take(16).enumerate() {
|
||||
palette[i * 3..i * 3 + 3].copy_from_slice(c);
|
||||
}
|
||||
f.extend(palette);
|
||||
f.extend([0, planes]);
|
||||
f.extend(line.to_le_bytes());
|
||||
f.extend(1u16.to_le_bytes());
|
||||
f.resize(128, 0);
|
||||
f.extend(data);
|
||||
f
|
||||
}
|
||||
|
||||
/// `raw` run-length encoded as the manual describes: runs of up to 63 bytes, and single bytes with
|
||||
/// their top two bits set written as runs of one.
|
||||
fn rle(raw: &[u8]) -> Vec<u8> {
|
||||
let mut out = vec![];
|
||||
let mut i = 0;
|
||||
while i < raw.len() {
|
||||
let n = raw[i..].iter().take(63).take_while(|b| **b == raw[i]).count();
|
||||
if n > 1 || raw[i] >= 0xc0 {
|
||||
out.extend([0xc0 | n as u8, raw[i]]);
|
||||
} else {
|
||||
out.push(raw[i]);
|
||||
}
|
||||
i += n;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The pixels `raster_image` stores for `f`, row by row.
|
||||
fn pixels(f: &[u8]) -> Vec<[u8; 4]> {
|
||||
let img = raster_image(f).unwrap();
|
||||
image::load_from_memory(&img.blob.bytes).unwrap().to_rgba8().pixels().map(|p| p.0).collect()
|
||||
}
|
||||
|
||||
/// The 16 colors of a header palette: entry `i` is (16 i, 255 − 16 i, i).
|
||||
fn header_colors() -> Vec<[u8; 3]> {
|
||||
(0..16u8).map(|i| [i * 16, 255 - i * 16, i]).collect()
|
||||
}
|
||||
|
||||
fn opaque([r, g, b]: [u8; 3]) -> [u8; 4] {
|
||||
[r, g, b, 255]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_depth_and_plane_layout_reads() {
|
||||
let colors = header_colors();
|
||||
let (white, black) = ([255; 4], [0, 0, 0, 255]);
|
||||
|
||||
// 1 bit, three pixels in a two-byte line: 101 and 010, in the header palette's first two colors
|
||||
// (here inverted: white, then black).
|
||||
let bits = rle(&[0b1010_0000, 0, 0b0100_0000, 0]);
|
||||
let f = pcx(5, 1, 1, 0, (3, 2), 2, 0, &[[255; 3], [0; 3]], &bits);
|
||||
assert_eq!(pixels(&f), [black, white, black, white, black, white]);
|
||||
let f = pcx(5, 1, 1, 0, (3, 2), 2, 0, &colors, &bits);
|
||||
let (zero, one) = (opaque(colors[0]), opaque(colors[1]));
|
||||
assert_eq!(pixels(&f), [one, zero, one, zero, one, zero]);
|
||||
// Black and white when the palette is left unset or the version has none.
|
||||
for (version, palette) in [(5, &[][..]), (3, &colors[..])] {
|
||||
let f = pcx(version, 1, 1, 0, (3, 2), 2, 0, palette, &bits);
|
||||
assert_eq!(pixels(&f), [white, black, white, black, white, black], "version {version}");
|
||||
}
|
||||
|
||||
// 1 bit in four planes: plane p gives bit p of the index; indices 5 and 10.
|
||||
let planes = rle(&[0x80, 0, 0x40, 0, 0x80, 0, 0x40, 0]);
|
||||
let f = pcx(5, 1, 4, 0, (2, 1), 2, 0, &colors, &planes);
|
||||
assert_eq!(pixels(&f), [opaque(colors[5]), opaque(colors[10])], "the header palette");
|
||||
// Versions 0 and 3 have no palette in their header: the EGA colors.
|
||||
for version in [0, 3] {
|
||||
let f = pcx(version, 1, 4, 0, (2, 1), 2, 0, &colors, &planes);
|
||||
assert_eq!(pixels(&f), [[0xaa, 0, 0xaa, 255], [0x55, 0xff, 0x55, 255]], "version {version}");
|
||||
}
|
||||
// Two planes give four colors, three give eight: index 3 and 6 (planes 1 and 2).
|
||||
let f = pcx(5, 1, 3, 0, (2, 1), 2, 0, &colors, &rle(&[0x80, 0, 0xc0, 0, 0x40, 0]));
|
||||
assert_eq!(pixels(&f), [opaque(colors[3]), opaque(colors[6])]);
|
||||
|
||||
// 2 bits: indices 0 to 3 in one byte, the first pixel in the top bits, in the CGA colors that
|
||||
// header bytes 16 (the background, in its top four bits) and 19 (C, P and I) pick.
|
||||
let cga = |background: u8, settings: u8| {
|
||||
let f = pcx(5, 2, 1, 0, (4, 1), 2, 0, &[[background, 0, 0], [settings, 0, 0]], &rle(&[0b0001_1011, 0]));
|
||||
pixels(&f)
|
||||
};
|
||||
let (blue, cyan, magenta, light_gray) = ([0, 0, 0xaa, 255], [0, 0xaa, 0xaa, 255], [0xaa, 0, 0xaa, 255], [0xaa; 3]);
|
||||
assert_eq!(cga(0x10, 0b0100_0000), [blue, cyan, magenta, opaque(light_gray)], "P = 1, dim");
|
||||
let (light_green, light_red, yellow) = ([0x55, 0xff, 0x55, 255], [0xff, 0x55, 0x55, 255], [0xff, 0xff, 0x55, 255]);
|
||||
assert_eq!(cga(0x0f, 0b0011_1111), [black, light_green, light_red, yellow], "P = 0, bright; low bits ignored");
|
||||
assert_eq!(cga(0xf0, 0b1010_0000), [white, [0x55, 0xff, 0xff, 255], light_red, white], "C = 1, bright");
|
||||
assert_eq!(cga(0, 0)[1..], [[0, 0xaa, 0, 255], [0xaa, 0, 0, 255], [0xaa, 0x55, 0, 255]], "P = 0, dim");
|
||||
|
||||
// 4 bits: indices 15, 1 and 7.
|
||||
let f = pcx(5, 4, 1, 0, (3, 1), 2, 0, &colors, &rle(&[0xf1, 0x70]));
|
||||
assert_eq!(pixels(&f), [opaque(colors[15]), opaque(colors[1]), opaque(colors[7])]);
|
||||
|
||||
// 8 bits with the 256-color palette after the image data, from a window that starts at x 10:
|
||||
// entry i is (i, 255 − i, 7).
|
||||
let mut f = pcx(5, 8, 1, 10, (2, 2), 2, 0, &[], &rle(&[0, 1, 254, 255]));
|
||||
f.push(0x0c);
|
||||
f.extend((0..=255u8).flat_map(|i| [i, 255 - i, 7]));
|
||||
assert_eq!(pixels(&f), [[0, 255, 7, 255], [1, 254, 7, 255], [254, 1, 7, 255], [255, 0, 7, 255]]);
|
||||
// Without it, the index is a gray level.
|
||||
let f = pcx(5, 8, 1, 0, (2, 2), 2, 0, &[], &rle(&[0, 1, 254, 255]));
|
||||
assert_eq!(pixels(&f), [[0, 0, 0, 255], [1, 1, 1, 255], [254, 254, 254, 255], [255; 4]]);
|
||||
// A gray level 12 769 bytes before the end of image data that runs to the end of the file.
|
||||
let mut levels = vec![1; 1000];
|
||||
levels[231] = 0x0c;
|
||||
let f = pcx(5, 8, 1, 0, (1000, 1), 1000, 0, &[], &levels);
|
||||
assert_eq!(f.len() - 769, 128 + 231);
|
||||
let read = pixels(&f);
|
||||
assert_eq!((read.len(), read[231], read[232]), (1000, [12, 12, 12, 255], [1, 1, 1, 255]));
|
||||
// A run may cross the end of a scan line.
|
||||
let f = pcx(5, 8, 1, 0, (2, 2), 2, 0, &[], &[0xc4, 9]);
|
||||
assert_eq!(pixels(&f), [[9, 9, 9, 255]; 4]);
|
||||
|
||||
// 24 bits: each line holds its red, green and blue planes in turn; the padding byte is skipped.
|
||||
let f = pcx(5, 8, 3, 0, (1, 2), 2, 0, &[], &rle(&[10, 0, 30, 0, 50, 0, 20, 0, 40, 0, 60, 0]));
|
||||
assert_eq!(pixels(&f), [[10, 30, 50, 255], [20, 40, 60, 255]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pcx_files_open_place_and_paste_at_their_resolution() {
|
||||
use crate::cmd::clipboard::{PASTE_ORDER, file_flavour};
|
||||
let b64 = vectorcraft_format::base64_encode;
|
||||
let f = pcx(5, 8, 3, 0, (2, 1), 2, 144, &[], &rle(&[10, 20, 30, 40, 50, 60]));
|
||||
assert_eq!(ppi::resolution(&f), Some((144.0, 144.0)));
|
||||
assert_eq!(ppi::resolution(&pcx(5, 8, 3, 0, (2, 1), 2, 0, &[], &[])), None, "no resolution");
|
||||
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("document.open", &json!({"name": "art.pcx", "dataBase64": b64(&f)})).unwrap();
|
||||
assert_eq!((&r["format"], &r["warnings"]), (&json!("pcx"), &json!([])), "{r}");
|
||||
assert_eq!(s.doc().unwrap().doc.artboards[0].rect.size(), vectorcraft_geom::Size::new(1.0, 0.5), "2 × 1 px at 144 ppi");
|
||||
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
let r = s.execute("file.place", &json!({"name": "ART.PCX", "dataBase64": b64(&f)})).unwrap();
|
||||
assert_eq!((&r["format"], r["ids"].as_array().map(Vec::len)), (&json!("pcx"), Some(1)), "{r}");
|
||||
|
||||
// Copied in a file manager, a PCX file pastes as a bitmap.
|
||||
assert_eq!(file_flavour("art.pcx", &f, &PASTE_ORDER), Some("image/x-pcx"));
|
||||
let r = s.execute("clipboard.importImage", &json!({"mime": "image/x-pcx", "dataBase64": b64(&f)})).unwrap();
|
||||
assert_eq!((&r["count"], &r["pixelWidth"], &r["pixelHeight"]), (&json!(1), &json!(2), &json!(1)), "{r}");
|
||||
// Glass's Load Texture… reads it too.
|
||||
assert!(texture_image(&f).is_ok());
|
||||
|
||||
// The dialogs list PCX; the desktop packaging doesn't associate it; nothing writes it.
|
||||
assert!(OPEN_EXTS.contains(&"pcx") && PLACE_EXTS.contains(&"pcx") && UNASSOCIATED_EXTS.contains(&"pcx"));
|
||||
let filter = ("PCX", &["pcx"][..]);
|
||||
assert!(open_filters().any(|f| f == filter) && place_filters().any(|f| f == filter));
|
||||
let r = s.execute("document.formats", &json!({})).unwrap();
|
||||
let pcx_format = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "pcx").unwrap().clone();
|
||||
assert_eq!((pcx_format["read"].as_bool(), pcx_format["write"].as_bool()), (Some(true), Some(false)));
|
||||
assert_eq!(format_for_name("a.pcx").map(|f| f.id), Some("pcx"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_and_unsupported_files_are_refused_with_a_message() {
|
||||
let message = |f: &[u8]| raster_image(f).err().expect("refused").to_string();
|
||||
let gray = pcx(5, 8, 1, 0, (2, 2), 2, 0, &[], &[1, 2, 3, 4]);
|
||||
|
||||
// A header or image data cut short.
|
||||
assert_eq!(message(&gray[..60]), "the PCX file is cut short");
|
||||
assert_eq!(message(&gray[..131]), "the PCX file is cut short");
|
||||
assert_eq!(message(&pcx(5, 8, 1, 0, (2, 2), 2, 0, &[], &[1, 0xc2])), "the PCX file is cut short", "a run without its byte");
|
||||
// The palette after the image data isn't read as pixels the image data lacks.
|
||||
let mut short = pcx(5, 8, 1, 0, (2, 2), 2, 0, &[], &[1, 2, 3]);
|
||||
short.push(0x0c);
|
||||
short.extend([7; 768]);
|
||||
assert_eq!(message(&short), "the PCX file is cut short", "three pixels and a palette");
|
||||
// A palette marker after the image data with fewer than 768 colors after it.
|
||||
let mut cut_palette = gray.clone();
|
||||
cut_palette.push(0x0c);
|
||||
cut_palette.extend([7; 767]);
|
||||
assert_eq!(message(&cut_palette), "the PCX file is cut short", "a palette cut short");
|
||||
assert_eq!(message(&[&gray[..], &[0x0c]].concat()), "the PCX file is cut short", "a marker alone");
|
||||
let mut s = Session::new();
|
||||
let e = s.execute("document.open", &json!({"name": "a.pcx", "dataBase64": vectorcraft_format::base64_encode(&gray[..131])}));
|
||||
assert!(e.expect_err("refused").to_string().contains("the PCX file is cut short"));
|
||||
|
||||
// Planes and depths the manual doesn't define.
|
||||
assert_eq!(message(&pcx(5, 8, 2, 0, (2, 2), 2, 0, &[], &[])), "PCX files with 8-bit pixels in 2 planes can't be read");
|
||||
assert_eq!(message(&pcx(5, 4, 3, 0, (2, 2), 2, 0, &[], &[])), "PCX files with 4-bit pixels in 3 planes can't be read");
|
||||
assert_eq!(message(&pcx(5, 1, 5, 0, (2, 2), 2, 0, &[], &[])), "PCX files with 1-bit pixels in 5 planes can't be read");
|
||||
|
||||
// A window that ends before it starts, and lines too short for the width.
|
||||
let mut backwards = gray.clone();
|
||||
backwards[8..10].copy_from_slice(&0u16.to_le_bytes());
|
||||
backwards[4..6].copy_from_slice(&5u16.to_le_bytes());
|
||||
assert_eq!(message(&backwards), "the PCX file's header is damaged");
|
||||
assert_eq!(message(&pcx(5, 8, 1, 0, (3, 1), 2, 0, &[], &[1, 2, 3, 4])), "the PCX file's header is damaged");
|
||||
assert_eq!(message(&pcx(5, 1, 1, 0, (17, 1), 2, 0, &[], &[0; 4])), "the PCX file's header is damaged");
|
||||
|
||||
// Past the allowance, from the header: the pixels as RGBA, and the scan lines of a narrow image
|
||||
// with long lines.
|
||||
let huge = pcx(5, 1, 1, 0, (0, 0), 8192, 0, &[], &[]);
|
||||
assert_eq!(message(&huge), "an image of 65536 × 65536 pixels is too large to draw");
|
||||
let long = pcx(5, 8, 3, 0, (1, 0), 65535, 0, &[], &[]);
|
||||
assert_eq!(message(&long), "an image of 1 × 65536 pixels is too large to draw");
|
||||
assert!(decode(&long, 1 << 20).is_err());
|
||||
|
||||
// Headers with values the manual doesn't define aren't PCX files.
|
||||
assert!(is_pcx(&gray));
|
||||
for bad in [[0x0a, 1, 1, 8], [0x0a, 6, 1, 8], [0x0a, 5, 0, 8], [0x0a, 5, 1, 3], [0x0b, 5, 1, 8]] {
|
||||
assert!(!is_pcx(&bad), "{bad:?}");
|
||||
}
|
||||
}
|
||||
@@ -183,7 +183,7 @@ fn ids_json(ids: &[NodeId]) -> Value {
|
||||
/// The whole number `p` gives for `key`, written `4` or `4.0`, or `default` when `p` gives none or null. A value that
|
||||
/// is not a number gives the [`check_numbers`] error, and a fraction or a negative number gives
|
||||
/// "`{key}` must be a whole number".
|
||||
fn whole_or(p: &Value, key: &str, default: u64, cmd: &str) -> Result<u64> {
|
||||
pub(crate) fn whole_or(p: &Value, key: &str, default: u64, cmd: &str) -> Result<u64> {
|
||||
check_numbers(p, &[key], cmd)?;
|
||||
let Some(v) = p.get(key).filter(|v| !v.is_null()) else { return Ok(default) };
|
||||
match (v.as_u64(), v.as_f64()) {
|
||||
|
||||
@@ -19,7 +19,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Fill",
|
||||
[],
|
||||
None,
|
||||
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, method?: classic|perceptual (as paint.editGradient; default classic), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), dither?: bool (linear and radial fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear or radial `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}",
|
||||
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, method?: classic|perceptual (as paint.editGradient; default classic), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), dither?: bool (linear and radial fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear or radial `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white) → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}",
|
||||
has_doc,
|
||||
|s, p| set_paint(s, p, true)
|
||||
),
|
||||
@@ -465,6 +465,12 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool)
|
||||
// The gradient mesh point last clicked with the Mesh tool or Direct Selection: a solid fill
|
||||
// recolours just that point of its mesh.
|
||||
let mesh_point = if fill && matches!(paint, Paint::Solid { .. }) { focused_mesh_point(s) } else { None };
|
||||
// Without `item`, unless the Appearance panel's active fill item takes the paint.
|
||||
let colorized = if fill && p.get("item").is_none() && item == ItemTarget::Top {
|
||||
colorized_images(&s.doc()?.doc, &ids, &paint).map_err(|e| bad(cmd, e))?
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
let label = if fill { "Fill Color" } else { "Stroke Color" };
|
||||
edit_items_then(
|
||||
s,
|
||||
@@ -474,6 +480,10 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool)
|
||||
label,
|
||||
fill,
|
||||
|n, index| {
|
||||
if index.is_none() && colorized.contains(&n.id) {
|
||||
n.extras_mut().colorize = paint.color().map(|_| Box::new(paint.clone()));
|
||||
return Ok(());
|
||||
}
|
||||
if index.is_none()
|
||||
&& let Paint::Solid { color, .. } = &paint
|
||||
&& let Some((_, i)) = mesh_point.filter(|(id, _)| *id == n.id)
|
||||
@@ -594,6 +604,91 @@ fn placed_across(d: &Document, ids: &[NodeId], paint: Paint, p: &Value, fill: bo
|
||||
}
|
||||
}
|
||||
|
||||
/// The images among `ids` that a fill of `paint` colorizes: grayscale and 1-bit images, for a solid
|
||||
/// color (or none on a colorized image, which takes the color off). The color may be black, white,
|
||||
/// a process color or a spot color; on an image with an alpha channel, only a spot color, black or
|
||||
/// white, and other colors are an error.
|
||||
fn colorized_images(d: &Document, ids: &[NodeId], paint: &Paint) -> std::result::Result<Vec<NodeId>, String> {
|
||||
let mut out = vec![];
|
||||
for id in ids {
|
||||
let Some(n) = d.node(*id) else { continue };
|
||||
let NodeKind::Image(im) = &n.kind else { continue };
|
||||
// `none` takes a color off whatever the image's pixels have become since.
|
||||
if matches!(paint, Paint::None) && n.extras().colorize.is_some() {
|
||||
out.push(*id);
|
||||
continue;
|
||||
}
|
||||
let Some(gray) = d.image_blob(im).and_then(|b| b.gray_source()) else { continue };
|
||||
match paint {
|
||||
// Without a color, `none` goes to the image's fill item.
|
||||
Paint::None => continue,
|
||||
Paint::Solid { .. } => {
|
||||
if gray.alpha && !colorizes_transparency(d, paint) {
|
||||
return Err(PROCESS_ON_TRANSPARENCY.into());
|
||||
}
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
out.push(*id);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The message for a process color on a grayscale image with an alpha channel.
|
||||
const PROCESS_ON_TRANSPARENCY: &str = "a process color can't colorize a grayscale image with transparency: use a spot color, black or white";
|
||||
|
||||
/// Can `paint` colorize a grayscale image with an alpha channel: a color linked to a spot swatch
|
||||
/// of `d`, black or white?
|
||||
fn colorizes_transparency(d: &Document, paint: &Paint) -> bool {
|
||||
match paint {
|
||||
Paint::Solid { color, swatch, .. } => swatch.as_deref().and_then(|name| d.swatch(name)).is_some_and(|w| w.spot) || black_or_white(color),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// An error when an image with an alpha channel in `d` (its art, opacity masks, symbols, graph
|
||||
/// designs and pattern tiles) is colorized with a color that [`colorizes_transparency`] refuses,
|
||||
/// as a swatch edit that turns a spot color into a process color or recolors a process swatch
|
||||
/// can leave one.
|
||||
pub(super) fn check_colorized(d: &Document) -> std::result::Result<(), String> {
|
||||
let mut refused = false;
|
||||
let mut visit = |n: &Node| {
|
||||
vectorcraft_doc::swatches::walk_with_masks(n, &mut |m| {
|
||||
if let (NodeKind::Image(im), Some(paint)) = (&m.kind, m.extras().colorize.as_deref())
|
||||
&& !refused
|
||||
&& !colorizes_transparency(d, paint)
|
||||
&& d.image_blob(im).and_then(|b| b.gray_source()).is_some_and(|g| g.alpha)
|
||||
{
|
||||
refused = true;
|
||||
}
|
||||
})
|
||||
};
|
||||
d.layers
|
||||
.iter()
|
||||
.chain(d.symbols.iter().map(|s| &s.art))
|
||||
.chain(d.graph_designs.iter().map(|g| &g.art))
|
||||
.chain(d.patterns.iter().flat_map(|p| &p.art))
|
||||
.for_each(|n| visit(n));
|
||||
if refused { Err(PROCESS_ON_TRANSPARENCY.into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// [`check_colorized`] after an edit, when the document passed it before the edit (`was_ok`), so
|
||||
/// an edit doesn't fail over an image it didn't change.
|
||||
pub(super) fn check_colorized_kept(was_ok: bool, d: &Document) -> std::result::Result<(), String> {
|
||||
if was_ok { check_colorized(d) } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// Is `c` black or white (white, or black alone at 100%)?
|
||||
fn black_or_white(c: &Color) -> bool {
|
||||
let (none, full) = (|v: f32| v <= 0.0, |v: f32| v >= 1.0);
|
||||
match *c {
|
||||
Color::Gray { k } => none(k) || full(k),
|
||||
Color::Cmyk { c, m, y, k } => none(c) && none(m) && none(y) && (none(k) || full(k)),
|
||||
Color::Rgb { r, g, b } => [r, g, b].into_iter().all(none) || [r, g, b].into_iter().all(full),
|
||||
Color::Lab { .. } => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The mesh point the active tool has focused (its `meshPoint` option: the Mesh tool's or Direct
|
||||
/// Selection's point last clicked).
|
||||
fn focused_mesh_point(s: &Session) -> Option<(NodeId, usize)> {
|
||||
|
||||
@@ -226,7 +226,7 @@ fn ids_json(ids: &[NodeId]) -> Value {
|
||||
|
||||
/// Paint identity carried through a Pathfinder operation.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct Style {
|
||||
pub(crate) struct Style {
|
||||
appearance: Appearance,
|
||||
opacity: f32,
|
||||
blend: BlendMode,
|
||||
@@ -236,7 +236,7 @@ impl Style {
|
||||
fn of(n: &Node) -> Self {
|
||||
Style { appearance: (*n.appearance).clone(), opacity: n.opacity, blend: n.blend }
|
||||
}
|
||||
fn apply(&self, n: &mut Node) {
|
||||
pub(crate) fn apply(&self, n: &mut Node) {
|
||||
n.appearance = self.appearance.clone().into();
|
||||
n.opacity = self.opacity;
|
||||
n.blend = self.blend;
|
||||
@@ -267,7 +267,7 @@ pub(crate) fn text_outline(t: &TextObject) -> PathData {
|
||||
PathData::from_bezpath(&lay.to_bezpath()).transformed(t.xf)
|
||||
}
|
||||
|
||||
fn style_of(n: &Node) -> Style {
|
||||
pub(crate) fn style_of(n: &Node) -> Style {
|
||||
match &n.kind {
|
||||
NodeKind::Text(t) => Style { appearance: t.first_style().appearance(), opacity: n.opacity, blend: n.blend },
|
||||
_ => Style::of(n),
|
||||
@@ -294,6 +294,20 @@ fn each_leaf(n: &Node, f: &mut impl FnMut(&Node)) {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`leaves`] as a Pathfinder operation takes them: a compound shape is one shape, the area it
|
||||
/// draws painted as it is (its members as they are past a live Pathfinder's budget).
|
||||
pub(crate) fn pf_leaves(n: &Node, out: &mut Vec<Node>) {
|
||||
match &n.kind {
|
||||
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => {
|
||||
match vectorcraft_render::effects::compound_shape_art(n, vectorcraft_render::effects::text_outliner()) {
|
||||
Some(art) => out.push(art),
|
||||
None => children.iter().for_each(|c| pf_leaves(c, out)),
|
||||
}
|
||||
}
|
||||
_ => leaves(n, out),
|
||||
}
|
||||
}
|
||||
|
||||
/// How many shapes a Pathfinder operation takes from `n` selected alone: its leaf shapes, however
|
||||
/// deep its groups nest (an expanded stroke is a group inside the group, #1635).
|
||||
pub fn shape_count(n: &Node) -> usize {
|
||||
@@ -554,7 +568,7 @@ fn run_pf(s: &mut Session, p: &Value, op: PathfinderOp) -> Result<Value> {
|
||||
for id in &roots {
|
||||
let n = d.node(*id).ok_or(EngineError::NoNode(*id))?;
|
||||
let mut lv = vec![];
|
||||
leaves(n, &mut lv);
|
||||
pf_leaves(n, &mut lv);
|
||||
if lv.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@ use vectorcraft_doc::swatches::{node_colors, node_paints};
|
||||
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
|
||||
|
||||
use super::colorcmds::{Recolor, Scope};
|
||||
use super::{CommandSpec, Result, Session, always, bad, bool_or, checked_ids_param, cmd, color_value, has_doc, index, str_param};
|
||||
use super::{
|
||||
CommandSpec, Result, Session, always, bad, check_switches, checked_ids_param, cmd, color_value, has_doc, index, str_param, switch_or, text_param,
|
||||
};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -152,15 +154,18 @@ fn colors(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "colors": v }))
|
||||
}
|
||||
|
||||
/// The `method` parameter (`default` without one).
|
||||
/// The `method` parameter (`default` without one, or for null). A `method` that is not a string,
|
||||
/// such as `5`, names no method.
|
||||
fn method_param(p: &Value, cmd: &str, default: Method) -> Result<Method> {
|
||||
match str_param(p, "method") {
|
||||
None => Ok(default),
|
||||
Some(m) => Method::parse(m).ok_or_else(|| {
|
||||
let ids: Vec<&str> = Method::ALL.iter().map(|m| m.id()).collect();
|
||||
bad(cmd, format!("unknown method `{m}` ({})", ids.join("|")))
|
||||
}),
|
||||
}
|
||||
let (m, named) = match p.get("method") {
|
||||
None | Some(Value::Null) => return Ok(default),
|
||||
Some(Value::String(m)) => (m.clone(), true),
|
||||
Some(v) => (v.to_string(), false),
|
||||
};
|
||||
Method::parse(&m).filter(|_| named).ok_or_else(|| {
|
||||
let ids: Vec<&str> = Method::ALL.iter().map(|m| m.id()).collect();
|
||||
bad(cmd, format!("unknown method `{m}` ({})", ids.join("|")))
|
||||
})
|
||||
}
|
||||
|
||||
fn key_str(c: &Color) -> Value {
|
||||
@@ -172,8 +177,17 @@ fn reduce(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let method = method_param(p, C, Method::ScaleTints)?;
|
||||
let palette = super::swatchlib::limit_param(s, p, C)?;
|
||||
let def = Preserve::default();
|
||||
let flag = |k: &str, d: bool| p.get("preserve").map_or(d, |v| bool_or(v, k, d));
|
||||
let preserve = Preserve { white: flag("white", def.white), black: flag("black", def.black), grays: flag("grays", def.grays) };
|
||||
let given = match p.get("preserve") {
|
||||
None | Some(Value::Null) => &Value::Null,
|
||||
Some(v @ Value::Object(_)) => v,
|
||||
Some(_) => return Err(bad(C, "`preserve` must be an object")),
|
||||
};
|
||||
let flag = |k: &str, d: bool| switch_or(given, k, d, C);
|
||||
let preserve = Preserve { white: flag("white", def.white)?, black: flag("black", def.black)?, grays: flag("grays", def.grays)? };
|
||||
let colors = match p.get("colors") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(_) => Some(index(super::menucmds::whole_or(p, "colors", 0, C)?)),
|
||||
};
|
||||
let (ids, only) = scope_ids(s, p, C)?;
|
||||
let st = s.doc()?;
|
||||
let (kept, used): (Vec<Used>, Vec<Used>) = used_colors(&st.doc, &ids, only.as_ref()).into_iter().partition(|u| preserve.keeps(&u.color));
|
||||
@@ -186,7 +200,7 @@ fn reduce(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
let unit_colors = |g: &[&Used]| g.iter().map(|u| u.color).collect::<Vec<_>>();
|
||||
let rows: Vec<Vec<&Used>> = match p.get("colors").and_then(Value::as_u64).map(index) {
|
||||
let rows: Vec<Vec<&Used>> = match colors {
|
||||
Some(n) if n < units.len() => {
|
||||
let keys: Vec<Color> = units.iter().map(|g| method.key_color(&unit_colors(g)).unwrap_or_default()).collect();
|
||||
let weights: Vec<f32> = units.iter().map(|g| g.iter().map(|u| u.count as f32).sum()).collect();
|
||||
@@ -275,7 +289,7 @@ fn parse_rows(p: &Value, cmd: &str) -> Result<Vec<Row>> {
|
||||
Some(v) => vec![cur(v)?],
|
||||
None => return Err(bad(cmd, "each row needs `from`")),
|
||||
};
|
||||
Ok(Row { from, to: new(r.get("to"))?, exclude: bool_or(r, "exclude", false) })
|
||||
Ok(Row { from, to: new(r.get("to"))?, exclude: switch_or(r, "exclude", false, cmd)? })
|
||||
})
|
||||
.collect(),
|
||||
_ => Err(bad(cmd, "missing `map` (rows [{from, to}] or {current: new})")),
|
||||
@@ -419,10 +433,11 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let row_swatches: Vec<Option<Swatch>> =
|
||||
rows.iter().map(|r| r.new_color().and_then(|c| lib_swatches.iter().find(|w| w.paint.color() == Some(c)).cloned())).collect();
|
||||
let ids = s.doc()?.selection.objects.clone();
|
||||
let group = str_param(p, "group");
|
||||
let group = text_param(p, "group", C)?;
|
||||
if ids.is_empty() && group.is_none() {
|
||||
return Err(bad(C, "select artwork to recolour, or give a colour `group`"));
|
||||
}
|
||||
check_switches(p, &["includeImages", "includePatterns"], C)?;
|
||||
let scope = Scope { fill: true, stroke: true, ..Scope::of(p) };
|
||||
let label = if ids.is_empty() { "Edit Color Group" } else { "Recolor Artwork" };
|
||||
// `model`: keep each colour's model (default), take the new colour's own, or the document's.
|
||||
@@ -448,7 +463,12 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
// The colours a colour group takes follow the model too (`document` converts them).
|
||||
let in_doc_model = |c: Color| doc_model.map_or(c, |m| c.in_model(m));
|
||||
let new: Vec<Color> = match p.get("groupColors").and_then(Value::as_array) {
|
||||
let group_colors = match p.get("groupColors") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::Array(cs)) => Some(cs),
|
||||
Some(_) => return Err(bad(C, "`groupColors` must be an array")),
|
||||
};
|
||||
let new: Vec<Color> = match group_colors {
|
||||
Some(cs) => cs
|
||||
.iter()
|
||||
.map(|v| {
|
||||
@@ -459,7 +479,7 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.collect::<Result<_>>()?,
|
||||
None => rows.iter().filter_map(Row::new_color).collect(),
|
||||
};
|
||||
let rename = str_param(p, "rename");
|
||||
let rename = text_param(p, "rename", C)?;
|
||||
let (changed, edit, added, converted) = s.edit(label, |d, _| {
|
||||
// The rows' library swatches join the document (one it has with that name and colour is
|
||||
// used as it is).
|
||||
@@ -518,12 +538,13 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn randomize(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "recolor.randomize";
|
||||
let rows = parse_rows(p, C)?;
|
||||
let mut rng = Rng::new(p.get("seed").and_then(Value::as_u64).unwrap_or(0));
|
||||
let mut rng = Rng::new(super::menucmds::whole_or(p, "seed", 0, C)?);
|
||||
let (order, saturation_brightness) = (switch_or(p, "order", true, C)?, switch_or(p, "saturationBrightness", false, C)?);
|
||||
let mut news: Vec<Color> = rows.iter().filter_map(Row::new_color).collect();
|
||||
if bool_or(p, "order", true) {
|
||||
if order {
|
||||
rng.shuffle(&mut news);
|
||||
}
|
||||
if bool_or(p, "saturationBrightness", false) {
|
||||
if saturation_brightness {
|
||||
news = news.into_iter().map(|c| rng.saturation_brightness(c)).collect();
|
||||
}
|
||||
let mut news = news.into_iter();
|
||||
|
||||
@@ -14,9 +14,8 @@ use vectorcraft_doc::style_libs::{self, STYLE_LIBRARIES, STYLES_EXT, builtin_sty
|
||||
use vectorcraft_doc::{Document, GraphicStyle, PatternDef, StyleLibrary};
|
||||
|
||||
use super::menucmds::squash;
|
||||
use super::swatch::str_list;
|
||||
use super::swatchlib::{FileData, LibraryFile, LibraryInfo, stem};
|
||||
use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, str_param, unique_name};
|
||||
use super::swatchlib::{FileData, LibraryFile, LibraryInfo, names_param, stem};
|
||||
use super::{CommandSpec, Result, Session, always, bad, checked_ids_param, cmd, has_doc, str_param, switch_or, text_param, unique_name};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -154,8 +153,12 @@ fn adopt(d: &mut Document, lib: &StyleLibrary, g: &GraphicStyle, renamed: &mut H
|
||||
fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "graphicStyle.addFromLibrary";
|
||||
let (info, lib) = library_param(s, p, C)?;
|
||||
let mut names = str_list(p, "names");
|
||||
names.extend(str_param(p, "name").map(str::to_string));
|
||||
let (apply, add) = (switch_or(p, "apply", false, C)?, switch_or(p, "add", false, C)?);
|
||||
if p.get("ids").is_some_and(|v| !v.is_null()) {
|
||||
checked_ids_param(s, p, "ids", C)?;
|
||||
}
|
||||
let mut names = names_param(p, C)?;
|
||||
names.extend(text_param(p, "name", C)?.map(str::to_string));
|
||||
if let Some(n) = names.iter().find(|n| lib.style(n).is_none()) {
|
||||
return Err(bad(C, format!("no graphic style `{n}` in `{}`", lib.name)));
|
||||
}
|
||||
@@ -192,8 +195,8 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
});
|
||||
let first = picked.first().map(|g| doc_name(&g.name));
|
||||
let targets = p.get("ids").is_some() || !super::appearance::appearance_targets(s, &json!({}))?.is_empty();
|
||||
if let Some(name) = first.filter(|_| bool_or(p, "apply", false) && targets) {
|
||||
let mut params = json!({"name": name, "add": bool_or(p, "add", false)});
|
||||
if let Some(name) = first.filter(|_| apply && targets) {
|
||||
let mut params = json!({"name": name, "add": add});
|
||||
if let Some(ids) = p.get("ids") {
|
||||
params["ids"] = ids.clone();
|
||||
}
|
||||
@@ -208,8 +211,9 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "graphicStyle.saveLibrary";
|
||||
let st = s.doc()?;
|
||||
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map_or_else(|| stem(&st.title()).to_string(), str::to_string);
|
||||
let lib = StyleLibrary::from_document(&st.doc, &str_list(p, "names"), name).map_err(|e| bad(C, e))?;
|
||||
let name = text_param(p, "name", C)?.map(str::trim).filter(|n| !n.is_empty()).map_or_else(|| stem(&st.title()).to_string(), str::to_string);
|
||||
let names = names_param(p, C)?;
|
||||
let lib = StyleLibrary::from_document(&st.doc, &names, name).map_err(|e| bad(C, e))?;
|
||||
let mut out = json!({ "count": lib.len() });
|
||||
s.style_libraries.write(p, &lib.name, STYLES_EXT, FileData::Text(style_libs::write(&lib)), &mut out, C)?;
|
||||
Ok(out)
|
||||
|
||||
@@ -252,12 +252,15 @@ fn swatch_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let mut unlink =
|
||||
|_: &mut Color, link: &mut Option<String>, _: &mut f32| link.as_ref().is_some_and(|l| global.contains(l)) && link.take().is_some();
|
||||
let colorized_ok = super::paint::check_colorized(d).is_ok();
|
||||
let unlinked = s.edit("Delete Swatch", |d, _| {
|
||||
d.swatch_groups.retain(|g| !names.contains(&g.name));
|
||||
for n in &names {
|
||||
d.remove_swatch(n);
|
||||
}
|
||||
Ok(map_swatch_links(d, &mut unlink))
|
||||
let unlinked = map_swatch_links(d, &mut unlink);
|
||||
super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(C, e))?;
|
||||
Ok(unlinked)
|
||||
})?;
|
||||
map_default_paints(s, &mut unlink);
|
||||
Ok(json!({"deleted": names, "unlinked": unlinked}))
|
||||
@@ -703,6 +706,7 @@ fn swatch_edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let global = spot || bool_or(p, "global", sw.global);
|
||||
let to = name_param(p, "newName").filter(|n| *n != name).map_or_else(|| name.clone(), |n| d.free_swatch_name(&n));
|
||||
let relink = Relink { from: name.clone(), to: to.clone(), color: color.filter(|_| global).map(|c| d.linked_color(c, spot)), keep: global };
|
||||
let colorized_ok = super::paint::check_colorized(d).is_ok();
|
||||
let relinked = s.edit("Swatch Options", |d, _| {
|
||||
let w = d.swatch_mut(&name).ok_or_else(|| bad(C, format!("no swatch `{name}`")))?;
|
||||
w.name = to.clone();
|
||||
@@ -716,7 +720,9 @@ fn swatch_edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(map_swatch_links(d, &mut |c, l, t| relink.apply(c, l, t)))
|
||||
let relinked = map_swatch_links(d, &mut |c, l, t| relink.apply(c, l, t));
|
||||
super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(C, e))?;
|
||||
Ok(relinked)
|
||||
})?;
|
||||
relink.defaults(s);
|
||||
Ok(json!({"name": to, "relinked": relinked}))
|
||||
@@ -867,11 +873,14 @@ fn swatch_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
*l = link.clone();
|
||||
true
|
||||
};
|
||||
let colorized_ok = super::paint::check_colorized(d).is_ok();
|
||||
let relinked = s.edit("Merge Swatches", |d, _| {
|
||||
for n in merged {
|
||||
d.remove_swatch(n);
|
||||
}
|
||||
Ok(map_swatch_links(d, &mut relink))
|
||||
let relinked = map_swatch_links(d, &mut relink);
|
||||
super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(C, e))?;
|
||||
Ok(relinked)
|
||||
})?;
|
||||
map_default_paints(s, &mut relink);
|
||||
Ok(json!({"name": keep, "merged": merged, "relinked": relinked}))
|
||||
@@ -980,6 +989,7 @@ pub(super) fn rewrite_group(d: &mut Document, group: &str, colors: &[Color], ren
|
||||
{
|
||||
return Err(bad(cmd, format!("`{}` can't be edited or removed", w.name)));
|
||||
}
|
||||
let colorized_ok = super::paint::check_colorized(d).is_ok();
|
||||
let mut relinks = vec![];
|
||||
let mut swatches = std::mem::take(&mut d.swatch_groups[gi].swatches);
|
||||
for gone in swatches.split_off(colors.len().min(swatches.len())) {
|
||||
@@ -1005,6 +1015,7 @@ pub(super) fn rewrite_group(d: &mut Document, group: &str, colors: &[Color], ren
|
||||
(g.name, g.swatches) = (name.clone(), swatches);
|
||||
let names = g.swatches.iter().map(|w| w.name.clone()).collect();
|
||||
let relinked = if relinks.is_empty() { 0 } else { map_swatch_links(d, &mut |c, l, t| relinks.iter().any(|r| r.apply(c, l, t))) };
|
||||
super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(cmd, e))?;
|
||||
Ok(GroupEdit { name, swatches: names, relinks, relinked })
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ use super::fileio::{create_dir, extension, read_file, write_file, write_new_file
|
||||
use super::menucmds::squash;
|
||||
use super::package::free_name;
|
||||
use super::swatch::{map_default_paints, map_swatch_links, str_list, swatch_json};
|
||||
use super::{Color, CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, str_param};
|
||||
use super::{Color, CommandSpec, Result, Session, always, bad, cmd, has_doc, switch_or, text_param};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -201,7 +201,8 @@ impl<L: LibraryFile> Libraries<L> {
|
||||
FileData::Text(text) => text.as_bytes(),
|
||||
FileData::Binary(bytes) => bytes.as_slice(),
|
||||
};
|
||||
if bool_or(p, "user", false) {
|
||||
let path = text_param(p, "path", cmd)?;
|
||||
if switch_or(p, "user", false, cmd)? {
|
||||
let dir = self
|
||||
.user_dir()
|
||||
.ok_or_else(|| bad(cmd, "no user library folder here (save with a path, or without one to get the data)"))?
|
||||
@@ -213,7 +214,7 @@ impl<L: LibraryFile> Libraries<L> {
|
||||
self.rescan();
|
||||
out["path"] = json!(path);
|
||||
out["library"] = json!(format!("user/{file}"));
|
||||
} else if let Some(path) = str_param(p, "path") {
|
||||
} else if let Some(path) = path {
|
||||
write_file(path, bytes)?;
|
||||
out["path"] = json!(path);
|
||||
} else {
|
||||
@@ -230,14 +231,14 @@ impl<L: LibraryFile> Libraries<L> {
|
||||
/// or a file with the same extension and bytes as one there, is that User Defined library;
|
||||
/// others are listed as loaded until the app quits.
|
||||
pub(crate) fn load(&mut self, p: &Value, cmd: &str, parse: impl FnOnce(&[u8], &str) -> Result<L>) -> Result<(LibraryInfo, Arc<L>)> {
|
||||
let path = str_param(p, "path");
|
||||
let bytes = match (path, str_param(p, "data"), str_param(p, "dataBase64")) {
|
||||
let path = text_param(p, "path", cmd)?;
|
||||
let bytes = match (path, text_param(p, "data", cmd)?, text_param(p, "dataBase64", cmd)?) {
|
||||
(Some(path), ..) => read_file(path)?,
|
||||
(None, Some(text), _) => text.as_bytes().to_vec(),
|
||||
(None, None, Some(b64)) => vectorcraft_format::base64_decode(b64).ok_or_else(|| bad(cmd, "bad dataBase64"))?,
|
||||
_ => return Err(bad(cmd, "give `path`, `data` or `dataBase64`")),
|
||||
};
|
||||
let file = str_param(p, "name").map(str::to_string).or_else(|| path.map(file_name)).unwrap_or_else(|| "Library".into());
|
||||
let file = text_param(p, "name", cmd)?.map(str::to_string).or_else(|| path.map(file_name)).unwrap_or_else(|| "Library".into());
|
||||
let lib = parse(&bytes, &file)?;
|
||||
self.rescan();
|
||||
let user =
|
||||
@@ -428,19 +429,36 @@ pub fn library_swatches(s: &Session, key: &str) -> Option<Vec<Swatch>> {
|
||||
Some(library(s, key)?.1.iter().filter(own).cloned().collect())
|
||||
}
|
||||
|
||||
/// The `limitTo` parameter's palette ([`limit_palette`]); `None` without one (or for "").
|
||||
/// The `limitTo` parameter's palette ([`limit_palette`]); `None` without one (or for "" or null).
|
||||
/// A `limitTo` that is not a string, such as `5`, names no library.
|
||||
pub(crate) fn limit_param(s: &Session, p: &Value, cmd: &str) -> Result<Option<Palette>> {
|
||||
let Some(key) = str_param(p, "limitTo").filter(|k| !k.is_empty()) else { return Ok(None) };
|
||||
let palette = limit_palette(s, key)
|
||||
.ok_or_else(|| bad(cmd, format!("no swatch library `{key}` (see swatch.library.list; \"{DOCUMENT_SWATCHES}\": the document's swatches)")))?;
|
||||
let no_library =
|
||||
|key: &str| bad(cmd, format!("no swatch library `{key}` (see swatch.library.list; \"{DOCUMENT_SWATCHES}\": the document's swatches)"));
|
||||
let key = match p.get("limitTo") {
|
||||
None | Some(Value::Null) => return Ok(None),
|
||||
Some(Value::String(k)) if k.is_empty() => return Ok(None),
|
||||
Some(Value::String(k)) => k.as_str(),
|
||||
Some(v) => return Err(no_library(&v.to_string())),
|
||||
};
|
||||
let palette = limit_palette(s, key).ok_or_else(|| no_library(key))?;
|
||||
if palette.is_empty() {
|
||||
return Err(bad(cmd, format!("`{key}` has no colours")));
|
||||
}
|
||||
Ok(Some(palette))
|
||||
}
|
||||
|
||||
/// The `names` parameter ([`str_list`]): empty when it is left out or null; anything but a list of
|
||||
/// strings is an error.
|
||||
pub(super) fn names_param(p: &Value, cmd: &str) -> Result<Vec<String>> {
|
||||
match p.get("names") {
|
||||
None | Some(Value::Null) => Ok(vec![]),
|
||||
Some(Value::Array(a)) if a.iter().all(Value::is_string) => Ok(str_list(p, "names")),
|
||||
Some(_) => Err(bad(cmd, "`names` must be a list of names")),
|
||||
}
|
||||
}
|
||||
|
||||
fn library_param(s: &Session, p: &Value, cmd: &str) -> Result<(LibraryInfo, Arc<SwatchLibrary>)> {
|
||||
let key = str_param(p, "library").ok_or_else(|| bad(cmd, "missing `library` (see swatch.library.list)"))?;
|
||||
let key = text_param(p, "library", cmd)?.ok_or_else(|| bad(cmd, "missing `library` (see swatch.library.list)"))?;
|
||||
library(s, key).ok_or_else(|| bad(cmd, format!("no swatch library `{key}` (see swatch.library.list)")))
|
||||
}
|
||||
|
||||
@@ -479,14 +497,15 @@ fn document_library(d: &vectorcraft_doc::Document, names: &[String], name: Strin
|
||||
|
||||
fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "swatch.library.save";
|
||||
let path = str_param(p, "path");
|
||||
let format = match str_param(p, "format").or_else(|| path.map(|p| p.rsplit_once('.').map_or("", |(_, e)| e))).filter(|f| !f.is_empty()) {
|
||||
let path = text_param(p, "path", C)?;
|
||||
let format = match text_param(p, "format", C)?.or_else(|| path.map(|p| p.rsplit_once('.').map_or("", |(_, e)| e))).filter(|f| !f.is_empty()) {
|
||||
Some(f) => PaletteFormat::parse(f).ok_or_else(|| bad(C, format!("unknown format `{f}` (vcswatches, gpl, ase or css)")))?,
|
||||
None => PaletteFormat::Native,
|
||||
};
|
||||
let names = names_param(p, C)?;
|
||||
let st = s.doc()?;
|
||||
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map_or_else(|| stem(&st.title()).to_string(), str::to_string);
|
||||
let lib = document_library(&st.doc, &str_list(p, "names"), name, C)?;
|
||||
let name = text_param(p, "name", C)?.map(str::trim).filter(|n| !n.is_empty()).map_or_else(|| stem(&st.title()).to_string(), str::to_string);
|
||||
let lib = document_library(&st.doc, &names, name, C)?;
|
||||
let bytes = palette_io::write(&lib, format).map_err(|e| bad(C, e))?;
|
||||
let data = if format.binary() { FileData::Binary(bytes) } else { FileData::Text(String::from_utf8_lossy(&bytes).into_owned()) };
|
||||
let mut out = json!({"format": format.id(), "count": lib.iter().filter(|w| format.holds(&w.paint)).count()});
|
||||
@@ -571,13 +590,14 @@ fn picked<'a>(lib: &'a SwatchLibrary, names: &[String], cmd: &str) -> Result<Vec
|
||||
fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "swatch.library.add";
|
||||
let (info, lib) = library_param(s, p, C)?;
|
||||
let apply = match str_param(p, "apply") {
|
||||
let apply = match text_param(p, "apply", C)? {
|
||||
None => None,
|
||||
Some("fill") => Some("paint.setFill"),
|
||||
Some("stroke") => Some("paint.setStroke"),
|
||||
Some(other) => return Err(bad(C, format!("apply is \"fill\" or \"stroke\", not `{other}`"))),
|
||||
};
|
||||
let picked = picked(&lib, &str_list(p, "names"), C)?;
|
||||
let focus = switch_or(p, "focus", true, C)?;
|
||||
let picked = picked(&lib, &names_param(p, C)?, C)?;
|
||||
// Swatches the document already has (same name and paint) are reused, not added again.
|
||||
let d = &s.doc()?.doc;
|
||||
let (existing, fresh): (Vec<_>, Vec<_>) = picked.iter().partition(|(_, w)| d.swatch(&w.name).is_some_and(|x| x.paint == w.paint));
|
||||
@@ -622,7 +642,7 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut out = json!({"library": info.id, "added": added.iter().map(|(_, n)| n).collect::<Vec<_>>(), "existing": existing});
|
||||
if let (Some(cmd), Some((_, first))) = (apply, picked.first()) {
|
||||
let name = added.iter().find(|(l, _)| *l == first.name).map_or(first.name.clone(), |(_, n)| n.clone());
|
||||
s.execute(cmd, &json!({"swatch": name, "focus": bool_or(p, "focus", true)}))?;
|
||||
s.execute(cmd, &json!({"swatch": name, "focus": focus}))?;
|
||||
// Adding and applying undo together.
|
||||
squash(s, undo_before, "Add to Swatches");
|
||||
out["applied"] = json!(name);
|
||||
@@ -631,12 +651,13 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn reset_defaults(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let replace = bool_or(p, "replace", false);
|
||||
let replace = switch_or(p, "replace", false, "swatch.resetDefaults")?;
|
||||
let model = s.doc()?.doc.color_mode.model();
|
||||
let (defaults, groups) = default_swatches(model);
|
||||
// With replace, links to swatches that are no longer global ones are dropped (the art keeps
|
||||
// its colour); filled in by the edit.
|
||||
let mut live: Vec<String> = vec![];
|
||||
let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok();
|
||||
let added = s.edit("Default Swatches", |d, _| {
|
||||
let mut added = vec![];
|
||||
if replace {
|
||||
@@ -645,6 +666,7 @@ fn reset_defaults(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
added.extend(d.swatches_iter().filter(|w| !before.contains(&w.name)).map(|w| w.name.clone()));
|
||||
live.extend(d.swatches_iter().filter(|w| w.global).map(|w| w.name.clone()));
|
||||
map_swatch_links(d, &mut |_, l, _| unlink_dead(&live, l));
|
||||
super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad("swatch.resetDefaults", e))?;
|
||||
return Ok(added);
|
||||
}
|
||||
for (i, w) in defaults.into_iter().enumerate() {
|
||||
|
||||
@@ -366,11 +366,27 @@ pub(crate) fn selected_style_attrs(s: &Session, para: bool) -> Result<Option<Map
|
||||
selection_attrs(s, &json!({}), if para { Kind::Para } else { Kind::Char })
|
||||
}
|
||||
|
||||
/// The attributes `charStyle.new` (`para`: `paraStyle.new`) would give a style made now from
|
||||
/// `target` (its `id`, `start` and `end`; empty for the selected objects). `None` without text
|
||||
/// there.
|
||||
pub fn target_style_attrs(s: &Session, target: &Value, para: bool) -> Option<Map<String, Value>> {
|
||||
selection_attrs(s, target, if para { Kind::Para } else { Kind::Char }).ok().flatten()
|
||||
}
|
||||
|
||||
/// Whether the document has a character (`para`: paragraph) style `name`, and its attributes.
|
||||
pub(crate) fn style_attrs(d: &Document, para: bool, name: &str) -> Option<Map<String, Value>> {
|
||||
if para { Kind::Para } else { Kind::Char }.attrs(d, name)
|
||||
}
|
||||
|
||||
/// The name `charStyle.new` (`para`: `paraStyle.new`) gives a style when it is given none: the
|
||||
/// first "Character Style N" ("Paragraph Style N") that no character style (paragraph style) of the
|
||||
/// document has.
|
||||
pub fn new_style_name(d: &Document, para: bool) -> String {
|
||||
let kind = if para { Kind::Para } else { Kind::Char };
|
||||
let base = if para { "Paragraph Style" } else { "Character Style" };
|
||||
(1..).map(|i| format!("{base} {i}")).find(|n| kind.attrs(d, n).is_none()).unwrap_or_else(|| base.to_string())
|
||||
}
|
||||
|
||||
fn name_param<'a>(p: &'a Value, kind: Kind, verb: &str) -> Result<&'a str> {
|
||||
str_param(p, "name").ok_or_else(|| bad(&kind.cmd(verb), "missing `name`"))
|
||||
}
|
||||
@@ -436,10 +452,9 @@ fn new(s: &mut Session, p: &Value, kind: Kind) -> Result<Value> {
|
||||
},
|
||||
};
|
||||
let d = &s.doc()?.doc;
|
||||
let base = if kind == Kind::Char { "Character Style" } else { "Paragraph Style" };
|
||||
let name = match str_param(p, "name") {
|
||||
Some(n) => n.trim().to_string(),
|
||||
None => (1..).map(|i| format!("{base} {i}")).find(|n| kind.attrs(d, n).is_none()).unwrap_or_else(|| base.to_string()),
|
||||
None => new_style_name(d, kind == Kind::Para),
|
||||
};
|
||||
if name.is_empty() {
|
||||
return Err(bad(&c, "give the style a name"));
|
||||
|
||||
@@ -96,7 +96,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Reshape Type Area",
|
||||
[],
|
||||
None,
|
||||
"{id, anchors: [[subpath, anchor]…], dx, dy} move anchors of area type's frame (the type area) by dx, dy points, with their handles; the text reflows at its size, through its thread too (what Direct Selection does dragging a frame corner or edge)",
|
||||
"{id, anchors: [[subpath, anchor]…], dx, dy} or {items: [{id, anchors}…], dx, dy} move anchors of area type's frame (the type area) by dx, dy points, with their handles; the text reflows at its size, through its thread too (what Direct Selection does dragging a frame corner or edge, or the frame anchors its marquee selected, and the arrow keys nudging those); with items, every listed frame moves in one step",
|
||||
has_doc,
|
||||
reshape_area
|
||||
),
|
||||
@@ -1063,22 +1063,45 @@ fn area_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn reshape_area(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "text.reshapeArea";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let mut refs = super::select::checked_refs(p.get("anchors"), C)?;
|
||||
refs.sort_unstable();
|
||||
refs.dedup();
|
||||
if refs.is_empty() {
|
||||
return Err(bad(C, "missing anchors [[subpath, anchor]…]"));
|
||||
// One area type (`id`, `anchors`), or several (`items`: [{id, anchors}…]).
|
||||
let raw: Vec<&Value> = match p.get("items") {
|
||||
Some(items) => {
|
||||
if p.get("id").is_some() || p.get("anchors").is_some() {
|
||||
return Err(bad(C, "give `id` and `anchors`, or `items`, not both"));
|
||||
}
|
||||
let items = items.as_array().ok_or_else(|| bad(C, "`items` must be an array of {id, anchors}"))?;
|
||||
if items.is_empty() {
|
||||
return Err(bad(C, "`items` is empty"));
|
||||
}
|
||||
items.iter().collect()
|
||||
}
|
||||
None => vec![p],
|
||||
};
|
||||
let mut items = Vec::with_capacity(raw.len());
|
||||
for it in raw {
|
||||
let id = id_param(it, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let mut refs = super::select::checked_refs(it.get("anchors"), C)?;
|
||||
refs.sort_unstable();
|
||||
refs.dedup();
|
||||
if refs.is_empty() {
|
||||
return Err(bad(C, "missing anchors [[subpath, anchor]…]"));
|
||||
}
|
||||
if items.iter().any(|(other, _)| *other == id) {
|
||||
return Err(bad(C, format!("area type {} is named twice", id.0)));
|
||||
}
|
||||
let node = s.doc()?.doc.node(id);
|
||||
if area_text(node).is_none() || node.is_some_and(|n| n.extras().perspective.is_some()) {
|
||||
return Err(bad(C, "not area type (text in a frame) outside perspective"));
|
||||
}
|
||||
items.push((id, refs));
|
||||
}
|
||||
let d = Vec2::new(f64_req(p, "dx", C)?, f64_req(p, "dy", C)?);
|
||||
let node = s.doc()?.doc.node(id);
|
||||
if area_text(node).is_none() || node.is_some_and(|n| n.extras().perspective.is_some()) {
|
||||
return Err(bad(C, "not area type (text in a frame) outside perspective"));
|
||||
}
|
||||
s.edit("Reshape Type Area", |doc, _| {
|
||||
let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) };
|
||||
if !reshape_area_with(t, |t| t.move_area_anchors(&refs, d)) {
|
||||
return Err(bad(C, "no such frame anchor, or the frame would reach past the canvas"));
|
||||
for (id, refs) in &items {
|
||||
let Some(NodeKind::Text(t)) = doc.node_mut(*id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(*id)) };
|
||||
if !reshape_area_with(t, |t| t.move_area_anchors(refs, d)) {
|
||||
return Err(bad(C, "no such frame anchor, or the frame would reach past the canvas"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
@@ -77,6 +77,10 @@ pub(crate) const SELECTS_GIVEN_IDS: &[&str] = &[
|
||||
"object.clippingMask.release",
|
||||
"object.compoundPath.make",
|
||||
"object.compoundPath.release",
|
||||
"object.compoundShape.expand",
|
||||
"object.compoundShape.make",
|
||||
"object.compoundShape.release",
|
||||
"object.compoundShape.setMode",
|
||||
"object.group",
|
||||
"object.hide",
|
||||
"object.lock",
|
||||
|
||||
@@ -161,6 +161,9 @@ pub struct Session {
|
||||
/// While [`Session::place_queue_read`] runs `file.place.queue`: the files read for it, which
|
||||
/// the command loads the place cursor with instead of reading them; `None` otherwise.
|
||||
pub(crate) queue_hand_off: Option<cmd::place::QueueRead>,
|
||||
/// What a [`cmd::TraceRun`] traced, until the next `imageTrace.make` or
|
||||
/// `imageTrace.makeAndExpand` takes it ([`cmd::Traced::hand_in`]).
|
||||
pub(crate) trace_hand_off: Option<cmd::Traced>,
|
||||
/// Whether a click or drag of the place cursor asks the frontend to place its file
|
||||
/// ([`UiRequest::Place`]) instead of running `file.place`: the desktop app sets it to read the
|
||||
/// file off the UI thread. False: the file is placed at once.
|
||||
|
||||
@@ -70,6 +70,7 @@ impl Session {
|
||||
batch_steps: None,
|
||||
place_hand_off: None,
|
||||
queue_hand_off: None,
|
||||
trace_hand_off: None,
|
||||
place_cursor_requests: false,
|
||||
package_hand_off: None,
|
||||
screens_hand_off: None,
|
||||
|
||||
@@ -26,9 +26,11 @@ mod cmdsplit;
|
||||
mod cmykflatten;
|
||||
mod colorguide;
|
||||
mod colorguidelib;
|
||||
mod colorize;
|
||||
pub(super) mod colormgmt;
|
||||
mod colorthemes;
|
||||
mod colourfonts;
|
||||
mod compound_shape;
|
||||
mod compoundpaint;
|
||||
mod constrain_new;
|
||||
mod constrain_tools;
|
||||
|
||||
@@ -2725,6 +2725,64 @@ fn image_trace_make_places_paths_over_the_image() {
|
||||
assert_eq!(paths[0].appearance.fill_paint(), Paint::solid(Color::rgb8(0, 0, 0)));
|
||||
}
|
||||
|
||||
/// #3098: a trace made away from the command (off the UI thread) and handed in makes the command's
|
||||
/// own edit, as one undo step; Cancel stops it; once its image has changed it no longer applies.
|
||||
#[test]
|
||||
fn a_trace_run_hands_its_trace_to_the_command() {
|
||||
let p = json!({"params": {"ignoreWhite": true}});
|
||||
let mut direct = session();
|
||||
image_doc(&mut direct);
|
||||
let want = direct.execute("imageTrace.make", &p).unwrap();
|
||||
let mut s = session();
|
||||
let img = image_doc(&mut s);
|
||||
let run = cmd::TraceRun::prepare(&s, "imageTrace.make", &p).unwrap();
|
||||
assert_eq!((run.command(), run.params()["id"].as_u64(), run.image_size()), ("imageTrace.make", Some(img.0), (100, 100)));
|
||||
let total = std::sync::atomic::AtomicU64::new(0);
|
||||
let traced = run
|
||||
.run(&|_, t| {
|
||||
total.fetch_max(t, std::sync::atomic::Ordering::Relaxed);
|
||||
true
|
||||
})
|
||||
.unwrap();
|
||||
assert!(total.into_inner() > 0, "the layers are counted");
|
||||
assert!(run.current(&s));
|
||||
traced.hand_in(&mut s);
|
||||
let got = s.execute(run.command(), run.params()).unwrap();
|
||||
assert!(s.trace_hand_off.is_none(), "taken");
|
||||
assert_eq!(got, want);
|
||||
let g = NodeId(got["id"].as_u64().unwrap());
|
||||
assert_eq!(node(&s, g), node(&direct, g));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(matches!(node(&s, img).kind, NodeKind::Image(_)), "one undo step");
|
||||
|
||||
assert!(matches!(run.run(&|_, _| false), Err(EngineError::Cancelled)));
|
||||
assert!(matches!(cmd::TraceRun::prepare(&s, "imageTrace.release", &p), Err(EngineError::Other(_))));
|
||||
// The image moved meanwhile: the run no longer applies, and the command traces it anew.
|
||||
let traced = run.run(&|_, _| true).unwrap();
|
||||
s.execute("object.move", &json!({"ids": [img.0], "dx": 10, "dy": 0})).unwrap();
|
||||
assert!(!run.current(&s));
|
||||
traced.hand_in(&mut s);
|
||||
let moved = s.execute(run.command(), run.params()).unwrap();
|
||||
assert!(s.trace_hand_off.is_none(), "dropped");
|
||||
let bb = outline(&traced_paths(&node(&s, NodeId(moved["id"].as_u64().unwrap())))[0]).0.bounds().unwrap();
|
||||
assert!((bb.center().x - 160.0).abs() < 1.5, "traced where the image is now: {bb:?}");
|
||||
}
|
||||
|
||||
/// A Document Library trace made away from its command no longer applies once the library's
|
||||
/// colours change: the settings' JSON leaves them out (#4605 review).
|
||||
#[test]
|
||||
fn a_trace_run_needs_the_library_colours_it_traced_with() {
|
||||
let mut s = session();
|
||||
image_doc(&mut s);
|
||||
let p = json!({"preset": "6 Colors", "params": {"palette": "documentLibrary"}});
|
||||
let run = cmd::TraceRun::prepare(&s, "imageTrace.make", &p).unwrap();
|
||||
assert!(run.current(&s));
|
||||
let swatch = |w: &&vectorcraft_color::Swatch| matches!(w.paint, Paint::Solid { .. }) && !w.name.starts_with('[');
|
||||
let name = s.doc().unwrap().doc.swatches_iter().find(swatch).map(|w| w.name.clone()).unwrap();
|
||||
s.execute("swatch.edit", &json!({"name": name, "color": "#123456"})).unwrap();
|
||||
assert!(!run.current(&s), "{name} changed");
|
||||
}
|
||||
|
||||
/// Shapes: the disc of the test image comes out as a live ellipse where the image lies, the same
|
||||
/// circle as its traced path; without the option it is a plain path.
|
||||
#[test]
|
||||
|
||||
@@ -48,6 +48,19 @@ fn every_guide_colour_is_a_library_member() {
|
||||
assert!(s.execute("color.harmony", &json!({"color": "#3a7bd5", "rule": "triad", "limitTo": "document"})).is_err(), "no document");
|
||||
}
|
||||
|
||||
/// A `limitTo` that is not a string names no library: `color.harmony` refuses it with the error it
|
||||
/// gives for a name it does not know, and reads a null as no limit.
|
||||
#[test]
|
||||
fn a_limit_that_is_not_a_string_names_no_library() {
|
||||
let mut s = Session::new();
|
||||
for (limit, shown) in [(json!(5), "`5`"), (json!(true), "`true`"), (json!(["web-safe-216"]), "`[\"web-safe-216\"]`")] {
|
||||
let e = s.execute("color.harmony", &json!({"color": "#3a7bd5", "rule": "triad", "limitTo": limit})).unwrap_err().to_string();
|
||||
assert!(e.contains(&format!("no swatch library {shown}")), "{limit}: {e}");
|
||||
}
|
||||
let free = s.execute("color.harmony", &json!({"color": "#3a7bd5", "rule": "triad", "limitTo": null})).unwrap();
|
||||
assert_eq!(free["colors"][0], "#3a7bd5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_swatches_limit_the_guide_and_recolor() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
//! A fill color colorizes grayscale and 1-bit images (`paint.setFill` on the image).
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{ImageBlob, Node, NodeKind};
|
||||
|
||||
use crate::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
s.execute("swatch.new", &json!({"name": "Ink", "color": {"c": 0, "m": 100, "y": 100, "k": 0}, "spot": true})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn png(img: image::DynamicImage) -> ImageBlob {
|
||||
let mut out = Vec::new();
|
||||
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
|
||||
ImageBlob::png(out)
|
||||
}
|
||||
|
||||
/// A gray 2 × 1 image (black, white), with an alpha channel when `alpha`.
|
||||
fn gray(alpha: bool) -> ImageBlob {
|
||||
if alpha {
|
||||
png(image::DynamicImage::ImageLumaA8(image::GrayAlphaImage::from_raw(2, 1, vec![0, 255, 255, 128]).unwrap()))
|
||||
} else {
|
||||
png(image::DynamicImage::ImageLuma8(image::GrayImage::from_raw(2, 1, vec![0, 255]).unwrap()))
|
||||
}
|
||||
}
|
||||
|
||||
/// A selected embedded 2 × 1 image of `blob`.
|
||||
fn image(s: &mut Session, key: &str, blob: ImageBlob) -> NodeId {
|
||||
let key = key.to_string();
|
||||
s.edit("Place", |d, sel| {
|
||||
d.images.insert(key.clone(), blob);
|
||||
let id = d.alloc_id();
|
||||
let im = vectorcraft_doc::ImageObject {
|
||||
key,
|
||||
width: 2,
|
||||
height: 1,
|
||||
xf: Default::default(),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
};
|
||||
d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?;
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn colorize(s: &Session, id: NodeId) -> Option<Paint> {
|
||||
s.doc().unwrap().doc.node(id).unwrap().extras().colorize.as_deref().cloned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fill_color_colorizes_a_gray_image_and_none_takes_it_off() {
|
||||
let mut s = session();
|
||||
let id = image(&mut s, "gray", gray(false));
|
||||
s.execute("paint.setFill", &json!({"swatch": "Ink", "tint": 40})).unwrap();
|
||||
let Some(Paint::Solid { swatch, tint, .. }) = colorize(&s, id) else { panic!("not colorized: {:?}", colorize(&s, id)) };
|
||||
assert_eq!((swatch.as_deref(), tint), (Some("Ink"), 0.4));
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
assert!(n.appearance.items.is_empty(), "no fill item over the image");
|
||||
assert_eq!(s.proxy_paints().0, colorize(&s, id).unwrap(), "the Fill proxy shows the color");
|
||||
// A process color too.
|
||||
s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap();
|
||||
assert!(matches!(colorize(&s, id), Some(Paint::Solid { swatch: None, .. })));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(matches!(colorize(&s, id), Some(Paint::Solid { swatch: Some(_), .. })), "undo restores the spot color");
|
||||
s.execute("paint.setFill", &json!({"none": true})).unwrap();
|
||||
assert_eq!(colorize(&s, id), None);
|
||||
// `none` takes the color off after an edit of the pixels leaves the image in color.
|
||||
s.execute("paint.setFill", &json!({"swatch": "Ink"})).unwrap();
|
||||
s.execute("edit.colors.invert", &json!({"includeImages": true})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let NodeKind::Image(im) = &d.node(id).unwrap().kind else { panic!() };
|
||||
assert_eq!(d.image_blob(im).and_then(|b| b.gray_source()), None, "the pixels are no longer gray");
|
||||
s.execute("paint.setFill", &json!({"none": true})).unwrap();
|
||||
assert_eq!(colorize(&s, id), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_with_alpha_takes_a_spot_color_black_or_white_only() {
|
||||
let mut s = session();
|
||||
let id = image(&mut s, "alpha", gray(true));
|
||||
let e = s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap_err().to_string();
|
||||
assert!(e.contains("a process color can't colorize a grayscale image with transparency"), "{e}");
|
||||
assert_eq!(colorize(&s, id), None);
|
||||
s.execute("paint.setFill", &json!({"color": {"gray": 100}})).unwrap();
|
||||
assert!(colorize(&s, id).is_some(), "black");
|
||||
s.execute("paint.setFill", &json!({"swatch": "Ink"})).unwrap();
|
||||
assert!(matches!(colorize(&s, id), Some(Paint::Solid { swatch: Some(_), .. })));
|
||||
}
|
||||
|
||||
/// A swatch edit that would leave an image with alpha in a process color is refused, as
|
||||
/// `paint.setFill` refuses one.
|
||||
#[test]
|
||||
fn swatch_edits_keep_an_image_with_alpha_out_of_process_colors() {
|
||||
let mut s = session();
|
||||
s.execute("swatch.new", &json!({"name": "Other", "color": {"c": 100, "m": 0, "y": 0, "k": 0}, "global": true})).unwrap();
|
||||
let id = image(&mut s, "alpha", gray(true));
|
||||
s.execute("paint.setFill", &json!({"swatch": "Ink"})).unwrap();
|
||||
let before = colorize(&s, id);
|
||||
let message = "a process color can't colorize a grayscale image with transparency";
|
||||
for (cmd, p) in [
|
||||
("swatch.edit", json!({"name": "Ink", "spot": false})),
|
||||
("swatch.edit", json!({"name": "Ink", "spot": false, "global": false})),
|
||||
("swatch.setSpot", json!({"name": "Ink", "spot": false})),
|
||||
("swatch.delete", json!({"names": ["Ink"]})),
|
||||
("swatch.merge", json!({"names": ["Other", "Ink"]})),
|
||||
("swatch.resetDefaults", json!({"replace": true})),
|
||||
] {
|
||||
let e = s.execute(cmd, &p).unwrap_err().to_string();
|
||||
assert!(e.contains(message), "{cmd} {p}: {e}");
|
||||
assert_eq!(colorize(&s, id), before, "{cmd} {p} left the image as it was");
|
||||
}
|
||||
// A new color for the spot swatch keeps it a spot color.
|
||||
s.execute("swatch.edit", &json!({"name": "Ink", "color": {"c": 100, "m": 0, "y": 0, "k": 0}})).unwrap();
|
||||
// An image without alpha takes a process color.
|
||||
let mut s = session();
|
||||
let id = image(&mut s, "gray", gray(false));
|
||||
s.execute("paint.setFill", &json!({"swatch": "Ink"})).unwrap();
|
||||
s.execute("swatch.edit", &json!({"name": "Ink", "spot": false})).unwrap();
|
||||
assert!(colorize(&s, id).is_some());
|
||||
}
|
||||
|
||||
/// With the Appearance panel's active item on a fill of the image, the paint goes to that fill.
|
||||
#[test]
|
||||
fn the_active_fill_item_takes_the_paint_instead() {
|
||||
let mut s = session();
|
||||
let id = image(&mut s, "alpha", gray(true));
|
||||
s.execute("appearance.addFill", &json!({})).unwrap();
|
||||
let index = s.doc().unwrap().doc.node(id).unwrap().appearance.items.iter().position(|it| it.is_fill()).unwrap();
|
||||
s.execute("appearance.setActiveItem", &json!({"index": index})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap();
|
||||
assert_eq!(colorize(&s, id), None);
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
let blue = vectorcraft_color::Color::rgb(0.0, 0.0, 1.0);
|
||||
assert_eq!(n.appearance.fill().and_then(|f| f.paint.color()), Some(blue), "the fill item took the color");
|
||||
}
|
||||
|
||||
/// `none` on a gray image without a color takes the paint off its fill item.
|
||||
#[test]
|
||||
fn none_on_a_gray_image_without_a_color_clears_its_fill_item() {
|
||||
let mut s = session();
|
||||
let id = image(&mut s, "gray", gray(false));
|
||||
s.execute("appearance.addFill", &json!({})).unwrap();
|
||||
let index = s.doc().unwrap().doc.node(id).unwrap().appearance.items.iter().position(|it| it.is_fill()).unwrap();
|
||||
s.execute("appearance.setActiveItem", &json!({"index": index})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap();
|
||||
s.execute("appearance.setActiveItem", &json!({"index": null})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"none": true})).unwrap();
|
||||
assert_eq!(colorize(&s, id), None);
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
assert_eq!(n.appearance.fill().map(|f| &f.paint), Some(&Paint::None), "the fill item's paint is none");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_color_image_takes_a_fill_item_as_before() {
|
||||
let mut s = session();
|
||||
let rgb = png(image::DynamicImage::ImageRgb8(image::RgbImage::from_raw(2, 1, vec![255, 0, 0, 0, 0, 255]).unwrap()));
|
||||
let id = image(&mut s, "rgb", rgb);
|
||||
s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap();
|
||||
assert_eq!(colorize(&s, id), None);
|
||||
assert!(s.doc().unwrap().doc.node(id).unwrap().appearance.fill().is_some(), "a fill item, as before");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colorized_images_save_and_open_with_their_color() {
|
||||
let mut s = session();
|
||||
let id = image(&mut s, "gray", gray(false));
|
||||
s.execute("paint.setFill", &json!({"swatch": "Ink"})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let back = vectorcraft_format::load(&vectorcraft_format::save(d, false)).unwrap();
|
||||
assert_eq!(&back.layers, &d.layers);
|
||||
assert!(back.node(id).unwrap().extras().colorize.is_some());
|
||||
// Swatch edits reach it as they reach fills.
|
||||
s.execute("swatch.edit", &json!({"name": "Ink", "color": {"c": 100, "m": 0, "y": 0, "k": 0}})).unwrap();
|
||||
let Some(Paint::Solid { color, .. }) = colorize(&s, id) else { panic!() };
|
||||
assert_eq!(color, vectorcraft_color::Color::Cmyk { c: 1.0, m: 0.0, y: 0.0, k: 0.0 });
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
//! Pathfinder panel › compound shapes (`object.compoundShape.*`): Make Compound Shape in each
|
||||
//! shape mode, a member's mode, Release, Expand, Undo, saving and exporting.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::Color;
|
||||
use vectorcraft_doc::{Node, ShapeMode};
|
||||
use vectorcraft_render::effects;
|
||||
|
||||
use crate::*;
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
s.execute(id, &p).unwrap()
|
||||
}
|
||||
|
||||
fn node(s: &Session, id: NodeId) -> Node {
|
||||
s.doc().unwrap().doc.node(id).cloned().unwrap()
|
||||
}
|
||||
|
||||
fn area(path: &vectorcraft_geom::PathData) -> f64 {
|
||||
vectorcraft_pathops::area(path, vectorcraft_geom::FillRule::NonZero).abs()
|
||||
}
|
||||
|
||||
/// The area a path or compound path fills.
|
||||
fn node_area(n: &Node) -> f64 {
|
||||
area(&crate::cmd::pathops::node_path(n).unwrap().0)
|
||||
}
|
||||
|
||||
fn square(s: &mut Session, x: f64, color: &str) -> NodeId {
|
||||
let id = NodeId(run(s, "shape.rectangle", json!({"x": x, "y": 0, "width": 100, "height": 100}))["id"].as_u64().unwrap());
|
||||
run(s, "paint.setFill", json!({"color": color}));
|
||||
id
|
||||
}
|
||||
|
||||
/// A red square (0–100) behind a blue one (50–150), both 100 high and selected → (session, back, front).
|
||||
fn two_squares() -> (Session, NodeId, NodeId) {
|
||||
let mut s = Session::new();
|
||||
run(&mut s, "file.new", json!({"width": 400, "height": 300}));
|
||||
let back = square(&mut s, 0.0, "#ff0000");
|
||||
let front = square(&mut s, 50.0, "#0000ff");
|
||||
run(&mut s, "select.set", json!({"ids": [back.0, front.0]}));
|
||||
(s, back, front)
|
||||
}
|
||||
|
||||
fn selected(s: &Session) -> Vec<NodeId> {
|
||||
s.doc().unwrap().selection.objects.clone()
|
||||
}
|
||||
|
||||
/// Make Compound Shape (Alt-clicking a Shape Mode gives its mode) keeps both squares as members in
|
||||
/// the mode, takes the paint of the topmost member not subtracting, draws the area the modes make,
|
||||
/// and Expand turns it into that area as one path; Undo brings each step back.
|
||||
#[test]
|
||||
fn make_and_expand_each_shape_mode() {
|
||||
for (mode, want, fill, kind) in [
|
||||
("add", 15_000.0, Color::rgb(0.0, 0.0, 1.0), "Path"),
|
||||
("subtract", 5_000.0, Color::rgb(1.0, 0.0, 0.0), "Path"),
|
||||
("intersect", 5_000.0, Color::rgb(0.0, 0.0, 1.0), "Path"),
|
||||
("exclude", 10_000.0, Color::rgb(0.0, 0.0, 1.0), "Compound Path"),
|
||||
] {
|
||||
let (mut s, back, front) = two_squares();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let id = NodeId(run(&mut s, "object.compoundShape.make", json!({"mode": mode}))["id"].as_u64().unwrap());
|
||||
assert_eq!(selected(&s), vec![id]);
|
||||
let shape = node(&s, id);
|
||||
assert!(shape.is_compound_shape(), "{mode}");
|
||||
assert_eq!(shape.kind_label(), "Compound Shape");
|
||||
let members: Vec<NodeId> = shape.children().unwrap().iter().map(|m| m.id).collect();
|
||||
assert_eq!(members, vec![back, front], "the members, in their stacking order");
|
||||
assert!(shape.children().unwrap().iter().all(|m| m.extras().shape_mode.name() == mode));
|
||||
assert_eq!(shape.appearance.fill_paint().color(), Some(fill), "{mode}");
|
||||
let drawn = effects::compound_shape_area(&shape, None).unwrap();
|
||||
assert!((area(&drawn) - want).abs() < 0.5, "{mode}: {}", area(&drawn));
|
||||
// Expand: the path, or compound path, of the area, painted as the compound shape.
|
||||
let made = run(&mut s, "object.compoundShape.expand", json!({}));
|
||||
let path = NodeId(made["ids"][0].as_u64().unwrap());
|
||||
let n = node(&s, path);
|
||||
assert_eq!(n.kind_label(), kind, "{mode}");
|
||||
assert!((node_area(&n) - want).abs() < 0.5, "{mode}");
|
||||
assert_eq!(n.appearance.fill_paint().color(), Some(fill));
|
||||
assert_eq!(selected(&s), vec![path]);
|
||||
assert!(s.doc().unwrap().doc.node(back).is_none() && s.doc().unwrap().doc.node(id).is_none());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(node(&s, id).is_compound_shape());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc().unwrap().doc.layers, before.layers, "{mode}: Undo leaves the squares as they were");
|
||||
}
|
||||
}
|
||||
|
||||
/// Make Compound Shape from the panel menu gives every member Add; Release gives the members back
|
||||
/// as they were, selected, in one undo step.
|
||||
#[test]
|
||||
fn release_returns_the_members_as_they_were() {
|
||||
let (mut s, back, front) = two_squares();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let id = NodeId(run(&mut s, "object.compoundShape.make", json!({}))["id"].as_u64().unwrap());
|
||||
assert!(node(&s, id).children().unwrap().iter().all(|m| m.extras().shape_mode == ShapeMode::Add));
|
||||
run(&mut s, "object.compoundShape.setMode", json!({"mode": "subtract", "ids": [front.0]}));
|
||||
run(&mut s, "select.set", json!({"ids": [id.0]}));
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let out = run(&mut s, "object.compoundShape.release", json!({}));
|
||||
assert_eq!(out["ids"], json!([back.0, front.0]));
|
||||
assert_eq!(selected(&s), vec![back, front]);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
|
||||
assert_eq!(s.doc().unwrap().doc.layers, before.layers, "the members' paint and place, no shape modes");
|
||||
// Nothing selected is in a compound shape: Release and Expand are off.
|
||||
assert!(s.execute("object.compoundShape.release", &json!({})).is_err());
|
||||
assert!(s.execute("object.compoundShape.expand", &json!({})).is_err());
|
||||
assert!(s.execute("object.compoundShape.setMode", &json!({"mode": "add"})).is_err());
|
||||
}
|
||||
|
||||
/// A member selected (Direct Selection) takes the mode of the Shape Mode clicked; the area follows,
|
||||
/// and Undo puts the mode back. Release and Expand act on the compound shape it is in.
|
||||
#[test]
|
||||
fn a_selected_member_takes_a_shape_mode() {
|
||||
let (mut s, _, front) = two_squares();
|
||||
let id = NodeId(run(&mut s, "object.compoundShape.make", json!({}))["id"].as_u64().unwrap());
|
||||
run(&mut s, "select.set", json!({"ids": [front.0]}));
|
||||
assert_eq!(cmd::selected_member_modes(&s), vec![ShapeMode::Add]);
|
||||
run(&mut s, "object.compoundShape.setMode", json!({"mode": "intersect"}));
|
||||
assert_eq!(cmd::selected_member_modes(&s), vec![ShapeMode::Intersect]);
|
||||
assert!((area(&effects::compound_shape_area(&node(&s, id), None).unwrap()) - 5_000.0).abs() < 0.5);
|
||||
// The same mode again is no edit.
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
run(&mut s, "object.compoundShape.setMode", json!({"mode": "intersect"}));
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
|
||||
assert!(s.execute("object.compoundShape.setMode", &json!({"mode": "divide"})).is_err());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(node(&s, front).extras().shape_mode, ShapeMode::Add);
|
||||
run(&mut s, "select.set", json!({"ids": [front.0]}));
|
||||
let made = run(&mut s, "object.compoundShape.expand", json!({}));
|
||||
assert!((node_area(&node(&s, NodeId(made["ids"][0].as_u64().unwrap()))) - 15_000.0).abs() < 0.5);
|
||||
}
|
||||
|
||||
/// A compound shape holds its paint: a fill given to it (or to a member) paints the compound shape,
|
||||
/// whose members keep theirs.
|
||||
#[test]
|
||||
fn paint_goes_to_the_compound_shape() {
|
||||
let (mut s, back, front) = two_squares();
|
||||
let id = NodeId(run(&mut s, "object.compoundShape.make", json!({}))["id"].as_u64().unwrap());
|
||||
let out = run(&mut s, "paint.setFill", json!({"color": "#00ff00"}));
|
||||
assert_eq!(out["objects"], 1);
|
||||
assert_eq!(node(&s, id).appearance.fill_paint().color(), Some(Color::rgb(0.0, 1.0, 0.0)));
|
||||
run(&mut s, "paint.setFill", json!({"color": "#ffff00", "ids": [front.0]}));
|
||||
assert_eq!(node(&s, id).appearance.fill_paint().color(), Some(Color::rgb(1.0, 1.0, 0.0)));
|
||||
assert_eq!(node(&s, back).appearance.fill_paint().color(), Some(Color::rgb(1.0, 0.0, 0.0)));
|
||||
assert_eq!(node(&s, front).appearance.fill_paint().color(), Some(Color::rgb(0.0, 0.0, 1.0)));
|
||||
}
|
||||
|
||||
/// Make Compound Shape needs two or more objects it can take.
|
||||
#[test]
|
||||
fn make_needs_two_objects_it_can_take() {
|
||||
let (mut s, back, _) = two_squares();
|
||||
run(&mut s, "select.set", json!({"ids": [back.0]}));
|
||||
assert!(s.execute("object.compoundShape.make", &json!({})).is_err());
|
||||
let (mut s, back, front) = two_squares();
|
||||
assert!(s.execute("object.compoundShape.make", &json!({"mode": "divide"})).is_err());
|
||||
assert_eq!(selected(&s), vec![back, front], "nothing changed");
|
||||
}
|
||||
|
||||
/// Saved and opened again, a compound shape keeps its members and their modes; a mode a file names
|
||||
/// that isn't one reads as Add.
|
||||
#[test]
|
||||
fn compound_shapes_survive_a_save() {
|
||||
let (mut s, _, front) = two_squares();
|
||||
let id = NodeId(run(&mut s, "object.compoundShape.make", json!({"mode": "subtract"}))["id"].as_u64().unwrap());
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let bytes = vectorcraft_format::save(&doc, false);
|
||||
let back = vectorcraft_format::load(&bytes).unwrap();
|
||||
assert_eq!(back.node(id), doc.node(id));
|
||||
assert!(back.node(id).unwrap().is_compound_shape());
|
||||
assert_eq!(back.node(front).unwrap().extras().shape_mode, ShapeMode::Subtract);
|
||||
let text = String::from_utf8(bytes).unwrap().replace("\"shape_mode\":\"subtract\"", "\"shape_mode\":\"divide\"");
|
||||
let odd = vectorcraft_format::load(text.as_bytes()).unwrap();
|
||||
assert_eq!(odd.node(front).unwrap().extras().shape_mode, ShapeMode::Add);
|
||||
}
|
||||
|
||||
/// SVG export draws the area, painted as the compound shape, not the members.
|
||||
#[test]
|
||||
fn svg_export_draws_the_area() {
|
||||
let (mut s, _, _) = two_squares();
|
||||
run(&mut s, "object.compoundShape.make", json!({"mode": "subtract"}));
|
||||
let svg = vectorcraft_svg::export(&s.doc().unwrap().doc, &Default::default());
|
||||
assert!(svg.contains("#ff0000"), "the back square's red paints the area");
|
||||
assert!(!svg.contains("#0000ff"), "the front square isn't drawn: {svg}");
|
||||
}
|
||||
|
||||
/// The Shape Modes and Pathfinders take a compound shape as the area it draws.
|
||||
#[test]
|
||||
fn pathfinder_operations_take_the_area() {
|
||||
let (mut s, _, _) = two_squares();
|
||||
let id = NodeId(run(&mut s, "object.compoundShape.make", json!({"mode": "subtract"}))["id"].as_u64().unwrap());
|
||||
let r = NodeId(run(&mut s, "shape.rectangle", json!({"x": 200, "y": 0, "width": 50, "height": 100}))["id"].as_u64().unwrap());
|
||||
run(&mut s, "select.set", json!({"ids": [id.0, r.0]}));
|
||||
let out = run(&mut s, "object.pathfinder.unite", json!({}));
|
||||
let n = node(&s, NodeId(out["ids"][0].as_u64().unwrap()));
|
||||
assert!((node_area(&n) - 10_000.0).abs() < 0.5, "the 50 × 100 area and the 50 × 100 rectangle: {}", node_area(&n));
|
||||
}
|
||||
@@ -1153,3 +1153,89 @@ fn mesh_create_on_an_image_keeps_alpha_and_refuses_linked_and_big_grids() {
|
||||
assert!(e.contains("linked"), "{e}");
|
||||
assert_eq!(node(&s, id).kind_label(), "Image", "left as it was");
|
||||
}
|
||||
|
||||
/// A CMYK document with the #3232 rectangle filled C0 M40 Y60 K10, made a 4×4 mesh with `create`.
|
||||
fn cmyk_mesh(create: Value) -> (Session, NodeId) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 226.77, "height": 170.08, "colorMode": "cmyk"})).unwrap();
|
||||
let a = rect(&mut s, 28.35, 28.35, 170.08, 113.39);
|
||||
s.execute("paint.setStroke", &json!({"ids": [a.0], "none": true})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"ids": [a.0], "color": {"c": 0, "m": 0.4, "y": 0.6, "k": 0.1}})).unwrap();
|
||||
let mut p = json!({"ids": [a.0], "rows": 4, "cols": 4});
|
||||
p.as_object_mut().unwrap().extend(create.as_object().unwrap().clone());
|
||||
s.execute("object.mesh.create", &p).unwrap();
|
||||
(s, a)
|
||||
}
|
||||
|
||||
/// The fill colours `document.exportPdf {standard: pdfX4}` writes (`… scn` operands), and its warnings.
|
||||
fn pdf_fill_colours(s: &mut Session) -> (Vec<Vec<f32>>, Vec<String>) {
|
||||
let v = s.execute("document.exportPdf", &json!({"standard": "pdfX4", "compression": {"compressText": false}})).unwrap();
|
||||
let bytes = vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap();
|
||||
let text = String::from_utf8_lossy(&bytes).into_owned();
|
||||
let mut out = vec![];
|
||||
for line in text.lines() {
|
||||
let words: Vec<&str> = line.split_whitespace().collect();
|
||||
for (i, w) in words.iter().enumerate() {
|
||||
if *w == "scn" {
|
||||
let nums: Vec<f32> = words[..i].iter().rev().map_while(|x| x.parse().ok()).collect();
|
||||
out.push(nums.into_iter().rev().collect());
|
||||
}
|
||||
}
|
||||
}
|
||||
(out, serde_json::from_value(v["warnings"].clone()).unwrap())
|
||||
}
|
||||
|
||||
/// #3232: a mesh made from a CMYK fill in a CMYK document keeps its points in CMYK, and the PDF
|
||||
/// writes them with their K, not separated again from RGB (C/M/Y with K = 0).
|
||||
#[test]
|
||||
fn cmyk_mesh_keeps_its_inks_in_pdf() {
|
||||
for app in ["flat", "center", "edge"] {
|
||||
let (mut s, a) = cmyk_mesh(json!({"appearance": app, "highlight": 0}));
|
||||
assert!(mesh_of(&s, a).points.iter().all(|p| p.color == Color::cmyk(0.0, 0.4, 0.6, 0.1)), "{app}");
|
||||
let (fills, warnings) = pdf_fill_colours(&mut s);
|
||||
assert!(!fills.is_empty() && warnings.is_empty(), "{app}: {warnings:?}");
|
||||
for f in &fills {
|
||||
let [c, m, y, k] = f[..] else { panic!("{app}: not CMYK {f:?}") };
|
||||
// The writer keeps 8 bits per ink: within one step of C0 M40 Y60 K10.
|
||||
let near = |a: f32, b: f32| (a - b).abs() <= 1.0 / 255.0 + 1e-6;
|
||||
assert!(near(c, 0.0) && near(m, 0.4) && near(y, 0.6) && near(k, 0.1), "{app}: {f:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #3232: lightening to the centre or the edges lightens CMYK points towards paper white in CMYK,
|
||||
/// so K shrinks with the other inks instead of being dropped.
|
||||
#[test]
|
||||
fn cmyk_mesh_highlight_lightens_in_cmyk() {
|
||||
for app in ["center", "edge"] {
|
||||
let (mut s, a) = cmyk_mesh(json!({"appearance": app, "highlight": 50}));
|
||||
let m = mesh_of(&s, a);
|
||||
let (corner, centre) = (m.points[0].color, m.points[m.idx(2, 2)].color);
|
||||
let (lit, plain) = if app == "center" { (centre, corner) } else { (corner, centre) };
|
||||
assert_eq!(plain, Color::cmyk(0.0, 0.4, 0.6, 0.1), "{app}");
|
||||
let Color::Cmyk { c, m: mg, y, k } = lit else { panic!("{app}: {lit:?}") };
|
||||
assert!(c.abs() < 1e-6 && (mg - 0.2).abs() < 1e-5 && (y - 0.3).abs() < 1e-5 && (k - 0.05).abs() < 1e-5, "{app}: {lit:?}");
|
||||
assert!(m.points.iter().all(|p| matches!(p.color, Color::Cmyk { .. })), "{app}");
|
||||
let (fills, warnings) = pdf_fill_colours(&mut s);
|
||||
assert!(warnings.is_empty(), "{app}: no RGB to separate {warnings:?}");
|
||||
assert!(!fills.is_empty(), "{app}: no PDF fills");
|
||||
for f in &fills {
|
||||
let [c, m, y, k] = f[..] else { panic!("{app}: not CMYK {f:?}") };
|
||||
// Every ink in proportion to C0 M40 Y60 K10, lightened by at most half.
|
||||
assert!(c < 1.5 / 255.0 && (0.05 - 1.5 / 255.0..=0.1 + 1.5 / 255.0).contains(&k), "{app}: {f:?}");
|
||||
assert!((m / 0.4 - k / 0.1).abs() < 0.06 && (y / 0.6 - k / 0.1).abs() < 0.06, "{app}: {f:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #3232: a CMYK colour given to a mesh point stays CMYK, and the PDF keeps its K.
|
||||
#[test]
|
||||
fn cmyk_mesh_point_colour_keeps_k() {
|
||||
let (mut s, a) = cmyk_mesh(json!({"appearance": "flat"}));
|
||||
s.execute("object.mesh.setPointColor", &json!({"id": a.0, "index": 0, "color": {"c": 0, "m": 0, "y": 0, "k": 1}})).unwrap();
|
||||
assert_eq!(mesh_of(&s, a).points[0].color, Color::cmyk(0.0, 0.0, 0.0, 1.0));
|
||||
let (fills, warnings) = pdf_fill_colours(&mut s);
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
assert!(fills.iter().all(|f| f.len() == 4 && f[0] < 1.5 / 255.0), "no cyan from a CMY black: {fills:?}");
|
||||
assert!(fills.iter().any(|f| f[3] > 0.8), "the black corner prints on K: {fills:?}");
|
||||
}
|
||||
|
||||
@@ -572,6 +572,54 @@ fn model_param_must_be_a_string_when_given() {
|
||||
assert_eq!(fill(&s, art).to_hex(), "#1a1a1a", "nothing changed");
|
||||
}
|
||||
|
||||
/// Recolor Artwork's commands refuse a parameter of the wrong form, such as `limitTo: 5` or
|
||||
/// `includeImages: "false"`, with an error that names it; the art keeps its color and no undo
|
||||
/// step is recorded. A null counts as a value left out.
|
||||
#[test]
|
||||
fn recolor_commands_refuse_params_of_the_wrong_form() {
|
||||
let mut s = session();
|
||||
let art = rect(&mut s, json!("#1a1a1a"));
|
||||
select_all(&mut s);
|
||||
let undo = |s: &Session| s.doc().unwrap().history.undo.len();
|
||||
let before = undo(&s);
|
||||
let map = json!({"#1a1a1a": "#ff0000"});
|
||||
let rows = json!([{"from": ["#1a1a1a"], "to": "#ff0000", "exclude": "true"}]);
|
||||
for (cmd, p, msg) in [
|
||||
("recolor.apply", json!({"map": map, "limitTo": 5}), "no swatch library `5`"),
|
||||
("recolor.apply", json!({"map": map, "limitTo": true}), "no swatch library `true`"),
|
||||
("recolor.reduce", json!({"limitTo": 216}), "no swatch library `216`"),
|
||||
("recolor.apply", json!({"map": map, "includeImages": "false"}), "`includeImages` must be true or false"),
|
||||
("recolor.apply", json!({"map": map, "includePatterns": 0}), "`includePatterns` must be true or false"),
|
||||
("recolor.apply", json!({"map": map, "method": 5}), "unknown method `5`"),
|
||||
("recolor.reduce", json!({"method": true}), "unknown method `true`"),
|
||||
("recolor.reduce", json!({"colors": "2"}), "`colors` must be a number"),
|
||||
("recolor.reduce", json!({"colors": 2.5}), "`colors` must be a whole number"),
|
||||
("recolor.reduce", json!({"preserve": "none"}), "`preserve` must be an object"),
|
||||
("recolor.reduce", json!({"preserve": {"white": "false"}}), "`white` must be true or false"),
|
||||
("recolor.apply", json!({"map": rows}), "`exclude` must be true or false"),
|
||||
("recolor.apply", json!({"map": map, "group": 5}), "`group` must be a string"),
|
||||
("recolor.apply", json!({"map": map, "rename": 5}), "`rename` must be a string"),
|
||||
("recolor.apply", json!({"map": map, "groupColors": "#00ff00"}), "`groupColors` must be an array"),
|
||||
("recolor.randomize", json!({"map": rows}), "`exclude` must be true or false"),
|
||||
("recolor.randomize", json!({"map": map, "seed": "4"}), "`seed` must be a number"),
|
||||
("recolor.randomize", json!({"map": map, "seed": -1}), "`seed` must be a whole number"),
|
||||
("recolor.randomize", json!({"map": map, "order": "false"}), "`order` must be true or false"),
|
||||
("recolor.randomize", json!({"map": map, "saturationBrightness": 1}), "`saturationBrightness` must be true or false"),
|
||||
] {
|
||||
let e = s.execute(cmd, &p).unwrap_err().to_string();
|
||||
assert!(e.contains(msg), "{cmd} {p}: {e}");
|
||||
}
|
||||
assert_eq!(fill(&s, art).to_hex(), "#1a1a1a", "nothing changed");
|
||||
assert_eq!(undo(&s), before, "no undo step");
|
||||
let r = run(&mut s, "recolor.reduce", json!({"colors": null, "method": null, "limitTo": null, "preserve": null}));
|
||||
assert_eq!(r["method"], "scaleTints");
|
||||
let r = run(&mut s, "recolor.randomize", json!({"map": map, "seed": null, "order": null, "saturationBrightness": null}));
|
||||
assert_eq!(r["map"][0]["to"], "rgb 255 0 0");
|
||||
let p = json!({"map": map, "limitTo": null, "method": null, "includeImages": null, "includePatterns": null, "group": null, "rename": null, "groupColors": null});
|
||||
run(&mut s, "recolor.apply", p);
|
||||
assert_eq!(fill(&s, art).to_hex(), "#ff0000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_model_converts_the_colours_of_a_group_too() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -73,6 +73,17 @@ fn ids_are_selected_first() {
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
vec![c]
|
||||
};
|
||||
let shape = |s: &mut Session, ids: &[u64]| {
|
||||
s.execute("select.set", &json!({"ids": &ids[..2]})).unwrap();
|
||||
let c = s.execute("object.compoundShape.make", &json!({})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
c
|
||||
};
|
||||
let shaped = |s: &mut Session, ids: &[u64]| vec![shape(s, ids)];
|
||||
let member = |s: &mut Session, ids: &[u64]| {
|
||||
shape(s, ids);
|
||||
vec![ids[1]]
|
||||
};
|
||||
let one = |_: &mut Session, ids: &[u64]| vec![ids[1]];
|
||||
let cases: Vec<(&str, Value, Setup<'_>)> = vec![
|
||||
("object.group", json!({}), &none),
|
||||
@@ -81,6 +92,10 @@ fn ids_are_selected_first() {
|
||||
("object.clippingMask.release", json!({}), &clipped),
|
||||
("object.compoundPath.make", json!({}), &none),
|
||||
("object.compoundPath.release", json!({}), &compound),
|
||||
("object.compoundShape.make", json!({"mode": "intersect"}), &none),
|
||||
("object.compoundShape.setMode", json!({"mode": "subtract"}), &member),
|
||||
("object.compoundShape.release", json!({}), &shaped),
|
||||
("object.compoundShape.expand", json!({}), &shaped),
|
||||
("object.arrange.bringToFront", json!({}), &one),
|
||||
("object.arrange.bringForward", json!({}), &one),
|
||||
("object.arrange.sendBackward", json!({}), &one),
|
||||
|
||||
@@ -162,6 +162,59 @@ fn save_and_load_round_trip_keeps_cmyk_spot_and_groups() {
|
||||
assert!(s.execute("swatch.library.save", &json!({"user": true})).is_err(), "no user folder in a headless session");
|
||||
}
|
||||
|
||||
/// The swatch library commands, and the graphic style library save and load that share their file
|
||||
/// handling, refuse a parameter of the wrong form, such as `user: "true"` or `names: "Grays"`, with
|
||||
/// an error that names it; the document keeps its swatches and no undo step is recorded. A null
|
||||
/// counts as a value left out.
|
||||
#[test]
|
||||
fn library_commands_refuse_params_of_the_wrong_form() {
|
||||
let mut s = session();
|
||||
run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}));
|
||||
let swatches = |s: &Session| doc(s).swatches_iter().map(|w| w.name.clone()).collect::<Vec<_>>();
|
||||
let (names, before) = (swatches(&s), undo_len(&s));
|
||||
let data = run(&mut s, "swatch.library.save", json!({"names": ["Grays"]}))["data"].clone();
|
||||
for (cmd, p, msg) in [
|
||||
("swatch.library.get", json!({"library": 5}), "`library` must be a string"),
|
||||
("swatch.library.add", json!({"library": "earth-tones", "names": "Clay"}), "`names` must be a list of names"),
|
||||
("swatch.library.add", json!({"library": "earth-tones", "names": ["Clay", 3]}), "`names` must be a list of names"),
|
||||
("swatch.library.add", json!({"library": "earth-tones", "names": ["Clay"], "apply": 5}), "`apply` must be a string"),
|
||||
("swatch.library.add", json!({"library": "earth-tones", "apply": "fill", "focus": "true"}), "`focus` must be true or false"),
|
||||
("swatch.resetDefaults", json!({"replace": "true"}), "`replace` must be true or false"),
|
||||
("swatch.library.save", json!({"user": "true"}), "`user` must be true or false"),
|
||||
("swatch.library.save", json!({"path": 5}), "`path` must be a string"),
|
||||
("swatch.library.save", json!({"format": 5}), "`format` must be a string"),
|
||||
("swatch.library.save", json!({"name": 5}), "`name` must be a string"),
|
||||
("swatch.library.save", json!({"names": "Grays"}), "`names` must be a list of names"),
|
||||
("swatch.library.load", json!({"path": 5, "data": data}), "`path` must be a string"),
|
||||
("swatch.library.load", json!({"data": 5}), "`data` must be a string"),
|
||||
("swatch.library.load", json!({"dataBase64": 5}), "`dataBase64` must be a string"),
|
||||
("swatch.library.load", json!({"data": data, "name": 5}), "`name` must be a string"),
|
||||
("graphicStyle.saveLibrary", json!({"user": "true"}), "`user` must be true or false"),
|
||||
("graphicStyle.saveLibrary", json!({"names": "Halo"}), "`names` must be a list of names"),
|
||||
("graphicStyle.saveLibrary", json!({"name": 5}), "`name` must be a string"),
|
||||
("graphicStyle.addFromLibrary", json!({"library": "shadows-glows", "names": "Halo"}), "`names` must be a list of names"),
|
||||
("graphicStyle.addFromLibrary", json!({"library": "shadows-glows", "name": 5}), "`name` must be a string"),
|
||||
("graphicStyle.addFromLibrary", json!({"library": "shadows-glows", "apply": "true"}), "`apply` must be true or false"),
|
||||
("graphicStyle.addFromLibrary", json!({"library": "shadows-glows", "apply": true, "add": "true"}), "`add` must be true or false"),
|
||||
("graphicStyle.addFromLibrary", json!({"library": "shadows-glows", "apply": true, "ids": "x"}), "ids must be an array"),
|
||||
("graphicStyle.loadLibrary", json!({"path": 5, "data": "{}"}), "`path` must be a string"),
|
||||
] {
|
||||
let e = s.execute(cmd, &p).unwrap_err().to_string();
|
||||
assert!(e.contains(msg), "{cmd} {p}: {e}");
|
||||
}
|
||||
assert_eq!((swatches(&s), undo_len(&s)), (names.clone(), before), "nothing changed");
|
||||
let r = run(&mut s, "swatch.library.save", json!({"path": null, "format": null, "name": null, "names": null, "user": null}));
|
||||
assert_eq!(r["format"], "vcswatches");
|
||||
let r = run(&mut s, "swatch.library.load", json!({"path": null, "data": data, "dataBase64": null, "name": null}));
|
||||
assert_eq!(r["count"], 9);
|
||||
run(&mut s, "swatch.resetDefaults", json!({"replace": null}));
|
||||
let r = run(&mut s, "swatch.library.add", json!({"library": "earth-tones", "names": null, "apply": null, "focus": null}));
|
||||
assert!(r["added"].as_array().is_some_and(|a| !a.is_empty()) && r.get("applied").is_none(), "{r}");
|
||||
run(&mut s, "graphicStyle.saveLibrary", json!({"name": null, "names": null}));
|
||||
let r = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "shadows-glows", "name": null, "names": null}));
|
||||
assert!(r["added"].as_array().is_some_and(|a| !a.is_empty()), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn libraries_saved_in_the_user_folder_are_user_defined() {
|
||||
let dir = temp_dir("user");
|
||||
|
||||
@@ -7,7 +7,7 @@ use vectorcraft_doc::{NodeKind, TextKind, TextObject};
|
||||
use vectorcraft_geom::{Affine, Point, Rect};
|
||||
use vectorcraft_text::TextLayout;
|
||||
use vectorcraft_tools::bbox::Handle;
|
||||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
|
||||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
|
||||
|
||||
use crate::*;
|
||||
|
||||
@@ -194,6 +194,87 @@ fn direct_selection_drags_a_frame_corner_or_edge() {
|
||||
assert_eq!(text(&s, id).runs[0].style.size, 12.0);
|
||||
}
|
||||
|
||||
/// Direct Selection's marquee selects the frame anchors of area type inside it: a drag of one moves
|
||||
/// only those, the arrow keys nudge them, and Shift-click takes one out.
|
||||
#[test]
|
||||
fn direct_selection_marquee_selects_frame_anchors() {
|
||||
let mut s = session();
|
||||
let id = area(&mut s, 40.0, 40.0, 120.0, 40.0, STORY);
|
||||
let other = area(&mut s, 300.0, 40.0, 120.0, 40.0, STORY);
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
let v = ViewInfo::default();
|
||||
let shift = Mods { shift: true, ..Mods::default() };
|
||||
// A marquee round the right-hand corners, (160, 40) and (160, 80).
|
||||
drag(&mut s, "directSelection", Point::new(150.0, 20.0), Point::new(180.0, 100.0));
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![id]);
|
||||
let n = undo_len(&s);
|
||||
drag(&mut s, "directSelection", Point::new(160.0, 80.0), Point::new(180.0, 100.0));
|
||||
assert_eq!(undo_len(&s), n + 1, "one step");
|
||||
assert_eq!(last_step(&s), "Reshape Type Area");
|
||||
assert_eq!(corners(&s, id), vec![Point::ZERO, Point::new(140.0, 20.0), Point::new(140.0, 60.0), Point::new(0.0, 40.0)]);
|
||||
// The arrow keys nudge them, each press one step, and Shift-click takes (179, 60) out.
|
||||
assert!(s.tool_claims_key(ToolKey::Left, v));
|
||||
s.tool_key(ToolKey::Left, Mods::default(), v).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 2);
|
||||
assert_eq!(corners(&s, id), vec![Point::ZERO, Point::new(139.0, 20.0), Point::new(139.0, 60.0), Point::new(0.0, 40.0)]);
|
||||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||||
s.pointer(&PointerEvent::new(kind, 179.0, 60.0).with_mods(shift), v).unwrap();
|
||||
}
|
||||
s.tool_key(ToolKey::Down, shift, v).unwrap();
|
||||
assert_eq!(corners(&s, id), vec![Point::ZERO, Point::new(139.0, 20.0), Point::new(139.0, 70.0), Point::new(0.0, 40.0)]);
|
||||
// A marquee across both frames' facing corners moves both frames in one step.
|
||||
drag(&mut s, "directSelection", Point::new(170.0, 10.0), Point::new(310.0, 65.0));
|
||||
assert_eq!(s.doc().unwrap().selection.objects.len(), 2);
|
||||
let n = undo_len(&s);
|
||||
drag(&mut s, "directSelection", Point::new(300.0, 40.0), Point::new(300.0, 30.0));
|
||||
assert_eq!(undo_len(&s), n + 1, "one step");
|
||||
assert_eq!(corners(&s, id)[1], Point::new(139.0, 10.0));
|
||||
assert_eq!(corners(&s, other), vec![Point::new(0.0, -10.0), Point::new(120.0, 0.0), Point::new(120.0, 40.0), Point::new(0.0, 40.0)]);
|
||||
// Shift-click on the other frame's only selected corner deselects that type; the corner of the
|
||||
// first frame still nudges.
|
||||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||||
s.pointer(&PointerEvent::new(kind, 300.0, 30.0).with_mods(shift), v).unwrap();
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![id]);
|
||||
s.tool_key(ToolKey::Up, Mods::default(), v).unwrap();
|
||||
assert_eq!(corners(&s, id)[1], Point::new(139.0, 9.0));
|
||||
// Deselect, then the same two selected again: they are selected whole, so the arrow keys move
|
||||
// them as objects.
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
sel(&mut s, &[id, other]);
|
||||
assert!(!s.tool_claims_key(ToolKey::Left, v));
|
||||
// A click on empty canvas deselects; the arrow keys are then no longer the tool's.
|
||||
drag(&mut s, "directSelection", Point::new(600.0, 500.0), Point::new(600.0, 500.0));
|
||||
assert!(!s.tool_claims_key(ToolKey::Left, v));
|
||||
}
|
||||
|
||||
/// `text.reshapeArea` with `items` moves several frames' anchors in one step, and fails whole.
|
||||
#[test]
|
||||
fn reshape_area_moves_the_anchors_of_several_frames() {
|
||||
let mut s = session();
|
||||
let a = area(&mut s, 40.0, 40.0, 120.0, 40.0, STORY);
|
||||
let b = area(&mut s, 300.0, 40.0, 120.0, 40.0, STORY);
|
||||
let n = undo_len(&s);
|
||||
let items = json!([{"id": a.0, "anchors": [[0, 2]]}, {"id": b.0, "anchors": [[0, 0], [0, 3]]}]);
|
||||
s.execute("text.reshapeArea", &json!({"items": items, "dx": -10, "dy": 5})).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1, "one step");
|
||||
assert_eq!(corners(&s, a)[2], Point::new(110.0, 45.0));
|
||||
assert_eq!(corners(&s, b), vec![Point::new(-10.0, 5.0), Point::new(120.0, 0.0), Point::new(120.0, 40.0), Point::new(-10.0, 45.0)]);
|
||||
for bad in [
|
||||
json!({"items": [], "dx": 1, "dy": 1}),
|
||||
json!({"items": {"id": a.0}, "dx": 1, "dy": 1}),
|
||||
json!({"items": [{"id": a.0, "anchors": [[0, 2]]}], "id": a.0, "dx": 1, "dy": 1}),
|
||||
json!({"items": [{"id": a.0, "anchors": [[0, 2]]}, {"id": a.0, "anchors": [[0, 1]]}], "dx": 1, "dy": 1}),
|
||||
// A bad item fails the whole step: the good one does not move either.
|
||||
json!({"items": [{"id": a.0, "anchors": [[0, 2]]}, {"id": b.0, "anchors": [[0, 9]]}], "dx": 1, "dy": 1}),
|
||||
json!({"items": [{"id": a.0, "anchors": [[0, 2]]}, {"anchors": [[0, 1]]}], "dx": 1, "dy": 1}),
|
||||
] {
|
||||
assert!(s.execute("text.reshapeArea", &bad).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(undo_len(&s), n + 1, "nothing more changed");
|
||||
assert_eq!(corners(&s, a)[2], Point::new(110.0, 45.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_resized_frame_reflows_its_thread() {
|
||||
let mut s = session();
|
||||
|
||||
Reference in New Issue
Block a user