mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-07 16:52:47 +00:00
Merge main into feat/native-save-precision
This commit is contained in:
+29
-14
@@ -140,33 +140,48 @@ pub fn backends(default: Backends, env_set: bool, opengl: bool) -> Backends {
|
||||
}
|
||||
|
||||
/// Whether a Windows start lists OpenGL's adapters beside Direct3D 12's: only when it leaves an
|
||||
/// adapter out (an earlier start crashed on it, or a restart failed on it) or DX12 lists none
|
||||
/// (Windows 7). wgpu's OpenGL backend keeps a hidden window on a thread that pumps no messages,
|
||||
/// and Windows waits on it when the input language changes, so the whole app hung on a switch to
|
||||
/// another keyboard layout (#4150, gfx-rs/wgpu#10504), even drawing with DX12.
|
||||
/// adapter out (an earlier start crashed on it, or a restart failed on it), when
|
||||
/// `WGPU_ADAPTER_NAME` names one (it may be an OpenGL adapter), or when DX12 lists no hardware
|
||||
/// adapter (Windows 7, or a GPU without a DX12 driver, which then draws through OpenGL as before
|
||||
/// rather than on the CPU with WARP). wgpu's OpenGL backend keeps a hidden window on a thread that
|
||||
/// pumps no messages, and Windows waits on it when the input language changes, so the whole app
|
||||
/// hung on a switch to another keyboard layout (#4150, gfx-rs/wgpu#10504), even drawing with DX12.
|
||||
pub fn windows_opengl(left_out: &LeftOut) -> bool {
|
||||
left_out.opengl_only || !left_out.adapters.is_empty() || !skipped().is_empty() || !dx12_lists_adapters()
|
||||
opengl_needed(left_out, !skipped().is_empty(), std::env::var_os(NAME_ENV).is_some(), dx12_lists_hardware)
|
||||
}
|
||||
|
||||
/// Whether Direct3D 12 lists any adapter, WARP's software one included. A driver that crashes here
|
||||
/// crashes a start while it lists the adapters, which the next start answers with OpenGL alone.
|
||||
/// [`windows_opengl`], with a restart leaving adapters out (`skipping`) and an adapter named
|
||||
/// (`named`): `dx12_hardware` is asked only when nothing else decides.
|
||||
fn opengl_needed(left_out: &LeftOut, skipping: bool, named: bool, dx12_hardware: impl FnOnce() -> bool) -> bool {
|
||||
left_out.opengl_only || !left_out.adapters.is_empty() || skipping || named || !dx12_hardware()
|
||||
}
|
||||
|
||||
/// Whether Direct3D 12 lists a hardware adapter; WARP, its software one, doesn't count. A driver
|
||||
/// that crashes here crashes a start while it lists the adapters, which the next start answers with
|
||||
/// OpenGL alone.
|
||||
#[cfg(windows)]
|
||||
fn dx12_lists_adapters() -> bool {
|
||||
fn dx12_lists_hardware() -> bool {
|
||||
let mut descriptor = wgpu::InstanceDescriptor::new_without_display_handle();
|
||||
descriptor.backends = Backends::DX12;
|
||||
// FXC is part of Windows, as for the window's own instance (#1194).
|
||||
descriptor.backend_options.dx12.shader_compiler = wgpu::Dx12Compiler::Fxc;
|
||||
// The shader compiler the window's own instance takes (#1194).
|
||||
descriptor.backend_options.dx12.shader_compiler = wgpu::Dx12Compiler::from_env().unwrap_or(wgpu::Dx12Compiler::Fxc);
|
||||
let instance = wgpu::Instance::new(descriptor);
|
||||
let found = pollster::block_on(instance.enumerate_adapters(Backends::DX12)).len();
|
||||
log::info!("Direct3D 12 lists {found} graphics adapters");
|
||||
found > 0
|
||||
let kinds: Vec<DeviceType> = pollster::block_on(instance.enumerate_adapters(Backends::DX12)).iter().map(|a| a.get_info().device_type).collect();
|
||||
log::info!("Direct3D 12 lists these graphics adapters: {kinds:?}");
|
||||
any_hardware(&kinds)
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn dx12_lists_adapters() -> bool {
|
||||
fn dx12_lists_hardware() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Is one of the adapters of these kinds hardware, not software such as WARP?
|
||||
#[cfg(any(windows, test))]
|
||||
fn any_hardware(kinds: &[DeviceType]) -> bool {
|
||||
kinds.iter().any(|k| *k != DeviceType::Cpu)
|
||||
}
|
||||
|
||||
/// The largest texture side to ask the device for, which bounds the window's surface: the
|
||||
/// adapter's own limit when it is above eframe's `default` (8192 px), so a window larger than
|
||||
/// that keeps its surface (#1459). Never below `default`, which is what was asked for before.
|
||||
|
||||
@@ -472,6 +472,31 @@ fn windows_lists_opengl_once_a_start_leaves_an_adapter_out() {
|
||||
assert_eq!(listing.backends(backends(Backends::all(), false, true)), Backends::GL);
|
||||
}
|
||||
|
||||
/// OpenGL stays where it drew before #4150's fix: a GPU without a Direct3D 12 driver (DX12 lists
|
||||
/// WARP alone) and an adapter `WGPU_ADAPTER_NAME` names; a start with a DX12 GPU lists DX12 alone.
|
||||
#[test]
|
||||
fn windows_lists_opengl_without_a_dx12_gpu_or_with_an_adapter_named() {
|
||||
let start = LeftOut::default();
|
||||
let asked = std::cell::Cell::new(false);
|
||||
let hardware = |found: bool| {
|
||||
asked.set(true);
|
||||
found
|
||||
};
|
||||
assert!(!opengl_needed(&start, false, false, || hardware(true)), "a DX12 GPU: DX12 alone (#4150)");
|
||||
assert!(asked.replace(false));
|
||||
assert!(opengl_needed(&start, false, false, || hardware(false)), "WARP alone: the GPU draws through OpenGL, not the CPU");
|
||||
assert!(opengl_needed(&start, false, true, || hardware(true)), "a named adapter may be an OpenGL one");
|
||||
assert!(opengl_needed(&start, true, false, || hardware(true)), "a restart left an adapter out");
|
||||
let listing = LeftOut { opengl_only: true, ..LeftOut::default() };
|
||||
asked.set(false);
|
||||
assert!(opengl_needed(&listing, false, false, || hardware(true)));
|
||||
assert!(!asked.get(), "DX12 isn't listed once OpenGL must be");
|
||||
assert!(any_hardware(&[DeviceType::Cpu, DeviceType::IntegratedGpu]));
|
||||
assert!(any_hardware(&[DeviceType::VirtualGpu]));
|
||||
assert!(!any_hardware(&[DeviceType::Cpu]), "WARP");
|
||||
assert!(!any_hardware(&[]), "Windows 7");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn power_preference_follows_the_environment_then_the_preference() {
|
||||
assert_eq!(power_preference(None, None), AUTOMATIC);
|
||||
|
||||
@@ -17,6 +17,11 @@
|
||||
# To remove your names, delete your entry (or set fields empty) in a PR. The next
|
||||
# contributors.py run, and the next build, drop them.
|
||||
|
||||
[people.el-j]
|
||||
real_name = "Fabian Althaus"
|
||||
display_name = "faff (el-j)"
|
||||
sync_github_name = false
|
||||
|
||||
[people.lollinoo]
|
||||
sync_github_name = true
|
||||
|
||||
|
||||
@@ -441,8 +441,34 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
_ => self.warn("layer effects of other kinds (left out)"),
|
||||
}
|
||||
}
|
||||
for a in s.objs(id, b"AdCh") {
|
||||
if let Some(effect) = self.attached_recolor(a) {
|
||||
out.push(effect);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
fn attached_recolor(&mut self, a: ObjId) -> Option<Effect> {
|
||||
let s = self.s;
|
||||
if !s.is(a, b"RcRA") {
|
||||
return None;
|
||||
}
|
||||
if s.bool(a, b"Visi") == Some(false) {
|
||||
return None;
|
||||
}
|
||||
let adjp = s.obj(a, b"AdjP")?;
|
||||
if !s.is(adjp, b"RecP") {
|
||||
return None;
|
||||
}
|
||||
let h = s.f64(adjp, b"RecH").unwrap_or(0.0);
|
||||
let sat = s.f64(adjp, b"RecS").unwrap_or(1.0);
|
||||
let l = s.f64(adjp, b"RecL").unwrap_or(0.5);
|
||||
let (r, g, b) = crate::paint::hsl(h, sat, l);
|
||||
let color = crate::paint::Color::Rgb { r, g, b, a: 1.0 };
|
||||
let opacity = s.f64(a, b"Opac").filter(|v| v.is_finite()).unwrap_or(1.0).clamp(0.0, 1.0);
|
||||
let blend = self.effect_blend(a);
|
||||
Some(Effect::ColorOverlay { color, opacity, blend })
|
||||
}
|
||||
|
||||
/// A layer effect's blend mode (`BlnM`); unknown modes are Normal (warned).
|
||||
fn effect_blend(&mut self, e: ObjId) -> Blend {
|
||||
@@ -479,6 +505,8 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
Some(_) => self.warn("several pixel masks on one layer (only the first is used)"),
|
||||
None => {}
|
||||
}
|
||||
} else if class == Tag::of(b"RcRA") {
|
||||
// Handled in `effects` as a colour overlay.
|
||||
} else {
|
||||
self.warn(format!("{} layers attached to other layers (imported without them)", describe(class)));
|
||||
}
|
||||
|
||||
@@ -194,7 +194,7 @@ fn color_within(r: &mut Reader, id: ObjId, depth: u32) -> Option<Color> {
|
||||
})
|
||||
}
|
||||
|
||||
fn hsl(h: f64, s: f64, l: f64) -> (f64, f64, f64) {
|
||||
pub(crate) fn hsl(h: f64, s: f64, l: f64) -> (f64, f64, f64) {
|
||||
let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
|
||||
let hp = (h.rem_euclid(1.0)) * 6.0;
|
||||
let x = c * (1.0 - (hp % 2.0 - 1.0).abs());
|
||||
@@ -355,3 +355,17 @@ pub(crate) fn stroke(r: &mut Reader, pen: ObjId, ldsc: ObjId, local: Affine, beh
|
||||
}
|
||||
Some(Stroke { paint, width, cap, join, miter_limit, align, dash, behind })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hsl_lightness_1_is_white_and_0_is_black() {
|
||||
assert_eq!(hsl(0.0, 1.0, 1.0), (1.0, 1.0, 1.0));
|
||||
assert_eq!(hsl(0.5, 1.0, 1.0), (1.0, 1.0, 1.0));
|
||||
assert_eq!(hsl(0.0, 1.0, 0.0), (0.0, 0.0, 0.0));
|
||||
let (r, g, b) = hsl(0.0, 1.0, 0.5);
|
||||
assert!((r - 1.0).abs() < 1e-6 && g.abs() < 1e-6 && b.abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
+34
-10
@@ -27,10 +27,16 @@ fn near(a: f64, b: f64, tol: f64) -> bool {
|
||||
(a - b).abs() <= tol
|
||||
}
|
||||
|
||||
/// Points along every outline under `n`, its anchors among them. Bent art keeps its curves and
|
||||
/// long lines since #3926, so its anchors alone no longer trace it.
|
||||
fn all_points(n: &Node, out: &mut Vec<Point>) {
|
||||
n.walk(&mut |c| {
|
||||
if let Some(p) = c.path_data() {
|
||||
out.extend(p.anchors().map(|(_, _, a)| a.p));
|
||||
for sp in c.path_data().into_iter().flat_map(|p| &p.subpaths) {
|
||||
out.extend(sp.anchors.iter().map(|a| a.p));
|
||||
for i in 0..sp.segment_count() {
|
||||
let seg = sp.segment(i);
|
||||
out.extend((1..32).map(|j| kurbo::ParamCurve::eval(&seg, j as f64 / 32.0)));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -185,9 +191,12 @@ fn art_brush_follows_curves() {
|
||||
}
|
||||
}
|
||||
let out = stroke_pieces(&Document::new(1.0, 1.0), &art_brush(rect_art(10.0, 4.0), ArtScale::Stretch), &bp_open, &stroke(1.0));
|
||||
// The long edges bend as curves (#3926), where they used to be cut into some 400 chords. The
|
||||
// path is run along in 0.05 pt chords, which the curves follow, so a few dozen remain.
|
||||
let (anchors, curved) = anchors_and_curved(&out[0]);
|
||||
assert!(anchors < 100 && curved > anchors / 2, "long edges bend as curves: {curved} of {anchors} anchors with handles");
|
||||
let mut pts = vec![];
|
||||
all_points(&out[0], &mut pts);
|
||||
assert!(pts.len() > 40, "long edges are subdivided so they bend: {}", pts.len());
|
||||
for p in pts {
|
||||
let r = p.distance(Point::new(100.0, 100.0));
|
||||
assert!((r - 100.0).abs() <= 2.0 + 0.3, "point {p:?} is {r} from the centre");
|
||||
@@ -1226,11 +1235,12 @@ fn a_pattern_tile_keeps_its_curves_on_a_straight_path() {
|
||||
assert!(near(b.height(), 40.0, 1.0), "and still stand the tile's height: {}", b.height());
|
||||
}
|
||||
|
||||
/// The other half of the same behaviour: where the path really does bend, the tile is still
|
||||
/// subdivided, because an affine cannot reproduce a bend. The fit is checked before it is used,
|
||||
/// so a bent run falls back to exactly what it did before.
|
||||
/// The other half of the same behaviour: where the path really does bend, an affine cannot
|
||||
/// reproduce the bend, so the tile is bent, and it still comes out as curves: cubics fitted to the
|
||||
/// bent art rather than the chords it used to be flattened into (#3926). Here a 4-anchor circle
|
||||
/// tile runs round a circle of radius 200, so its outline stays in the band 20 either side of it.
|
||||
#[test]
|
||||
fn a_pattern_tile_on_a_bent_path_is_still_subdivided() {
|
||||
fn a_pattern_tile_on_a_bent_path_keeps_its_curves() {
|
||||
let circle =
|
||||
Node::path(NodeId(0), shapes::ellipse(Rect::new(0.0, -20.0, 40.0, 20.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
|
||||
let brush = Brush {
|
||||
@@ -1240,9 +1250,23 @@ fn a_pattern_tile_on_a_bent_path_is_still_subdivided() {
|
||||
// A circle to run along: every tile sits on a curving section.
|
||||
let round = shapes::ellipse(Rect::new(0.0, 0.0, 400.0, 400.0)).to_bezpath();
|
||||
let out = stroke_pieces(&Document::new(1.0, 1.0), &brush, &round, &stroke(1.0));
|
||||
assert!(!out.is_empty());
|
||||
let bent = out.iter().filter(|n| anchors_and_curved(n).0 > 8).count();
|
||||
assert!(bent > out.len() / 2, "tiles on a bend are still densified: {bent} of {}", out.len());
|
||||
assert!(out.len() > 20, "tiles all the way round: {}", out.len());
|
||||
let (mut lo, mut hi) = (f64::MAX, f64::MIN);
|
||||
for n in &out {
|
||||
let (anchors, curved) = anchors_and_curved(n);
|
||||
assert!((4..=16).contains(&anchors), "a bent circle tile is a few curves, not a polygon: {anchors} anchors");
|
||||
assert!(curved == anchors, "every anchor keeps a handle: {curved} of {anchors}");
|
||||
let mut pts = vec![];
|
||||
all_points(n, &mut pts);
|
||||
for q in pts {
|
||||
let r = q.distance(Point::new(200.0, 200.0));
|
||||
(lo, hi) = (lo.min(r), hi.max(r));
|
||||
}
|
||||
}
|
||||
// The path is a cubic circle (0.06 out at most) run along in chords of 0.05, and the fit holds
|
||||
// a quarter of the 0.25 flattening tolerance, so the band holds to 0.2.
|
||||
assert!(lo > 179.8 && hi < 220.2, "the curves stay in the tile's band round the path: {lo:.3}..{hi:.3}");
|
||||
assert!(lo < 180.2 && hi > 219.8, "and reach both edges of it: {lo:.3}..{hi:.3}");
|
||||
}
|
||||
|
||||
/// The kind of every node in a piece of brushed art, in paint order.
|
||||
|
||||
+268
-56
@@ -1,13 +1,15 @@
|
||||
//! Art, scatter and pattern brushes: art bent along the path by arc length.
|
||||
//!
|
||||
//! Art coordinates `(x, y)` map to the path point at arc length `s(x)` offset by
|
||||
//! `(y - centre) × cross` along the path's left normal. Art outlines are flattened and
|
||||
//! subdivided first so long straight edges bend with the path instead of cutting chords.
|
||||
//! `(y - centre) × cross` along the path's left normal. Art outlines come out as cubics fitted
|
||||
//! to the bent art, so curves stay curves and long straight edges bend with the path (#3926);
|
||||
//! where that would take more anchors than chords, they are flattened and subdivided instead.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use kurbo::ParamCurveNearest;
|
||||
use vectorcraft_doc::{Appearance, Node, NodeId, NodeKind, PressureProfile};
|
||||
use vectorcraft_geom::{Affine, BezPath, PathData, PathEl, Point, Rect, SubPath, Vec2};
|
||||
use vectorcraft_geom::{Affine, BezPath, CubicBez, ParamCurve, PathData, PathEl, Point, Rect, SubPath, Vec2};
|
||||
|
||||
use crate::track::{Rng, Track, normal, seed_of, tracks};
|
||||
use crate::{ArtBrush, ArtScale, CornerSource, Direction, PatternBrush, PatternFit, Scatter, Variation, tolerance};
|
||||
@@ -34,22 +36,26 @@ const MAX_WALK: usize = 10_000;
|
||||
/// Points on each side of the box an Auto-Sliced pattern brush corner masks its tiles with.
|
||||
const MASK_STEPS: usize = 32;
|
||||
|
||||
/// Bend every path under `n` through `f`. Paths are flattened with `flat_tol` (art space) and
|
||||
/// segments subdivided so none is longer than `max_du` along x. Stroke widths scale by
|
||||
/// `width_scale`. Text, images and symbol instances can't bend and are dropped.
|
||||
pub(crate) fn warp_node(n: &Node, f: &dyn Fn(Point) -> Point, flat_tol: f64, max_du: f64, width_scale: f64) -> Option<Node> {
|
||||
/// Bend every path under `n` through `f`. Each art segment comes out as cubics that follow it
|
||||
/// through the bend to within [`fit_tol`] of `doc_tol`, the flattening tolerance in document
|
||||
/// space, so the art keeps its curves (#3926). Where that cannot be done in no more anchors than
|
||||
/// chords would take, the path is flattened with `flat_tol` (art space) and subdivided so no chord
|
||||
/// is longer than `max_du` along x, as it always was. Stroke widths scale by `width_scale`.
|
||||
/// Text, images and symbol instances can't bend and are dropped.
|
||||
pub(crate) fn warp_node(n: &Node, f: &dyn Fn(Point) -> Point, flat_tol: f64, max_du: f64, doc_tol: f64, width_scale: f64) -> Option<Node> {
|
||||
let mut out = n.clone();
|
||||
out.appearance.scale_strokes(width_scale);
|
||||
match &mut out.kind {
|
||||
NodeKind::Path { path, live, .. } => {
|
||||
*live = None;
|
||||
*path = warp_path(path, f, flat_tol, max_du);
|
||||
*path = warp_path(path, f, flat_tol, max_du, fit_tol(doc_tol));
|
||||
if path.is_empty() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } | NodeKind::Compound { children, .. } => {
|
||||
let kids: Vec<Arc<Node>> = children.iter().filter_map(|c| warp_node(c, f, flat_tol, max_du, width_scale)).map(Arc::new).collect();
|
||||
let kids: Vec<Arc<Node>> =
|
||||
children.iter().filter_map(|c| warp_node(c, f, flat_tol, max_du, doc_tol, width_scale)).map(Arc::new).collect();
|
||||
if kids.is_empty() {
|
||||
return None;
|
||||
}
|
||||
@@ -60,54 +66,156 @@ pub(crate) fn warp_node(n: &Node, f: &dyn Fn(Point) -> Point, flat_tol: f64, max
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn warp_path(p: &PathData, f: &dyn Fn(Point) -> Point, flat_tol: f64, max_du: f64) -> PathData {
|
||||
let bp = p.to_bezpath();
|
||||
/// Each subpath of `p` through `f`: fitted cubics when they take no more anchors than the chords
|
||||
/// would, else the chords. Points `f` cannot place (not finite) are left out of the chords.
|
||||
fn warp_path(p: &PathData, f: &dyn Fn(Point) -> Point, flat_tol: f64, max_du: f64, fit_tol: f64) -> PathData {
|
||||
let mut subs = vec![];
|
||||
let mut cur: Vec<Point> = vec![];
|
||||
let mut els = vec![];
|
||||
kurbo::flatten(bp.iter(), flat_tol.max(1e-4), |e| els.push(e));
|
||||
let finish = |cur: &mut Vec<Point>, closed: bool, subs: &mut Vec<SubPath>| {
|
||||
let pts = std::mem::take(cur);
|
||||
if pts.len() < 2 {
|
||||
return;
|
||||
for sp in &p.subpaths {
|
||||
let Some(dense) = densified(sp, flat_tol, max_du) else { continue };
|
||||
if let Some(fit) = fitted(sp, f, fit_tol, dense.len()) {
|
||||
subs.push(fit);
|
||||
continue;
|
||||
}
|
||||
let mut dense = vec![pts[0]];
|
||||
let n = pts.len();
|
||||
let segs = if closed { n } else { n - 1 };
|
||||
for i in 0..segs {
|
||||
let (a, b) = (pts[i], pts[(i + 1) % n]);
|
||||
let k = (((b.x - a.x).abs() / max_du).ceil() as usize).clamp(1, MAX_SUB);
|
||||
for j in 1..=k {
|
||||
let q = a.lerp(b, j as f64 / k as f64);
|
||||
if closed && i == segs - 1 && j == k {
|
||||
break;
|
||||
}
|
||||
dense.push(q);
|
||||
}
|
||||
}
|
||||
let mapped: Vec<Point> = dense.into_iter().map(f).collect();
|
||||
subs.push(SubPath::polyline(&mapped, closed));
|
||||
};
|
||||
for e in els {
|
||||
match e {
|
||||
PathEl::MoveTo(q) => {
|
||||
finish(&mut cur, false, &mut subs);
|
||||
cur.push(q);
|
||||
}
|
||||
PathEl::LineTo(q) => cur.push(q),
|
||||
PathEl::ClosePath => {
|
||||
if cur.len() > 1 && cur[0].distance(cur[cur.len() - 1]) < 1e-9 {
|
||||
cur.pop();
|
||||
}
|
||||
finish(&mut cur, true, &mut subs);
|
||||
}
|
||||
_ => {}
|
||||
let mapped: Vec<Point> = dense.into_iter().map(f).filter(|q| q.is_finite()).collect();
|
||||
if mapped.len() >= 2 {
|
||||
subs.push(SubPath::polyline(&mapped, sp.closed));
|
||||
}
|
||||
}
|
||||
finish(&mut cur, false, &mut subs);
|
||||
PathData::new(subs)
|
||||
}
|
||||
|
||||
/// `sp` flattened with `flat_tol` and its chords subdivided so none is longer than `max_du` along
|
||||
/// x, in art space: the points the chord route maps. `None` below two points.
|
||||
fn densified(sp: &SubPath, flat_tol: f64, max_du: f64) -> Option<Vec<Point>> {
|
||||
let mut bp = BezPath::new();
|
||||
sp.to_bezpath_into(&mut bp);
|
||||
let mut pts = vec![];
|
||||
kurbo::flatten(bp.iter(), flat_tol.max(1e-4), |e| {
|
||||
if let PathEl::MoveTo(q) | PathEl::LineTo(q) = e {
|
||||
pts.push(q);
|
||||
}
|
||||
});
|
||||
if sp.closed && pts.len() > 1 && pts.first()?.distance(*pts.last()?) < 1e-9 {
|
||||
pts.pop();
|
||||
}
|
||||
if pts.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let segs = if sp.closed { pts.len() } else { pts.len() - 1 };
|
||||
let mut dense = vec![*pts.first()?];
|
||||
for (i, (&a, &b)) in pts.iter().zip(pts.iter().cycle().skip(1)).take(segs).enumerate() {
|
||||
let k = (((b.x - a.x).abs() / max_du).ceil() as usize).clamp(1, MAX_SUB);
|
||||
for j in 1..=k {
|
||||
if sp.closed && i == segs - 1 && j == k {
|
||||
break;
|
||||
}
|
||||
dense.push(a.lerp(b, j as f64 / k as f64));
|
||||
}
|
||||
}
|
||||
Some(dense)
|
||||
}
|
||||
|
||||
/// Times an art segment is halved at most while looking for cubics that fit it.
|
||||
const FIT_DEPTH: u32 = 12;
|
||||
/// Parameter step of the finite differences that stand in for the bend's derivative.
|
||||
const FIT_DT: f64 = 1e-6;
|
||||
/// Sections a fitted piece is checked at the ends of (the ends themselves are exact).
|
||||
const FIT_CHECKS: usize = 8;
|
||||
/// Share of the flattening tolerance the fitted curves hold to. The chords were exact at their
|
||||
/// points and a few hundredths off between them, so a straight edge bends about as closely.
|
||||
const FIT_SHARE: f64 = 0.25;
|
||||
/// Tolerance paths are flattened to before art is laid along them.
|
||||
const TRACK_TOL: f64 = 0.05;
|
||||
|
||||
/// The distance (document space) fitted curves keep to the bent art, for flattening tolerance
|
||||
/// `doc_tol`: never below [`TRACK_TOL`], since following the flattened path more closely than it
|
||||
/// follows the real one only traces its chords.
|
||||
fn fit_tol(doc_tol: f64) -> f64 {
|
||||
(doc_tol * FIT_SHARE).max(TRACK_TOL)
|
||||
}
|
||||
|
||||
/// `sp` through `f` as cubics within `tol` (document space), one or more per art segment so its
|
||||
/// anchors, corners included, stay anchors. `None` when that takes more than `most` anchors, when
|
||||
/// a piece still does not fit after [`FIT_DEPTH`] halvings, or when `f` cannot place a point.
|
||||
///
|
||||
/// Each piece is the cubic with the bent piece's ends and their derivatives, which is the piece
|
||||
/// itself where the bend is affine and converges fast where it is not. It is checked against the
|
||||
/// bend at the same parameters, so the distance measured is never less than the true one. The
|
||||
/// halving is our own rather than kurbo's `fit_to_bezpath`, which halves until the floats run out
|
||||
/// on a map that never fits; the depth and the anchor count bound this one.
|
||||
fn fitted(sp: &SubPath, f: &dyn Fn(Point) -> Point, tol: f64, most: usize) -> Option<SubPath> {
|
||||
if !(tol.is_finite() && tol > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let start = f(sp.anchors.first()?.p);
|
||||
if !start.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let mut bp = BezPath::new();
|
||||
bp.move_to(start);
|
||||
// Pieces allowed: an open subpath's first anchor is one more, a closed one's last piece ends on it.
|
||||
let mut room = if sp.closed { most } else { most.checked_sub(1)? };
|
||||
for i in 0..sp.segment_count() {
|
||||
let c = sp.segment(i);
|
||||
let line = sp.segment_is_line(i);
|
||||
// A line's handles sit on its ends, which stalls its parameter there; thirds keep it even.
|
||||
let c = if line { CubicBez::new(c.p0, c.p0.lerp(c.p3, 1.0 / 3.0), c.p0.lerp(c.p3, 2.0 / 3.0), c.p3) } else { c };
|
||||
let at = |t: f64| Some(f(c.eval(t))).filter(|q| q.is_finite());
|
||||
fit_into(&at, (0.0, 1.0), line, tol, FIT_DEPTH, &mut bp, &mut room)?;
|
||||
}
|
||||
if sp.closed {
|
||||
bp.close_path();
|
||||
}
|
||||
// The last piece ends on the first anchor's image, so a closed subpath merges it back in; the
|
||||
// count is checked again for a map that jumps right there and keeps the two apart.
|
||||
PathData::from_bezpath(&bp).subpaths.into_iter().next().filter(|s| s.anchors.len() <= most)
|
||||
}
|
||||
|
||||
/// Pieces for parameters `t0..t1` of the bent segment `at` onto `bp`, which ends at `at(t0)`:
|
||||
/// halved until each is within `tol`, at most `depth` times and `room` pieces. A `line` that is
|
||||
/// still straight within `tol` stays a line.
|
||||
fn fit_into(
|
||||
at: &dyn Fn(f64) -> Option<Point>,
|
||||
(t0, t1): (f64, f64),
|
||||
line: bool,
|
||||
tol: f64,
|
||||
depth: u32,
|
||||
bp: &mut BezPath,
|
||||
room: &mut usize,
|
||||
) -> Option<()> {
|
||||
let span = t1 - t0;
|
||||
let (p0, p3) = (at(t0)?, at(t1)?);
|
||||
// Derivatives read from inside the piece, so a kink at either end (a corner, a turn of the
|
||||
// track) stays one.
|
||||
let d0 = (at(t0 + FIT_DT)? - p0) * (span / FIT_DT);
|
||||
let d1 = (p3 - at(t1 - FIT_DT)?) * (span / FIT_DT);
|
||||
let c = CubicBez::new(p0, p0 + d0 / 3.0, p3 - d1 / 3.0, p3);
|
||||
let chord = kurbo::Line::new(p0, p3);
|
||||
let (mut curve_fits, mut line_fits) = (c.is_finite(), line);
|
||||
for k in 1..FIT_CHECKS {
|
||||
if !(curve_fits || line_fits) {
|
||||
break;
|
||||
}
|
||||
let u = k as f64 / FIT_CHECKS as f64;
|
||||
let q = at(t0 + span * u)?;
|
||||
curve_fits &= q.distance(c.eval(u)) <= tol;
|
||||
line_fits &= chord.nearest(q, 1e-9).distance_sq <= tol * tol;
|
||||
}
|
||||
if line_fits || curve_fits {
|
||||
*room = room.checked_sub(1)?;
|
||||
if line_fits {
|
||||
bp.line_to(p3);
|
||||
} else {
|
||||
bp.curve_to(c.p1, c.p2, p3);
|
||||
}
|
||||
return Some(());
|
||||
}
|
||||
let depth = depth.checked_sub(1)?;
|
||||
let mid = t0 + span / 2.0;
|
||||
fit_into(at, (t0, mid), line, tol, depth, bp, room)?;
|
||||
fit_into(at, (mid, t1), line, tol, depth, bp, room)
|
||||
}
|
||||
|
||||
/// Art turned so it runs left → right, flipped as requested; returns it with its bounds.
|
||||
fn oriented(art: &Node, dir: Direction, flip_along: bool, flip_across: bool) -> Option<(Node, Rect)> {
|
||||
let mut a = art.clone();
|
||||
@@ -281,7 +389,7 @@ fn bend(t: &Track, art: &Node, b: Rect, along: AlongMap, cross: f64) -> Option<N
|
||||
{
|
||||
return affine_node(art, a, cross.abs());
|
||||
}
|
||||
warp_node(art, &f, flat_tol, MAX_SEG / along.max_scale(), cross.abs())
|
||||
warp_node(art, &f, flat_tol, MAX_SEG / along.max_scale(), flat_tol * scale, cross.abs())
|
||||
}
|
||||
|
||||
/// [`bend`], with the cross scale at arc length `s` multiplied by `widen(s)` (0..=1). The strokes
|
||||
@@ -297,7 +405,7 @@ fn bend_widened(t: &Track, art: &Node, b: Rect, along: AlongMap, cross: f64, wid
|
||||
let art = widen_strokes(art, &factor);
|
||||
let scale = along.max_scale().max(cross.abs()).max(1e-6);
|
||||
let flat_tol = tolerance(b.height().max(b.width()) * scale) / scale;
|
||||
warp_node(&art, &f, flat_tol, MAX_SEG / along.max_scale(), cross.abs())
|
||||
warp_node(&art, &f, flat_tol, MAX_SEG / along.max_scale(), flat_tol * scale, cross.abs())
|
||||
}
|
||||
|
||||
/// `n` with the strokes of it and of everything inside it scaled by `factor` of the object they
|
||||
@@ -377,7 +485,7 @@ pub(crate) fn art(a: &ArtBrush, bp: &BezPath, weight: f64, pressure: Option<&Pre
|
||||
let width = light.max(heavy);
|
||||
let cross = weight * width / 100.0;
|
||||
let mut out = vec![];
|
||||
for t in tracks(bp, 0.05) {
|
||||
for t in tracks(bp, TRACK_TOL) {
|
||||
let l = t.len();
|
||||
if l <= 1e-9 {
|
||||
continue;
|
||||
@@ -419,7 +527,7 @@ pub(crate) fn scatter(sc: &Scatter, bp: &BezPath, weight: f64, pressure: Option<
|
||||
let mut rng = Rng::new(seed_of(bp, name));
|
||||
let [size_mode, spacing_mode, scatter_mode, rotation_mode] = sc.modes();
|
||||
let mut out = Budget::default();
|
||||
for t in tracks(bp, 0.05) {
|
||||
for t in tracks(bp, TRACK_TOL) {
|
||||
if out.full() {
|
||||
break;
|
||||
}
|
||||
@@ -588,7 +696,7 @@ fn auto_corner(t: &Track, i: usize, source: CornerSource, side: &(Node, Rect), k
|
||||
}
|
||||
let s = if tw > 1e-9 { len / tw } else { 1.0 };
|
||||
let max_du = MAX_SEG / (2.0 * k * s.max(1.0));
|
||||
let warp = |f: &dyn Fn(Point) -> Point| warp_node(art, f, flat_tol, max_du, k);
|
||||
let warp = |f: &dyn Fn(Point) -> Point| warp_node(art, f, flat_tol, max_du, flat_tol * k, k);
|
||||
// Art point → (distance along from the left edge, stretched to `len`, and offset across).
|
||||
let along = move |q: Point| {
|
||||
let (x, y) = local(q);
|
||||
@@ -686,7 +794,7 @@ pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec<Node>
|
||||
let outer_tile = outer.as_ref().map(Corner::Art).or(pb.outer_source.map(Corner::Source));
|
||||
let inner_tile = inner.as_ref().map(Corner::Art).or(pb.inner_source.map(Corner::Source));
|
||||
let mut out = Budget::default();
|
||||
for t in tracks(bp, 0.05) {
|
||||
for t in tracks(bp, TRACK_TOL) {
|
||||
if out.full() {
|
||||
break;
|
||||
}
|
||||
@@ -921,4 +1029,108 @@ mod tests {
|
||||
let moved = bend(&straight, &circle, b, along, cross).expect("the straight run still draws something");
|
||||
assert_eq!(anchors(&moved), 4, "the straight run keeps the tile's curves");
|
||||
}
|
||||
|
||||
/// A 4-anchor circle tile filling `b`.
|
||||
fn circle_tile(b: Rect) -> Node {
|
||||
Node::path(
|
||||
NodeId(0),
|
||||
vectorcraft_geom::shapes::ellipse(b),
|
||||
Appearance::basic(vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), vectorcraft_color::Paint::None, 0.0),
|
||||
)
|
||||
}
|
||||
|
||||
/// `per` points along each segment of `sp`, through `f`.
|
||||
fn traced(sp: &SubPath, f: &dyn Fn(Point) -> Point, per: usize) -> Vec<Point> {
|
||||
(0..sp.segment_count())
|
||||
.flat_map(|i| {
|
||||
let c = sp.segment(i);
|
||||
(0..per).map(move |j| f(c.eval(j as f64 / per as f64)))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// How far apart two closed outlines get (Hausdorff distance, each taken as a polygon).
|
||||
fn apart(a: &[Point], b: &[Point]) -> f64 {
|
||||
let to = |q: Point, pts: &[Point]| {
|
||||
pts.iter()
|
||||
.zip(pts.iter().cycle().skip(1))
|
||||
.map(|(&a, &b)| {
|
||||
let ab = b - a;
|
||||
let t = ((q - a).dot(ab) / ab.hypot2().max(1e-18)).clamp(0.0, 1.0);
|
||||
q.distance(a + ab * t)
|
||||
})
|
||||
.fold(f64::MAX, f64::min)
|
||||
};
|
||||
let one_way = |a: &[Point], b: &[Point]| a.iter().map(|&q| to(q, b)).fold(0.0, f64::max);
|
||||
one_way(a, b).max(one_way(b, a))
|
||||
}
|
||||
|
||||
/// #3926: on a bend the tile is fitted with cubics instead of flattened into chords. A circle
|
||||
/// tile bent round a circle stays a handful of smooth curves, closer to the bent art than the
|
||||
/// chords were, in a small fraction of their anchors.
|
||||
#[test]
|
||||
fn a_circle_tile_bent_round_a_circle_stays_a_few_curves() {
|
||||
let round = vectorcraft_geom::shapes::ellipse(Rect::new(0.0, 0.0, 400.0, 400.0)).to_bezpath();
|
||||
let t = tracks(&round, 0.05).remove(0);
|
||||
let b = Rect::new(0.0, -20.0, 40.0, 20.0);
|
||||
let along = AlongMap::linear(b.width(), 100.0, 140.0);
|
||||
let tile = circle_tile(b);
|
||||
let NodeKind::Path { path: art, .. } = &tile.kind else { panic!("the tile is a path") };
|
||||
let art = &art.subpaths[0];
|
||||
|
||||
let bent = bend(&t, &tile, b, along, 1.0).expect("the tile is drawn");
|
||||
let NodeKind::Path { path, .. } = &bent.kind else { panic!("a bent path is a path") };
|
||||
assert_eq!(path.subpaths.len(), 1);
|
||||
let sp = &path.subpaths[0];
|
||||
assert!(sp.closed, "still a closed outline");
|
||||
let n = sp.anchors.len();
|
||||
assert!((4..=16).contains(&n), "a few anchors, the tile's own four among them: {n}");
|
||||
assert!(sp.anchors.iter().all(|a| a.has_in() && a.has_out()), "every anchor keeps both handles");
|
||||
assert!(sp.anchors.first().map(|a| a.p) != sp.anchors.last().map(|a| a.p), "closed without a duplicate anchor");
|
||||
|
||||
// The same map `bend` uses, at scale 1, and the tolerance it fits to.
|
||||
let (cy, x0) = (b.center().y, b.x0);
|
||||
let f = |p: Point| t.map(along.at(p.x - x0), p.y - cy);
|
||||
let tol = tolerance(b.height().max(b.width()));
|
||||
let exact = traced(art, &f, 1000);
|
||||
let chords: Vec<Point> = densified(art, tol, MAX_SEG).expect("the tile flattens").into_iter().map(f).collect();
|
||||
let curves = traced(sp, &|p| p, 200);
|
||||
let (e_curves, e_chords) = (apart(&curves, &exact), apart(&chords, &exact));
|
||||
// Checked at eight points a piece, so a little room between them.
|
||||
assert!(e_curves <= fit_tol(tol) * 1.5, "the curves follow the bent art within {}: {e_curves:.4}", fit_tol(tol));
|
||||
assert!(e_curves < e_chords, "closer than the chords were: {e_curves:.4} against {e_chords:.4}");
|
||||
assert!(chords.len() >= 4 * n, "far fewer anchors than the {} chords took: {n}", chords.len());
|
||||
}
|
||||
|
||||
/// Fitting is bounded however badly the map behaves: where it gives points that are not finite
|
||||
/// the tile falls back to chords with those points left out (or to nothing), and where it never
|
||||
/// fits (a map that jumps everywhere) the halving stops at its depth and anchor count. Either
|
||||
/// way the call comes back promptly with finite geometry.
|
||||
#[test]
|
||||
fn a_map_that_misbehaves_still_returns_promptly_and_finite() {
|
||||
let b = Rect::new(0.0, -20.0, 40.0, 20.0);
|
||||
let tile = circle_tile(b);
|
||||
let finite = |n: &Node| match &n.kind {
|
||||
NodeKind::Path { path, .. } => {
|
||||
path.subpaths.iter().flat_map(|s| &s.anchors).all(|a| a.p.is_finite() && a.h_in.is_finite() && a.h_out.is_finite())
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
let maps: [&dyn Fn(Point) -> Point; 4] = [
|
||||
&|p| if p.x > 25.0 { Point::new(f64::NAN, p.y) } else { p },
|
||||
&|p| if p.y > 10.0 { Point::new(f64::INFINITY, p.y) } else { p + Vec2::new(p.x.sin(), 0.0) },
|
||||
&|p| Point::new(p.x, p.y + if (p.x * 1e3).sin() > 0.0 { 5.0 } else { 0.0 }),
|
||||
&|_| Point::new(f64::NAN, f64::NAN),
|
||||
];
|
||||
for (i, f) in maps.iter().enumerate() {
|
||||
let t0 = std::time::Instant::now();
|
||||
let out = warp_node(&tile, f, 0.25, 1.5, 0.25, 1.0);
|
||||
assert!(t0.elapsed() < std::time::Duration::from_secs(2), "map {i} took {:?}", t0.elapsed());
|
||||
match (i, &out) {
|
||||
(3, out) => assert!(out.is_none(), "a map that places nothing draws nothing"),
|
||||
(_, Some(n)) => assert!(finite(n), "map {i} drew non-finite geometry"),
|
||||
(_, None) => panic!("map {i} places most points, so something is drawn"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,7 +125,7 @@ pub fn oversized_tiff(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||
}
|
||||
|
||||
/// The color type of TIFF `bytes` and its decoder; `None` for [`oversized_tiff`] images too.
|
||||
fn tiff(bytes: &[u8]) -> Option<(tiff::ColorType, tiff::decoder::Decoder<Cursor<&[u8]>>)> {
|
||||
pub(crate) fn tiff(bytes: &[u8]) -> Option<(tiff::ColorType, tiff::decoder::Decoder<Cursor<&[u8]>>)> {
|
||||
if !is_tiff(bytes) || oversized_tiff(bytes).is_some() {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
//! Compound shapes (Pathfinder panel › Make Compound Shape): a group whose members each have a
|
||||
//! shape mode. It draws as the one area the modes make, from the backmost member up, painted with
|
||||
//! the group's own appearance; the members stay editable. Evaluated in `vectorcraft-effects`.
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
|
||||
use crate::{Node, NodeKind};
|
||||
|
||||
/// How a compound shape's member combines with the area of the members below it. The backmost
|
||||
/// member's mode does not matter: it starts the area.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ShapeMode {
|
||||
/// Adds the member's area.
|
||||
#[default]
|
||||
Add,
|
||||
/// Takes the member's area away.
|
||||
Subtract,
|
||||
/// Keeps only what the member's area overlaps.
|
||||
Intersect,
|
||||
/// Keeps where exactly one of the area and the member's area is.
|
||||
Exclude,
|
||||
}
|
||||
|
||||
impl ShapeMode {
|
||||
/// Every mode, in the Pathfinder panel's order.
|
||||
pub const ALL: [ShapeMode; 4] = [ShapeMode::Add, ShapeMode::Subtract, ShapeMode::Intersect, ShapeMode::Exclude];
|
||||
|
||||
/// The mode's name in commands and files.
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
ShapeMode::Add => "add",
|
||||
ShapeMode::Subtract => "subtract",
|
||||
ShapeMode::Intersect => "intersect",
|
||||
ShapeMode::Exclude => "exclude",
|
||||
}
|
||||
}
|
||||
|
||||
/// The mode named `s` ([`ShapeMode::name`]).
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|m| m.name() == s)
|
||||
}
|
||||
}
|
||||
|
||||
/// Files are untrusted: a mode that isn't one of the names reads as Add.
|
||||
impl<'de> Deserialize<'de> for ShapeMode {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let v = serde_json::Value::deserialize(d)?;
|
||||
Ok(v.as_str().and_then(ShapeMode::parse).unwrap_or_default())
|
||||
}
|
||||
}
|
||||
|
||||
impl Node {
|
||||
/// Is this a compound shape? Only a group that doesn't clip can be one: the mark on anything
|
||||
/// else (from a file) is ignored.
|
||||
pub fn is_compound_shape(&self) -> bool {
|
||||
matches!(self.kind, NodeKind::Group { clip: false, .. }) && self.extras().compound_shape
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn modes_read_back_and_unknown_ones_are_add() {
|
||||
for m in ShapeMode::ALL {
|
||||
let v = serde_json::to_value(m).unwrap();
|
||||
assert_eq!(v, serde_json::json!(m.name()));
|
||||
assert_eq!(serde_json::from_value::<ShapeMode>(v).unwrap(), m);
|
||||
}
|
||||
for v in [serde_json::json!("divide"), serde_json::json!(3), serde_json::json!(null), serde_json::json!({"a": [1]})] {
|
||||
assert_eq!(serde_json::from_value::<ShapeMode>(v).unwrap(), ShapeMode::Add);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -109,9 +109,9 @@ fn visit_paint(p: &mut Paint, f: &mut ColorVisitor) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Visit every colour of a node tree (fills, strokes, gradient stops, text runs, mesh points, not
|
||||
/// opacity masks) with its swatch link and tint. Mesh points have no link: changes to it are
|
||||
/// dropped.
|
||||
/// Visit every colour of a node tree (fills, strokes, gradient stops, text runs, mesh points, an
|
||||
/// image's colorize color, not opacity masks) with its swatch link and tint. Mesh points have no
|
||||
/// link: changes to it are dropped.
|
||||
pub fn visit_node_colors(n: &mut Node, f: &mut ColorVisitor) {
|
||||
for it in &mut n.appearance.items {
|
||||
match it {
|
||||
@@ -119,6 +119,9 @@ pub fn visit_node_colors(n: &mut Node, f: &mut ColorVisitor) {
|
||||
AppearanceItem::Stroke(l) => visit_paint(&mut l.paint, f),
|
||||
}
|
||||
}
|
||||
if let Some(p) = n.extras.0.as_deref_mut().and_then(|e| e.colorize.as_deref_mut()) {
|
||||
visit_paint(p, f);
|
||||
}
|
||||
match &mut n.kind {
|
||||
NodeKind::Text(t) => {
|
||||
for r in &mut t.runs {
|
||||
|
||||
@@ -11,6 +11,7 @@ pub mod blend;
|
||||
pub mod cancel;
|
||||
pub mod clipnest;
|
||||
pub mod cmyk;
|
||||
pub mod compound_shape;
|
||||
pub mod corners;
|
||||
pub mod depth;
|
||||
pub mod dimension;
|
||||
@@ -42,7 +43,7 @@ pub mod overprint;
|
||||
pub mod pattern;
|
||||
pub mod perspective;
|
||||
mod pixels;
|
||||
pub use pixels::MAX_RASTER_ALLOC;
|
||||
pub use pixels::{GraySource, MAX_RASTER_ALLOC};
|
||||
pub mod placed_document;
|
||||
pub mod plugin_data;
|
||||
pub mod profiles;
|
||||
@@ -76,6 +77,7 @@ pub use appearance::{
|
||||
StrokeAlign, StrokeLayer, WidthProfile,
|
||||
};
|
||||
pub use assets::ExportAsset;
|
||||
pub use compound_shape::ShapeMode;
|
||||
pub use corners::LiveCorners;
|
||||
pub use depth::{MAX_DEPTH, MAX_SYMBOL_NODES};
|
||||
pub use graph::{
|
||||
|
||||
@@ -221,6 +221,25 @@ mod tests {
|
||||
assert!(matches!(m.color_at(0, 0, 0.5, 0.5).0, Color::Rgb { .. }));
|
||||
}
|
||||
|
||||
/// #3232: the Appearance highlight lightens each point towards white in its own model, so a
|
||||
/// CMYK mesh stays CMYK (K shrinks with the other inks) and a grey one stays grey.
|
||||
#[test]
|
||||
fn mesh_highlight_keeps_each_points_model() {
|
||||
let square = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0));
|
||||
let ink = Color::Cmyk { c: 0.0, m: 0.4, y: 0.6, k: 0.1 };
|
||||
let m = GradientMesh::for_path(&square, 2, 2, ink, MeshAppearance::ToCenter, 50.0).unwrap();
|
||||
assert_eq!(m.points[0].color, ink, "corners stay");
|
||||
let centre = m.points[m.idx(1, 1)].color;
|
||||
let Color::Cmyk { c, m: mg, y, k } = centre else { panic!("{centre:?}") };
|
||||
assert!(c.abs() < 1e-6 && (mg - 0.2).abs() < 1e-6 && (y - 0.3).abs() < 1e-6 && (k - 0.05).abs() < 1e-6, "{c} {mg} {y} {k}");
|
||||
assert!(matches!(m.color_at(0, 0, 0.5, 0.5).0, Color::Cmyk { .. }), "patches mix in CMYK");
|
||||
let m = GradientMesh::for_path(&square, 2, 2, Color::gray(0.8), MeshAppearance::ToEdge, 100.0).unwrap();
|
||||
assert_eq!((m.points[0].color, m.points[m.idx(1, 1)].color), (Color::gray(0.0), Color::gray(0.8)));
|
||||
// RGB lightens in RGB as before.
|
||||
let m = GradientMesh::for_path(&square, 2, 2, Color::rgb(1.0, 0.0, 0.0), MeshAppearance::ToCenter, 100.0).unwrap();
|
||||
assert_eq!(m.points[m.idx(1, 1)].color.to_hex(), "#ffffff");
|
||||
}
|
||||
|
||||
/// Alt-clicking a point of a mesh from a file with fewer points than its rows and columns need
|
||||
/// deletes nothing (it indexed past the points, a panic).
|
||||
#[test]
|
||||
|
||||
@@ -267,7 +267,8 @@ impl GradientMesh {
|
||||
}
|
||||
|
||||
/// Create Gradient Mesh → Appearance: lighten the points towards white, to the centre or to
|
||||
/// the edges, by at most `highlight` %.
|
||||
/// the edges, by at most `highlight` %. Each point lightens towards white in its own colour
|
||||
/// model (paper white, no ink, for CMYK), so a CMYK or grey mesh keeps its inks (#3232).
|
||||
pub fn highlight(&mut self, appearance: MeshAppearance, highlight: f64) {
|
||||
let h = (highlight / 100.0).clamp(0.0, 1.0) as f32;
|
||||
let (rows, cols) = (self.rows as usize, self.cols as usize);
|
||||
@@ -282,7 +283,8 @@ impl GradientMesh {
|
||||
};
|
||||
if w > 0.0 {
|
||||
let i = self.idx(r, c);
|
||||
self.points[i].color = lerp_color(&self.points[i].color, &Color::WHITE, w);
|
||||
let color = self.points[i].color;
|
||||
self.points[i].color = lerp_color(&color, &Color::WHITE.in_model(color.model()), w);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,7 +250,7 @@ fn node_bytes(n: &Node, other: Option<&Node>) -> usize {
|
||||
if let Some(e) = n.extras_box() {
|
||||
b += ALLOC + size_of::<NodeExtras>();
|
||||
b += boxed(&e.mask) + boxed(&e.graph) + boxed(&e.shaper) + boxed(&e.attrs) + boxed(&e.slice) + boxed(&e.perspective);
|
||||
b += boxed(&e.dimension) + boxed(&e.live_paint_gaps);
|
||||
b += boxed(&e.dimension) + boxed(&e.live_paint_gaps) + boxed(&e.colorize);
|
||||
b += e.trace.as_ref().map_or(0, |t| ALLOC + size_of::<Value>() + json_bytes(t));
|
||||
b += e.intertwine.as_deref().map_or(0, |a| ALLOC + size_of::<PathData>() + path_bytes(a));
|
||||
b += e.objects_on_path.as_deref().map_or(0, |o| {
|
||||
|
||||
@@ -49,6 +49,15 @@ pub struct NodeExtras {
|
||||
/// Object › Objects on Path: the group's path and the objects attached to it (see
|
||||
/// [`crate::objects_on_path`]).
|
||||
pub objects_on_path: Option<Box<crate::objects_on_path::ObjectsOnPath>>,
|
||||
/// A grayscale or 1-bit image's fill color (`paint.setFill` on the image): a solid paint that
|
||||
/// colorizes it. Each pixel is drawn in this color, as opaque as the pixel is dark, so white
|
||||
/// pixels are transparent. `None`: the image shows its own pixels.
|
||||
pub colorize: Option<Box<vectorcraft_color::Paint>>,
|
||||
/// Pathfinder panel › Make Compound Shape: this group is a compound shape
|
||||
/// ([`Node::is_compound_shape`], [`crate::compound_shape`]).
|
||||
pub compound_shape: bool,
|
||||
/// As a member of a compound shape, how this object combines with the members below it.
|
||||
pub shape_mode: crate::ShapeMode,
|
||||
}
|
||||
|
||||
/// No extras: what [`Node::extras`] gives for a node without any.
|
||||
@@ -69,6 +78,9 @@ pub(super) static NO_EXTRAS: NodeExtras = NodeExtras {
|
||||
intertwine: None,
|
||||
live_paint_gaps: None,
|
||||
objects_on_path: None,
|
||||
colorize: None,
|
||||
compound_shape: false,
|
||||
shape_mode: crate::ShapeMode::Add,
|
||||
};
|
||||
|
||||
/// A node's [`NodeExtras`], if it has any ([`Node::extras`]). Extras that are all unset equal
|
||||
|
||||
@@ -127,6 +127,7 @@ impl Node {
|
||||
NodeKind::Layer { .. } => "Layer",
|
||||
NodeKind::Group { clip: true, .. } => "Clip Group",
|
||||
NodeKind::Group { .. } if self.extras().objects_on_path.is_some() => "Objects on Path",
|
||||
NodeKind::Group { .. } if self.extras().compound_shape => "Compound Shape",
|
||||
NodeKind::Group { .. } => "Group",
|
||||
NodeKind::Path { live: Some(l), .. } => l.label(),
|
||||
NodeKind::Path { guide: true, .. } => "Guide",
|
||||
@@ -545,9 +546,16 @@ impl Node {
|
||||
/// item) stands in for the topmost fill or stroke when it is of the proxy's kind. Otherwise
|
||||
/// type objects paint their runs (the first run speaks for all) in text space, fitted to the
|
||||
/// layout bounds; everything else paints its fill or stroke in the document, fitted to the
|
||||
/// geometric bounds. Strokes fit the box grown by half their weight.
|
||||
/// geometric bounds. Strokes fit the box grown by half their weight. A colorized image shows
|
||||
/// its colorize paint as its fill.
|
||||
pub fn proxy_paint(&self, stroke: bool, item: Option<usize>) -> Option<(&vectorcraft_color::Paint, Affine, Rect)> {
|
||||
let item = self.appearance.item_of_kind(item, !stroke);
|
||||
if item.is_none()
|
||||
&& !stroke
|
||||
&& let Some(c) = self.extras().colorize.as_deref()
|
||||
{
|
||||
return Some((c, Affine::IDENTITY, self.geometric_bounds().unwrap_or(Rect::ZERO)));
|
||||
}
|
||||
if item.is_none()
|
||||
&& let NodeKind::Text(t) = &self.kind
|
||||
{
|
||||
|
||||
@@ -19,6 +19,8 @@ fn extras_are_written_flat_in_order() {
|
||||
e.slice = Some(Box::default());
|
||||
e.perspective = Some(Box::new(crate::PerspectiveAttachment::new("left", 1.0)));
|
||||
e.live_paint_gaps = Some(Box::default());
|
||||
e.compound_shape = true;
|
||||
e.shape_mode = crate::ShapeMode::Exclude;
|
||||
}
|
||||
/// The top-level keys of a JSON object, in the order written.
|
||||
struct Keys(Vec<String>);
|
||||
@@ -61,8 +63,11 @@ fn extras_are_written_flat_in_order() {
|
||||
"bbox_angle",
|
||||
"perspective",
|
||||
"live_paint_gaps",
|
||||
"compound_shape",
|
||||
"shape_mode",
|
||||
];
|
||||
assert_eq!(keys, order);
|
||||
assert_eq!(v["shape_mode"], "exclude");
|
||||
assert_eq!(serde_json::from_value::<Node>(v).unwrap(), n);
|
||||
let plain = Node::path(NodeId(9), shapes::rectangle(Rect::new(0.0, 0.0, 4.0, 2.0)), Appearance::default_art());
|
||||
let back: Node = serde_json::from_value(serde_json::to_value(&plain).unwrap()).unwrap();
|
||||
|
||||
@@ -5,6 +5,12 @@ use super::kind::NodeKind;
|
||||
use super::opacity::{Knockout, OpacityMask};
|
||||
use super::{Appearance, BlendMode, Deserialize, Node, PathData, Serialize, one, yes};
|
||||
|
||||
/// Field `name` of a [`Node`] written as `v`, or skipped. A function of its own, so the long field
|
||||
/// list stays one straight run of calls.
|
||||
fn write_field<S: serde::ser::SerializeStruct, T: Serialize + ?Sized>(st: &mut S, skip: bool, name: &'static str, v: &T) -> Result<(), S::Error> {
|
||||
if skip { st.skip_field(name) } else { st.serialize_field(name, v) }
|
||||
}
|
||||
|
||||
/// How a [`Node`] is written: the flat field list of the file format, in its order, borrowed from
|
||||
/// the node and its extras (nothing is copied). The node reads back through `NodeWire`.
|
||||
impl Serialize for Node {
|
||||
@@ -42,16 +48,15 @@ impl Serialize for Node {
|
||||
e.intertwine.is_none(),
|
||||
e.live_paint_gaps.is_none(),
|
||||
e.objects_on_path.is_none(),
|
||||
e.colorize.is_none(),
|
||||
is_default(&e.compound_shape),
|
||||
is_default(&e.shape_mode),
|
||||
];
|
||||
let mut st = s.serialize_struct("Node", skips.iter().filter(|s| !**s).count())?;
|
||||
let mut skip = skips.into_iter();
|
||||
macro_rules! field {
|
||||
($name:literal, $v:expr) => {
|
||||
if skip.next().unwrap_or(false) {
|
||||
st.skip_field($name)?;
|
||||
} else {
|
||||
st.serialize_field($name, $v)?;
|
||||
}
|
||||
write_field(&mut st, skip.next().unwrap_or(false), $name, $v)?;
|
||||
};
|
||||
}
|
||||
field!("id", &self.id);
|
||||
@@ -82,6 +87,9 @@ impl Serialize for Node {
|
||||
field!("intertwine", &e.intertwine);
|
||||
field!("live_paint_gaps", &e.live_paint_gaps);
|
||||
field!("objects_on_path", &e.objects_on_path);
|
||||
field!("colorize", &e.colorize);
|
||||
field!("compound_shape", &e.compound_shape);
|
||||
field!("shape_mode", &e.shape_mode);
|
||||
st.end()
|
||||
}
|
||||
}
|
||||
@@ -182,6 +190,12 @@ pub(super) struct NodeWire {
|
||||
live_paint_gaps: Option<Box<crate::live_paint::GapOptions>>,
|
||||
#[serde(default)]
|
||||
objects_on_path: Option<Box<crate::objects_on_path::ObjectsOnPath>>,
|
||||
#[serde(default)]
|
||||
colorize: Option<Box<vectorcraft_color::Paint>>,
|
||||
#[serde(default)]
|
||||
compound_shape: bool,
|
||||
#[serde(default)]
|
||||
shape_mode: crate::ShapeMode,
|
||||
}
|
||||
|
||||
impl From<NodeWire> for Node {
|
||||
@@ -203,6 +217,9 @@ impl From<NodeWire> for Node {
|
||||
intertwine: w.intertwine,
|
||||
live_paint_gaps: w.live_paint_gaps,
|
||||
objects_on_path: w.objects_on_path,
|
||||
colorize: w.colorize,
|
||||
compound_shape: w.compound_shape,
|
||||
shape_mode: w.shape_mode,
|
||||
};
|
||||
Node {
|
||||
id: w.id,
|
||||
|
||||
@@ -62,6 +62,61 @@ impl ImageBlob {
|
||||
}
|
||||
}
|
||||
|
||||
/// How a grayscale image's file stores its pixels ([`ImageBlob::gray_source`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct GraySource {
|
||||
/// One bit a pixel: black and white only.
|
||||
pub one_bit: bool,
|
||||
/// The file has an alpha channel.
|
||||
pub alpha: bool,
|
||||
}
|
||||
|
||||
impl ImageBlob {
|
||||
/// Whether the image's file stores grayscale or 1-bit pixels (the images a fill color
|
||||
/// colorizes), and how; `None` for color images and data that isn't an image. Reads only its
|
||||
/// header.
|
||||
pub fn gray_source(&self) -> Option<GraySource> {
|
||||
let gray = |one_bit: bool, alpha: bool| Some(GraySource { one_bit, alpha });
|
||||
if let Some((kind, _)) = crate::cmyk::tiff(&self.bytes) {
|
||||
return match kind {
|
||||
tiff::ColorType::Gray(bits) => gray(bits == 1, false),
|
||||
tiff::ColorType::GrayA(bits) => gray(bits == 1, true),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
if crate::cmyk::jpeg_components(&self.bytes) == Some(1) {
|
||||
return gray(false, false);
|
||||
}
|
||||
use image::{ExtendedColorType as E, ImageDecoder as _};
|
||||
let reader = image::ImageReader::new(Cursor::new(self.bytes.as_slice())).with_guessed_format().ok()?;
|
||||
match reader.into_decoder().ok()?.original_color_type() {
|
||||
E::L1 => gray(true, false),
|
||||
E::La1 => gray(true, true),
|
||||
E::L2 | E::L4 | E::L8 | E::L16 => gray(false, false),
|
||||
E::La2 | E::La4 | E::La8 | E::La16 => gray(false, true),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// How much of a colorize paint each pixel shows, row by row from the top: how dark the pixel
|
||||
/// is (255 less its Rec. 709 luma) scaled by its alpha, so white and transparent pixels are 0.
|
||||
/// Returns the width, the height and one byte a pixel; `None` when the image can't be decoded
|
||||
/// within [`MAX_RASTER_ALLOC`].
|
||||
pub fn coverage(&self) -> Option<(u32, u32, Vec<u8>)> {
|
||||
let img = rgba_within(&self.bytes, MAX_RASTER_ALLOC)?;
|
||||
let (w, h) = img.dimensions();
|
||||
let cover = img
|
||||
.pixels()
|
||||
.map(|p| {
|
||||
let [r, g, b, a] = p.0;
|
||||
let luma = 0.2126 * r as f32 + 0.7152 * g as f32 + 0.0722 * b as f32;
|
||||
((255.0 - luma).clamp(0.0, 255.0) * a as f32 / 255.0).round() as u8
|
||||
})
|
||||
.collect();
|
||||
Some((w, h, cover))
|
||||
}
|
||||
}
|
||||
|
||||
impl ImageBlob {
|
||||
/// The colour around pixel (`x`, `y`) (pixel space): the alpha-weighted average of the `size` ×
|
||||
/// `size` pixels centred on it, clipped to the image, as straight RGBA. `None` outside the
|
||||
@@ -298,4 +353,66 @@ mod tests {
|
||||
assert!(b.map_rgb(|c| c).is_none(), "nothing changed");
|
||||
assert!(ImageBlob::png(vec![1, 2, 3]).map_rgb(|c| c).is_none(), "undecodable");
|
||||
}
|
||||
|
||||
/// An uncompressed 1-bit TIFF, BlackIsZero, of `w` × `h` pixels: `rows` holds each row's bits,
|
||||
/// padded to a byte, most significant bit first.
|
||||
fn bilevel_tiff(w: u16, h: u16, rows: &[u8]) -> Vec<u8> {
|
||||
// Tag, type (3 short, 4 long) and value of each entry, in tag order.
|
||||
let data_at = 8 + 2 + 9 * 12 + 4;
|
||||
let entries: [(u16, u16, u32); 9] = [
|
||||
(256, 3, w.into()),
|
||||
(257, 3, h.into()),
|
||||
(258, 3, 1),
|
||||
(259, 3, 1),
|
||||
(262, 3, 1),
|
||||
(273, 4, data_at),
|
||||
(277, 3, 1),
|
||||
(278, 3, h.into()),
|
||||
(279, 4, rows.len() as u32),
|
||||
];
|
||||
let mut out = b"II*\0".to_vec();
|
||||
out.extend_from_slice(&8u32.to_le_bytes());
|
||||
out.extend_from_slice(&(entries.len() as u16).to_le_bytes());
|
||||
for (tag, kind, value) in entries {
|
||||
out.extend_from_slice(&tag.to_le_bytes());
|
||||
out.extend_from_slice(&kind.to_le_bytes());
|
||||
out.extend_from_slice(&1u32.to_le_bytes());
|
||||
out.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
out.extend_from_slice(&0u32.to_le_bytes());
|
||||
out.extend_from_slice(rows);
|
||||
out
|
||||
}
|
||||
|
||||
fn png_of(img: image::DynamicImage) -> ImageBlob {
|
||||
let mut png = Vec::new();
|
||||
img.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
ImageBlob::png(png)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grayscale_and_1_bit_files_are_told_from_color_ones() {
|
||||
let gray = |one_bit, alpha| Some(GraySource { one_bit, alpha });
|
||||
let l8 = png_of(image::DynamicImage::ImageLuma8(image::GrayImage::from_raw(2, 1, vec![0, 255]).unwrap()));
|
||||
assert_eq!(l8.gray_source(), gray(false, false));
|
||||
let la8 = png_of(image::DynamicImage::ImageLumaA8(image::GrayAlphaImage::from_raw(1, 1, vec![0, 128]).unwrap()));
|
||||
assert_eq!(la8.gray_source(), gray(false, true));
|
||||
let mut jpeg = Vec::new();
|
||||
jpeg_encoder::Encoder::new(&mut jpeg, 90).encode(&[0, 255, 0, 255], 2, 2, jpeg_encoder::ColorType::Luma).unwrap();
|
||||
assert_eq!(ImageBlob::new("image/jpeg", jpeg).gray_source(), gray(false, false));
|
||||
// Black, white; white, black.
|
||||
let tif = ImageBlob::new("image/tiff", bilevel_tiff(2, 2, &[0b0100_0000, 0b1000_0000]));
|
||||
assert_eq!(tif.gray_source(), gray(true, false));
|
||||
assert_eq!(tif.coverage(), Some((2, 2, vec![255, 0, 0, 255])), "black covers, white doesn't");
|
||||
// Color images and data that isn't an image.
|
||||
assert_eq!(blob(&[[0, 0, 0, 255], [9, 9, 9, 255]], 2, 1).gray_source(), None);
|
||||
assert_eq!(ImageBlob::png(vec![1, 2, 3]).gray_source(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coverage_is_darkness_times_alpha() {
|
||||
let la8 = png_of(image::DynamicImage::ImageLumaA8(image::GrayAlphaImage::from_raw(4, 1, vec![0, 255, 255, 255, 51, 255, 0, 128]).unwrap()));
|
||||
assert_eq!(la8.coverage(), Some((4, 1, vec![255, 0, 204, 128])));
|
||||
assert_eq!(ImageBlob::png(vec![1, 2, 3]).coverage(), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,13 +229,14 @@ fn item_paint_mut(it: &mut AppearanceItem) -> &mut Paint {
|
||||
}
|
||||
}
|
||||
|
||||
/// The paints a node holds itself (not its children): appearance items, then text runs.
|
||||
/// The paints a node holds itself (not its children): appearance items, then text runs, then an
|
||||
/// image's colorize paint.
|
||||
pub fn node_paints(n: &Node) -> impl Iterator<Item = &Paint> {
|
||||
let runs = match &n.kind {
|
||||
NodeKind::Text(t) => t.runs.as_slice(),
|
||||
_ => &[],
|
||||
};
|
||||
n.appearance.items.iter().map(item_paint).chain(runs.iter().flat_map(|r| [&r.style.fill, &r.style.stroke]))
|
||||
n.appearance.items.iter().map(item_paint).chain(runs.iter().flat_map(|r| [&r.style.fill, &r.style.stroke])).chain(n.extras().colorize.as_deref())
|
||||
}
|
||||
|
||||
/// Mutable [`node_paints`], in the same order.
|
||||
@@ -244,7 +245,8 @@ pub fn node_paints_mut(n: &mut Node) -> impl Iterator<Item = &mut Paint> {
|
||||
NodeKind::Text(t) => t.runs.as_mut_slice(),
|
||||
_ => &mut [],
|
||||
};
|
||||
n.appearance.items.iter_mut().map(item_paint_mut).chain(runs.iter_mut().flat_map(|r| [&mut r.style.fill, &mut r.style.stroke]))
|
||||
let colorize = n.extras.0.as_deref_mut().and_then(|e| e.colorize.as_deref_mut());
|
||||
n.appearance.items.iter_mut().map(item_paint_mut).chain(runs.iter_mut().flat_map(|r| [&mut r.style.fill, &mut r.style.stroke])).chain(colorize)
|
||||
}
|
||||
|
||||
/// `n` with `f` applied to its subtree's linkable colours, or `None` when nothing changed (then
|
||||
|
||||
@@ -114,16 +114,67 @@ pub fn apply_geometry_with(effects: &[Effect], path: &PathData, bounds: Rect, ct
|
||||
apply_geometry_within(effects, path, bounds, ctx, Budget::EFFECTS)
|
||||
}
|
||||
|
||||
/// Does effect `e` safely commute with pure translation when it is terminal?
|
||||
/// Only positive offsets with round joins commute exactly with pure translations for non-zero fills (#4145).
|
||||
fn is_terminal_offset_eligible(e: &Effect) -> bool {
|
||||
if e.id != "path.offsetPath" {
|
||||
return false;
|
||||
}
|
||||
let p = merged_params(&e.id, &e.params);
|
||||
util::join(&p, "joins") == vectorcraft_pathops::Join::Round && util::num(&p, "offset", 10.0) >= 0.0
|
||||
}
|
||||
|
||||
/// Optimize a list of visible geometry effects: move pure-translation Transform effects with
|
||||
/// copies after a terminal Offset Path effect when the effective style is safe (NonZero fill and
|
||||
/// no stroke), so that the offset path is evaluated once on the base path rather than on dozens
|
||||
/// of overlapping copies, avoiding combinatorial explosions in the planar sweep (#4145).
|
||||
pub(crate) fn optimize_geometry_effects<'a>(effects: &[&'a Effect], style_safe: bool) -> Vec<&'a Effect> {
|
||||
let mut out: Vec<&'a Effect> = effects.to_vec();
|
||||
if !style_safe || out.len() < 2 {
|
||||
return out;
|
||||
}
|
||||
let mut changed = true;
|
||||
while changed {
|
||||
changed = false;
|
||||
for i in 0..out.len().saturating_sub(1) {
|
||||
let e1 = out[i];
|
||||
if e1.id == "distort.transform" {
|
||||
let p1 = merged_params(&e1.id, &e1.params);
|
||||
if let Some((_, copies)) = crate::distort::transform_move(&p1)
|
||||
&& copies > 0
|
||||
&& is_terminal_offset_eligible(out[i + 1])
|
||||
&& out.get(i + 2..).is_some_and(|tail| tail.iter().all(|e| e.id == "distort.transform"))
|
||||
{
|
||||
out.swap(i, i + 1);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// [`apply_geometry_with`] whose effects keep to `budget`, raised to what `path` costs where that
|
||||
/// is more.
|
||||
pub(crate) fn apply_geometry_within(effects: &[Effect], path: &PathData, bounds: Rect, ctx: &GeomContext, budget: Budget) -> PathData {
|
||||
// `budget` raised to what `path` costs, measured once, and only for a path that has geometry
|
||||
// effects.
|
||||
let has_stroke = match (ctx.item, ctx.appearance) {
|
||||
(Some(idx), Some(app)) => match app.items.get(idx) {
|
||||
Some(AppearanceItem::Stroke(s)) => !s.paint.is_none() && s.width > 0.0,
|
||||
Some(AppearanceItem::Fill(_)) => false,
|
||||
None => ctx.stroke.is_some_and(|s| !s.paint.is_none() && s.width > 0.0),
|
||||
},
|
||||
_ => ctx.stroke.is_some_and(|s| !s.paint.is_none() && s.width > 0.0),
|
||||
};
|
||||
let style_safe = ctx.rule == FillRule::NonZero && !has_stroke;
|
||||
let visible_fx: Vec<&Effect> = effects.iter().filter(|e| e.visible && is_geometry(&e.id)).collect();
|
||||
let fx = optimize_geometry_effects(&visible_fx, style_safe);
|
||||
let mut limit = None;
|
||||
let mut cur = path.clone();
|
||||
let mut b = bounds;
|
||||
let mut first = true;
|
||||
for e in effects.iter().filter(|e| e.visible && is_geometry(&e.id)) {
|
||||
for e in fx {
|
||||
if !first {
|
||||
b = cur.bounds().unwrap_or(b);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
//! result keeps the appearance of the object whose paint it takes. Trap ([`crate::trap`]) keeps the
|
||||
//! children and adds its traps above them.
|
||||
//!
|
||||
//! **Compound shapes** ([`compound_shape_area`]): a compound shape draws as the one area its
|
||||
//! members' shape modes make, painted by its own appearance as a group's own fills and strokes
|
||||
//! paint its members.
|
||||
//!
|
||||
//! **Container appearance** ([`evaluate_container`]): a group's or layer's own fills and strokes
|
||||
//! paint every member's geometry, below or above the members as its contents slot says
|
||||
//! ([`Appearance::contents_at`]); its geometry effects reshape the members as one piece; its raster
|
||||
@@ -16,7 +20,7 @@ use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
use vectorcraft_doc::color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Node, NodeId, NodeKind, StrokeLayer};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Node, NodeId, NodeKind, ShapeMode, StrokeLayer};
|
||||
use vectorcraft_geom::{FillRule, PathData, Rect};
|
||||
use vectorcraft_pathops::{self as po, PathfinderOp};
|
||||
|
||||
@@ -45,10 +49,11 @@ fn op_of(id: &str) -> Option<PathfinderOp> {
|
||||
PATHFINDER_EFFECTS.iter().find(|e| e.0 == id).map(|e| e.2)
|
||||
}
|
||||
|
||||
/// Does `n` (a group or layer) carry a visible Pathfinder effect?
|
||||
/// Does `n` (a group or layer) carry a visible Pathfinder effect, or is it a compound shape?
|
||||
pub fn has_pathfinder(n: &Node) -> bool {
|
||||
matches!(n.kind, NodeKind::Group { clip: false, .. } | NodeKind::Layer { .. })
|
||||
&& n.appearance.effects.iter().any(|e| e.visible && is_pathfinder(&e.id))
|
||||
n.is_compound_shape()
|
||||
|| (matches!(n.kind, NodeKind::Group { clip: false, .. } | NodeKind::Layer { .. })
|
||||
&& n.appearance.effects.iter().any(|e| e.visible && is_pathfinder(&e.id)))
|
||||
}
|
||||
|
||||
/// Converts objects that aren't paths (e.g. text) to outline art, if possible. The paths and
|
||||
@@ -62,11 +67,15 @@ fn leaves(n: &Node, hook: Option<OutlineHook>, out: &mut Vec<Node>) {
|
||||
}
|
||||
match &n.kind {
|
||||
NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => out.push(n.clone()),
|
||||
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => {
|
||||
for c in children {
|
||||
leaves(c, hook, out);
|
||||
// A compound shape takes part as its area, styled like it.
|
||||
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => match compound_shape_art(n, hook) {
|
||||
Some(art) => out.push(art),
|
||||
None => {
|
||||
for c in children {
|
||||
leaves(c, hook, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
// Every path of the outline art, at any depth, takes the object's opacity and blend
|
||||
// mode.
|
||||
@@ -91,6 +100,63 @@ fn geometry(n: &Node) -> Option<(PathData, FillRule)> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The operation that combines a compound shape's member in `mode` with the area below it.
|
||||
fn shape_op(mode: ShapeMode) -> po::BoolOp {
|
||||
match mode {
|
||||
ShapeMode::Add => po::BoolOp::Union,
|
||||
ShapeMode::Subtract => po::BoolOp::Difference,
|
||||
ShapeMode::Intersect => po::BoolOp::Intersect,
|
||||
ShapeMode::Exclude => po::BoolOp::Xor,
|
||||
}
|
||||
}
|
||||
|
||||
/// The area compound shape `n` draws: each visible member's shape (a group's leaves together, type
|
||||
/// as its outlines through `hook`, open paths closed) combined with the area of the members below
|
||||
/// it by the member's shape mode; the backmost member starts the area. `None` when `n` is not a
|
||||
/// compound shape, or its members are past a live Pathfinder's budget ([`PATHFINDER_MAX_SEGMENTS`],
|
||||
/// [`PATHFINDER_MAX_OVERLAPS`]).
|
||||
pub fn compound_shape_area(n: &Node, hook: Option<OutlineHook>) -> Option<PathData> {
|
||||
if !n.is_compound_shape() {
|
||||
return None;
|
||||
}
|
||||
let mut all: Vec<Node> = vec![];
|
||||
// Each member that has a shape: its operation and how many of the leaves are its.
|
||||
let mut members: Vec<(po::BoolOp, usize)> = vec![];
|
||||
for c in n.children()? {
|
||||
let before = all.len();
|
||||
leaves(c, hook, &mut all);
|
||||
if all.len() > before {
|
||||
members.push((shape_op(c.extras().shape_mode), all.len() - before));
|
||||
}
|
||||
}
|
||||
if !within_budget(&all) {
|
||||
return None;
|
||||
}
|
||||
if all.is_empty() {
|
||||
return Some(PathData::default());
|
||||
}
|
||||
let parts: Vec<(PathData, FillRule)> = all.iter().map(|l| geometry(l).unwrap_or_default()).collect();
|
||||
let refs: Vec<(&PathData, FillRule)> = parts.iter().map(|(p, r)| (p, *r)).collect();
|
||||
Some(po::boolean_n(&refs, |inside| {
|
||||
let (mut start, mut area) = (0, false);
|
||||
for (k, (op, count)) in members.iter().enumerate() {
|
||||
let here = inside.get(start..start + count).is_some_and(|flags| flags.contains(&true));
|
||||
start += count;
|
||||
area = if k == 0 { here } else { op.apply(area, here) };
|
||||
}
|
||||
area
|
||||
}))
|
||||
}
|
||||
|
||||
/// Compound shape `n` as one path of its area ([`compound_shape_area`]) with its appearance,
|
||||
/// opacity and blend mode: how it takes part in a Pathfinder operation.
|
||||
pub fn compound_shape_art(n: &Node, hook: Option<OutlineHook>) -> Option<Node> {
|
||||
let area = compound_shape_area(n, hook)?;
|
||||
let mut art = Node::path(n.id, area, Appearance { items: n.appearance.items.clone(), ..Default::default() });
|
||||
(art.opacity, art.blend) = (n.opacity, n.blend);
|
||||
Some(art)
|
||||
}
|
||||
|
||||
/// A result path styled like `src` (its appearance, opacity and blend mode).
|
||||
fn styled(src: &Node, path: PathData, outline: bool) -> Node {
|
||||
let mut appearance = Appearance { items: src.appearance.items.clone(), ..Default::default() };
|
||||
@@ -196,7 +262,16 @@ pub fn pathfinder_children(group: &Node, hook: Option<OutlineHook>) -> Option<Ve
|
||||
return None;
|
||||
}
|
||||
let source: &[Arc<Node>] = group.children()?;
|
||||
let mut stack: Vec<Arc<Node>> = source.to_vec();
|
||||
let mut stack: Vec<Arc<Node>> = if group.is_compound_shape() {
|
||||
// The group's own appearance paints the area ([`evaluate_container`]), so the art has none.
|
||||
match compound_shape_area(group, hook) {
|
||||
Some(area) if area.is_empty() => vec![],
|
||||
Some(area) => vec![Arc::new(Node::path(group.id, area, Appearance::default()))],
|
||||
None => return Some(source.to_vec()),
|
||||
}
|
||||
} else {
|
||||
source.to_vec()
|
||||
};
|
||||
for e in group.appearance.effects.iter().filter(|e| e.visible) {
|
||||
if e.id == crate::trap::TRAP {
|
||||
let mut lv = vec![];
|
||||
@@ -375,6 +450,13 @@ pub fn member_shapes(doc: Option<&Document>, n: &Node) -> Vec<(PathData, FillRul
|
||||
}
|
||||
match &n.kind {
|
||||
NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => out.extend(crate::reshape::leaf_geometry(n)),
|
||||
NodeKind::Group { .. } if n.is_compound_shape() => {
|
||||
let hook: &dyn Fn(&Node) -> Option<Node> = &crate::outline_text;
|
||||
match compound_shape_area(n, Some(hook)) {
|
||||
Some(area) => out.push((area, FillRule::NonZero)),
|
||||
None => members(doc, n, out),
|
||||
}
|
||||
}
|
||||
NodeKind::Text(t) => {
|
||||
let (_, all) = crate::reshape::glyph_outlines(t);
|
||||
if !all.elements().is_empty() {
|
||||
@@ -454,6 +536,8 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option<Node> {
|
||||
if let Some(ch) = m.children_mut() {
|
||||
*ch = result;
|
||||
}
|
||||
// The area is art now: drawn again, it is no compound shape of its own.
|
||||
m.extras_mut().compound_shape = false;
|
||||
}
|
||||
if !fx.is_empty() {
|
||||
m = crate::reshape::outlined_members(doc, &m);
|
||||
|
||||
@@ -347,3 +347,72 @@ fn pathfinder_on_many_stacked_bars_still_runs() {
|
||||
let out = pathfinder_children(&g, None).unwrap();
|
||||
assert_eq!(out.len(), 1);
|
||||
}
|
||||
|
||||
/// Two overlapping squares (0–10 and 5–15 by 10 high) as a compound shape, the front one in `mode`.
|
||||
fn compound_shape(mode: ShapeMode) -> Node {
|
||||
let mut front = (*sq(2, 5.0, Color::rgb(0.0, 0.0, 1.0))).clone();
|
||||
front.extras_mut().shape_mode = mode;
|
||||
let mut g = Node::group(NodeId(10), vec![sq(1, 0.0, Color::rgb(1.0, 0.0, 0.0)), Arc::new(front)]);
|
||||
g.extras_mut().compound_shape = true;
|
||||
g.appearance = Appearance::basic(Paint::solid(Color::rgb(0.0, 1.0, 0.0)), Paint::None, 0.0).into();
|
||||
g
|
||||
}
|
||||
|
||||
/// A compound shape draws the area its members' modes make, one unpainted path that its own
|
||||
/// appearance paints; evaluated, it is plain art painted with that appearance.
|
||||
#[test]
|
||||
fn compound_shapes_draw_the_area_their_shape_modes_make() {
|
||||
for (mode, want) in [(ShapeMode::Add, 150.0), (ShapeMode::Subtract, 50.0), (ShapeMode::Intersect, 50.0), (ShapeMode::Exclude, 100.0)] {
|
||||
let g = compound_shape(mode);
|
||||
assert!(has_pathfinder(&g), "{mode:?}");
|
||||
let area_path = compound_shape_area(&g, None).unwrap();
|
||||
assert!((po::area(&area_path, FillRule::NonZero).abs() - want).abs() < 0.01, "{mode:?}");
|
||||
let out = pathfinder_children(&g, None).unwrap();
|
||||
assert_eq!(out.len(), 1, "{mode:?}");
|
||||
assert!(out[0].appearance.items.is_empty(), "the group's own appearance paints it");
|
||||
let art = evaluate_container(None, &g).unwrap();
|
||||
assert!(!art.is_compound_shape(), "evaluated, it is art");
|
||||
let painted: Vec<&Arc<Node>> = art.children().unwrap().iter().filter(|c| !c.appearance.items.is_empty()).collect();
|
||||
assert_eq!(painted.len(), 1);
|
||||
assert_eq!(painted[0].appearance.fill_paint().color(), Some(Color::rgb(0.0, 1.0, 0.0)), "{mode:?}");
|
||||
assert!((area(painted[0]) - want).abs() < 0.01, "{mode:?}");
|
||||
}
|
||||
// The backmost member's mode doesn't change the area.
|
||||
let mut g = compound_shape(ShapeMode::Add);
|
||||
if let Some(ch) = g.children_mut() {
|
||||
let mut back = (*ch[0]).clone();
|
||||
back.extras_mut().shape_mode = ShapeMode::Subtract;
|
||||
ch[0] = Arc::new(back);
|
||||
}
|
||||
assert!((po::area(&compound_shape_area(&g, None).unwrap(), FillRule::NonZero).abs() - 150.0).abs() < 0.01);
|
||||
// Hidden members take no part; the mark on a clip group is ignored.
|
||||
let mut g = compound_shape(ShapeMode::Subtract);
|
||||
if let Some(ch) = g.children_mut() {
|
||||
let mut front = (*ch[1]).clone();
|
||||
front.visible = false;
|
||||
ch[1] = Arc::new(front);
|
||||
}
|
||||
assert!((po::area(&compound_shape_area(&g, None).unwrap(), FillRule::NonZero).abs() - 100.0).abs() < 0.01);
|
||||
let mut clip = compound_shape(ShapeMode::Add);
|
||||
clip.kind = NodeKind::Group { children: clip.children().unwrap().clone(), clip: true };
|
||||
assert!(!clip.is_compound_shape() && compound_shape_area(&clip, None).is_none());
|
||||
}
|
||||
|
||||
/// A compound shape inside another (and inside an Effect › Pathfinder group) takes part as its area,
|
||||
/// styled like it.
|
||||
#[test]
|
||||
fn nested_compound_shapes_take_part_as_their_area() {
|
||||
let inner = compound_shape(ShapeMode::Subtract);
|
||||
let mut cutter = (*sq(3, 2.0, Color::BLACK)).clone();
|
||||
cutter.extras_mut().shape_mode = ShapeMode::Intersect;
|
||||
let mut outer = Node::group(NodeId(20), vec![Arc::new(inner.clone()), Arc::new(cutter)]);
|
||||
outer.extras_mut().compound_shape = true;
|
||||
// (0–5 of the inner area) ∩ (2–12) = 2–5 by 10 high.
|
||||
assert!((po::area(&compound_shape_area(&outer, None).unwrap(), FillRule::NonZero).abs() - 30.0).abs() < 0.01);
|
||||
let mut effect = Node::group(NodeId(30), vec![Arc::new(inner)]);
|
||||
effect.appearance.effects.push(Effect { id: "pathfinder.add".into(), params: serde_json::json!({}), visible: true });
|
||||
let out = pathfinder_children(&effect, None).unwrap();
|
||||
assert_eq!(out.len(), 1);
|
||||
assert!((area(&out[0]) - 50.0).abs() < 0.01);
|
||||
assert_eq!(out[0].appearance.fill_paint().color(), Some(Color::rgb(0.0, 1.0, 0.0)), "styled like the compound shape");
|
||||
}
|
||||
|
||||
@@ -69,8 +69,8 @@ pub use adjust::{
|
||||
pub use bake::{StrokeArt, bake_appearance, bake_document, expand_art, expand_leaf, fresh_ids, needs_bake};
|
||||
pub use clip::clip_outline;
|
||||
pub use group::{
|
||||
OutlineHook, PATHFINDER_EFFECTS, evaluate_container, has_container_appearance, has_pathfinder, is_pathfinder, member_shapes, paints,
|
||||
pathfinder_children,
|
||||
OutlineHook, PATHFINDER_EFFECTS, compound_shape_area, compound_shape_art, evaluate_container, has_container_appearance, has_pathfinder,
|
||||
is_pathfinder, member_shapes, paints, pathfinder_children,
|
||||
};
|
||||
pub use lighting_presets::{LIGHTING_PRESETS, LightingPreset, lighting_preset_params, matching_lighting_preset};
|
||||
pub use live::{expand_live, expand_live_deep, expanded_live_group, text_outliner};
|
||||
|
||||
@@ -431,6 +431,22 @@ pub(crate) fn reshape_leaves_within(art: &mut Node, fx: &[&Effect], budget: Budg
|
||||
if fx.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut leaves_safe = true;
|
||||
for_each_leaf(art, &mut |leaf| {
|
||||
if !leaves_safe {
|
||||
return;
|
||||
}
|
||||
if let Some((_, rule)) = leaf_geometry(leaf) {
|
||||
let has_stroke = leaf.appearance.stroke().is_some_and(|s| !s.paint.is_none() && s.width > 0.0);
|
||||
if rule != FillRule::NonZero || has_stroke {
|
||||
leaves_safe = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
let art_stroke = art.appearance.stroke().is_some_and(|s| !s.paint.is_none() && s.width > 0.0);
|
||||
let style_safe = leaves_safe && !art_stroke;
|
||||
let opt_fx = crate::geometry::optimize_geometry_effects(fx, style_safe);
|
||||
let fx = opt_fx.as_slice();
|
||||
let seed = art.id.0;
|
||||
// The placed gradients of type's outlines in `art` (`outline_text`) move and scale with the
|
||||
// pieces, from the outlines' bounds before the effects to their bounds after them.
|
||||
|
||||
@@ -438,6 +438,93 @@ fn three_max_detail_roughens_stay_within_the_effect_budget() {
|
||||
assert_ne!(out, apply_geometry(&[rough], &rect, rect.bounds().unwrap()), "more than one Roughen applies");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_copies_before_offset_path_evaluates_without_ram_spike() {
|
||||
let effects = [
|
||||
fx("distort.transform", json!({"moveH": -0.66, "moveV": 0.66, "copies": 30})),
|
||||
fx("path.offsetPath", json!({"offset": 10.0, "joins": "round"})),
|
||||
];
|
||||
let path = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0));
|
||||
let start = std::time::Instant::now();
|
||||
let out = apply_geometry(&effects, &path, path.bounds().unwrap());
|
||||
let elapsed = start.elapsed();
|
||||
assert!(elapsed < std::time::Duration::from_secs(2), "elapsed: {:?}", elapsed);
|
||||
assert_eq!(out.subpaths.len(), 31);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reshape_leaves_within_optimizes_transform_before_terminal_offset_path() {
|
||||
use std::sync::Arc;
|
||||
use vectorcraft_doc::{Appearance, Node, NodeId};
|
||||
|
||||
let id = NodeId(10);
|
||||
let leaf = |x: f64| Arc::new(Node::path(id, shapes::rectangle(Rect::new(x, 0.0, x + 20.0, 20.0)), Appearance::default()));
|
||||
let mut art = Node::group(id, (0..5).map(|k| leaf(k as f64 * 30.0)).collect());
|
||||
let transform = fx("distort.transform", json!({"moveH": 2.0, "moveV": 2.0, "copies": 10}));
|
||||
let offset = fx("path.offsetPath", json!({"offset": 4.0, "joins": "round"}));
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
crate::reshape::reshape_leaves_within(&mut art, &[&transform, &offset], Budget::EFFECTS);
|
||||
let elapsed = start.elapsed();
|
||||
assert!(elapsed < std::time::Duration::from_secs(2));
|
||||
|
||||
let children = art.children().unwrap();
|
||||
assert_eq!(children.len(), 5);
|
||||
for c in children {
|
||||
let (p, _) = crate::reshape::leaf_geometry(c).unwrap();
|
||||
// Base rectangle offset once, then copied 10 times -> 11 subpaths
|
||||
assert_eq!(p.subpaths.len(), 11);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reshape_leaves_within_preserves_order_when_not_terminal_or_not_style_safe() {
|
||||
use std::sync::Arc;
|
||||
use vectorcraft_doc::{Appearance, Node, NodeId, NodeKind};
|
||||
|
||||
let id = NodeId(11);
|
||||
// 1. EvenOdd fill rule: preserving order causes Transform copies to union in Offset Path into 1 subpath.
|
||||
let mut leaf = Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::default());
|
||||
if let NodeKind::Path { rule, .. } = &mut leaf.kind {
|
||||
*rule = FillRule::EvenOdd;
|
||||
}
|
||||
let mut art_evenodd = Node::group(id, vec![Arc::new(leaf)]);
|
||||
let transform = fx("distort.transform", json!({"moveH": 2.0, "moveV": 2.0, "copies": 2}));
|
||||
let offset = fx("path.offsetPath", json!({"offset": 4.0}));
|
||||
crate::reshape::reshape_leaves_within(&mut art_evenodd, &[&transform, &offset], Budget::EFFECTS);
|
||||
let p = art_evenodd.children().unwrap()[0].path_data().unwrap();
|
||||
assert_eq!(p.subpaths.len(), 1, "EvenOdd does not swap; copies union into 1 outer subpath");
|
||||
|
||||
// 2. Non-terminal Offset Path: followed by Round Corners, must preserve order.
|
||||
let leaf_round = Arc::new(Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::default()));
|
||||
let mut art_round = Node::group(id, vec![leaf_round]);
|
||||
let round_corners = fx("stylize.roundCorners", json!({"radius": 2.0}));
|
||||
crate::reshape::reshape_leaves_within(&mut art_round, &[&transform, &offset, &round_corners], Budget::EFFECTS);
|
||||
let p = art_round.children().unwrap()[0].path_data().unwrap();
|
||||
assert_eq!(p.subpaths.len(), 1, "Non-terminal Offset Path does not swap; copies union into 1 outer subpath");
|
||||
|
||||
// 3. Non-round join (e.g. miter): preserving order is required as corners differ (#4145).
|
||||
let leaf_miter = Arc::new(Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::default()));
|
||||
let mut art_miter = Node::group(id, vec![leaf_miter]);
|
||||
let offset_miter = fx("path.offsetPath", json!({"offset": 4.0, "joins": "miter"}));
|
||||
crate::reshape::reshape_leaves_within(&mut art_miter, &[&transform, &offset_miter], Budget::EFFECTS);
|
||||
let p = art_miter.children().unwrap()[0].path_data().unwrap();
|
||||
assert_eq!(p.subpaths.len(), 1, "Miter join does not swap; copies union into 1 outer subpath");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consecutive_transforms_before_terminal_offset_path_all_move_after() {
|
||||
let t1 = fx("distort.transform", json!({"moveH": 1.0, "moveV": 0.0, "copies": 2}));
|
||||
let t2 = fx("distort.transform", json!({"moveH": 0.0, "moveV": 1.0, "copies": 3}));
|
||||
let offset = fx("path.offsetPath", json!({"offset": 4.0, "joins": "round"}));
|
||||
|
||||
let opt = crate::geometry::optimize_geometry_effects(&[&t1, &t2, &offset], true);
|
||||
assert_eq!(opt.len(), 3);
|
||||
assert_eq!(opt[0].id, "path.offsetPath");
|
||||
assert_eq!(opt[1].id, "distort.transform");
|
||||
assert_eq!(opt[2].id, "distort.transform");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_copies_stop_before_they_overflow() {
|
||||
let out = run("distort.transform", json!({"scaleH": 100000, "scaleV": 100000, "copies": 1000}), &square());
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1719,6 +1719,60 @@ proptest! {
|
||||
}
|
||||
}
|
||||
|
||||
/// PCX files of each layout, 3 × 2 pixels at 150 dpi: 1 bit in two colors, 1 bit in four planes,
|
||||
/// 2 bits with CGA palette settings, 4 bits with the header palette, 8 bits with the 256-color palette after the image data,
|
||||
/// and 24 bits in three planes, with runs that cross a scan line.
|
||||
fn pcx_samples() -> Vec<(&'static str, Vec<u8>)> {
|
||||
let pcx = |bits: u8, planes: u8, line: u16, data: &[u8]| {
|
||||
let mut f = vec![0x0a, 5, 1, bits, 0, 0, 0, 0, 2, 0, 1, 0, 150, 0, 150, 0];
|
||||
f.extend((0..48u8).map(|i| i.wrapping_mul(37)));
|
||||
f.extend([0, planes]);
|
||||
f.extend(line.to_le_bytes());
|
||||
f.extend(1u16.to_le_bytes());
|
||||
f.resize(128, 0);
|
||||
f.extend(data);
|
||||
f
|
||||
};
|
||||
let mut paletted = pcx(8, 1, 4, &[0xc5, 7, 0xc1, 0xff, 1, 2]);
|
||||
paletted.push(0x0c);
|
||||
paletted.extend((0..=255u8).flat_map(|i| [i, 255 - i, i / 2]));
|
||||
vec![
|
||||
("a.pcx", pcx(1, 1, 2, &[0xa0, 0, 0x40, 0])),
|
||||
("b.pcx", pcx(1, 4, 2, &[0xc8, 0xe0, 0x20, 0, 0x60, 0, 0xc4, 0x80])),
|
||||
("c.pcx", pcx(2, 1, 2, &[0x1b, 0, 0xc2, 0xe4])),
|
||||
("d.pcx", pcx(4, 1, 2, &[0xf1, 0x70, 0x23, 0x40])),
|
||||
("e.pcx", paletted),
|
||||
("f.pcx", pcx(8, 3, 4, &[0xc9, 200, 0xcf, 40])),
|
||||
]
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(config())]
|
||||
|
||||
/// Mutated PCX files (header fields, run-length bytes and the palette after the image data)
|
||||
/// never crash placing, opening or pasting them.
|
||||
#[test]
|
||||
fn mutated_pcx_files_place_without_panics(which in 0usize..6, cut in 0usize..960, edits in prop::collection::vec((0usize..960, any::<u8>()), 0..12)) {
|
||||
let (name, mut bytes) = pcx_samples().swap_remove(which);
|
||||
for (at, b) in edits {
|
||||
if let Some(x) = bytes.get_mut(at) {
|
||||
*x = b;
|
||||
}
|
||||
}
|
||||
bytes.truncate(cut.max(4));
|
||||
let r = catch_quiet(|| {
|
||||
let mut s = vectorcraft_engine::Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
let p = json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&bytes), "thumbnail": 8});
|
||||
let _ = no_panic(s.execute("file.place.info", &p));
|
||||
let _ = no_panic(s.execute("file.place", &p));
|
||||
let _ = no_panic(s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&bytes)})));
|
||||
let _ = no_panic(s.execute("clipboard.importImage", &json!({"dataBase64": vectorcraft_format::base64_encode(&bytes)})));
|
||||
});
|
||||
prop_assert!(r.is_ok(), "{name}: panicked: {:?}", r.err());
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- SVG saved with hidden layers and editing data ----------
|
||||
|
||||
/// The rich document with a hidden layer, saved as SVG with its editing data.
|
||||
@@ -3850,8 +3904,8 @@ fn pdf_a_huge_form_drawn_over_and_over_is_refused_in_bounded_time() {
|
||||
#[test]
|
||||
fn a_stream_past_the_cap_is_named() {
|
||||
let image = "/Type /XObject /Subtype /Image /Width 10 /Height 10 /ColorSpace /DeviceRGB /BitsPerComponent 8";
|
||||
let page = |entries: &str| {
|
||||
format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R /Resources << /XObject << /Im 5 0 R >> >> {entries} >>")
|
||||
let page = |contents: &str, entries: &str| {
|
||||
format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents {contents} /Resources << /XObject << /Im 5 0 R >> >> {entries} >>")
|
||||
.into_bytes()
|
||||
};
|
||||
let plain = |content: &str| format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len() + 1).into_bytes();
|
||||
@@ -3864,17 +3918,27 @@ fn a_stream_past_the_cap_is_named() {
|
||||
let files = [
|
||||
(
|
||||
"an image",
|
||||
pdf(page(""), vec![plain("q 10 0 0 10 0 0 cm /Im Do Q"), packed_as_is(image, &spaces)]),
|
||||
pdf(page("4 0 R", ""), vec![plain("q 10 0 0 10 0 0 cm /Im Do Q"), packed_as_is(image, &spaces)]),
|
||||
"an image of 10 × 10 px in it (object 5) would decode to more than 256 MB",
|
||||
),
|
||||
(
|
||||
"a page's content",
|
||||
pdf(page(""), vec![packed_as_is("", &spaces), packed(image, "\0\0\0")]),
|
||||
pdf(page("4 0 R", ""), vec![packed_as_is("", &spaces), packed(image, "\0\0\0")]),
|
||||
"a page's content in it (object 4) would decode to more than 256 MB",
|
||||
),
|
||||
(
|
||||
"a page's content in an array",
|
||||
pdf(page("[4 0 R]", ""), vec![packed_as_is("", &spaces), packed(image, "\0\0\0")]),
|
||||
"a page's content in it (object 4) would decode to more than 256 MB",
|
||||
),
|
||||
(
|
||||
"a page's content in an array of its own",
|
||||
pdf(page("6 0 R", ""), vec![packed_as_is("", &spaces), packed(image, "\0\0\0"), b"[4 0 R]".to_vec()]),
|
||||
"a page's content in it (object 4) would decode to more than 256 MB",
|
||||
),
|
||||
(
|
||||
"the editing data",
|
||||
pdf(page(editing), vec![plain("0 0 5 5 re f"), packed(image, "\0\0\0"), packed_as_is("", &spaces)]),
|
||||
pdf(page("4 0 R", editing), vec![plain("0 0 5 5 re f"), packed(image, "\0\0\0"), packed_as_is("", &spaces)]),
|
||||
"its editing data (object 6) would decode to more than 256 MB",
|
||||
),
|
||||
];
|
||||
@@ -3888,6 +3952,39 @@ fn a_stream_past_the_cap_is_named() {
|
||||
});
|
||||
}
|
||||
|
||||
// Pages that share one `Contents` array, the array an object of its own: its stream is named as
|
||||
// a page's content, and a refused stream elsewhere is still found past the pages (#4612 review).
|
||||
#[test]
|
||||
fn pages_sharing_a_contents_array_name_its_stream() {
|
||||
let image = "/Type /XObject /Subtype /Image /Width 10 /Height 10 /ColorSpace /DeviceRGB /BitsPerComponent 8";
|
||||
let plain = |content: &str| format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len() + 1).into_bytes();
|
||||
let spaces = spaces_past_the_cap();
|
||||
// 1 catalog, 2 pages, 3 the shared array, 4 its stream, 5 to 7 the pages, 8 an image they draw.
|
||||
let pdf = |content: Vec<u8>, image: Vec<u8>| {
|
||||
let page = b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 3 0 R /Resources << /XObject << /Im 8 0 R >> >> >>";
|
||||
let head = [
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [5 0 R 6 0 R 7 0 R] /Count 3 >>".to_vec(),
|
||||
b"[4 0 R]".to_vec(),
|
||||
content,
|
||||
];
|
||||
pdf_of(&head.into_iter().chain([page.to_vec(), page.to_vec(), page.to_vec(), image]).collect::<Vec<_>>())
|
||||
};
|
||||
let files = [
|
||||
(pdf(packed_as_is("", &spaces), packed(image, "\0\0\0")), "a page's content in it (object 4) would decode to more than 256 MB"),
|
||||
(
|
||||
pdf(plain("q 10 0 0 10 0 0 cm /Im Do Q"), packed_as_is(image, &spaces)),
|
||||
"an image of 10 × 10 px in it (object 8) would decode to more than 256 MB",
|
||||
),
|
||||
];
|
||||
on_a_small_stack(move || {
|
||||
for (bytes, why) in &files {
|
||||
let e = vectorcraft_engine::cmd::fileio::load("shared.pdf", bytes).err().map(|e| e.to_string());
|
||||
assert!(e.as_deref().is_some_and(|e| e.contains(why)), "{why}: {e:?}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_art_that_draws_itself() {
|
||||
let own = "<< /XObject << /F 5 0 R >> /Pattern << /P 5 0 R >> /Font << /F1 5 0 R >> /ExtGState << /G << /SMask << /S /Alpha /G 5 0 R >> >> >> >>";
|
||||
|
||||
@@ -702,3 +702,21 @@ fn objects_beyond_the_document_range_are_left_out() {
|
||||
assert!(names.iter().any(|n| n == "Magenta box") && !names.iter().any(|n| n == "Far box"), "{names:?}");
|
||||
assert!(loaded.warnings.iter().any(|w| w.contains("beyond the document range")), "{:?}", loaded.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_af_g_symbol_fill_is_white() {
|
||||
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../test.af");
|
||||
let Ok(bytes) = std::fs::read(&path) else {
|
||||
return;
|
||||
};
|
||||
let loaded = vectorcraft_engine::cmd::fileio::load("test.af", &bytes).unwrap();
|
||||
assert!(!loaded.preview_only, "{:?}", loaded.warnings);
|
||||
let d = &loaded.doc;
|
||||
// The visible "g41" group holds the "g" symbol (path43).
|
||||
let g41 = all(d).into_iter().find(|n| n.visible && n.name.as_deref() == Some("g41")).expect("visible g41 group must be present");
|
||||
let path43 = g41.children().and_then(|c| c.iter().find(|n| n.name.as_deref() == Some("path43"))).expect("path43 inside g41 must be present");
|
||||
let Some(AppearanceItem::Fill(fill)) = path43.appearance.items.first() else {
|
||||
panic!("path43 has no fill: {:?}", path43.appearance);
|
||||
};
|
||||
assert_eq!(fill.paint, Paint::solid(Color::rgb(1.0, 1.0, 1.0)), "The 'g' symbol must have a white fill in test.af, got {:?}", fill.paint);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,106 @@
|
||||
use super::exporter::{UNDECODED, writable, xf};
|
||||
use super::{BlendMode, Exporter, Image, NodeKind, Rect, Size, Surface};
|
||||
use super::exporter::{UNDECODED, q, writable, xf};
|
||||
use super::{Arc, BlendMode, Color, Exporter, Image, NodeKind, Paint, Rect, Size, Surface, cmyk, krilla};
|
||||
use krilla::color::device_n::{Colorant, DeviceNSpace, Process};
|
||||
use krilla::image::{BitsPerComponent, CustomImage, ImageColorspace};
|
||||
|
||||
/// A colorized image's pixels: one color in every pixel, shown as much as its soft mask says.
|
||||
#[derive(Clone, Hash)]
|
||||
struct Tinted {
|
||||
color: Arc<Vec<u8>>,
|
||||
alpha: Arc<Vec<u8>>,
|
||||
width: u32,
|
||||
height: u32,
|
||||
space: ImageColorspace,
|
||||
device_n: Option<DeviceNSpace>,
|
||||
}
|
||||
|
||||
impl CustomImage for Tinted {
|
||||
fn color_channel(&self) -> &[u8] {
|
||||
&self.color
|
||||
}
|
||||
|
||||
fn alpha_channel(&self) -> Option<&[u8]> {
|
||||
Some(&self.alpha)
|
||||
}
|
||||
|
||||
fn bits_per_component(&self) -> BitsPerComponent {
|
||||
BitsPerComponent::Eight
|
||||
}
|
||||
|
||||
fn size(&self) -> (u32, u32) {
|
||||
(self.width, self.height)
|
||||
}
|
||||
|
||||
fn icc_profile(&self) -> Option<&[u8]> {
|
||||
None
|
||||
}
|
||||
|
||||
fn color_space(&self) -> ImageColorspace {
|
||||
self.space
|
||||
}
|
||||
|
||||
fn device_n_space(&self) -> Option<DeviceNSpace> {
|
||||
self.device_n.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Exporter<'_> {
|
||||
/// Image `im` colorized with `paint` ([`vectorcraft_doc::NodeExtras::colorize`]): every pixel
|
||||
/// in the paint's color, with the image's coverage ([`vectorcraft_doc::ImageBlob::coverage`])
|
||||
/// as its soft mask, so white pixels are transparent. A spot color is written as its tint of
|
||||
/// that ink (a DeviceN space of the one ink, which lists its Separation space), Registration in
|
||||
/// every process ink and every spot ink of the document, other colors in gray, RGB or CMYK as
|
||||
/// the Output settings write them. The coverage is resampled as the Compression settings ask
|
||||
/// ([`crate::images::coverage_sized`]). `None` when the paint isn't a solid color or the pixels
|
||||
/// can't be decoded; cached like [`Self::load_image`]. With `interpolate` (and the export's own
|
||||
/// setting), viewers may smooth it.
|
||||
pub(super) fn colorized_image(&mut self, im: &vectorcraft_doc::ImageObject, paint: &Paint, interpolate: bool) -> Option<Image> {
|
||||
let Paint::Solid { color, swatch, tint } = paint else { return None };
|
||||
let size = placed_size(im);
|
||||
let interpolate = interpolate && self.interpolate;
|
||||
let mut cache = format!("{}\u{0}{paint:?}\u{0}{:.3}x{:.3}", im.key, size.0, size.1);
|
||||
if !interpolate && self.interpolate {
|
||||
cache.push_str("\u{0}pixelated");
|
||||
}
|
||||
if let Some(i) = self.images.get(&cache) {
|
||||
return i.clone();
|
||||
}
|
||||
let doc = self.doc;
|
||||
let (width, height, alpha) = doc
|
||||
.image_blob(im)
|
||||
.filter(|_| !self.left_out.contains(&im.key))
|
||||
.and_then(|b| b.coverage())
|
||||
.and_then(|(w, h, cover)| crate::images::coverage_sized(w, h, cover, size, self.compression))?;
|
||||
let n = alpha.len();
|
||||
let registration = swatch.as_deref() == Some(vectorcraft_color::swatch::REGISTRATION);
|
||||
let inks: Vec<(String, [f32; 4])> = if registration {
|
||||
let spots: Vec<String> = doc.swatches_iter().filter(|w| w.spot).map(|w| w.name.clone()).collect();
|
||||
spots.iter().filter_map(|name| self.spot_ink(name)).collect()
|
||||
} else {
|
||||
swatch.as_deref().and_then(|name| self.spot_ink(name)).into_iter().collect()
|
||||
};
|
||||
let spot_colorants =
|
||||
inks.into_iter().map(|(name, full)| Colorant::Spot { name, alternate: cmyk::Color::new(q(full[0]), q(full[1]), q(full[2]), q(full[3])) });
|
||||
let device_n = if registration {
|
||||
DeviceNSpace::new(Process::ALL.into_iter().map(Colorant::Process).chain(spot_colorants))
|
||||
} else {
|
||||
DeviceNSpace::new(spot_colorants)
|
||||
};
|
||||
let (samples, space) = match &device_n {
|
||||
Some(d) if registration => (vec![q(*tint); d.colorants().len()], ImageColorspace::DeviceN),
|
||||
Some(_) => (vec![q(*tint)], ImageColorspace::DeviceN),
|
||||
None if registration => (vec![q(*tint); 4], ImageColorspace::Cmyk),
|
||||
None => match self.page_color(color) {
|
||||
Color::Gray { k } => (vec![q(1.0 - k)], ImageColorspace::Luma),
|
||||
Color::Cmyk { c, m, y, k } => ([c, m, y, k].map(q).to_vec(), ImageColorspace::Cmyk),
|
||||
other => (other.to_rgb().map(q).to_vec(), ImageColorspace::Rgb),
|
||||
},
|
||||
};
|
||||
let color = Arc::new(samples.repeat(n));
|
||||
let img = Image::from_custom(Tinted { color, alpha: Arc::new(alpha), width, height, space, device_n }, interpolate).ok();
|
||||
self.images.insert(cache, img.clone());
|
||||
img
|
||||
}
|
||||
/// Image `im` placed `size` points wide and high, resampled and compressed as the Compression
|
||||
/// settings say ([`crate::images::recode`], [`crate::images::recode_cmyk`] for CMYK images);
|
||||
/// cached by key (and size when images are resampled). With `interpolate` (and the export's
|
||||
|
||||
@@ -182,7 +182,10 @@ impl Exporter<'_> {
|
||||
self.shape(s, n, &bp, *rule, page);
|
||||
}
|
||||
NodeKind::Text(t) => self.text(s, n, t, page),
|
||||
NodeKind::Image(im) => self.image(s, im, alpha, n.extras().pixelated),
|
||||
NodeKind::Image(im) => {
|
||||
let colorized = n.extras().colorize.as_deref().and_then(|p| self.colorized_image(im, p, !n.extras().pixelated));
|
||||
self.image(s, im, colorized.or(alpha), n.extras().pixelated)
|
||||
}
|
||||
NodeKind::SymbolInstance { symbol, xf } => {
|
||||
// Within the objects a frame may draw of symbol instances, as on screen: nested
|
||||
// instances multiply.
|
||||
|
||||
@@ -9,6 +9,12 @@ impl Exporter<'_> {
|
||||
/// conversion, RGB and Lab colours of CMYK documents are separated into CMYK through the
|
||||
/// document's colour spaces, so the file carries press values.
|
||||
fn col(&mut self, c: &Color) -> krilla::color::Color {
|
||||
let c = self.page_color(c);
|
||||
color(&c)
|
||||
}
|
||||
|
||||
/// Color `c` as [`Self::col`] writes it, before it becomes the PDF writer's color.
|
||||
pub(super) fn page_color(&mut self, c: &Color) -> Color {
|
||||
let cmyk_doc = self.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk;
|
||||
if let (true, true, Color::Rgb { r, g, b }) = (cmyk_doc, self.convert, c) {
|
||||
self.rgb_in_cmyk.colours.insert([r.to_bits(), g.to_bits(), b.to_bits()]);
|
||||
@@ -18,13 +24,21 @@ impl Exporter<'_> {
|
||||
let note = note.clone();
|
||||
self.warn(note);
|
||||
}
|
||||
color(&c)
|
||||
c
|
||||
}
|
||||
|
||||
/// The Separation colour space of spot swatch `name` (its CMYK equivalent is the alternate
|
||||
/// space; a Lab spot colour also gets a Lab one, [`crate::lab_spot`], unless the Spot Colors
|
||||
/// options use CMYK values); `None` when `name` isn't a spot colour.
|
||||
fn separation(&mut self, name: &str) -> Option<SeparationSpace> {
|
||||
let (ink, full) = self.spot_ink(name)?;
|
||||
let alt = krilla::color::RegularColor::Cmyk(cmyk::Color::new(q(full[0]), q(full[1]), q(full[2]), q(full[3])));
|
||||
Some(SeparationSpace::new(SeparationColorant::Custom(ink), alt))
|
||||
}
|
||||
|
||||
/// The ink name of spot swatch `name` and its CMYK equivalent at 100% (a Lab spot color is
|
||||
/// noted for [`crate::lab_spot`]); `None` when `name` isn't a spot color.
|
||||
pub(super) fn spot_ink(&mut self, name: &str) -> Option<(String, [f32; 4])> {
|
||||
let doc = self.doc;
|
||||
let sw = doc.swatch(name).filter(|s| s.spot)?;
|
||||
let color = doc.linked_color(sw.paint.color()?, true);
|
||||
@@ -33,9 +47,7 @@ impl Exporter<'_> {
|
||||
{
|
||||
self.lab_spots.push((name.to_string(), vectorcraft_color::cms::Lab::new(l, a, b)));
|
||||
}
|
||||
let full = self.out.cmyk(&color, self.intent);
|
||||
let alt = krilla::color::RegularColor::Cmyk(cmyk::Color::new(q(full[0]), q(full[1]), q(full[2]), q(full[3])));
|
||||
Some(SeparationSpace::new(SeparationColorant::Custom(sw.name.clone()), alt))
|
||||
Some((sw.name.clone(), self.out.cmyk(&color, self.intent)))
|
||||
}
|
||||
|
||||
/// A solid paint, in the Separation colour space at its tint when it's linked to a spot swatch
|
||||
|
||||
@@ -277,6 +277,28 @@ pub(crate) fn recode(bytes: &[u8], size: (f64, f64), c: &CompressionSettings, in
|
||||
Recoded { image, warning: rule.instead }
|
||||
}
|
||||
|
||||
/// A colorized image's coverage (one sample a pixel, `w` × `h`, see
|
||||
/// [`ImageBlob::coverage`]) placed `size` points wide and high, resampled as the Compression
|
||||
/// settings `c` ask for monochrome images (when every sample is 0 or 255) or grayscale ones.
|
||||
pub(crate) fn coverage_sized(w: u32, h: u32, cover: Vec<u8>, size: (f64, f64), c: &CompressionSettings) -> Option<(u32, u32, Vec<u8>)> {
|
||||
let mono = cover.iter().all(|v| *v == 0 || *v == 255);
|
||||
let rule = rule(c, if mono { Kind::Mono } else { Kind::Gray });
|
||||
let to = sized(&rule, (w, h), size);
|
||||
if to == (w, h) {
|
||||
return Some((w, h, cover));
|
||||
}
|
||||
let img = DynamicImage::ImageLuma8(image::GrayImage::from_raw(w, h, cover)?);
|
||||
let mut out = resample(img, &rule, to).into_luma8();
|
||||
if mono {
|
||||
// Resampling greys the edges: back to covered and not.
|
||||
for p in out.pixels_mut() {
|
||||
p[0] = if p[0] >= 128 { 255 } else { 0 };
|
||||
}
|
||||
}
|
||||
let (w, h) = out.dimensions();
|
||||
Some((w, h, out.into_raw()))
|
||||
}
|
||||
|
||||
/// [`recode`] of CMYK image `blob` ([`ImageBlob::cmyk`]): a JPEG already as asked is kept as it
|
||||
/// is when its numbers are (`convert` is `None`); otherwise its ink amounts are resampled,
|
||||
/// converted by `convert` and compressed again. With `alpha` (one sample a pixel, see
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! The pages of a PDF to import: opening with a password, the pages picked, the box each page
|
||||
//! is cropped to ([`CropTo`]) and [`info`].
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use craft_pdf::syntax::object::{Array, Dict, MaybeRef, Name, Object, ObjectIdentifier, Stream, dict_or_stream};
|
||||
use craft_pdf::syntax::page::Page;
|
||||
@@ -193,9 +193,16 @@ fn stream_kind(pdf: &Pdf, id: ObjectIdentifier, dict: &Dict<'_>) -> String {
|
||||
}
|
||||
let is = |m: Option<MaybeRef<Object<'_>>>| matches!(m, Some(MaybeRef::Ref(r)) if ObjectIdentifier::from(r) == id);
|
||||
let pages = pdf.pages();
|
||||
// `Contents` is a stream or an array of them, the array in the page or an object of its own.
|
||||
// Each object is read once however many pages share it, so a file can't make this quadratic.
|
||||
let mut read = HashSet::new();
|
||||
let content = pages.iter().any(|page| match page.raw().get_raw::<Object<'_>>(b"Contents") {
|
||||
Some(MaybeRef::NotRef(Object::Array(a))) => items(&a).any(|m| is(Some(m))),
|
||||
other => is(other),
|
||||
Some(MaybeRef::Ref(r)) => {
|
||||
let at = ObjectIdentifier::from(r);
|
||||
read.insert(at) && page.raw().get::<Array<'_>>(b"Contents").map_or(at == id, |a| items(&a).any(|m| is(Some(m))))
|
||||
}
|
||||
_ => false,
|
||||
});
|
||||
if content {
|
||||
return "a page's content in it".to_string();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Separations: the inks of a job ([`print_inks`]) and the document as one ink's plate, every
|
||||
//! colour (and image pixel) replaced by the grey of that ink's coverage.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_color::Color;
|
||||
@@ -8,7 +9,7 @@ use vectorcraft_color::cms::{self, Cms, Model, ProfileKind};
|
||||
use vectorcraft_color::swatch::REGISTRATION;
|
||||
use vectorcraft_doc::inks::{Link, inks, plates, visit_node_colors};
|
||||
use vectorcraft_doc::overprint::darken_overprints;
|
||||
use vectorcraft_doc::{ColorMode, Document, Node};
|
||||
use vectorcraft_doc::{ColorMode, Document, Node, NodeKind};
|
||||
|
||||
use super::{DEFAULT_FREQUENCY, PrintInk, PrintSettings};
|
||||
|
||||
@@ -99,10 +100,20 @@ impl<'a> Separator<'a> {
|
||||
pub fn plate(&self, plate: &str) -> Document {
|
||||
let mut d = self.doc.clone();
|
||||
let discard_white = d.setup.discard_white_overprint;
|
||||
let mut colorized = BTreeSet::new();
|
||||
let nodes = d.layers.iter_mut().chain(d.symbols.iter_mut().map(|s| &mut s.art)).chain(d.patterns.iter_mut().flat_map(|p| p.art.iter_mut()));
|
||||
for n in nodes {
|
||||
darken_overprints(n, discard_white);
|
||||
self.node(plate, Arc::make_mut(n));
|
||||
keep_colorized_pixels(n, &mut colorized);
|
||||
}
|
||||
// A colorized image's pixels say where its color prints, on every plate: they are kept
|
||||
// under their own key, and only the color is separated.
|
||||
let mut kept = BTreeMap::new();
|
||||
for key in colorized {
|
||||
if let Some(blob) = d.images.get(&key) {
|
||||
kept.insert(colorized_key(&key), blob.clone());
|
||||
}
|
||||
}
|
||||
for blob in d.images.values_mut() {
|
||||
let gray = blob.map_rgb(|[r, g, b]| {
|
||||
@@ -114,8 +125,35 @@ impl<'a> Separator<'a> {
|
||||
*blob = gray;
|
||||
}
|
||||
}
|
||||
d.images.extend(kept);
|
||||
// Every colour is a grey now: nothing to separate on the way out.
|
||||
d.color_mode = ColorMode::Rgb;
|
||||
d
|
||||
}
|
||||
}
|
||||
|
||||
/// The key a plate keeps colorized image `key`'s own pixels under.
|
||||
fn colorized_key(key: &str) -> String {
|
||||
format!("{key}\u{0}colorized")
|
||||
}
|
||||
|
||||
/// Point the colorized images in `n`'s subtree at [`colorized_key`], collecting their keys in
|
||||
/// `keys`.
|
||||
fn keep_colorized_pixels(n: &mut Arc<Node>, keys: &mut BTreeSet<String>) {
|
||||
let colorized = |n: &Node| matches!(n.kind, NodeKind::Image(ref im) if im.pixels.is_none()) && n.extras().colorize.is_some();
|
||||
if !colorized(n) && n.children().is_none_or(|ch| ch.is_empty()) {
|
||||
return;
|
||||
}
|
||||
let node = Arc::make_mut(n);
|
||||
if colorized(node)
|
||||
&& let NodeKind::Image(im) = &mut node.kind
|
||||
{
|
||||
keys.insert(im.key.clone());
|
||||
im.key = colorized_key(&im.key);
|
||||
}
|
||||
if let Some(ch) = node.children_mut() {
|
||||
for c in ch.iter_mut() {
|
||||
keep_colorized_pixels(c, keys);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ mod blend;
|
||||
mod charstroke;
|
||||
mod cmykblend;
|
||||
mod cmykimages;
|
||||
mod colorize;
|
||||
mod compression;
|
||||
mod dashalign;
|
||||
mod editing;
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
//! Grayscale and 1-bit images colorized by a fill color are written in that color, a spot color
|
||||
//! as its ink, with the image's coverage as the soft mask.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use craft_pdf::syntax::Pdf;
|
||||
use craft_pdf::syntax::object::{Array, Name, Object, Stream};
|
||||
use vectorcraft_color::{Color, Paint, Swatch};
|
||||
use vectorcraft_doc::{ColorMode, Document, ImageBlob, ImageObject, Node, NodeKind};
|
||||
use vectorcraft_geom::Affine;
|
||||
|
||||
use crate::*;
|
||||
|
||||
/// A page with a 2 × 1 gray image (black, white), colorized with `colorize`.
|
||||
fn doc(mode: ColorMode, colorize: Option<Paint>) -> Document {
|
||||
let mut d = Document::new_with_mode(40.0, 20.0, mode);
|
||||
d.swatches.push(Swatch { name: "Ink".into(), paint: Paint::solid(Color::cmyk(0.0, 1.0, 1.0, 0.0)), global: true, spot: true });
|
||||
let gray = image::GrayImage::from_raw(2, 1, vec![0, 255]).unwrap();
|
||||
let mut png = vec![];
|
||||
gray.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
d.images.insert("pic".into(), ImageBlob::png(png));
|
||||
let im = ImageObject { key: "pic".into(), width: 2, height: 1, xf: Affine::scale(20.0), link: None, placement: Default::default(), pixels: None };
|
||||
let mut n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)));
|
||||
n.extras_mut().colorize = colorize.map(Box::new);
|
||||
d.insert(d.default_layer(), 0, n).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// Each image XObject of `pdf`: its color space's name (the first of an array) with the names
|
||||
/// in the array's second item, and whether it has a soft mask.
|
||||
fn images(pdf: &[u8]) -> Vec<(String, Vec<String>, bool)> {
|
||||
let file = Pdf::new(Arc::new(pdf.to_vec())).unwrap();
|
||||
let mut out = vec![];
|
||||
for o in file.objects() {
|
||||
let Object::Stream(s) = o else { continue };
|
||||
let d = s.dict();
|
||||
if d.get::<Name<'_>>(b"Subtype").is_none_or(|n| n.as_ref() != b"Image") || d.get::<bool>(b"ImageMask") == Some(true) {
|
||||
continue;
|
||||
}
|
||||
let (space, names) = match (d.get::<Name<'_>>(b"ColorSpace"), d.get::<Array<'_>>(b"ColorSpace")) {
|
||||
(Some(n), _) => (n.as_str().to_string(), vec![]),
|
||||
(_, Some(a)) => {
|
||||
let mut it = a.flex_iter();
|
||||
let name = it.next::<Name<'_>>().unwrap().as_str().to_string();
|
||||
let names = it.next::<Array<'_>>().map(|a| a.iter::<Name<'_>>().map(|n| n.as_str().to_string()).collect()).unwrap_or_default();
|
||||
(name, names)
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
out.push((space, names, d.get::<Stream<'_>>(b"SMask").is_some()));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn pdf(d: &Document) -> Vec<u8> {
|
||||
export_with_report(d, &PdfOptions { created: Some(1_700_000_000), ..PdfOptions::uncompressed() }).unwrap().bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_spot_color_writes_the_image_in_its_ink() {
|
||||
let spot = Paint::Solid { color: Color::cmyk(0.0, 0.5, 0.5, 0.0), swatch: Some("Ink".into()), tint: 0.5 };
|
||||
let out = pdf(&doc(ColorMode::Cmyk, Some(spot)));
|
||||
let found = images(&out);
|
||||
assert!(found.contains(&("DeviceN".into(), vec!["Ink".into()], true)), "{found:?}");
|
||||
assert!(String::from_utf8_lossy(&out).contains("/Separation/Ink"), "the ink's Separation space");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_process_color_writes_the_image_in_that_color_with_a_soft_mask() {
|
||||
let out = pdf(&doc(ColorMode::Cmyk, Some(Paint::solid(Color::cmyk(1.0, 0.0, 0.0, 0.0)))));
|
||||
// The image and its soft mask, a gray image.
|
||||
assert_eq!(images(&out), [("DeviceGray".into(), vec![], false), ("DeviceCMYK".into(), vec![], true)]);
|
||||
// Uncolorized: the gray image itself, opaque.
|
||||
assert_eq!(images(&pdf(&doc(ColorMode::Cmyk, None))), [("DeviceGray".into(), vec![], false)]);
|
||||
}
|
||||
|
||||
/// Registration prints on every plate: the process inks and the document's spot inks.
|
||||
#[test]
|
||||
fn registration_writes_the_image_in_every_ink() {
|
||||
let reg = Paint::Solid { color: Color::cmyk(1.0, 1.0, 1.0, 1.0), swatch: Some(vectorcraft_color::swatch::REGISTRATION.into()), tint: 1.0 };
|
||||
let found = images(&pdf(&doc(ColorMode::Cmyk, Some(reg.clone()))));
|
||||
let all = ["Cyan", "Magenta", "Yellow", "Black", "Ink"].map(String::from).to_vec();
|
||||
assert!(found.contains(&("DeviceN".into(), all, true)), "{found:?}");
|
||||
|
||||
// A spot swatch in a color group is one of the document's inks too.
|
||||
let mut d = doc(ColorMode::Cmyk, Some(reg));
|
||||
let grouped = Swatch { name: "Grouped Ink".into(), paint: Paint::solid(Color::cmyk(1.0, 0.0, 1.0, 0.0)), global: true, spot: true };
|
||||
d.swatch_groups.push(vectorcraft_color::SwatchGroup { name: "Group".into(), swatches: vec![grouped] });
|
||||
let found = images(&pdf(&d));
|
||||
let all = ["Cyan", "Magenta", "Yellow", "Black", "Ink", "Grouped Ink"].map(String::from).to_vec();
|
||||
assert!(found.contains(&("DeviceN".into(), all, true)), "{found:?}");
|
||||
}
|
||||
|
||||
/// The Compression settings resample a colorized image as they do a grayscale one.
|
||||
#[test]
|
||||
fn colorized_images_are_downsampled() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let gray = image::GrayImage::from_fn(600, 600, |x, y| image::Luma([((x + y) % 200) as u8]));
|
||||
let mut png = vec![];
|
||||
gray.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
d.images.insert("pic".into(), ImageBlob::png(png));
|
||||
// One inch wide: 600 ppi.
|
||||
let im = ImageObject {
|
||||
key: "pic".into(),
|
||||
width: 600,
|
||||
height: 600,
|
||||
xf: Affine::scale(72.0 / 600.0),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
};
|
||||
let mut n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)));
|
||||
n.extras_mut().colorize = Some(Box::new(Paint::solid(Color::rgb(0.0, 0.0, 1.0))));
|
||||
d.insert(d.default_layer(), 0, n).unwrap();
|
||||
let mut settings: PdfSettings =
|
||||
serde_json::from_value(serde_json::json!({"compression": {"gray": {"downsample": "bicubic", "ppi": 150}}})).unwrap();
|
||||
settings.compression.compress_text = false;
|
||||
let out = export_with_report(&d, &PdfOptions { settings, ..Default::default() }).unwrap().bytes;
|
||||
let text = String::from_utf8_lossy(&out);
|
||||
assert!(text.contains("/Width 150") && !text.contains("/Width 600"), "resampled to 150 ppi");
|
||||
}
|
||||
|
||||
/// Printed separations keep a colorized image's pixels on every plate and separate its color:
|
||||
/// a spot-colorized image prints on its ink's plate only. An uncolorized image of the same pixels
|
||||
/// is separated as before.
|
||||
#[test]
|
||||
fn separations_print_the_image_on_its_ink_plate() {
|
||||
let spot = Paint::Solid { color: Color::cmyk(0.0, 1.0, 1.0, 0.0), swatch: Some("Ink".into()), tint: 1.0 };
|
||||
let mut d = doc(ColorMode::Cmyk, Some(spot));
|
||||
let im = ImageObject { key: "pic".into(), width: 2, height: 1, xf: Affine::scale(20.0), link: None, placement: Default::default(), pixels: None };
|
||||
let plain = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)));
|
||||
d.insert(d.default_layer(), 1, plain).unwrap();
|
||||
let settings: PrintSettings = serde_json::from_value(serde_json::json!({"output": {"mode": "separations"}})).unwrap();
|
||||
let names: Vec<String> = print_inks(&d, &settings).0.into_iter().map(|i| i.name).collect();
|
||||
let job = plan(&d, &PrintOptions { settings, ..Default::default() }).unwrap();
|
||||
assert_eq!(names.len(), job.plates.len());
|
||||
for (name, p) in names.iter().zip(&job.plates) {
|
||||
let images: Vec<_> = p.layers[0]
|
||||
.children()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|n| match &n.kind {
|
||||
NodeKind::Image(im) => Some((p.image_blob(im).and_then(|b| b.coverage()).unwrap().2, n.extras().colorize.as_deref().cloned())),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let [(colorized, Some(Paint::Solid { color, .. })), (plain, None)] = images.as_slice() else { panic!("{name}: {images:?}") };
|
||||
assert_eq!(colorized, &[255, 0], "{name}: the black pixel still covers");
|
||||
let k = if name == "Ink" { 1.0 } else { 0.0 };
|
||||
assert_eq!(color, &Color::Gray { k }, "{name}: the color is the plate's coverage");
|
||||
// The gray image itself has no spot ink.
|
||||
if name == "Ink" {
|
||||
assert_eq!(plain, &[0, 0], "{name}: the uncolorized image");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A pixelated colorized image (an envelope's with Anti-Alias off) is written without
|
||||
/// /Interpolate, as an uncolorized one is.
|
||||
#[test]
|
||||
fn a_pixelated_colorized_image_asks_viewers_not_to_smooth_it() {
|
||||
for pixelated in [false, true] {
|
||||
let mut d = doc(ColorMode::Cmyk, Some(Paint::solid(Color::cmyk(1.0, 0.0, 0.0, 0.0))));
|
||||
let id = d.layers[0].children().unwrap()[0].id;
|
||||
d.node_mut(id).unwrap().extras_mut().pixelated = pixelated;
|
||||
let out = pdf(&d);
|
||||
assert_eq!(images(&out), [("DeviceGray".into(), vec![], false), ("DeviceCMYK".into(), vec![], true)], "pixelated: {pixelated}");
|
||||
assert_eq!(String::from_utf8_lossy(&out).contains("/Interpolate"), !pixelated, "pixelated: {pixelated}");
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,15 @@ pub(crate) fn lookup(key: &images::ImageKey, touch: bool) -> Option<Arc<Pixmap>>
|
||||
pub(crate) fn blob_key(blob: &vectorcraft_doc::ImageBlob) -> (images::ImageKey, Option<Arc<ink::InkLut>>, Option<(u16, u16)>) {
|
||||
let id = images::blob_id(blob);
|
||||
let lut = id.cmyk.then(ink::display_lut);
|
||||
let key =
|
||||
images::ImageKey { content: id.content, adjust: None, grey: false, ink: Ink::Display, luts: (lut.as_ref().map_or(0, |l| l.1), 0), mip: 0 };
|
||||
let key = images::ImageKey {
|
||||
content: id.content,
|
||||
adjust: None,
|
||||
grey: false,
|
||||
tint: None,
|
||||
ink: Ink::Display,
|
||||
luts: (lut.as_ref().map_or(0, |l| l.1), 0),
|
||||
mip: 0,
|
||||
};
|
||||
(key, lut.map(|l| l.0), id.size)
|
||||
}
|
||||
|
||||
|
||||
@@ -85,6 +85,8 @@ pub(crate) struct ImageKey {
|
||||
pub adjust: Option<u64>,
|
||||
/// Greyscale (outline mode).
|
||||
pub grey: bool,
|
||||
/// Colorized with this display color ([`colorized`]).
|
||||
pub tint: Option<[u8; 3]>,
|
||||
/// The ink plane painted ([`Ink::Display`]: the colours themselves).
|
||||
pub ink: Ink,
|
||||
/// Generations of the colour tables the pixels were made with (0 = none): a CMYK image's
|
||||
@@ -377,6 +379,21 @@ pub(crate) fn half(pm: &Pixmap) -> Pixmap {
|
||||
Pixmap::from_parts(data, ow as u16, oh as u16)
|
||||
}
|
||||
|
||||
/// Pixels `pm` colorized with `rgb`: each pixel takes the color, as opaque as the pixel is dark
|
||||
/// (255 less its Rec. 709 luma) times its alpha, so white pixels are transparent.
|
||||
pub(crate) fn colorized(pm: &Pixmap, [r, g, b]: [u8; 3]) -> Pixmap {
|
||||
let mut out = pm.clone();
|
||||
for px in out.data_mut() {
|
||||
// The luma of premultiplied components is the premultiplied luma: the alpha less it is
|
||||
// the darkness times the alpha.
|
||||
let luma = 0.2126 * px.r as f32 + 0.7152 * px.g as f32 + 0.0722 * px.b as f32;
|
||||
let a = (px.a as f32 - luma).clamp(0.0, 255.0);
|
||||
let mul = |c: u8| (c as f32 * a / 255.0).round() as u8;
|
||||
*px = PremulRgba8 { r: mul(r), g: mul(g), b: mul(b), a: a.round() as u8 };
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// How many times to halve an image drawn with `m` (image pixels → device pixels): while a pixel
|
||||
/// of the halved image still covers at most one device pixel each way, at most down to one pixel.
|
||||
pub(crate) fn mip_level(m: vectorcraft_geom::Affine, w: u16, h: u16) -> u8 {
|
||||
@@ -397,7 +414,7 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
fn key(content: &str) -> ImageKey {
|
||||
ImageKey { content: content.into(), adjust: None, grey: false, ink: Ink::Display, luts: (0, 0), mip: 0 }
|
||||
ImageKey { content: content.into(), adjust: None, grey: false, tint: None, ink: Ink::Display, luts: (0, 0), mip: 0 }
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -523,8 +523,15 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn darkness_image_cache_keeps_amount_and_coverage() {
|
||||
let key =
|
||||
ImageKey { content: Arc::from("darkness-rgb-cache-regression"), adjust: None, grey: false, ink: Ink::Display, luts: (0, 0), mip: 0 };
|
||||
let key = ImageKey {
|
||||
content: Arc::from("darkness-rgb-cache-regression"),
|
||||
adjust: None,
|
||||
grey: false,
|
||||
tint: None,
|
||||
ink: Ink::Display,
|
||||
luts: (0, 0),
|
||||
mip: 0,
|
||||
};
|
||||
let pm =
|
||||
Arc::new(Pixmap::from_parts(vec![PremulRgba8 { r: 204, g: 102, b: 51, a: 255 }, PremulRgba8 { r: 102, g: 51, b: 26, a: 128 }], 2, 1));
|
||||
let (k25, p25) = ink_image(&key, &pm, Ink::Display.darkened(0.25), None, false);
|
||||
|
||||
@@ -386,7 +386,7 @@ impl Renderer {
|
||||
if blob.is_proxy()
|
||||
&& let Some(im) = p.preview_image(&blob.bytes)
|
||||
{
|
||||
self.draw_image(ctx, f, &im);
|
||||
self.draw_image(ctx, f, &im, None);
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -1,23 +1,33 @@
|
||||
use super::{
|
||||
Affine, Arc, BezPath, Document, FillRule, Frame, HashMap, Pixmap, Rect, RenderContext, Renderer, Shape, blob_key, blob_pixmap, fill_rule,
|
||||
image_mip, ink, lookup, mip_level, peniko, vello_cpu,
|
||||
Affine, Arc, BezPath, Document, FillRule, Frame, HashMap, Ink, Node, Pixmap, Rect, RenderContext, Renderer, Shape, blob_key, blob_pixmap,
|
||||
fill_rule, image_mip, ink, lookup, mip_level, peniko, vello_cpu,
|
||||
};
|
||||
use crate::images;
|
||||
|
||||
/// The color image node `n` is colorized with ([`vectorcraft_doc::NodeExtras::colorize`]), if it
|
||||
/// is, as `ink` paints it: the display color, or on an ink plane the color's own inks, as a fill
|
||||
/// of that color paints them.
|
||||
pub(super) fn image_tint(n: &Node, ink: Ink) -> Option<[u8; 3]> {
|
||||
let c = n.extras().colorize.as_deref()?.color()?;
|
||||
Some(ink.rgb(&c).map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8))
|
||||
}
|
||||
|
||||
impl Renderer {
|
||||
pub(crate) fn draw_image(&mut self, ctx: &mut RenderContext, f: &Frame, im: &vectorcraft_doc::ImageObject) {
|
||||
self.draw_image_in(ctx, f, im, None, false);
|
||||
/// Draw an image, colorized with `tint` when given ([`image_tint`]).
|
||||
pub(crate) fn draw_image(&mut self, ctx: &mut RenderContext, f: &Frame, im: &vectorcraft_doc::ImageObject, tint: Option<[u8; 3]>) {
|
||||
self.draw_image_in(ctx, f, im, None, tint, false);
|
||||
}
|
||||
|
||||
/// Draw an image, or with `area` (a region in the document inside the image's frame, see
|
||||
/// [`plain_image`]) the image painted into that region; with `pixelated`
|
||||
/// ([`vectorcraft_doc::NodeExtras::pixelated`]) without smoothing.
|
||||
/// [`plain_image`]) the image painted into that region; colorized with `tint` when given; with
|
||||
/// `pixelated` ([`vectorcraft_doc::NodeExtras::pixelated`]) without smoothing.
|
||||
pub(super) fn draw_image_in(
|
||||
&mut self,
|
||||
ctx: &mut RenderContext,
|
||||
f: &Frame,
|
||||
im: &vectorcraft_doc::ImageObject,
|
||||
area: Option<&(BezPath, FillRule)>,
|
||||
tint: Option<[u8; 3]>,
|
||||
pixelated: bool,
|
||||
) {
|
||||
let rect = Rect::new(0.0, 0.0, im.width as f64, im.height as f64);
|
||||
@@ -29,13 +39,15 @@ impl Renderer {
|
||||
// pixelated image is drawn from its full pixels: a mip averages them.
|
||||
let mips = f.opts.image_mips && !pixelated;
|
||||
let unit = f.view * im.xf * Affine::scale_non_uniform(im.width as f64, im.height as f64);
|
||||
// The outline view shows the image's own pixels.
|
||||
let tint = tint.filter(|_| !outline);
|
||||
// With `kept_images_only`, an image still to be decoded: it draws as a light box, not as
|
||||
// an undecoded image.
|
||||
let mut pending = false;
|
||||
let shown = if outline && !f.doc.setup.outline_images {
|
||||
None
|
||||
} else if f.opts.kept_images_only {
|
||||
let kept = self.nearest_kept(f, &im.key, im.pixels.as_deref(), outline, unit);
|
||||
let kept = self.nearest_kept(f, &im.key, im.pixels.as_deref(), outline, tint, unit);
|
||||
pending = kept.is_none() && !self.noted_undecodable(f.doc, &im.key, im.pixels.as_deref());
|
||||
if pending {
|
||||
// Not decoded: a light box where the image will be, as a placed document's
|
||||
@@ -54,11 +66,16 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
kept
|
||||
} else if let Some(pm) = mips.then(|| self.kept_mip(f, &im.key, im.pixels.as_deref(), outline, unit)).flatten() {
|
||||
} else if let Some(pm) = mips.then(|| self.kept_mip(f, &im.key, im.pixels.as_deref(), outline, tint, unit)).flatten() {
|
||||
Some(pm)
|
||||
} else {
|
||||
self.image_pixmap(f.doc, &im.key, im.pixels.as_deref(), outline, !mips).map(|(key, pm)| {
|
||||
let (key, pm) = if outline { (key, pm) } else { ink::ink_image(&key, &pm, f.ink, f.doc.image_blob(im), !mips) };
|
||||
let (key, pm) = match tint {
|
||||
Some(rgb) => colorized_pixmap(key, &pm, rgb, !mips),
|
||||
None => (key, pm),
|
||||
};
|
||||
// A colorized image is already in the frame's ink ([`image_tint`]).
|
||||
let (key, pm) = if outline || tint.is_some() { (key, pm) } else { ink::ink_image(&key, &pm, f.ink, f.doc.image_blob(im), !mips) };
|
||||
if mips { image_mip(&key, pm, unit) } else { pm }
|
||||
})
|
||||
};
|
||||
@@ -220,11 +237,20 @@ impl Renderer {
|
||||
}
|
||||
|
||||
/// The mip [`image_mip`] draws image `key` (or `own`) with through `m`, in greyscale with
|
||||
/// `grey` (the outline view's), when the store holds it, marked used there; `None` when it
|
||||
/// draws the full pixels or the mip is yet to be made.
|
||||
fn kept_mip(&self, f: &Frame, key: &str, own: Option<&vectorcraft_doc::ImageBlob>, grey: bool, m: Affine) -> Option<Arc<Pixmap>> {
|
||||
/// `grey` (the outline view's) or colorized with `tint`, when the store holds it, marked used
|
||||
/// there; `None` when it draws the full pixels or the mip is yet to be made.
|
||||
fn kept_mip(
|
||||
&self,
|
||||
f: &Frame,
|
||||
key: &str,
|
||||
own: Option<&vectorcraft_doc::ImageBlob>,
|
||||
grey: bool,
|
||||
tint: Option<[u8; 3]>,
|
||||
m: Affine,
|
||||
) -> Option<Arc<Pixmap>> {
|
||||
let (key, (w, h)) = self.image_key(f.doc, key, own, grey)?;
|
||||
let key = if grey { key } else { ink::ink_key(&key, f.ink) };
|
||||
let key = images::ImageKey { tint, ..key };
|
||||
let key = if grey || tint.is_some() { key } else { ink::ink_key(&key, f.ink) };
|
||||
let level = mip_level(m, w, h);
|
||||
if level == 0 {
|
||||
return None;
|
||||
@@ -233,12 +259,21 @@ impl Renderer {
|
||||
}
|
||||
|
||||
/// The pixels of image `key` (or `own`) that [`RenderOptions::kept_images_only`] draws through
|
||||
/// `m`, in greyscale with `grey` (the outline view's): of the mips the store holds, the one
|
||||
/// [`image_mip`] would draw, else the nearest finer one (the full pixels last), else the
|
||||
/// nearest coarser one. `None` when the store holds none. Nothing is marked used.
|
||||
fn nearest_kept(&self, f: &Frame, key: &str, own: Option<&vectorcraft_doc::ImageBlob>, grey: bool, m: Affine) -> Option<Arc<Pixmap>> {
|
||||
/// `m`, in greyscale with `grey` (the outline view's) or colorized with `tint`: of the mips the
|
||||
/// store holds, the one [`image_mip`] would draw, else the nearest finer one (the full pixels
|
||||
/// last), else the nearest coarser one. `None` when the store holds none. Nothing is marked used.
|
||||
fn nearest_kept(
|
||||
&self,
|
||||
f: &Frame,
|
||||
key: &str,
|
||||
own: Option<&vectorcraft_doc::ImageBlob>,
|
||||
grey: bool,
|
||||
tint: Option<[u8; 3]>,
|
||||
m: Affine,
|
||||
) -> Option<Arc<Pixmap>> {
|
||||
let (key, (w, h)) = self.image_key(f.doc, key, own, grey)?;
|
||||
let key = if grey { key } else { ink::ink_key(&key, f.ink) };
|
||||
let key = images::ImageKey { tint, ..key };
|
||||
let key = if grey || tint.is_some() { key } else { ink::ink_key(&key, f.ink) };
|
||||
let level = mip_level(m, w, h);
|
||||
let s = images::store();
|
||||
let at = |l: u8| s.peek(&images::ImageKey { mip: l, ..key.clone() });
|
||||
@@ -257,3 +292,14 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Image pixels `pm` (kept as `key`) colorized with `rgb` ([`images::colorized`]), kept with the
|
||||
/// images, and their key. `touch` marks them used there.
|
||||
fn colorized_pixmap(key: images::ImageKey, pm: &Pixmap, rgb: [u8; 3], touch: bool) -> (images::ImageKey, Arc<Pixmap>) {
|
||||
let key = images::ImageKey { tint: Some(rgb), ..key };
|
||||
if let Some(p) = lookup(&key, touch) {
|
||||
return (key, p);
|
||||
}
|
||||
let pm = images::store().insert(key.clone(), Arc::new(images::colorized(pm, rgb)));
|
||||
(key, pm)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use super::{
|
||||
Affine, Arc, BezPath, ClipEntry, ClipPaintEntry, Compose, Composite, Document, FillRule, Frame, GeomEntry, ISOLATION_DIM, Ink, MAX_GUIDE_DASHES,
|
||||
Node, NodeId, NodeKind, Rect, RenderContext, RenderOptions, Renderer, Shape, SymbolBoxes, TraceView, blend_mode, brush_fx, effects, fx, group,
|
||||
holds_instance, holds_plugin_effect, kurbo, lru, mask_value, painted_items, plain_image, polygon_length, rects_overlap, shape_masked_image,
|
||||
single_plain_fill, text_geom_snapped, white_shape_mask,
|
||||
holds_instance, holds_plugin_effect, images, kurbo, lru, mask_value, painted_items, plain_image, polygon_length, rects_overlap,
|
||||
shape_masked_image, single_plain_fill, text_geom_snapped, white_shape_mask,
|
||||
};
|
||||
|
||||
impl Renderer {
|
||||
@@ -153,7 +153,7 @@ impl Renderer {
|
||||
&& let Some(region) = self.clip_of(&m.art)
|
||||
&& shape_masked_image(a, im, ®ion.0)
|
||||
{
|
||||
self.draw_image_in(ctx, f, im, Some(®ion), a.extras().pixelated);
|
||||
self.draw_image_in(ctx, f, im, Some(®ion), images::image_tint(a, f.ink), a.extras().pixelated);
|
||||
self.stats.drawn += 1;
|
||||
return;
|
||||
}
|
||||
@@ -388,7 +388,7 @@ impl Renderer {
|
||||
&& let NodeKind::Image(im) = &image.kind
|
||||
&& plain_image(image, im, ®ion.0)
|
||||
{
|
||||
self.draw_image_in(ctx, f, im, Some(®ion), image.extras().pixelated);
|
||||
self.draw_image_in(ctx, f, im, Some(®ion), images::image_tint(image, f.ink), image.extras().pixelated);
|
||||
self.stats.drawn += 1;
|
||||
return;
|
||||
}
|
||||
@@ -442,7 +442,7 @@ impl Renderer {
|
||||
self.draw_shape(ctx, f, n, &bp, *rule);
|
||||
}
|
||||
NodeKind::Text(t) => self.draw_text(ctx, f, n, t),
|
||||
NodeKind::Image(im) => self.draw_image_in(ctx, f, im, None, n.extras().pixelated),
|
||||
NodeKind::Image(im) => self.draw_image_in(ctx, f, im, None, images::image_tint(n, f.ink), n.extras().pixelated),
|
||||
NodeKind::SymbolInstance { symbol, xf } => {
|
||||
if let Some(sym) = f.doc.symbols.iter().find(|s| &s.name == symbol)
|
||||
&& self.charge_symbol(&sym.art)
|
||||
|
||||
@@ -11,6 +11,7 @@ mod changed;
|
||||
mod charstroke;
|
||||
mod clip;
|
||||
mod cmykblend;
|
||||
mod colorize;
|
||||
mod colrglyphs;
|
||||
mod container;
|
||||
mod dither;
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
//! Grayscale and 1-bit images colorized by a fill color draw in that color, white pixels
|
||||
//! transparent.
|
||||
|
||||
use crate::*;
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, ImageBlob, ImageObject, Node, NodeKind};
|
||||
use vectorcraft_geom::shapes;
|
||||
|
||||
/// An uncompressed 1-bit TIFF, BlackIsZero, of 2 × 1 pixels: black, white.
|
||||
fn bilevel_tiff() -> Vec<u8> {
|
||||
let entries: [(u16, u16, u32); 9] =
|
||||
[(256, 3, 2), (257, 3, 1), (258, 3, 1), (259, 3, 1), (262, 3, 1), (273, 4, 122), (277, 3, 1), (278, 3, 1), (279, 4, 1)];
|
||||
let mut out = b"II*\0".to_vec();
|
||||
out.extend(8u32.to_le_bytes());
|
||||
out.extend((entries.len() as u16).to_le_bytes());
|
||||
for (tag, kind, value) in entries {
|
||||
out.extend(tag.to_le_bytes());
|
||||
out.extend(kind.to_le_bytes());
|
||||
out.extend(1u32.to_le_bytes());
|
||||
out.extend(value.to_le_bytes());
|
||||
}
|
||||
out.extend(0u32.to_le_bytes());
|
||||
out.push(0b0100_0000);
|
||||
out
|
||||
}
|
||||
|
||||
/// A 2 × 1 gray PNG: black, white.
|
||||
fn gray_png() -> Vec<u8> {
|
||||
let img = image::GrayImage::from_raw(2, 1, vec![0, 255]).unwrap();
|
||||
let mut png = vec![];
|
||||
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
png
|
||||
}
|
||||
|
||||
/// A 40 × 20 pt page: a green band behind the image of `blob` (black, white), 20 pt a pixel,
|
||||
/// colorized with `colorize`.
|
||||
fn doc(blob: ImageBlob, colorize: Option<Paint>) -> Document {
|
||||
doc_of(blob, colorize, (2, 1), 20.0)
|
||||
}
|
||||
|
||||
/// [`doc`] for an image of `size` pixels, `scale` pt a pixel.
|
||||
fn doc_of(blob: ImageBlob, colorize: Option<Paint>, (width, height): (u32, u32), scale: f64) -> Document {
|
||||
let mut d = Document::new(40.0, 20.0);
|
||||
let layer = Some(d.layers[0].id);
|
||||
let band = Node::path(
|
||||
d.alloc_id(),
|
||||
shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 20.0)),
|
||||
Appearance::basic(Paint::solid(Color::rgb(0.0, 1.0, 0.0)), Paint::None, 0.0),
|
||||
);
|
||||
d.insert(layer, 0, band).unwrap();
|
||||
d.images.insert("pic".into(), blob);
|
||||
let im = ImageObject { key: "pic".into(), width, height, xf: Affine::scale(scale), link: None, placement: Default::default(), pixels: None };
|
||||
let mut n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)));
|
||||
n.extras_mut().colorize = colorize.map(Box::new);
|
||||
d.insert(layer, 1, n).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gray_and_1_bit_images_draw_in_their_spot_color_with_white_transparent() {
|
||||
let ink = Color::Cmyk { c: 0.0, m: 1.0, y: 1.0, k: 0.0 };
|
||||
let spot = Paint::Solid { color: ink, swatch: Some("Ink".into()), tint: 1.0 };
|
||||
let sharp = RenderOptions { smooth_images: false, ..Default::default() };
|
||||
let shown = Renderer::new().render(&doc(ImageBlob::new("image/png", gray_png()), None), 40, 20, Affine::IDENTITY, &sharp);
|
||||
assert_eq!(shown.pixel(10, 10), [0, 0, 0, 255], "uncolorized: black");
|
||||
let [r, g, b] = ink.to_rgb().map(|v| (v * 255.0).round() as u8);
|
||||
for (what, blob) in [("1-bit", ImageBlob::new("image/tiff", bilevel_tiff())), ("gray", ImageBlob::new("image/png", gray_png()))] {
|
||||
for image_mips in [false, true] {
|
||||
let d = doc(blob.clone(), Some(spot.clone()));
|
||||
let out = Renderer::new().render(&d, 40, 20, Affine::IDENTITY, &RenderOptions { image_mips, smooth_images: false, ..Default::default() });
|
||||
let px = out.pixel(10, 10);
|
||||
assert!(px[0].abs_diff(r) <= 2 && px[1].abs_diff(g) <= 2 && px[2].abs_diff(b) <= 2, "{what}: the black pixel takes the ink, got {px:?}");
|
||||
assert_eq!(out.pixel(30, 10), [0, 255, 0, 255], "{what}: the white pixel shows the band below");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A shrunk copy kept from one frame draws colorized in the next, and the uncolorized image keeps
|
||||
/// its own copy.
|
||||
#[test]
|
||||
fn shrunk_copies_are_kept_per_color() {
|
||||
let black = image::GrayImage::from_raw(64, 64, vec![0; 64 * 64]).unwrap();
|
||||
let mut png = vec![];
|
||||
black.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
let blob = ImageBlob::new("image/png", png);
|
||||
let d = doc_of(blob.clone(), Some(Paint::solid(Color::rgb(0.0, 0.0, 1.0))), (64, 64), 1.0);
|
||||
let plain = doc_of(blob, None, (64, 64), 1.0);
|
||||
let opts = RenderOptions { image_mips: true, ..Default::default() };
|
||||
let view = Affine::scale(0.25);
|
||||
let mut r = Renderer::new();
|
||||
for _ in 0..2 {
|
||||
let px = r.render(&d, 16, 16, view, &opts).pixel(8, 8);
|
||||
assert!(px[2] >= 250 && px[0] <= 5 && px[1] <= 5, "blue, got {px:?}");
|
||||
let px = r.render(&plain, 16, 16, view, &opts).pixel(8, 8);
|
||||
assert!(px[..3].iter().all(|v| *v <= 5), "black, got {px:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// With `kept_images_only` (the canvas's low-resolution fill), a colorized image draws from the
|
||||
/// colorized pixels the store holds, not from the image's own.
|
||||
#[test]
|
||||
fn kept_images_only_draw_the_colorized_pixels() {
|
||||
let black = image::GrayImage::from_raw(48, 48, vec![0; 48 * 48]).unwrap();
|
||||
let mut png = vec![];
|
||||
black.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
let d = doc_of(ImageBlob::new("image/png", png), Some(Paint::solid(Color::rgb(0.0, 0.0, 1.0))), (48, 48), 1.0);
|
||||
let view = Affine::scale(0.25);
|
||||
let mut r = Renderer::new();
|
||||
let _ = r.render(&d, 12, 12, view, &RenderOptions { image_mips: true, ..Default::default() });
|
||||
let px = r.render(&d, 12, 12, view, &RenderOptions { kept_images_only: true, image_mips: true, ..Default::default() }).pixel(6, 6);
|
||||
assert!(px[2] >= 250 && px[0] <= 5 && px[1] <= 5, "blue, got {px:?}");
|
||||
}
|
||||
|
||||
/// Drawn as ink amounts (CMYK exports, CMYK documents that show transparency), a colorized image
|
||||
/// keeps its color's own inks, as a fill of that color does.
|
||||
#[test]
|
||||
fn ink_planes_keep_the_colors_own_inks() {
|
||||
for (color, want) in [(Color::cmyk(0.0, 0.0, 0.0, 1.0), [0u8, 0, 0, 255]), (Color::cmyk(1.0, 0.0, 0.0, 0.0), [255, 0, 0, 0])] {
|
||||
let mut d = Document::new_with_mode(40.0, 20.0, vectorcraft_doc::ColorMode::Cmyk);
|
||||
d.images.insert("pic".into(), ImageBlob::new("image/png", gray_png()));
|
||||
let im =
|
||||
ImageObject { key: "pic".into(), width: 2, height: 1, xf: Affine::scale(20.0), link: None, placement: Default::default(), pixels: None };
|
||||
let mut n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)));
|
||||
n.extras_mut().colorize = Some(Box::new(Paint::solid(color)));
|
||||
d.insert(Some(d.layers[0].id), 0, n).unwrap();
|
||||
let opts = RenderOptions { smooth_images: false, ..Default::default() };
|
||||
let inks = Renderer::new().render_region_inks(&d, Rect::new(0.0, 0.0, 40.0, 20.0), 1.0, &opts);
|
||||
let at = |x: usize| inks[(10 * 40 + x) * 4..][..4].to_vec();
|
||||
assert!(at(10).iter().zip(want).all(|(a, b)| a.abs_diff(b) <= 1), "{color:?}: the black pixel, got {:?}", at(10));
|
||||
assert_eq!(at(30), [0, 0, 0, 0], "{color:?}: the white pixel has no ink");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colorize_dims_with_the_grays() {
|
||||
let gray = image::GrayImage::from_raw(2, 1, vec![128, 255]).unwrap();
|
||||
let mut png = vec![];
|
||||
gray.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
let d = doc(ImageBlob::new("image/png", png), Some(Paint::solid(Color::rgb(0.0, 0.0, 1.0))));
|
||||
let px = Renderer::new().render(&d, 40, 20, Affine::IDENTITY, &RenderOptions { smooth_images: false, ..Default::default() }).pixel(10, 10);
|
||||
// Half blue over the green band.
|
||||
assert!(px[1].abs_diff(128) <= 3 && px[2].abs_diff(127) <= 3 && px[0] <= 2, "got {px:?}");
|
||||
}
|
||||
|
||||
/// Separations Preview shows a spot-colorized image on its ink's plate only.
|
||||
#[test]
|
||||
fn separations_preview_shows_the_image_on_its_spot_plate() {
|
||||
let mut d = Document::new_with_mode(40.0, 20.0, vectorcraft_doc::ColorMode::Cmyk);
|
||||
let ink = Color::cmyk(0.0, 1.0, 1.0, 0.0);
|
||||
d.swatches.push(vectorcraft_color::Swatch { name: "Ink".into(), paint: Paint::solid(ink), global: true, spot: true });
|
||||
d.images.insert("pic".into(), ImageBlob::new("image/png", gray_png()));
|
||||
let im = ImageObject { key: "pic".into(), width: 2, height: 1, xf: Affine::scale(20.0), link: None, placement: Default::default(), pixels: None };
|
||||
let mut n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)));
|
||||
n.extras_mut().colorize = Some(Box::new(Paint::Solid { color: ink, swatch: Some("Ink".into()), tint: 1.0 }));
|
||||
d.insert(Some(d.layers[0].id), 0, n).unwrap();
|
||||
let shown = |plates: &[&str]| {
|
||||
let proof = proof::ProofSetup { separations: Some(plates.iter().map(|p| p.to_string()).collect()), ..Default::default() };
|
||||
let opts = RenderOptions { proof: Some(proof), overprint_preview: true, smooth_images: false, ..Default::default() };
|
||||
Renderer::new().render(&d, 40, 20, Affine::IDENTITY, &opts).pixel(10, 10)
|
||||
};
|
||||
let on = shown(&["Ink"]);
|
||||
// One plate shows as black ink.
|
||||
assert!(on[..3].iter().all(|v| *v < 128), "the Ink plate shows the image, got {on:?}");
|
||||
let off = shown(&["Cyan", "Magenta", "Yellow", "Black"]);
|
||||
assert!(off[..3].iter().all(|v| *v >= 250), "the process plates show paper, got {off:?}");
|
||||
}
|
||||
@@ -141,6 +141,8 @@ pub enum Op {
|
||||
ClipRelease,
|
||||
Compound,
|
||||
CompoundRelease,
|
||||
/// Make Compound Shape in one of the four shape modes, a member's mode, Release or Expand.
|
||||
CompoundShape(u8),
|
||||
Lock,
|
||||
UnlockAll,
|
||||
Hide,
|
||||
@@ -238,6 +240,7 @@ fn arb_leaf_op() -> impl Strategy<Value = Op> {
|
||||
1 => Just(Op::ClipRelease),
|
||||
1 => Just(Op::Compound),
|
||||
1 => Just(Op::CompoundRelease),
|
||||
1 => (0u8..7).prop_map(Op::CompoundShape),
|
||||
1 => Just(Op::Lock),
|
||||
1 => Just(Op::UnlockAll),
|
||||
1 => Just(Op::Hide),
|
||||
@@ -369,6 +372,15 @@ impl Op {
|
||||
Op::ClipRelease => c("object.clippingMask.release", json!({})),
|
||||
Op::Compound => c("object.compoundPath.make", json!({})),
|
||||
Op::CompoundRelease => c("object.compoundPath.release", json!({})),
|
||||
Op::CompoundShape(i) => {
|
||||
const MODES: [&str; 4] = ["add", "subtract", "intersect", "exclude"];
|
||||
match i % 7 {
|
||||
i @ 0..4 => c("object.compoundShape.make", json!({"mode": MODES[i as usize]})),
|
||||
4 => c("object.compoundShape.setMode", json!({"mode": "exclude"})),
|
||||
5 => c("object.compoundShape.release", json!({})),
|
||||
_ => c("object.compoundShape.expand", json!({})),
|
||||
}
|
||||
}
|
||||
Op::Lock => c("object.lock", json!({})),
|
||||
Op::UnlockAll => c("object.unlockAll", json!({})),
|
||||
Op::Hide => c("object.hide", json!({})),
|
||||
|
||||
+228
-31
@@ -27,8 +27,11 @@
|
||||
//! with it) and, once a point is clicked, its handles; and the points of selected gradient meshes
|
||||
//! and mesh envelopes and their handles ([`MeshEdit`]; a solid fill colour recolours a gradient
|
||||
//! mesh's point last clicked). Dragging a corner or an edge of area
|
||||
//! type's frame reshapes the type area (`text.reshapeArea`): the text reflows at its size; dragging
|
||||
//! an anchor of type on a path's path reshapes the path (`text.reshapePath`): the text reflows along it.
|
||||
//! type's frame reshapes the type area (`text.reshapeArea`): the text reflows at its size. A click
|
||||
//! on a frame anchor or a marquee selects frame anchors (Shift toggles them). While the selection
|
||||
//! holds only area type with selected frame anchors, those show filled, a drag of one of them moves
|
||||
//! them all, and the arrow keys nudge them; otherwise the type is selected whole. Dragging an anchor of type on
|
||||
//! a path's path reshapes the path (`text.reshapePath`): the text reflows along it.
|
||||
//! Dragging the brackets of selected type on a path moves or flips it ([`crate::pathtype`]).
|
||||
//!
|
||||
//! A press on the stroke of a path that isn't selected as a whole selects that segment's two anchors
|
||||
@@ -36,6 +39,7 @@
|
||||
//! moves its anchors.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::hit::{HitKind, compound_part_at, hit_test, selected_hidden_at};
|
||||
@@ -125,16 +129,34 @@ enum State {
|
||||
},
|
||||
/// Dragging a mesh point or handle ([`MeshEdit`]).
|
||||
Mesh,
|
||||
/// Dragging anchors of area type's frame (a corner, or the two ends of an edge), or an anchor
|
||||
/// of type on a path's path.
|
||||
/// Dragging anchors of area type's frames (a corner, the two ends of an edge, or the selected
|
||||
/// frame anchors), or an anchor of type on a path's path: each object with its anchors.
|
||||
TypeArea {
|
||||
id: NodeId,
|
||||
anchors: Vec<AnchorRef>,
|
||||
items: FrameAnchors,
|
||||
start: Point,
|
||||
began: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// Anchors of the frames of area type (or of the path of type on a path), each object with its
|
||||
/// anchors.
|
||||
type FrameAnchors = Vec<(NodeId, Vec<AnchorRef>)>;
|
||||
|
||||
/// What [`DirectSelectionTool::notify`] hears after a press or a marquee selected frame anchors:
|
||||
/// the selected objects to keep them with.
|
||||
const FRAME_PICKED: &str = "frameAnchors";
|
||||
|
||||
/// The params of `text.reshapeArea` or `text.reshapePath` moving `items` by `d`.
|
||||
fn reshape_params(items: &[(NodeId, Vec<AnchorRef>)], d: Vec2) -> Value {
|
||||
match items {
|
||||
[(id, anchors)] => json!({"id": id.0, "anchors": anchors_json(anchors), "dx": d.x, "dy": d.y}),
|
||||
_ => {
|
||||
let items: Vec<Value> = items.iter().map(|(id, v)| json!({"id": id.0, "anchors": anchors_json(v)})).collect();
|
||||
json!({"items": items, "dx": d.x, "dy": d.y})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How far (pt) a snap must move a dragged point for the arrow to say it snapped.
|
||||
const SNAPPED: f64 = 1e-9;
|
||||
|
||||
@@ -182,6 +204,15 @@ pub struct DirectSelectionTool {
|
||||
/// An Alt-click on a path selected whole adds this enclosing group on release, as a Group
|
||||
/// Selection click does; an Alt-drag still copies the selection.
|
||||
click_group: Option<NodeId>,
|
||||
/// The selected anchors of area type's frames, picked by a click or a marquee: they show
|
||||
/// filled, a drag of one moves them all, and the arrow keys nudge them. They stay selected
|
||||
/// while the document and its selection are at `frame_revision`, the revision after they were
|
||||
/// picked or the tool last moved them.
|
||||
frame: BTreeMap<NodeId, BTreeSet<AnchorRef>>,
|
||||
frame_revision: (u64, u64),
|
||||
/// The already selected frame anchor the press landed on without Shift: a click (no drag)
|
||||
/// selects it alone, as a drag moves every selected frame anchor with it.
|
||||
frame_click: Option<(NodeId, AnchorRef)>,
|
||||
}
|
||||
|
||||
impl DirectSelectionTool {
|
||||
@@ -204,9 +235,67 @@ impl DirectSelectionTool {
|
||||
click: None,
|
||||
click_segment: None,
|
||||
click_group: None,
|
||||
frame: BTreeMap::new(),
|
||||
frame_revision: (0, 0),
|
||||
frame_click: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The selected frame anchors that still apply, each object with its anchors. They apply while
|
||||
/// nothing else changed the document or the selection since they were picked (a drag of them
|
||||
/// changes it as it goes), and only while the selection holds nothing else: every selected
|
||||
/// object is editable area type with selected frame anchors, and no path anchors or ruler
|
||||
/// guides are selected. Otherwise the type is selected whole.
|
||||
fn frame_anchors(&self, cx: &ToolContext) -> FrameAnchors {
|
||||
let dragging = matches!(self.state, State::TypeArea { began: true, .. });
|
||||
if self.group
|
||||
|| self.frame.is_empty()
|
||||
|| (!dragging && cx.revision != self.frame_revision)
|
||||
|| !cx.selection.anchors.is_empty()
|
||||
|| !cx.selection.guides.is_empty()
|
||||
{
|
||||
return vec![];
|
||||
}
|
||||
let editable = cx.doc.editable();
|
||||
let v: FrameAnchors = self
|
||||
.frame
|
||||
.iter()
|
||||
.filter_map(|(id, set)| {
|
||||
let n = cx
|
||||
.doc
|
||||
.node(*id)
|
||||
.filter(|n| cx.selection.contains(*id) && is_area_type(n) && editable(*id) && in_scope(cx.doc, cx.isolation, *id))?;
|
||||
let frame = editable_path(n)?;
|
||||
let v: Vec<AnchorRef> =
|
||||
set.iter().copied().filter(|(si, ai)| frame.subpaths.get(*si).is_some_and(|sp| *ai < sp.anchors.len())).collect();
|
||||
(!v.is_empty()).then_some((*id, v))
|
||||
})
|
||||
.collect();
|
||||
if v.len() == cx.selection.objects.len() { v } else { vec![] }
|
||||
}
|
||||
|
||||
/// The move an arrow key nudges the selected frame anchors by (General › Keyboard Increment,
|
||||
/// ten times with Shift, along General › Constrain Angle's axes), with those anchors.
|
||||
fn frame_nudge(&self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Option<(FrameAnchors, Vec2)> {
|
||||
if !matches!(self.state, State::Idle) {
|
||||
return None;
|
||||
}
|
||||
let step = cx.keyboard_increment * if mods.shift { 10.0 } else { 1.0 };
|
||||
let d = match key {
|
||||
ToolKey::Left => Point::new(-step, 0.0),
|
||||
ToolKey::Right => Point::new(step, 0.0),
|
||||
ToolKey::Up => Point::new(0.0, -step),
|
||||
ToolKey::Down => Point::new(0.0, step),
|
||||
_ => return None,
|
||||
};
|
||||
let items = self.frame_anchors(cx);
|
||||
if items.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let d = vectorcraft_geom::constrain_axes(cx.constrain_angle).map_or(d, |axes| axes * d);
|
||||
Some((items, d.to_vec2()))
|
||||
}
|
||||
|
||||
/// Is `key` the E that Group Selection takes while a marquee is being dragged? Direct
|
||||
/// Selection takes no letter.
|
||||
fn claims_letter(&self, key: ToolKey) -> bool {
|
||||
@@ -492,7 +581,11 @@ impl Tool for DirectSelectionTool {
|
||||
/// goes back and the tool waits for the next press (#1743).
|
||||
/// With Group Selection, E during a marquee drag makes the marquee select only the objects
|
||||
/// entirely inside it, whole, and E pressed again makes it select what it selects by default.
|
||||
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
|
||||
/// The arrow keys nudge the selected frame anchors of area type.
|
||||
fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec<Action> {
|
||||
if let Some((items, d)) = self.frame_nudge(cx, key, mods) {
|
||||
return vec![Action::Exec("text.reshapeArea".into(), reshape_params(&items, d)), Action::Notify(FRAME_PICKED.into())];
|
||||
}
|
||||
if self.claims_letter(key)
|
||||
&& let State::Marquee { enclosed, .. } = &mut self.state
|
||||
{
|
||||
@@ -507,14 +600,21 @@ impl Tool for DirectSelectionTool {
|
||||
}
|
||||
// A mesh point drag ends here too; the `Action::Cancel` below undoes it.
|
||||
self.mesh.cancel();
|
||||
// The frame anchors a cancelled drag held stay selected.
|
||||
let frame = matches!(self.state, State::TypeArea { .. }) && !self.frame.is_empty();
|
||||
self.state = State::Idle;
|
||||
self.guides.clear();
|
||||
(self.anchor_snap, self.copy_snap, self.move_snap) = (None, None, None);
|
||||
(self.click, self.click_segment, self.click_group) = (None, None, None);
|
||||
vec![Action::Cancel]
|
||||
(self.click, self.click_segment, self.click_group, self.frame_click) = (None, None, None, None);
|
||||
if frame { vec![Action::Cancel, Action::Notify(FRAME_PICKED.into())] } else { vec![Action::Cancel] }
|
||||
}
|
||||
fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool {
|
||||
self.claims_letter(key)
|
||||
fn claims_key(&self, cx: &ToolContext, key: ToolKey) -> bool {
|
||||
self.claims_letter(key) || self.frame_nudge(cx, key, Mods::default()).is_some()
|
||||
}
|
||||
fn notify(&mut self, cx: &ToolContext, what: &str) {
|
||||
if what == FRAME_PICKED {
|
||||
self.frame_revision = cx.revision;
|
||||
}
|
||||
}
|
||||
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
||||
if let Some(out) = self.guide.pointer(cx, ev) {
|
||||
@@ -535,6 +635,7 @@ impl Tool for DirectSelectionTool {
|
||||
self.click = None;
|
||||
self.click_segment = None;
|
||||
self.click_group = None;
|
||||
self.frame_click = None;
|
||||
if let Some(c) = CornerDrag::hit(cx, ev, true) {
|
||||
self.state = State::Corner(c);
|
||||
return vec![];
|
||||
@@ -572,7 +673,8 @@ impl Tool for DirectSelectionTool {
|
||||
return self.press_anchor(cx, ev, p, (id, si, ai, grab));
|
||||
}
|
||||
if let Some((id, anchors)) = hit_frame_edge(cx, p, tol) {
|
||||
return self.press_type_area(cx, id, anchors, p, ev.mods.shift);
|
||||
self.frame.clear();
|
||||
return self.press_type_area(cx, id, vec![(id, anchors)], p, ev.mods.shift);
|
||||
}
|
||||
// A curved segment of a selected blend's spine reshapes, as a path's does.
|
||||
if let Some((id, seg, t, false)) = hit_spine_segment(cx, p, tol) {
|
||||
@@ -610,9 +712,9 @@ impl Tool for DirectSelectionTool {
|
||||
out
|
||||
}
|
||||
(PointerKind::Drag, State::MoveObject { start, grab, hold, began, .. }) => self.drag_move_object(cx, ev, p, (start, grab, hold, began)),
|
||||
(PointerKind::Drag, State::TypeArea { id, anchors, start, began }) => {
|
||||
(PointerKind::Drag, State::TypeArea { items, start, began }) => {
|
||||
let mut out = vec![];
|
||||
let on_path = cx.doc.node(id).is_some_and(is_path_type);
|
||||
let on_path = items.first().and_then(|(id, _)| cx.doc.node(*id)).is_some_and(is_path_type);
|
||||
if !began {
|
||||
if p.distance(start) < cx.tol(3.0) {
|
||||
return out;
|
||||
@@ -621,8 +723,8 @@ impl Tool for DirectSelectionTool {
|
||||
}
|
||||
let d = move_delta(start, p, ev.mods.shift, cx.constrain_angle);
|
||||
let cmd = if on_path { "text.reshapePath" } else { "text.reshapeArea" };
|
||||
out.push(Action::Preview(cmd.into(), json!({"id": id.0, "anchors": anchors_json(&anchors), "dx": d.x, "dy": d.y})));
|
||||
self.state = State::TypeArea { id, anchors, start, began: true };
|
||||
out.push(Action::Preview(cmd.into(), reshape_params(&items, d)));
|
||||
self.state = State::TypeArea { items, start, began: true };
|
||||
out
|
||||
}
|
||||
(PointerKind::Drag, State::Handle { id, si, ai, out }) => {
|
||||
@@ -702,7 +804,7 @@ impl Tool for DirectSelectionTool {
|
||||
| State::SpinePoint { began, .. }
|
||||
| State::SpineSegment { began, .. }
|
||||
| State::TypeArea { began, .. },
|
||||
) => self.release_move(began),
|
||||
) => self.release_move(cx, began),
|
||||
(PointerKind::Up, State::Handle { .. } | State::SpineHandle { .. }) => {
|
||||
self.state = State::Idle;
|
||||
self.guides.clear();
|
||||
@@ -720,7 +822,7 @@ impl Tool for DirectSelectionTool {
|
||||
let mut out = self.spine_overlays(cx);
|
||||
out.extend(self.mesh.overlays(cx));
|
||||
if !self.group {
|
||||
out.extend(frame_overlays(cx));
|
||||
out.extend(frame_overlays(cx, &self.frame_anchors(cx)));
|
||||
out.extend(pathtype::overlays(cx));
|
||||
}
|
||||
if matches!(self.state, State::Handle { .. } | State::MoveAnchors { .. } | State::MoveObject { .. }) {
|
||||
@@ -833,22 +935,60 @@ fn hovered_anchor(cx: &ToolContext, p: Point) -> Option<(NodeId, Point)> {
|
||||
}
|
||||
|
||||
/// The frame anchors of the selected area type and the path anchors of the selected type on a
|
||||
/// path, which Direct Selection drags.
|
||||
fn frame_overlays(cx: &ToolContext) -> Vec<Overlay> {
|
||||
/// path, which Direct Selection drags; the `selected` frame anchors are filled.
|
||||
fn frame_overlays(cx: &ToolContext, selected: &[(NodeId, Vec<AnchorRef>)]) -> Vec<Overlay> {
|
||||
let mut out = vec![];
|
||||
for id in &cx.selection.objects {
|
||||
let Some(frame) = cx.doc.node(*id).filter(|n| is_area_type(n) || is_path_type(n)).and_then(editable_path) else { continue };
|
||||
let color = cx.doc.layer_color(*id);
|
||||
out.extend(frame.anchors().map(|(_, _, a)| Overlay::Anchor { p: a.p, color, filled: false, size: 5.0 }));
|
||||
let picked = selected.iter().find(|(s, _)| s == id).map(|(_, v)| v.as_slice()).unwrap_or_default();
|
||||
out.extend(frame.anchors().map(|(si, ai, a)| Overlay::Anchor { p: a.p, color, filled: picked.contains(&(si, ai)), size: 5.0 }));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl DirectSelectionTool {
|
||||
/// Press on a frame anchor of area type: Shift toggles it among the selected frame anchors;
|
||||
/// without Shift, one not among them becomes the only one, and one among them becomes the only
|
||||
/// one when the press is released without a drag. A drag moves them all.
|
||||
fn press_frame_anchor(&mut self, cx: &ToolContext, id: NodeId, anchor: AnchorRef, p: Point, shift: bool) -> Vec<Action> {
|
||||
let mut picked: BTreeMap<NodeId, BTreeSet<AnchorRef>> =
|
||||
self.frame_anchors(cx).into_iter().map(|(id, v)| (id, v.into_iter().collect())).collect();
|
||||
let on = picked.get(&id).is_some_and(|set| set.contains(&anchor));
|
||||
if shift {
|
||||
let set = picked.entry(id).or_default();
|
||||
if on {
|
||||
set.remove(&anchor);
|
||||
} else {
|
||||
set.insert(anchor);
|
||||
}
|
||||
if set.is_empty() {
|
||||
picked.remove(&id);
|
||||
}
|
||||
} else if !on {
|
||||
picked = BTreeMap::from([(id, BTreeSet::from([anchor]))]);
|
||||
}
|
||||
// The selected objects follow: the type is selected while it has selected frame anchors,
|
||||
// and a click that picks a new anchor without Shift selects that type alone.
|
||||
let mut out = vec![];
|
||||
let any = picked.contains_key(&id);
|
||||
if !shift && !on && cx.selection.objects != [id] {
|
||||
out.push(Action::Exec("select.set".into(), json!({"ids": [id.0]})));
|
||||
} else if shift && cx.selection.contains(id) != any {
|
||||
out.push(Action::Exec("select.toggle".into(), json!({"id": id.0})));
|
||||
}
|
||||
let items: FrameAnchors = picked.iter().map(|(id, set)| (*id, set.iter().copied().collect())).collect();
|
||||
self.state = if items.is_empty() { State::Idle } else { State::TypeArea { items, start: p, began: false } };
|
||||
self.frame = picked;
|
||||
self.frame_click = (on && !shift).then_some((id, anchor));
|
||||
out.push(Action::Notify(FRAME_PICKED.into()));
|
||||
out
|
||||
}
|
||||
|
||||
/// Press on area type's frame anchors (a corner, or an edge's two ends) or an anchor of type
|
||||
/// on a path's path: select the text and get ready to drag them.
|
||||
fn press_type_area(&mut self, cx: &ToolContext, id: NodeId, anchors: Vec<AnchorRef>, p: Point, shift: bool) -> Vec<Action> {
|
||||
self.state = State::TypeArea { id, anchors, start: p, began: false };
|
||||
/// on a path's path, of `id`: select the text and get ready to drag `items`.
|
||||
fn press_type_area(&mut self, cx: &ToolContext, id: NodeId, items: FrameAnchors, p: Point, shift: bool) -> Vec<Action> {
|
||||
self.state = State::TypeArea { items, start: p, began: false };
|
||||
if cx.selection.contains(id) {
|
||||
vec![]
|
||||
} else if shift {
|
||||
@@ -949,8 +1089,11 @@ impl DirectSelectionTool {
|
||||
vec![]
|
||||
}
|
||||
fn press_anchor(&mut self, cx: &ToolContext, ev: &PointerEvent, p: Point, (id, si, ai, grab): (NodeId, usize, usize, Point)) -> Vec<Action> {
|
||||
if cx.doc.node(id).is_some_and(|n| is_area_type(n) || is_path_type(n)) {
|
||||
return self.press_type_area(cx, id, vec![(si, ai)], p, ev.mods.shift);
|
||||
if cx.doc.node(id).is_some_and(is_area_type) {
|
||||
return self.press_frame_anchor(cx, id, (si, ai), p, ev.mods.shift);
|
||||
}
|
||||
if cx.doc.node(id).is_some_and(is_path_type) {
|
||||
return self.press_type_area(cx, id, vec![(id, vec![(si, ai)])], p, ev.mods.shift);
|
||||
}
|
||||
// Alt picks the whole path (and Alt-drag copies it), as on a segment. On a path
|
||||
// already selected whole, an Alt-click adds the next enclosing group on release.
|
||||
@@ -1026,6 +1169,7 @@ impl DirectSelectionTool {
|
||||
if ev.mods.shift {
|
||||
return vec![Action::Exec("select.toggle".into(), json!({"id": h.leaf.0}))];
|
||||
}
|
||||
self.frame.clear();
|
||||
if !cx.selection.contains(h.leaf) || cx.selection.partial(h.leaf).is_some() {
|
||||
return vec![Action::Exec("select.set".into(), json!({"ids": [h.leaf.0]}))];
|
||||
}
|
||||
@@ -1119,16 +1263,29 @@ impl DirectSelectionTool {
|
||||
));
|
||||
out
|
||||
}
|
||||
fn release_move(&mut self, began: bool) -> Vec<Action> {
|
||||
fn release_move(&mut self, cx: &ToolContext, began: bool) -> Vec<Action> {
|
||||
let deselect = match self.state {
|
||||
State::MoveObject { deselect, .. } => deselect,
|
||||
_ => None,
|
||||
};
|
||||
// The frame anchors a drag moved stay selected.
|
||||
let frame = matches!(self.state, State::TypeArea { .. }) && !self.frame.is_empty();
|
||||
self.state = State::Idle;
|
||||
self.guides.clear();
|
||||
(self.anchor_snap, self.copy_snap, self.move_snap) = (None, None, None);
|
||||
let group = self.click_group.take();
|
||||
// A click on one of several selected frame anchors selects it alone.
|
||||
if let (false, Some((id, anchor))) = (began, self.frame_click.take()) {
|
||||
self.frame = BTreeMap::from([(id, BTreeSet::from([anchor]))]);
|
||||
let mut out = vec![];
|
||||
if cx.selection.objects != [id] {
|
||||
out.push(Action::Exec("select.set".into(), json!({"ids": [id.0]})));
|
||||
}
|
||||
out.push(Action::Notify(FRAME_PICKED.into()));
|
||||
return out;
|
||||
}
|
||||
match (began, deselect, self.click.take(), self.click_segment.take()) {
|
||||
(true, ..) if frame => vec![Action::Commit, Action::Notify(FRAME_PICKED.into())],
|
||||
(true, ..) => vec![Action::Commit],
|
||||
(false, Some(id), _, _) => vec![Action::Exec("select.toggle".into(), json!({"id": id.0}))],
|
||||
(false, None, Some((id, si, ai)), _) => {
|
||||
@@ -1144,7 +1301,11 @@ impl DirectSelectionTool {
|
||||
self.state = State::Idle;
|
||||
let r = Rect::from_points(start, p);
|
||||
if r.width() < cx.tol(3.0) && r.height() < cx.tol(3.0) {
|
||||
return if toggle { vec![] } else { vec![Action::Exec("select.none".into(), json!({}))] };
|
||||
if toggle {
|
||||
return vec![];
|
||||
}
|
||||
self.frame.clear();
|
||||
return vec![Action::Exec("select.none".into(), json!({}))];
|
||||
}
|
||||
// With Group Selection's enclosed marquee, the tool selects the objects inside it
|
||||
// (members of groups too), each whole, as a click selects one.
|
||||
@@ -1166,18 +1327,54 @@ impl DirectSelectionTool {
|
||||
}
|
||||
}
|
||||
});
|
||||
let editable = cx.doc.editable();
|
||||
// Direct Selection: the frame anchors of area type inside the rect are selected
|
||||
// (with Shift, toggled), and the type with them, whole.
|
||||
let mut framed: Vec<(NodeId, BTreeSet<AnchorRef>)> = vec![];
|
||||
if !self.group {
|
||||
walk_pickable(cx.doc, cx.isolation, |n| {
|
||||
if is_area_type(n)
|
||||
&& let Some(frame) = editable_path(n)
|
||||
{
|
||||
let inside: BTreeSet<AnchorRef> = frame.anchors().filter(|(_, _, a)| r.contains(a.p)).map(|(s, i, _)| (s, i)).collect();
|
||||
if !inside.is_empty() {
|
||||
framed.push((n.id, inside));
|
||||
}
|
||||
}
|
||||
});
|
||||
framed.retain(|(id, _)| editable(*id));
|
||||
}
|
||||
let mut marked: BTreeMap<NodeId, BTreeSet<AnchorRef>> =
|
||||
if toggle { self.frame_anchors(cx).into_iter().map(|(id, v)| (id, v.into_iter().collect())).collect() } else { BTreeMap::new() };
|
||||
for (id, inside) in &framed {
|
||||
let set = marked.entry(*id).or_default();
|
||||
*set = if toggle { set.symmetric_difference(inside).copied().collect() } else { inside.clone() };
|
||||
let any = !set.is_empty();
|
||||
if !any {
|
||||
marked.remove(id);
|
||||
}
|
||||
// With Shift the type is toggled only when it gains its first selected frame
|
||||
// anchor or loses its last one.
|
||||
if !toggle || cx.selection.contains(*id) != any {
|
||||
sel.push((*id, vec![]));
|
||||
}
|
||||
}
|
||||
// Objects without anchors to pick (type, images, symbols…) are selected whole when
|
||||
// the marquee touches them, as with the Selection tool (#927).
|
||||
for id in vectorcraft_doc::hit::marquee(cx.doc, r, cx.isolation, true) {
|
||||
let whole = cx.doc.node(id).is_some_and(|n| !matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }));
|
||||
if whole && !sel.iter().any(|(s, _)| *s == id) {
|
||||
if whole && !sel.iter().any(|(s, _)| *s == id) && !framed.iter().any(|(f, _)| *f == id) {
|
||||
sel.push((id, vec![]));
|
||||
}
|
||||
}
|
||||
let editable = cx.doc.editable();
|
||||
sel.retain(|(id, _)| editable(*id));
|
||||
self.frame = marked;
|
||||
let items: Vec<Value> = sel.iter().map(|(id, v)| json!({"id": id.0, "anchors": anchors_json(v)})).collect();
|
||||
let art = Action::Exec("select.anchorsMany".into(), json!({"items": items, "mode": if toggle { "toggle" } else { "set" }}));
|
||||
std::iter::once(art).chain(crate::rulerguide::marquee_select(cx, r, false)).collect()
|
||||
let mut out: Vec<Action> = std::iter::once(art).chain(crate::rulerguide::marquee_select(cx, r, false)).collect();
|
||||
if !self.frame.is_empty() {
|
||||
out.push(Action::Notify(FRAME_PICKED.into()));
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,13 +284,13 @@ fn dragging_a_frame_corner_or_edge_reshapes_area_type() {
|
||||
// The bottom-right corner of the 120 × 40 frame at (300, 300) selects the text...
|
||||
assert_eq!(
|
||||
t.pointer(&cx1, &PointerEvent::new(PointerKind::Down, 421.0, 339.0)),
|
||||
vec![Action::Exec("select.set".into(), json!({"ids": [text.0]}))]
|
||||
vec![Action::Exec("select.set".into(), json!({"ids": [text.0]})), Action::Notify(FRAME_PICKED.into())]
|
||||
);
|
||||
// ...and drags that corner.
|
||||
let a = t.pointer(&cx1, &PointerEvent::new(PointerKind::Drag, 461.0, 399.0));
|
||||
assert_eq!(a[0], Action::Begin("Reshape Type Area".into()));
|
||||
assert_eq!(a[1], Action::Preview("text.reshapeArea".into(), json!({"id": text.0, "anchors": [[0, 2]], "dx": 40.0, "dy": 60.0})));
|
||||
assert_eq!(t.pointer(&cx1, &PointerEvent::new(PointerKind::Up, 461.0, 399.0)), vec![Action::Commit]);
|
||||
assert_eq!(t.pointer(&cx1, &PointerEvent::new(PointerKind::Up, 461.0, 399.0)), vec![Action::Commit, Action::Notify(FRAME_PICKED.into())]);
|
||||
// The top edge drags both its ends; a click alone changes nothing.
|
||||
let mut s = Selection::default();
|
||||
s.add(text);
|
||||
@@ -299,10 +299,146 @@ fn dragging_a_frame_corner_or_edge_reshapes_area_type() {
|
||||
let a = t.pointer(&cx2, &PointerEvent::new(PointerKind::Drag, 350.0, 281.0));
|
||||
assert_eq!(a[1], Action::Preview("text.reshapeArea".into(), json!({"id": text.0, "anchors": [[0, 0], [0, 1]], "dx": 0.0, "dy": -20.0})));
|
||||
t.pointer(&cx2, &PointerEvent::new(PointerKind::Up, 350.0, 281.0));
|
||||
assert!(t.pointer(&cx2, &PointerEvent::new(PointerKind::Down, 300.0, 340.0)).is_empty());
|
||||
assert_eq!(t.pointer(&cx2, &PointerEvent::new(PointerKind::Down, 300.0, 340.0)), vec![Action::Notify(FRAME_PICKED.into())]);
|
||||
assert!(t.pointer(&cx2, &PointerEvent::new(PointerKind::Up, 300.0, 340.0)).is_empty());
|
||||
// The selected frame's corners show.
|
||||
assert_eq!(t.overlays(&cx2).iter().filter(|o| matches!(o, Overlay::Anchor { filled: false, .. })).count(), 4);
|
||||
// The selected frame's corners show, the one clicked filled.
|
||||
assert_eq!(t.overlays(&cx2).iter().filter(|o| matches!(o, Overlay::Anchor { filled: false, .. })).count(), 3);
|
||||
assert_eq!(t.overlays(&cx2).iter().filter(|o| matches!(o, Overlay::Anchor { p, filled: true, .. } if *p == Point::new(300.0, 340.0))).count(), 1);
|
||||
}
|
||||
|
||||
/// The frame anchor squares of the selected area type that `t` draws: (filled, hollow).
|
||||
fn frame_squares(t: &DirectSelectionTool, c: &ToolContext) -> (Vec<Point>, Vec<Point>) {
|
||||
let mut out = (vec![], vec![]);
|
||||
for o in t.overlays(c) {
|
||||
if let Overlay::Anchor { p, filled, size: 5.0, .. } = o {
|
||||
if filled { out.0.push(p) } else { out.1.push(p) }
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A Direct Selection marquee selects the frame anchors of area type inside it, which show
|
||||
/// filled: a drag of one moves them all, the arrow keys nudge them, and Shift toggles them.
|
||||
#[test]
|
||||
fn a_marquee_selects_frame_anchors_of_area_type() {
|
||||
let (d, text) = doc_with_area_type();
|
||||
let p = paint();
|
||||
let none = Selection::default();
|
||||
let cx1 = cx(&d, &none, &p);
|
||||
let mut t = DirectSelectionTool::new(false);
|
||||
let ev = |k, x, y| PointerEvent::new(k, x, y);
|
||||
let shift = |k, x, y| PointerEvent::new(k, x, y).with_mods(Mods { shift: true, ..Mods::default() });
|
||||
// A marquee round the 120 × 40 frame's right-hand corners, (420, 300) and (420, 340).
|
||||
assert!(t.pointer(&cx1, &ev(PointerKind::Down, 400.0, 280.0)).is_empty());
|
||||
t.pointer(&cx1, &ev(PointerKind::Drag, 440.0, 360.0));
|
||||
assert_eq!(
|
||||
t.pointer(&cx1, &ev(PointerKind::Up, 440.0, 360.0)),
|
||||
vec![
|
||||
Action::Exec("select.anchorsMany".into(), json!({"items": [{"id": text.0, "anchors": []}], "mode": "set"})),
|
||||
Action::Notify(FRAME_PICKED.into()),
|
||||
]
|
||||
);
|
||||
let mut s = Selection::default();
|
||||
s.add(text);
|
||||
let cx2 = cx(&d, &s, &p);
|
||||
t.notify(&cx2, FRAME_PICKED);
|
||||
let (filled, hollow) = frame_squares(&t, &cx2);
|
||||
assert_eq!(filled, vec![Point::new(420.0, 300.0), Point::new(420.0, 340.0)]);
|
||||
assert_eq!(hollow, vec![Point::new(300.0, 300.0), Point::new(300.0, 340.0)]);
|
||||
// A drag of one of them moves both.
|
||||
assert_eq!(t.pointer(&cx2, &ev(PointerKind::Down, 420.0, 340.0)), vec![Action::Notify(FRAME_PICKED.into())]);
|
||||
let a = t.pointer(&cx2, &ev(PointerKind::Drag, 440.0, 360.0));
|
||||
assert_eq!(a[0], Action::Begin("Reshape Type Area".into()));
|
||||
assert_eq!(a[1], Action::Preview("text.reshapeArea".into(), json!({"id": text.0, "anchors": [[0, 1], [0, 2]], "dx": 20.0, "dy": 20.0})));
|
||||
assert_eq!(t.pointer(&cx2, &ev(PointerKind::Up, 440.0, 360.0)), vec![Action::Commit, Action::Notify(FRAME_PICKED.into())]);
|
||||
// The arrow keys nudge them by the keyboard increment, ten times that with Shift.
|
||||
let nudged = |anchors: Value, dx: f64, dy: f64| {
|
||||
vec![
|
||||
Action::Exec("text.reshapeArea".into(), json!({"id": text.0, "anchors": anchors, "dx": dx, "dy": dy})),
|
||||
Action::Notify(FRAME_PICKED.into()),
|
||||
]
|
||||
};
|
||||
assert!(t.claims_key(&cx2, ToolKey::Right) && !t.claims_key(&cx2, ToolKey::Enter));
|
||||
assert_eq!(t.key(&cx2, ToolKey::Right, Mods::default()), nudged(json!([[0, 1], [0, 2]]), 1.0, 0.0));
|
||||
assert_eq!(t.key(&cx2, ToolKey::Up, Mods { shift: true, ..Mods::default() }), nudged(json!([[0, 1], [0, 2]]), 0.0, -10.0));
|
||||
// Any other change of the document or the selection (Deselect, then the same type selected
|
||||
// again) leaves the type selected whole: the arrow keys are no longer the tool's.
|
||||
let mut later = cx(&d, &s, &p);
|
||||
later.revision = (0, 1);
|
||||
assert!(frame_squares(&t, &later).0.is_empty());
|
||||
assert!(!t.claims_key(&later, ToolKey::Right));
|
||||
// Shift-click takes one out; the other still nudges.
|
||||
assert_eq!(t.pointer(&cx2, &shift(PointerKind::Down, 420.0, 300.0)), vec![Action::Notify(FRAME_PICKED.into())]);
|
||||
assert!(t.pointer(&cx2, &shift(PointerKind::Up, 420.0, 300.0)).is_empty());
|
||||
assert_eq!(frame_squares(&t, &cx2).0, vec![Point::new(420.0, 340.0)]);
|
||||
assert_eq!(t.key(&cx2, ToolKey::Down, Mods::default()), nudged(json!([[0, 2]]), 0.0, 1.0));
|
||||
// A Shift-marquee round the bottom corners toggles them: (300, 340) joins, (420, 340) leaves.
|
||||
t.pointer(&cx2, &shift(PointerKind::Down, 280.0, 330.0));
|
||||
t.pointer(&cx2, &shift(PointerKind::Drag, 440.0, 360.0));
|
||||
let a = t.pointer(&cx2, &shift(PointerKind::Up, 440.0, 360.0));
|
||||
assert_eq!(a[0], Action::Exec("select.anchorsMany".into(), json!({"items": [], "mode": "toggle"})), "the type stays selected");
|
||||
assert_eq!(frame_squares(&t, &cx2).0, vec![Point::new(300.0, 340.0)]);
|
||||
// When the selection changes, the frame anchors are no longer selected.
|
||||
assert!(frame_squares(&t, &cx1).0.is_empty());
|
||||
assert!(!t.claims_key(&cx1, ToolKey::Right) && t.key(&cx1, ToolKey::Right, Mods::default()).is_empty());
|
||||
// A marquee that also reaches a path's anchor (the rectangle's corner at (200, 200)) selects
|
||||
// the type whole, as before: the arrow keys move the path's anchor and the type alike.
|
||||
let rect = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id).unwrap();
|
||||
t.pointer(&cx1, &ev(PointerKind::Down, 440.0, 360.0));
|
||||
t.pointer(&cx1, &ev(PointerKind::Drag, 190.0, 190.0));
|
||||
let a = t.pointer(&cx1, &ev(PointerKind::Up, 190.0, 190.0));
|
||||
let items = json!([{"id": rect.0, "anchors": [[0, 2]]}, {"id": text.0, "anchors": []}]);
|
||||
assert_eq!(a[0], Action::Exec("select.anchorsMany".into(), json!({"items": items, "mode": "set"})));
|
||||
let mut mixed = Selection::default();
|
||||
mixed.add(rect);
|
||||
mixed.anchors.insert(rect, [(0, 2)].into());
|
||||
mixed.add(text);
|
||||
let cx3 = cx(&d, &mixed, &p);
|
||||
t.notify(&cx3, FRAME_PICKED);
|
||||
assert!(frame_squares(&t, &cx3).0.is_empty());
|
||||
assert!(!t.claims_key(&cx3, ToolKey::Right));
|
||||
// Group Selection's marquee selects the type whole, without its frame anchors.
|
||||
let mut g = DirectSelectionTool::new(true);
|
||||
g.pointer(&cx1, &ev(PointerKind::Down, 400.0, 280.0));
|
||||
g.pointer(&cx1, &ev(PointerKind::Drag, 440.0, 360.0));
|
||||
assert_eq!(
|
||||
g.pointer(&cx1, &ev(PointerKind::Up, 440.0, 360.0)),
|
||||
vec![Action::Exec("select.anchorsMany".into(), json!({"items": [{"id": text.0, "anchors": []}], "mode": "set"}))]
|
||||
);
|
||||
}
|
||||
|
||||
/// A click without a drag on one of several selected frame anchors selects it alone, as a click on
|
||||
/// a selected path anchor does; a drag of it still moves them all.
|
||||
#[test]
|
||||
fn a_click_on_a_selected_frame_anchor_selects_it_alone() {
|
||||
let (d, text) = doc_with_area_type();
|
||||
let p = paint();
|
||||
let none = Selection::default();
|
||||
let cx1 = cx(&d, &none, &p);
|
||||
let mut t = DirectSelectionTool::new(false);
|
||||
let ev = |k, x, y| PointerEvent::new(k, x, y);
|
||||
// A marquee round the right-hand corners, (420, 300) and (420, 340).
|
||||
t.pointer(&cx1, &ev(PointerKind::Down, 400.0, 280.0));
|
||||
t.pointer(&cx1, &ev(PointerKind::Drag, 440.0, 360.0));
|
||||
t.pointer(&cx1, &ev(PointerKind::Up, 440.0, 360.0));
|
||||
let mut s = Selection::default();
|
||||
s.add(text);
|
||||
let cx2 = cx(&d, &s, &p);
|
||||
t.notify(&cx2, FRAME_PICKED);
|
||||
assert_eq!(frame_squares(&t, &cx2).0, vec![Point::new(420.0, 300.0), Point::new(420.0, 340.0)]);
|
||||
// A click on (420, 340) keeps both until the release, then selects it alone.
|
||||
assert_eq!(t.pointer(&cx2, &ev(PointerKind::Down, 420.0, 340.0)), vec![Action::Notify(FRAME_PICKED.into())]);
|
||||
assert_eq!(frame_squares(&t, &cx2).0.len(), 2, "both, for a drag");
|
||||
assert_eq!(t.pointer(&cx2, &ev(PointerKind::Up, 420.0, 340.0)), vec![Action::Notify(FRAME_PICKED.into())]);
|
||||
t.notify(&cx2, FRAME_PICKED);
|
||||
assert_eq!(frame_squares(&t, &cx2).0, vec![Point::new(420.0, 340.0)]);
|
||||
assert_eq!(
|
||||
t.key(&cx2, ToolKey::Right, Mods::default()),
|
||||
vec![
|
||||
Action::Exec("text.reshapeArea".into(), json!({"id": text.0, "anchors": [[0, 2]], "dx": 1.0, "dy": 0.0})),
|
||||
Action::Notify(FRAME_PICKED.into()),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// #3980: type on a path shows its path's anchors and a drag of one reshapes the path, the last
|
||||
@@ -710,7 +846,8 @@ fn marquee_selects_type_it_touches() {
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 150.0));
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 150.0));
|
||||
let items = json!([{"id": rect.0, "anchors": [[0, 2]]}, {"id": text.0, "anchors": []}]);
|
||||
assert_eq!(a, vec![Action::Exec("select.anchorsMany".into(), json!({"items": items, "mode": "set"}))]);
|
||||
// The frame's corners are inside too: they are selected with the type.
|
||||
assert_eq!(a, vec![Action::Exec("select.anchorsMany".into(), json!({"items": items, "mode": "set"})), Action::Notify(FRAME_PICKED.into())]);
|
||||
// Away from the type, only anchors.
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 50.0));
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 150.0));
|
||||
|
||||
@@ -112,7 +112,7 @@ impl Tool for TypeTool {
|
||||
self.clicks = (None, 0);
|
||||
// Pasting what we copied keeps its formatting (unless pasted text is kept plain).
|
||||
let clip: String = self.clipboard.iter().map(|r| r.text.as_str()).collect();
|
||||
if !committing && s.len() > 1 && s == clip && !cx.paste_plain_text {
|
||||
if !committing && !s.is_empty() && s == clip && !cx.paste_plain_text {
|
||||
let runs = self.clipboard.clone();
|
||||
return self.replace(cx, "", Some(runs));
|
||||
}
|
||||
@@ -145,7 +145,7 @@ impl Tool for TypeTool {
|
||||
return false;
|
||||
}
|
||||
let clip: String = self.clipboard.iter().map(|r| r.text.as_str()).collect();
|
||||
!(self.preedit.is_none() && normalized.len() > 1 && normalized == clip && !cx.paste_plain_text)
|
||||
!(self.preedit.is_none() && !normalized.is_empty() && normalized == clip && !cx.paste_plain_text)
|
||||
}
|
||||
/// Whether the typing session still describes the edited document; a session ended by an outside
|
||||
/// command reports false until a replacement commit starts a new one.
|
||||
@@ -508,7 +508,7 @@ impl Tool for TypeTool {
|
||||
// Transient selection transferred between Type and Eyedropper; never a saved setting.
|
||||
"textSelection" => {
|
||||
if let (Some(id), Some(a), Some(b)) = (
|
||||
v.get("editing").and_then(Value::as_u64),
|
||||
v.get("editing").or_else(|| v.get("id")).and_then(Value::as_u64),
|
||||
v.get("anchor").or_else(|| v.get("start")).and_then(Value::as_u64),
|
||||
v.get("caret").or_else(|| v.get("end")).and_then(Value::as_u64),
|
||||
) && let (Ok(a), Ok(b)) = (usize::try_from(a), usize::try_from(b))
|
||||
|
||||
@@ -426,7 +426,7 @@ impl Tool for ArtboardTool {
|
||||
vec![Action::Commit]
|
||||
}
|
||||
Drag::Resize { began: true, .. } => vec![Action::Commit],
|
||||
Drag::Create { toggle: Some((i, at)), .. } if p.distance(at) < cx.tol(3.0) => {
|
||||
Drag::Create { toggle: Some((i, at)), .. } if p.distance(at) < cx.tol(5.0) => {
|
||||
self.toggle(cx, i);
|
||||
vec![]
|
||||
}
|
||||
@@ -562,9 +562,14 @@ impl Tool for ArtboardTool {
|
||||
if let Some(Drag::Marquee { start, cur }) = self.drag {
|
||||
o.push(Overlay::Marquee(Rect::from_points(start, cur)));
|
||||
}
|
||||
// The other selected artboards: dashed bounds (in a drag the document shows them moved).
|
||||
for r in self.others.iter().filter_map(|i| cx.doc.artboards.get(*i)).map(|a| a.rect) {
|
||||
o.push(Overlay::Path { path: polygon(&rect_corners(r), true), color: BLUE, width: 1.0, dashed: true });
|
||||
// The other selected artboards: dashed bounds and labels.
|
||||
for &i in &self.others {
|
||||
if let Some(ab) = cx.doc.artboards.get(i) {
|
||||
let r = ab.rect;
|
||||
o.push(Overlay::Path { path: polygon(&rect_corners(r), true), color: BLUE, width: 1.0, dashed: true });
|
||||
let at = Point::new(r.x0 - cx.tol(8.0), r.y0 - cx.tol(4.0));
|
||||
o.push(Overlay::Label { p: at, text: format!("{:02} - {}", i + 1, ab.name), color: BLUE });
|
||||
}
|
||||
}
|
||||
let Some(ab) = cx.doc.artboards.get(self.active) else { return o };
|
||||
let r = self.preview.unwrap_or(ab.rect);
|
||||
|
||||
@@ -79,7 +79,7 @@ use layers::parallel_map_at_most;
|
||||
#[cfg(test)]
|
||||
pub(crate) use layers::shift_probe;
|
||||
pub use params::{Method, Mode, PRESET_NAMES, Palette, TraceParams, enhanced_preset, preset, presets};
|
||||
pub use pipeline::{TraceResult, TracedPath, trace, trace_within};
|
||||
pub use pipeline::{TraceResult, TracedPath, trace, trace_watched, trace_within};
|
||||
#[cfg(test)]
|
||||
use raster::fit_size;
|
||||
pub use raster::{MAX_PIXELS, MAX_SOURCE_PIXELS, Raster, TraceError};
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
|
||||
use super::layers::{Layers, parallel_map_at_most};
|
||||
use super::params::{Method, Mode, TraceParams};
|
||||
use super::raster::{Raster, TraceError};
|
||||
@@ -68,11 +70,24 @@ pub fn trace(img: &Raster, params: &TraceParams) -> TraceResult {
|
||||
/// than `max_anchors` anchor points (a photo traced at high fidelity can make millions). The
|
||||
/// colour layers are traced in parallel where threads are available.
|
||||
pub fn trace_within(img: &Raster, params: &TraceParams, max_anchors: usize) -> Result<TraceResult, TraceError> {
|
||||
trace_watched(img, params, max_anchors, &|_, _| true)
|
||||
}
|
||||
|
||||
/// [`trace_within`], telling `steps` how far it has got: `steps(done, total)` after each stage
|
||||
/// that prepares the colour layers (`total` 0 while they aren't known yet) and after each colour
|
||||
/// layer, from whichever thread traced it. Once `steps` answers `false` the trace stops at the
|
||||
/// next stage or layer with [`TraceError::Cancelled`].
|
||||
pub fn trace_watched(
|
||||
img: &Raster,
|
||||
params: &TraceParams,
|
||||
max_anchors: usize,
|
||||
steps: &(dyn Fn(u64, u64) -> bool + Sync),
|
||||
) -> Result<TraceResult, TraceError> {
|
||||
let (w, h) = (img.width as usize, img.height as usize);
|
||||
if w == 0 || h == 0 || w.checked_mul(h).and_then(|n| n.checked_mul(4)) != Some(img.rgba.len()) {
|
||||
return Ok(TraceResult::default());
|
||||
}
|
||||
let mut out = trace_paths(img, params, max_anchors)?;
|
||||
let mut out = trace_paths(img, params, max_anchors, steps)?;
|
||||
if params.auto_grouping {
|
||||
// Abutting areas cannot overlap; stacked layers, Logo's shapes and stroked lines can.
|
||||
let stacked = params.method == Method::Overlapping || params.mode == Mode::Logo || params.strokes;
|
||||
@@ -81,9 +96,12 @@ pub fn trace_within(img: &Raster, params: &TraceParams, max_anchors: usize) -> R
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize) -> Result<TraceResult, TraceError> {
|
||||
fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize, steps: &(dyn Fn(u64, u64) -> bool + Sync)) -> Result<TraceResult, TraceError> {
|
||||
let (w, h) = (img.width as usize, img.height as usize);
|
||||
// After each stage before the colour layers, whose number isn't known yet.
|
||||
let go_on = || if steps(0, 0) { Ok(()) } else { Err(TraceError::Cancelled) };
|
||||
if params.mode == Mode::Logo {
|
||||
go_on()?;
|
||||
return logo::trace_logo(img, params, max_anchors);
|
||||
}
|
||||
// The colour Ignore Color leaves out, if not white.
|
||||
@@ -94,6 +112,7 @@ fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize) -> Result
|
||||
// A photo is traced from a copy with its noise smoothed away (see `smooth`).
|
||||
let smoothed = (params.mode != Mode::BlackAndWhite && quantize::continuous_tone(img)).then(|| smooth::smoothed(img));
|
||||
let img = smoothed.as_ref().unwrap_or(img);
|
||||
go_on()?;
|
||||
// Gradients are found first and kept out of the palette: no colour is spent on bands of them.
|
||||
// Grayscale finds them in the image's grays (as its flat colours are), so their stops are gray.
|
||||
let grays = (params.gradients && params.mode == Mode::Grayscale).then(|| {
|
||||
@@ -106,6 +125,7 @@ fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize) -> Result
|
||||
});
|
||||
let traced_as = grays.as_ref().unwrap_or(img);
|
||||
let found = (params.gradients && params.mode != Mode::BlackAndWhite).then(|| gradient::detect(traced_as)).filter(|f| !f.1.is_empty());
|
||||
go_on()?;
|
||||
let without = found.as_ref().map(|(of, _)| {
|
||||
let mut rest = img.clone();
|
||||
for (p, &g) in rest.rgba.as_chunks_mut::<4>().0.iter_mut().zip(of) {
|
||||
@@ -116,6 +136,7 @@ fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize) -> Result
|
||||
rest
|
||||
});
|
||||
let (mut q, rare) = quantize::quantize_keeping(without.as_ref().unwrap_or(img), params);
|
||||
go_on()?;
|
||||
// The semi-transparent edge of a cut-out image traces as the color beside it.
|
||||
if matches!(params.mode, Mode::Color | Mode::Grayscale) {
|
||||
quantize::settle_cut_edges(&mut q, img, params.mode == Mode::Grayscale);
|
||||
@@ -138,6 +159,7 @@ fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize) -> Result
|
||||
// Noise clears specks, not the regions of a rare colour Automatic kept on purpose.
|
||||
let keep: Vec<bool> = q.palette.iter().map(|c| rare.contains(c)).collect();
|
||||
quantize::denoise_keeping(&mut q.labels, w, h, params.noise as usize, &keep);
|
||||
go_on()?;
|
||||
// Pixel count per palette entry, and the box its pixels lie in.
|
||||
let mut counts = vec![0usize; q.palette.len()];
|
||||
let mut boxes: Vec<Option<(usize, usize, usize, usize)>> = vec![None; q.palette.len()];
|
||||
@@ -194,7 +216,26 @@ fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize) -> Result
|
||||
// freed before the trace starts.)
|
||||
let largest = job.boxes.iter().flatten().map(|b| (b.2 - b.0) * (b.3 - b.1)).max().unwrap_or(1).max(1);
|
||||
let at_a_time = ((512 << 20) / (largest * 10)).max(1);
|
||||
for (paths, &(_, ci)) in parallel_map_at_most(at_a_time, &layers, |&(r, ci)| job.layer(r, ci)).into_iter().zip(&layers) {
|
||||
let total = layers.len() as u64;
|
||||
if !steps(0, total) {
|
||||
return Err(TraceError::Cancelled);
|
||||
}
|
||||
// Once stopped, the layers not started yet are skipped.
|
||||
let (done, stopped) = (AtomicU64::new(0), AtomicBool::new(false));
|
||||
let traced = parallel_map_at_most(at_a_time, &layers, |&(r, ci)| {
|
||||
if stopped.load(Ordering::Relaxed) {
|
||||
return None;
|
||||
}
|
||||
let paths = job.layer(r, ci);
|
||||
if !steps(done.fetch_add(1, Ordering::Relaxed) + 1, total) {
|
||||
stopped.store(true, Ordering::Relaxed);
|
||||
}
|
||||
paths
|
||||
});
|
||||
if stopped.load(Ordering::Relaxed) {
|
||||
return Err(TraceError::Cancelled);
|
||||
}
|
||||
for (paths, &(_, ci)) in traced.into_iter().zip(&layers) {
|
||||
let paths = paths.ok_or(TraceError::TooComplex { max: max_anchors })?;
|
||||
// A layer with no paths adds no color to the palette.
|
||||
if paths.is_empty() {
|
||||
|
||||
@@ -15,6 +15,9 @@ pub enum TraceError {
|
||||
"this trace would make more than {max} anchor points, more than the document can show smoothly: raise Noise, lower Paths or Colors, or pick a lower-fidelity preset"
|
||||
)]
|
||||
TooComplex { max: usize },
|
||||
/// The caller of [`crate::trace_watched`] stopped the trace.
|
||||
#[error("the trace was cancelled")]
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// The most pixels an image to trace may have (64 megapixels: 256 MB decoded); larger images are
|
||||
|
||||
@@ -586,6 +586,28 @@ fn a_trace_past_its_anchor_budget_is_refused() {
|
||||
assert_eq!(trace_within(&img, &p, anchors).unwrap(), full);
|
||||
}
|
||||
|
||||
/// #3098: a watched trace says how far it has got, layer by layer, and stops when told to.
|
||||
#[test]
|
||||
fn a_watched_trace_counts_its_layers_and_stops_when_told() {
|
||||
let img = stripes(120, 8);
|
||||
let p = TraceParams { mode: Mode::Color, colors: 16, noise: 1, ..TraceParams::default() };
|
||||
let seen = std::sync::Mutex::new(vec![]);
|
||||
let watched = trace_watched(&img, &p, usize::MAX, &|done, total| {
|
||||
seen.lock().unwrap().push((done, total));
|
||||
true
|
||||
});
|
||||
assert_eq!(watched.unwrap(), trace(&img, &p), "watching changes nothing");
|
||||
let seen = seen.into_inner().unwrap();
|
||||
let total = seen.last().unwrap().1;
|
||||
assert!(total > 1, "{seen:?}");
|
||||
let mut layers: Vec<u64> = seen.iter().filter(|s| s.1 == total && s.0 > 0).map(|s| s.0).collect();
|
||||
layers.sort_unstable();
|
||||
assert_eq!(layers, (1..=total).collect::<Vec<_>>(), "each layer once: {seen:?}");
|
||||
// Stopped before the layers, and after the first.
|
||||
assert!(matches!(trace_watched(&img, &p, usize::MAX, &|_, _| false), Err(TraceError::Cancelled)));
|
||||
assert!(matches!(trace_watched(&img, &p, usize::MAX, &|done, _| done == 0), Err(TraceError::Cancelled)));
|
||||
}
|
||||
|
||||
/// Layers traced in parallel come out in the same order every time: bottom (largest) first.
|
||||
#[test]
|
||||
fn parallel_layers_keep_their_order() {
|
||||
|
||||
@@ -12,7 +12,7 @@ use super::{
|
||||
Arc, AutomationRoots, ControlRequest, Ordering, Receiver, Session, UiState, Value, VectorcraftApp, View, ViewInfo, background, canvas, chrome,
|
||||
clipboard_probe, dialogs, export_progress, file_access, fileio, font_menu, graphics, i18n, io, json, menus, native_menu, opening, palette,
|
||||
panel_docking, panels, picks, place, placing, plugin_runs, prefs_dialog, recovery, screen_color, scrub, shortcut_editor, shortcuts, theme,
|
||||
titlebar, toolbar, tools_float, unsaved, updates_ui, widgets,
|
||||
titlebar, toolbar, tools_float, trace_runs, unsaved, updates_ui, widgets,
|
||||
};
|
||||
|
||||
mod clipboard;
|
||||
@@ -121,6 +121,7 @@ impl VectorcraftApp {
|
||||
opening: Default::default(),
|
||||
plugin_runs: Default::default(),
|
||||
placing: Default::default(),
|
||||
trace_runs: Default::default(),
|
||||
recovery: Default::default(),
|
||||
host_modifiers: Default::default(),
|
||||
forwarded: Vec::new(),
|
||||
@@ -289,6 +290,7 @@ impl VectorcraftApp {
|
||||
screen_color::poll(self, ctx);
|
||||
opening::poll(self);
|
||||
plugin_runs::poll(self);
|
||||
trace_runs::poll(self);
|
||||
placing::poll(self);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
self.tick_link_updates(ctx, now);
|
||||
@@ -296,6 +298,7 @@ impl VectorcraftApp {
|
||||
|| !self.opening.pending.is_empty()
|
||||
|| !self.plugin_runs.running.is_empty()
|
||||
|| !self.placing.pending.is_empty()
|
||||
|| !self.trace_runs.running.is_empty()
|
||||
{
|
||||
// Keep the status bar's progress moving and pick the result up when it arrives.
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||||
@@ -487,6 +490,7 @@ impl VectorcraftApp {
|
||||
updates_ui::show(self, &ctx);
|
||||
export_progress::show(self, &ctx);
|
||||
plugin_runs::show(self, &ctx);
|
||||
trace_runs::show(self, &ctx);
|
||||
palette::show(self, &ctx);
|
||||
if self.custom_titlebar {
|
||||
titlebar::resize_zones(ui);
|
||||
|
||||
@@ -713,11 +713,11 @@ fn reproject_frame(app: &VectorcraftApp, painter: &egui::Painter, xf: &Xf, rect:
|
||||
fn artboard_outlines(app: &VectorcraftApp, ui: &Ui, painter: &egui::Painter, xf: &Xf, t: &Tokens) {
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let doc = &st.doc;
|
||||
// Artboard edges and names. The Artboard tool labels its active artboard itself (following
|
||||
// a move or resize): that one isn't named twice.
|
||||
// Artboard edges and names. The Artboard tool labels its selected artboards itself (following
|
||||
// a move or resize): those aren't named twice.
|
||||
let active_ab = crate::panels::artboards::selected(app, doc.artboards.len());
|
||||
let artboard_tool = app.session.tool_id() == "artboard";
|
||||
let tool_labelled = artboard_tool.then(|| app.session.tool_options()["active"].as_u64()).flatten();
|
||||
let tool_selected = artboards::tool_labelled(app);
|
||||
let editing_name = ui.data(|d| d.get_temp::<ArtboardNameEdit>(name_edit_id()).map(|e| e.index));
|
||||
for (i, ab) in doc.artboards.iter().enumerate() {
|
||||
let c = if i == active_ab { Color32::from_gray(0) } else { Color32::from_gray(120) };
|
||||
@@ -729,7 +729,8 @@ fn artboard_outlines(app: &VectorcraftApp, ui: &Ui, painter: &egui::Painter, xf:
|
||||
// Show Center Mark, Show Cross Hairs and Show Video Safe Areas (Artboard Options). Only the
|
||||
// canvas draws them, as it does the edges: exports and print leave them out.
|
||||
artboard_marks(painter, xf, ab, t.artboard_mark);
|
||||
if editing_name != Some(i) && (artboard_tool || doc.artboards.len() > 1) && tool_labelled != u64::try_from(i).ok() {
|
||||
let labelled_by_tool = u64::try_from(i).ok().is_some_and(|idx| tool_selected.contains(&idx));
|
||||
if editing_name != Some(i) && (artboard_tool || doc.artboards.len() > 1) && !labelled_by_tool {
|
||||
// At the artboard's own top-left corner, which a rotated view turns away from its
|
||||
// bounding box's (#1095).
|
||||
let corner = xf.to_screen(Point::new(ab.rect.x0, ab.rect.y0));
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use super::{Color32, Point, Pos2, Rect, Shape, Stroke, Ui, VectorcraftApp, Xf, json, vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{Color32, Point, Pos2, Rect, Shape, Stroke, Ui, VectorcraftApp, Xf, vec2};
|
||||
|
||||
/// The width an artboard needs on screen for its name to show (px): narrower, the name is left
|
||||
/// out rather than spilling over its neighbours.
|
||||
@@ -89,6 +91,15 @@ pub(super) fn tool_name_rect(painter: &egui::Painter, xf: &Xf, rect: Rect, index
|
||||
egui::Rect::from_min_size(sp, galley.size()).expand(2.0)
|
||||
}
|
||||
|
||||
/// The artboards the Artboard tool labels itself, following a move or resize (its selected
|
||||
/// ones): the canvas doesn't name them again. Empty with another tool.
|
||||
pub(super) fn tool_labelled(app: &VectorcraftApp) -> Vec<u64> {
|
||||
if app.session.tool_id() != "artboard" {
|
||||
return vec![];
|
||||
}
|
||||
app.session.tool_options()["selected"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The artboard whose on-canvas name contains `screen`, if that name is being drawn.
|
||||
pub(super) fn artboard_name_at(app: &VectorcraftApp, ui: &Ui, xf: &Xf, screen: Pos2) -> Option<usize> {
|
||||
let st = app.session.active()?;
|
||||
@@ -96,11 +107,12 @@ pub(super) fn artboard_name_at(app: &VectorcraftApp, ui: &Ui, xf: &Xf, screen: P
|
||||
if !artboard_tool && st.doc.artboards.len() <= 1 {
|
||||
return None;
|
||||
}
|
||||
let tool_labelled = artboard_tool.then(|| app.session.tool_options()["active"].as_u64()).flatten();
|
||||
let tool_selected = tool_labelled(app);
|
||||
let painter = ui.painter();
|
||||
let mut hit = None;
|
||||
for (i, ab) in st.doc.artboards.iter().enumerate() {
|
||||
if tool_labelled == u64::try_from(i).ok() {
|
||||
let is_tool_labelled = u64::try_from(i).ok().is_some_and(|idx| tool_selected.contains(&idx));
|
||||
if is_tool_labelled {
|
||||
continue;
|
||||
}
|
||||
let corner = xf.to_screen(Point::new(ab.rect.x0, ab.rect.y0));
|
||||
@@ -110,16 +122,18 @@ pub(super) fn artboard_name_at(app: &VectorcraftApp, ui: &Ui, xf: &Xf, screen: P
|
||||
hit = Some(i);
|
||||
}
|
||||
}
|
||||
if let Some(i) = tool_labelled.and_then(|i| usize::try_from(i).ok())
|
||||
&& let Some(ab) = st.doc.artboards.get(i)
|
||||
&& tool_name_rect(painter, xf, ab.rect, i, &ab.name).contains(screen)
|
||||
{
|
||||
return Some(i);
|
||||
for &idx in &tool_selected {
|
||||
if let Some(i) = usize::try_from(idx).ok()
|
||||
&& let Some(ab) = st.doc.artboards.get(i)
|
||||
&& tool_name_rect(painter, xf, ab.rect, i, &ab.name).contains(screen)
|
||||
{
|
||||
return Some(i);
|
||||
}
|
||||
}
|
||||
hit
|
||||
}
|
||||
|
||||
pub(super) fn select_named_artboard(app: &mut VectorcraftApp, index: usize) {
|
||||
pub(super) fn select_named_artboard(app: &mut VectorcraftApp, index: usize, shift: bool) {
|
||||
let n = app.session.active().map(|st| st.doc.artboards.len()).unwrap_or(0);
|
||||
if index >= n {
|
||||
return;
|
||||
@@ -128,7 +142,11 @@ pub(super) fn select_named_artboard(app: &mut VectorcraftApp, index: usize) {
|
||||
v.artboard = index;
|
||||
}
|
||||
if app.session.tool_id() == "artboard" {
|
||||
app.session.set_tool_option("active", &json!(index));
|
||||
if shift {
|
||||
crate::panels::artboards::toggle(app, index, n);
|
||||
} else {
|
||||
app.session.set_tool_option("active", &json!(index));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,10 +212,10 @@ pub(super) fn artboard_name_editor(app: &mut VectorcraftApp, ui: &mut Ui, xf: &X
|
||||
pub(super) fn artboard_name_field_rect(app: &VectorcraftApp, ui: &Ui, xf: &Xf, index: usize) -> Option<egui::Rect> {
|
||||
let st = app.session.active()?;
|
||||
let ab = st.doc.artboards.get(index)?;
|
||||
let artboard_tool = app.session.tool_id() == "artboard";
|
||||
let tool_labelled = artboard_tool.then(|| app.session.tool_options()["active"].as_u64()).flatten();
|
||||
let tool_selected = tool_labelled(app);
|
||||
let painter = ui.painter();
|
||||
let rect = if tool_labelled == u64::try_from(index).ok() {
|
||||
let is_tool_labelled = u64::try_from(index).ok().is_some_and(|idx| tool_selected.contains(&idx));
|
||||
let rect = if is_tool_labelled {
|
||||
tool_name_rect(painter, xf, ab.rect, index, &ab.name)
|
||||
} else {
|
||||
let corner = xf.to_screen(Point::new(ab.rect.x0, ab.rect.y0));
|
||||
|
||||
@@ -91,7 +91,15 @@ pub(super) fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Respo
|
||||
// The artboard's name sits above its corner. A press there renames on a double-click
|
||||
// and does not start a tool gesture (a fitted artboard, a new one, Artboard Options).
|
||||
if let Some(i) = artboard_name_at(app, ui, &xf, p) {
|
||||
ui.data_mut(|dd| dd.insert_temp(name_press_id(), i));
|
||||
let press_shift = ui
|
||||
.input(|inp| {
|
||||
inp.events.iter().rev().find_map(|event| match event {
|
||||
egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed: true, modifiers, .. } => Some(modifiers.shift),
|
||||
_ => None,
|
||||
})
|
||||
})
|
||||
.unwrap_or(m.shift);
|
||||
ui.data_mut(|dd| dd.insert_temp(name_press_id(), (i, press_shift)));
|
||||
} else {
|
||||
begin_drag(app, ui, &f, p);
|
||||
}
|
||||
@@ -134,9 +142,17 @@ pub(super) fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Respo
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(16));
|
||||
}
|
||||
if pointer.primary_released()
|
||||
&& let Some(i) = ui.data_mut(|d| d.remove_temp::<usize>(name_press_id()))
|
||||
&& let Some((i, shift)) = ui.data_mut(|d| d.remove_temp::<(usize, bool)>(name_press_id()))
|
||||
{
|
||||
select_named_artboard(app, i);
|
||||
let release_shift = ui
|
||||
.input(|inp| {
|
||||
inp.events.iter().rev().find_map(|event| match event {
|
||||
egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed: false, modifiers, .. } => Some(modifiers.shift),
|
||||
_ => None,
|
||||
})
|
||||
})
|
||||
.unwrap_or(m.shift);
|
||||
select_named_artboard(app, i, shift || release_shift);
|
||||
}
|
||||
if resp.double_clicked()
|
||||
&& let Some(p) = at
|
||||
|
||||
@@ -705,6 +705,57 @@ fn the_artboard_tool_names_its_active_artboard_once() {
|
||||
assert!((active[0].1 - other[0].1).abs() < 2.0, "on the artboard's top edge like the other name: {active:?} vs {other:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_clicking_canvas_artboard_label_toggles_multi_selection() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 3})).unwrap();
|
||||
app.select_tool("artboard");
|
||||
if let Some(v) = app.view_mut() {
|
||||
v.center = Point::new(320.0, 50.0);
|
||||
v.zoom = 0.8;
|
||||
v.fitted = true;
|
||||
}
|
||||
let ctx = egui::Context::default();
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
assert_eq!(app.session.tool_options()["selected"], json!([0]));
|
||||
|
||||
let drawn = shapes(&mut app, &ctx);
|
||||
let label_pos = |name: &str| {
|
||||
drawn
|
||||
.iter()
|
||||
.find_map(|s| match s {
|
||||
Shape::Text(t) if t.galley.text() == name => Some(t.pos + vec2(10.0, 5.0)),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap()
|
||||
};
|
||||
let at1 = label_pos("02 - Artboard 2");
|
||||
|
||||
// Shift-click artboard 2's name label (Shift held on press, released before mouse release)
|
||||
let shift = egui::Modifiers::SHIFT;
|
||||
let button = |pressed| egui::Event::PointerButton { pos: at1, button: egui::PointerButton::Primary, pressed, modifiers: shift };
|
||||
frame(&mut app, &ctx, vec![egui::Event::ModifiersChanged(shift), egui::Event::PointerMoved(at1), button(true)]);
|
||||
frame(
|
||||
&mut app,
|
||||
&ctx,
|
||||
vec![
|
||||
egui::Event::ModifiersChanged(egui::Modifiers::NONE),
|
||||
egui::Event::PointerButton { pos: at1, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE },
|
||||
],
|
||||
);
|
||||
|
||||
assert_eq!(app.session.tool_options()["selected"], json!([1, 0]), "Shift-clicking artboard label adds it to selection");
|
||||
|
||||
// Click artboard 3's name label without Shift
|
||||
let at2 = label_pos("03 - Artboard 3");
|
||||
let plain_btn =
|
||||
|pressed| egui::Event::PointerButton { pos: at2, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };
|
||||
frame(&mut app, &ctx, vec![egui::Event::ModifiersChanged(egui::Modifiers::NONE), egui::Event::PointerMoved(at2), plain_btn(true)]);
|
||||
frame(&mut app, &ctx, vec![plain_btn(false)]);
|
||||
|
||||
assert_eq!(app.session.tool_options()["selected"], json!([2]), "Plain click selects that artboard alone");
|
||||
}
|
||||
|
||||
/// Enable Touch Gestures (#585): a two-finger tap undoes and a three-finger one redoes, and the
|
||||
/// first finger's press (the pointer egui makes of it) draws nothing. Off, a tap does nothing.
|
||||
#[test]
|
||||
|
||||
@@ -29,7 +29,7 @@ pub use status_bar::status_bar;
|
||||
use status_bar::{StatusReadoutWidths, status_readouts};
|
||||
#[cfg(test)]
|
||||
use tabs::{CLOSE_BEFORE_TITLE, first_tab_shown, tab_title};
|
||||
pub use tabs::{doc_tabs, isolation_bar};
|
||||
pub use tabs::{doc_tabs, isolation_bar, step_document};
|
||||
|
||||
/// The application bar's height, in points (compact, as in PhotoCraft).
|
||||
pub const APP_BAR_HEIGHT: f32 = 32.0;
|
||||
|
||||
@@ -114,21 +114,47 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
app.status(e);
|
||||
}
|
||||
} else if let Some(i) = activate {
|
||||
app.ui.home = None;
|
||||
let changed = app.session.active_index() != Some(i);
|
||||
if app.session.set_active(i)
|
||||
&& changed
|
||||
&& app.session.tool_id() == "artboard"
|
||||
&& let Some(n) = app.session.active().map(|d| d.doc.artboards.len())
|
||||
{
|
||||
// A document switch recreates the tool; restore the displayed board as its target.
|
||||
// Clicking the current tab keeps its existing multiselection and tool options.
|
||||
let selected = crate::panels::artboards::selected(app, n);
|
||||
crate::panels::artboards::select(app, selected);
|
||||
}
|
||||
show_document(app, i);
|
||||
}
|
||||
}
|
||||
|
||||
/// Makes document `i` the current one, as a click on its tab does: the Home screen closes, and
|
||||
/// with the Artboard tool the artboard the document shows becomes the tool's target.
|
||||
fn show_document(app: &mut VectorcraftApp, i: usize) {
|
||||
app.ui.home = None;
|
||||
let changed = app.session.active_index() != Some(i);
|
||||
if app.session.set_active(i)
|
||||
&& changed
|
||||
&& app.session.tool_id() == "artboard"
|
||||
&& let Some(n) = app.session.active().map(|d| d.doc.artboards.len())
|
||||
{
|
||||
// A document switch recreates the tool; restore the displayed board as its target.
|
||||
// Clicking the current tab keeps its existing multiselection and tool options.
|
||||
let selected = crate::panels::artboards::selected(app, n);
|
||||
crate::panels::artboards::select(app, selected);
|
||||
}
|
||||
}
|
||||
|
||||
/// `window.nextDocument` (`forward`) and `window.previousDocument`: makes the open document after
|
||||
/// the current one in tab order (before it) the current one, as a click on its tab does. After the
|
||||
/// last document comes the first, and before the first document comes the last. On the Home
|
||||
/// screen, where no tab is the current one, the next document is the first and the previous
|
||||
/// document is the last. Returns `{index}`.
|
||||
pub fn step_document(app: &mut VectorcraftApp, forward: bool) -> Result<serde_json::Value, String> {
|
||||
let n = app.session.documents().len();
|
||||
if n == 0 {
|
||||
return Err("no document open".into());
|
||||
}
|
||||
let i = match (app.session.active_index().filter(|_| app.ui.home.is_none()), forward) {
|
||||
(Some(a), true) => (a + 1) % n,
|
||||
(Some(a), false) => (a + n - 1) % n,
|
||||
(None, true) => 0,
|
||||
(None, false) => n - 1,
|
||||
};
|
||||
show_document(app, i);
|
||||
Ok(json!({ "index": i }))
|
||||
}
|
||||
|
||||
/// The isolation mode breadcrumb bar. It is drawn over the top of `area` (the canvas), after the
|
||||
/// canvas, so entering isolation mode does not change the canvas's size (#3126).
|
||||
pub fn isolation_bar(app: &mut VectorcraftApp, ui: &mut Ui, area: egui::Rect) {
|
||||
|
||||
@@ -1294,6 +1294,127 @@ fn artboard_document_tab_roundtrip_preserves_other_tools() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Press F6 with `modifiers` through the shortcut handler.
|
||||
fn press_f6(app: &mut crate::VectorcraftApp, modifiers: egui::Modifiers) {
|
||||
let ctx = egui::Context::default();
|
||||
let ev = egui::Event::Key { key: egui::Key::F6, physical_key: None, pressed: true, repeat: false, modifiers };
|
||||
let mut out = ctx.run_ui(egui::RawInput { events: vec![ev], ..Default::default() }, |ui| crate::shortcuts::handle(app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
/// Cmd+F6 is Next Document and Cmd+Shift+F6 is Previous Document by default: Ctrl+F6 and
|
||||
/// Ctrl+Shift+F6 on Windows and Linux, Command+F6 and Shift+Command+F6 on macOS. Keyboard
|
||||
/// Shortcuts lists both under Window. F6 and Shift+F6 are still the Color and Appearance panels'
|
||||
/// keys, and no other command, tool or panel has either key by default.
|
||||
#[test]
|
||||
fn cmd_f6_and_cmd_shift_f6_are_next_and_previous_document() {
|
||||
use crate::shortcut_editor::{command_for_key, default_command_shortcut, panel_shortcut};
|
||||
assert_eq!(default_command_shortcut("window.nextDocument"), Some("Cmd+F6"));
|
||||
assert_eq!(default_command_shortcut("window.previousDocument"), Some("Cmd+Shift+F6"));
|
||||
assert_eq!(command_for_key("Cmd+F6"), Some("window.nextDocument"));
|
||||
assert_eq!(command_for_key("Cmd+Shift+F6"), Some("window.previousDocument"));
|
||||
assert_eq!((panel_shortcut("color"), panel_shortcut("appearance")), (Some("F6"), Some("Shift+F6")));
|
||||
let chord = crate::shortcuts::parse("Cmd+Shift+F6").expect("parses");
|
||||
assert_eq!((chord.modifiers, chord.logical_key), (egui::Modifiers::COMMAND | egui::Modifiers::SHIFT, egui::Key::F6));
|
||||
let shown = (crate::menus::pretty_shortcut("Cmd+F6"), crate::menus::pretty_shortcut("Cmd+Shift+F6"));
|
||||
let want = if cfg!(target_os = "macos") { ("⌘F6", "⇧⌘F6") } else { ("Ctrl+F6", "Ctrl+Shift+F6") };
|
||||
assert_eq!((shown.0.as_str(), shown.1.as_str()), want);
|
||||
let mut app = crate::VectorcraftApp::new(Session::new(), Default::default());
|
||||
let listed = app.run("shortcuts.list", json!({"query": "Document"})).unwrap();
|
||||
let rows: Vec<_> = listed
|
||||
.as_array()
|
||||
.expect("rows")
|
||||
.iter()
|
||||
.filter(|r| r["id"].as_str().is_some_and(|id| id.ends_with("Document") && id.starts_with("window.")))
|
||||
.map(|r| (r["label"].clone(), r["group"].clone(), r["default"].clone()))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
rows,
|
||||
[(json!("Next Document"), json!("Window"), json!("Cmd+F6")), (json!("Previous Document"), json!("Window"), json!("Cmd+Shift+F6"))],
|
||||
"Keyboard Shortcuts lists both"
|
||||
);
|
||||
assert_eq!(app.run("shortcuts.conflicts", json!({})).unwrap(), json!([]), "no other default has the keys");
|
||||
}
|
||||
|
||||
/// Cmd+F6 makes the next open document in tab order the current one, and Cmd+Shift+F6 makes the
|
||||
/// previous one the current one. After the last document comes the first, and before the first document comes the
|
||||
/// last. From the Home screen the keys go to the first and the last document and close the screen.
|
||||
/// With no document open the commands are disabled and fail with "no document open".
|
||||
#[test]
|
||||
fn cmd_f6_goes_to_the_next_document_and_cmd_shift_f6_to_the_previous_one() {
|
||||
let mut app = crate::VectorcraftApp::new(Session::new(), Default::default());
|
||||
assert!(!crate::menus::enabled(&app, "window.nextDocument") && !crate::menus::enabled(&app, "window.previousDocument"));
|
||||
assert_eq!(app.run("window.nextDocument", json!({})).unwrap_err(), "no document open");
|
||||
assert_eq!(app.run("window.previousDocument", json!({})).unwrap_err(), "no document open");
|
||||
for name in ["A", "B", "C"] {
|
||||
app.run("file.new", json!({"width": 100, "height": 100, "name": name})).unwrap();
|
||||
}
|
||||
assert!(crate::menus::enabled(&app, "window.nextDocument") && crate::menus::enabled(&app, "window.previousDocument"));
|
||||
assert_eq!(app.session.active_index(), Some(2));
|
||||
let mut seen = vec![];
|
||||
for modifiers in [egui::Modifiers::COMMAND; 4] {
|
||||
press_f6(&mut app, modifiers);
|
||||
seen.push(app.session.active_index());
|
||||
}
|
||||
assert_eq!(seen, [Some(0), Some(1), Some(2), Some(0)], "Cmd+F6 goes right and wraps from the last to the first");
|
||||
seen.clear();
|
||||
for modifiers in [egui::Modifiers::COMMAND | egui::Modifiers::SHIFT; 4] {
|
||||
press_f6(&mut app, modifiers);
|
||||
seen.push(app.session.active_index());
|
||||
}
|
||||
assert_eq!(seen, [Some(2), Some(1), Some(0), Some(2)], "Cmd+Shift+F6 goes left and wraps from the first to the last");
|
||||
assert_eq!(app.run("window.nextDocument", json!({})).unwrap(), json!({"index": 0}));
|
||||
assert_eq!(app.run("window.previousDocument", json!({})).unwrap(), json!({"index": 2}));
|
||||
app.session.set_active(1);
|
||||
app.run("app.home", json!({})).unwrap();
|
||||
press_f6(&mut app, egui::Modifiers::COMMAND);
|
||||
assert_eq!((app.session.active_index(), app.ui.home), (Some(0), None), "from the Home screen Cmd+F6 shows the first");
|
||||
app.session.set_active(1);
|
||||
app.run("app.home", json!({})).unwrap();
|
||||
press_f6(&mut app, egui::Modifiers::COMMAND | egui::Modifiers::SHIFT);
|
||||
assert_eq!((app.session.active_index(), app.ui.home), (Some(2), None), "and Cmd+Shift+F6 the last");
|
||||
// One document: the keys keep it current.
|
||||
let mut one = crate::VectorcraftApp::new(Session::new(), Default::default());
|
||||
one.run("file.new", json!({"width": 100, "height": 100})).unwrap();
|
||||
press_f6(&mut one, egui::Modifiers::COMMAND);
|
||||
press_f6(&mut one, egui::Modifiers::COMMAND | egui::Modifiers::SHIFT);
|
||||
assert_eq!(one.session.active_index(), Some(0));
|
||||
}
|
||||
|
||||
/// With the Artboard tool, Cmd+F6 and Cmd+Shift+F6 switch documents as a click on a tab does: the
|
||||
/// document switched back to keeps the artboard it shows as the tool's target, so the Properties
|
||||
/// panel's Delete Artboard then removes that artboard. Switching is not an edit.
|
||||
#[test]
|
||||
fn cmd_f6_switches_documents_as_a_tab_click_does_with_the_artboard_tool() {
|
||||
let (mut app, ctx) = artboard_document_app();
|
||||
app.select_tool("artboard");
|
||||
artboard_navigation_next(&mut app, &ctx);
|
||||
assert_eq!(app.session.tool_options()["selected"], json!([1]));
|
||||
let before = app.session.active().unwrap().doc.clone();
|
||||
let undo = app.session.active().unwrap().history.undo.len();
|
||||
press_f6(&mut app, egui::Modifiers::COMMAND);
|
||||
assert_eq!(app.session.active_index(), Some(1));
|
||||
// The app draws frames while the other document is the current one.
|
||||
artboard_navigation_properties_frame(&mut app, &ctx, vec![]);
|
||||
artboard_document_tabs_frame(&mut app, &ctx, vec![]);
|
||||
press_f6(&mut app, egui::Modifiers::COMMAND | egui::Modifiers::SHIFT);
|
||||
assert_eq!(app.session.active_index(), Some(0));
|
||||
app.sync_views();
|
||||
assert_eq!(app.session.tool_options()["selected"], json!([1]), "the tool targets the displayed artboard");
|
||||
assert_eq!(app.view().unwrap().artboard, 1, "the document keeps its displayed artboard");
|
||||
assert_eq!(app.session.active().unwrap().doc, before, "switching documents is not a document edit");
|
||||
assert_eq!(app.session.active().unwrap().history.undo.len(), undo);
|
||||
artboard_navigation_properties_frame(&mut app, &ctx, vec![]);
|
||||
let texts = artboard_navigation_properties_frame(&mut app, &ctx, vec![]);
|
||||
let at = texts.iter().find(|(label, _)| label == "Delete Artboard").map(|(_, r)| r.center()).expect("Properties Delete button");
|
||||
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
for event in [egui::Event::PointerMoved(at), button(true), button(false)] {
|
||||
artboard_navigation_properties_frame(&mut app, &ctx, vec![event]);
|
||||
}
|
||||
let names: Vec<_> = app.session.active().unwrap().doc.artboards.iter().map(|a| a.name.clone()).collect();
|
||||
assert_eq!(names, ["Keep"], "Delete Artboard after the keys removes the artboard the document shows");
|
||||
}
|
||||
|
||||
/// One history entry: its label, document and selection.
|
||||
type HistoryRow = (String, Arc<vectorcraft_doc::Document>, Arc<vectorcraft_doc::Selection>);
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ pub mod new_brush;
|
||||
pub mod new_color_group;
|
||||
mod new_document;
|
||||
pub mod new_swatch;
|
||||
pub mod new_text_style;
|
||||
pub mod office_export;
|
||||
pub mod package;
|
||||
pub(crate) mod path_ops;
|
||||
@@ -298,6 +299,7 @@ registry! {
|
||||
GradientStop: ["gradientStop"] => gradient_stop::SPEC,
|
||||
ColorPicker: [color_picker::KIND] => color_picker::SPEC,
|
||||
GraphicStyleOptions: [graphic_style_options::KIND] => graphic_style_options::SPEC,
|
||||
NewTextStyle: [new_text_style::KIND] => new_text_style::SPEC,
|
||||
EffectExists: [effect::EXISTS] => effect::EXISTS_SPEC,
|
||||
ColorGuideOptions: [color_guide_options::KIND] => color_guide_options::SPEC,
|
||||
ColorBalance: [color_balance::KIND] => color_balance::SPEC,
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
//! New Character Style and New Paragraph Style: the new style's name. OK makes the style from the
|
||||
//! text that was selected when the dialog opened (`charStyle.new`, `paraStyle.new`).
|
||||
//!
|
||||
//! Fields: `name`, `__para` (true for a paragraph style) and `__target` (the `id`, `start` and
|
||||
//! `end` of the Type tool's selection, empty for the selected objects, and the `attrs` that text had
|
||||
//! when the dialog opened).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::swatch_options::{grid, label};
|
||||
use super::{DialogSpec, form};
|
||||
use crate::VectorcraftApp;
|
||||
use crate::state::Dialog;
|
||||
|
||||
/// The dialog kind of New Character Style and New Paragraph Style.
|
||||
pub const KIND: &str = "newTextStyle";
|
||||
|
||||
pub(super) const SPEC: DialogSpec = DialogSpec { heading, body, confirm, min_width: 320.0, ..DialogSpec::FORM };
|
||||
|
||||
fn para(d: &Dialog) -> bool {
|
||||
d.fields.get("__para").and_then(Value::as_bool).unwrap_or(false)
|
||||
}
|
||||
|
||||
fn heading(d: &Dialog) -> String {
|
||||
if para(d) { tl!("New Paragraph Style") } else { tl!("New Character Style") }.into()
|
||||
}
|
||||
|
||||
/// Open New Character Style (`para`: New Paragraph Style) with the name `charStyle.new` would give
|
||||
/// the style, to make it from the text `target` names.
|
||||
pub fn open(app: &mut VectorcraftApp, para: bool, target: Value) -> Result<Value, String> {
|
||||
let d = &app.session.active().ok_or("no document open")?.doc;
|
||||
let name = vectorcraft_engine::cmd::textstyles::new_style_name(d, para);
|
||||
let mut target = target;
|
||||
if let Some(attrs) = vectorcraft_engine::cmd::textstyles::target_style_attrs(&app.session, &target, para) {
|
||||
target["attrs"] = Value::Object(attrs);
|
||||
}
|
||||
app.ui.dialog = Some(Dialog::new(KIND, json!({ "name": name, "__para": para, "__target": target })));
|
||||
Ok(json!({ "dialog": KIND }))
|
||||
}
|
||||
|
||||
fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
grid(ui, |ui| {
|
||||
label(ui, tl!("Style Name:"));
|
||||
form::text(ui, d, "name", 190.0);
|
||||
ui.end_row();
|
||||
});
|
||||
false
|
||||
}
|
||||
|
||||
/// OK: make the style with the name. A name another style has, or an empty one, keeps the dialog
|
||||
/// open.
|
||||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
let mut params = d.fields.get("__target").filter(|t| t.is_object()).cloned().unwrap_or_else(|| json!({}));
|
||||
params["name"] = json!(d.str("name"));
|
||||
let r = app.run(if para(d) { "paraStyle.new" } else { "charStyle.new" }, params);
|
||||
if r.is_ok() {
|
||||
app.ui.dialog = None;
|
||||
}
|
||||
r
|
||||
}
|
||||
@@ -393,6 +393,7 @@ fn dialogs_do_not_stretch_to_the_screen() {
|
||||
("ui.swatchOptions", json!({"name": "White"})),
|
||||
("ui.colorPicker", json!({})),
|
||||
("ui.graphicStyleOptions", json!({})),
|
||||
("ui.newCharStyle", json!({})),
|
||||
("ui.newArtboard", json!({})),
|
||||
("ui.brushOptions", json!({"name": "3 pt. Round"})),
|
||||
("ui.brushOptions", json!({"name": "Bristle Round"})),
|
||||
|
||||
@@ -550,6 +550,7 @@ font-technology Variable Променливи
|
||||
Complexity: Сложност:
|
||||
Composite colours are converted to it; separations separate with it when it is a CMYK profile. Композитните цветове се конвертират в него; сепарациите се сепарират с него, когато е CMYK профил.
|
||||
Compound Path Съставен път
|
||||
Compound Shape Съставна фигура
|
||||
Compress (RLE) Компресиране (RLE)
|
||||
Compress text and line art Компресиране на текст и линеарна графика
|
||||
Compression Компресия
|
||||
@@ -974,7 +975,6 @@ font-technology Variable Променливи
|
||||
Exit Isolation Mode Излизане от режим на изолация
|
||||
Expand Разширяване
|
||||
Expand Appearance Разширяване на външния вид
|
||||
Expand applies to compound shapes (Alt-click a shape mode) — on the roadmap Разширяването се прилага към съставни фигури (Alt-клик върху режим на фигура) — в план-графика
|
||||
Expand Brush Strokes Разширяване на щрихи на четка
|
||||
Expand Compound Shape Разширяване на съставна фигура
|
||||
Expand Gradient Mesh Разширяване на градиентна мрежа
|
||||
@@ -1963,6 +1963,7 @@ font-technology Variable Променливи
|
||||
Brush Art Графика на четка
|
||||
New Calligraphic Brush Нова калиграфска четка
|
||||
New Character Style Нов стил на знак
|
||||
New Character Style… Нов стил на знак…
|
||||
New Color Group Нова цветова група
|
||||
New Color Group… Нова цветова група…
|
||||
New Document Presets Предварителни настройки за нов документ
|
||||
@@ -1978,6 +1979,7 @@ font-technology Variable Променливи
|
||||
New Opacity Masks Are Clipping Новите маски за непрозрачност са изрязващи
|
||||
New Opacity Masks Are Inverted Новите маски за непрозрачност са инвертирани
|
||||
New Paragraph Style Нов стил на абзац
|
||||
New Paragraph Style… Нов стил на абзац…
|
||||
New Pattern Brush from Selection Нова щампова четка от селекцията
|
||||
New PDF Preset Нова предварителна настройка за PDF
|
||||
New Print Preset Нова предварителна настройка за печат
|
||||
@@ -1991,6 +1993,8 @@ font-technology Variable Променливи
|
||||
New View Нов изглед
|
||||
New View… Нов изглед…
|
||||
New Window Нов прозорец
|
||||
Next Document Следващ документ
|
||||
Previous Document Предишен документ
|
||||
New Without Dialog Нов без диалогов прозорец
|
||||
New Workspace Ново работно пространство
|
||||
New Workspace… Ново работно пространство…
|
||||
@@ -2900,6 +2904,7 @@ font-technology Variable Променливи
|
||||
Shape Builder Regions Области на създателя на фигури
|
||||
Shape Builder Tool Инструмент „Създател на фигури“
|
||||
Shape Effects Ефекти за фигури
|
||||
Shape Mode Режим на фигура
|
||||
Shape Modes: Режими на фигури:
|
||||
Shape Properties: Свойства на фигурата:
|
||||
Shaper Tool Инструмент „Shaper“
|
||||
@@ -3001,6 +3006,7 @@ font-technology Variable Променливи
|
||||
Single Quotes: Единични кавички:
|
||||
Single Right Quotation Mark Дясна единична кавичка
|
||||
Single-line Composer Ред по ред композитор
|
||||
Toggle Line Composer Превключване на композитора на редове
|
||||
Sixth Space Шесто разстояние
|
||||
Size Размер
|
||||
Size Tile to Art Оразмеряване на плочката според обектите
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -789,6 +789,7 @@
|
||||
Compound Compost
|
||||
Compound 2 Compost 2
|
||||
Compound Path Traçat compost
|
||||
Compound Shape Forma composta
|
||||
Compress (RLE) Comprimeix (RLE)
|
||||
Compress text and line art Comprimeix text i dibuix de línies
|
||||
Compression Compressió
|
||||
@@ -1399,7 +1400,6 @@
|
||||
Expand Expandeix
|
||||
Expand 1 Selected Expandeix 1 seleccionat
|
||||
Expand Appearance Expandeix l’aparença
|
||||
Expand applies to compound shapes (Alt-click a shape mode) — on the roadmap Expandeix s’aplica a les formes compostes (fes Alt-clic en un mode de forma) — previst a la llista de funcions
|
||||
Expand Blend Expandeix la fusió
|
||||
Expand Brush Strokes Expandeix els traços del pinzell
|
||||
Expand Compound Shape Expandeix la forma composta
|
||||
@@ -3735,6 +3735,7 @@
|
||||
Shape Builder Regions Regions del constructor de formes
|
||||
Shape Builder Tool Eina Constructor de formes
|
||||
Shape Effects Efectes de forma
|
||||
Shape Mode Mode de forma
|
||||
Shape Modes: Modes de forma:
|
||||
Shape Properties: Propietats de la forma:
|
||||
Shape: Forma:
|
||||
@@ -4717,6 +4718,7 @@
|
||||
@msg `freeform.lines` must be an array `freeform.lines` ha de ser una matriu
|
||||
@msg `freeform.points` must be an array `freeform.points` ha de ser una matriu
|
||||
@msg `gradient` must be an object `gradient` ha de ser un objecte
|
||||
@msg `groupColors` must be an array `groupColors` ha de ser una matriu
|
||||
@msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` és la referència del pas {r}, que només resolen `run` i `command_batch`
|
||||
@msg `ids` must be an array of object ids, not {v} `ids` ha de ser una matriu d’identificadors d’objecte, no {v}
|
||||
@msg `library` is a library id or name (see {list}) `library` és un identificador o un nom de biblioteca (vegeu {list})
|
||||
@@ -4727,6 +4729,7 @@
|
||||
@msg `name` is empty `name` és buit
|
||||
@msg `newName` is empty `newName` és buit
|
||||
@msg `pathfinder` must be an object `pathfinder` ha de ser un objecte
|
||||
@msg `preserve` must be an object `preserve` ha de ser un objecte
|
||||
@msg `sampleSize` must be 1, 3 or 5 `sampleSize` ha de ser 1, 3 o 5
|
||||
@msg `select` must be true or false `select` ha de ser cert o fals
|
||||
@msg `stops` must be an array `stops` ha de ser una matriu
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -287,6 +287,7 @@
|
||||
Clipping Mask Ořezová maska
|
||||
Edit Clipping Path Upravit ořezovou cestu
|
||||
Compound Path Složená cesta
|
||||
Compound Shape Složený tvar
|
||||
Artboards Pracovní plochy
|
||||
Convert to Artboards Převést na pracovní plochy
|
||||
Rearrange All Artboards… Přeuspořádat všechny pracovní plochy…
|
||||
@@ -625,6 +626,8 @@
|
||||
Saved View 25 Uložené zobrazení 25
|
||||
Window Okno
|
||||
New Window Nové okno
|
||||
Next Document Další dokument
|
||||
Previous Document Předchozí dokument
|
||||
Cascade Do kaskády
|
||||
Tile Vedle sebe
|
||||
Float in Window Uvolnit do samostatného okna
|
||||
@@ -1823,13 +1826,13 @@ lighting Light Světlo
|
||||
Roman Hanging Punctuation Visící latinková interpunkce
|
||||
Select a text object to edit its paragraph attributes. Vyberte textový objekt, abyste mohli upravit atributy jeho odstavce.
|
||||
Single-line Composer Sazba jednoho řádku
|
||||
Toggle Line Composer Přepnout sazbu řádků
|
||||
Space After Paragraph Mezera za odstavcem
|
||||
Space Before Paragraph Mezera před odstavcem
|
||||
Top-to-Top Leading Proklad od horní k horní
|
||||
Bottom-to-Bottom Leading Proklad od dolní k dolní
|
||||
Burasagari Burasagari
|
||||
Force Vynutit
|
||||
Expand applies to compound shapes (Alt-click a shape mode) — on the roadmap Rozložení se týká složených tvarů (Alt+kliknutí na režim tvaru) — v plánu
|
||||
Expand Compound Shape Rozložit složený tvar
|
||||
Make Compound Shape Vytvořit složený tvar
|
||||
Pathfinder Options… Možnosti operací s cestami…
|
||||
@@ -3987,6 +3990,7 @@ font-technology Variable Variabilní
|
||||
Default Fill and Stroke (D) Výchozí výplň a tah (D)
|
||||
Fill (X), double-click for the Color Picker Výplň (X), dvojklikem otevřete výběr barvy
|
||||
Opacity: Krytí:
|
||||
Shape Mode Režim tvaru
|
||||
Shape Modes: Režimy tvarů:
|
||||
Stroke (X), double-click for the Color Picker Tah (X), dvojklikem otevřete výběr barvy
|
||||
Stroke: Tah:
|
||||
@@ -4035,7 +4039,9 @@ font-technology Variable Variabilní
|
||||
Mojikumi Mojikumi
|
||||
N pt per N pt na
|
||||
New Character Style Nový styl znaků
|
||||
New Character Style… Nový styl znaků…
|
||||
New Paragraph Style Nový styl odstavce
|
||||
New Paragraph Style… Nový styl odstavce…
|
||||
Paragraph Style Options Možnosti stylu odstavce
|
||||
Redefine Character Style Předefinovat styl znaků
|
||||
Redefine Paragraph Style Předefinovat styl odstavce
|
||||
@@ -4238,8 +4244,10 @@ imageTrace Outline Obrys
|
||||
@msg `freeform.points` must be an array `freeform.points` musí být pole
|
||||
@msg `freeform.lines` must be an array `freeform.lines` musí být pole
|
||||
@msg `gradient` must be an object `gradient` musí být objekt
|
||||
@msg `groupColors` must be an array `groupColors` musí být pole
|
||||
@msg `library` is a library id or name (see {list}) `library` je ID nebo název knihovny (viz {list})
|
||||
@msg `mode` applies to freeform gradients `mode` platí pro volné přechody
|
||||
@msg `preserve` must be an object `preserve` musí být objekt
|
||||
@msg `sampleSize` must be 1, 3 or 5 `sampleSize` musí být 1, 3 nebo 5
|
||||
@msg `stops` must be an array `stops` musí být pole
|
||||
@msg `svg` must be an object of SVG options (see document.formats) `svg` musí být objekt s možnostmi SVG (viz document.formats)
|
||||
@@ -4274,6 +4282,7 @@ imageTrace Outline Obrys
|
||||
@msg `{name}` is a process colour: tints apply to global and spot colours `{name}` je procesní barva: tóny platí pro globální a přímé barvy
|
||||
@msg `{name}` is no recovery entry name `{name}` není název položky obnovení
|
||||
@msg `{name}` isn't a colour swatch `{name}` není barevná položka vzorníku
|
||||
@msg a process color can't colorize a grayscale image with transparency: use a spot color, black or white procesní barva nemůže obarvit obraz ve stupních šedi s průhledností: použijte přímou barvu, černou nebo bílou
|
||||
@msg `{path}` must be a bool or an object of bools `{path}` musí být bool nebo objekt hodnot bool
|
||||
@msg `{s}` is not a colour (#rrggbb or a preset name) `{s}` není barva (#rrggbb nebo název přednastavení)
|
||||
@msg `{tool}` isn't a Liquify tool `{tool}` není nástroj zkapalnění
|
||||
@@ -4865,6 +4874,9 @@ imageTrace Outline Obrys
|
||||
@msg not a PSD file nejde o soubor PSD
|
||||
@msg the PSD file has an unknown colour mode ({m}) soubor PSD má neznámý barevný režim ({m})
|
||||
@msg the PSD file's image data uses an unknown compression ({m}) obrazová data souboru PSD používají neznámou kompresi ({m})
|
||||
@msg the PCX file is cut short soubor PCX je useknutý
|
||||
@msg the PCX file's header is damaged hlavička souboru PCX je poškozená
|
||||
@msg PCX files with {bits}-bit pixels in {planes} planes can't be read soubory PCX s {bits}bitovými obrazovými body v {planes} rovinách nelze přečíst
|
||||
@msg the PSD image is too large to read ({_1} × {_2} pixels) obraz PSD je příliš velký na přečtení ({_1} × {_2} obrazových bodů)
|
||||
@msg zipped 32-bit PSD image data with prediction can't be read zazipovaná 32bitová obrazová data PSD s predikcí nelze přečíst
|
||||
@msg {depth}-bit PSD files in this colour mode can't be read {depth}bitové soubory PSD v tomto barevném režimu nelze přečíst
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user