Merge main into fix/outlined-strokes-under-a-non-uniform-type

This commit is contained in:
dotCooCoo
2026-11-15 06:17:25 -08:00
301 changed files with 21509 additions and 1572 deletions
Generated
+2
View File
@@ -6632,6 +6632,7 @@ dependencies = [
"serde",
"serde_json",
"thiserror 2.0.21",
"vectorcraft-brush",
"vectorcraft-doc",
"vectorcraft-geom 0.24.1",
"vectorcraft-testkit",
@@ -6929,6 +6930,7 @@ name = "vectorcraft-ui-egui"
version = "0.24.1"
dependencies = [
"bytemuck",
"chrono",
"craft-keymap",
"craft-ui",
"craft-update",
+7
View File
@@ -120,6 +120,13 @@ impl StoredArt {
doc.number_mounted_art();
}
/// Sets the art on `doc` as [`Document::mounted_art`] with the ids it is stored with, the id
/// counter left as it is: what a save looks through, writing back only the brushes whose art it
/// changed ([`Self::write_back`]).
pub fn mount_as_stored(&self, doc: &mut Document) {
doc.mounted_art = self.entries.iter().flat_map(|e| e.art.iter().cloned()).collect();
}
/// Takes [`Document::mounted_art`] off `doc` and writes each brush whose art changed (new ids
/// included) back into its library entry: the entry at its index when that entry is as it was
/// read or reads as the brush as it was apart from its name, else the first entry that reads as
+98 -2
View File
@@ -94,6 +94,50 @@ impl Intent {
}
}
/// A color management policy (Edit → Color Settings): what opening a document does with a profile
/// it is tagged with that is not the working space of its model.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Policy {
/// The document loses the profile; its color numbers stay as they are.
Off,
/// The document keeps the profile.
#[default]
PreserveEmbedded,
/// The colors are converted from the profile into the working space, which keeps their
/// appearance, and the document loses the profile.
Convert,
/// CMYK only: the document loses the profile; its color numbers stay as they are.
PreserveNumbers,
}
impl Policy {
/// The RGB policies, in menu order.
pub const RGB: [Policy; 3] = [Policy::Off, Policy::PreserveEmbedded, Policy::Convert];
/// The CMYK policies, in menu order.
pub const CMYK: [Policy; 4] = [Policy::Off, Policy::PreserveEmbedded, Policy::Convert, Policy::PreserveNumbers];
/// Parse an id of [`Policy::id`].
pub fn parse(s: &str) -> Option<Self> {
[Policy::Off, Policy::PreserveEmbedded, Policy::Convert, Policy::PreserveNumbers].into_iter().find(|p| p.id() == s)
}
pub fn id(&self) -> &'static str {
match self {
Policy::Off => "off",
Policy::PreserveEmbedded => "preserveEmbedded",
Policy::Convert => "convert",
Policy::PreserveNumbers => "preserveNumbers",
}
}
pub fn label(&self) -> &'static str {
match self {
Policy::Off => "Off",
Policy::PreserveEmbedded => "Preserve Embedded Profiles",
Policy::Convert => "Convert to Working Space",
Policy::PreserveNumbers => "Preserve Numbers",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ProfileKind {
@@ -134,11 +178,25 @@ pub struct ColorSettings {
pub intent: Intent,
/// Black-point compensation.
pub bpc: bool,
/// The color management policy for RGB profiles (one of [`Policy::RGB`]). Settings saved
/// without one take the default.
#[serde(default)]
pub rgb_policy: Policy,
/// The color management policy for CMYK profiles. Settings saved without one take the default.
#[serde(default)]
pub cmyk_policy: Policy,
}
impl Default for ColorSettings {
fn default() -> Self {
Self { rgb: SRGB.into(), cmyk: GENERIC_CMYK.into(), intent: Intent::RelativeColorimetric, bpc: true }
Self {
rgb: SRGB.into(),
cmyk: GENERIC_CMYK.into(),
intent: Intent::RelativeColorimetric,
bpc: true,
rgb_policy: Policy::default(),
cmyk_policy: Policy::default(),
}
}
}
@@ -518,6 +576,41 @@ impl Cms {
Color::Rgb { r, g, b }
}
/// An RGB or CMYK colour of this context's space as the colour of the same model in `dest`'s
/// space that looks the same, converted with `intent` (Edit → Color Settings › Convert to
/// Working Space). RGB converts directly from profile to profile, and CMYK between two loaded
/// profiles through ProPhoto RGB ([`IccProfile::to_same_model_of`]), both with this context's
/// black-point compensation; CMYK goes through Lab when either space is built in. Grey and
/// Lab colours stay as they are.
pub fn convert_into(&self, dest: &Cms, c: &Color, intent: Intent) -> Color {
match *c {
Color::Rgb { r, g, b } => {
let icc = |s: &RgbSpace| match s {
RgbSpace::Icc(p) => Some(p.clone()),
RgbSpace::Srgb => builtin_rgb_cache(SRGB),
};
let [r, g, b] = icc(&self.rgb)
.zip(icc(&dest.rgb))
.and_then(|(s, d)| s.to_rgb_of(&d, &[r, g, b], intent, self.settings.bpc))
.unwrap_or_else(|| dest.srgb_to_rgb(self.rgb_to_srgb([r, g, b])));
Color::Rgb { r, g, b }
}
Color::Cmyk { c, m, y, k } => {
let inks = [c, m, y, k].map(|v| v.clamp(0.0, 1.0));
let direct = match (&self.cmyk, &dest.cmyk) {
(CmykSpace::Icc(s), CmykSpace::Icc(d)) => s.to_same_model_of(d, &inks, intent, self.settings.bpc),
_ => None,
};
let [c, m, y, k] = match direct.as_deref() {
Some(&[c, m, y, k]) => [c, m, y, k],
_ => dest.lab_to_cmyk(self.cmyk_to_lab(inks), intent),
};
Color::Cmyk { c, m, y, k }
}
Color::Gray { .. } | Color::Lab { .. } => *c,
}
}
/// How far (ΔE2000) the best CMYK reproduction of `c` is from `c` (0 for CMYK/grey colours).
pub fn gamut_error(&self, c: &Color) -> f32 {
match c {
@@ -966,8 +1059,11 @@ pub fn working_settings() -> ColorSettings {
}
/// Replace the working settings (Edit → Color Settings) and make them the active settings (fails,
/// leaving both unchanged, if a profile is unknown).
/// leaving both unchanged, if a profile is unknown or the RGB policy is Preserve Numbers).
pub fn set_active(settings: &ColorSettings) -> Result<(), CmsError> {
if !Policy::RGB.contains(&settings.rgb_policy) {
return Err(CmsError::Unsupported(format!("the RGB policy can't be {}", settings.rgb_policy.id())));
}
let cms = Arc::new(Cms::new(settings)?);
*WORKING.write().unwrap_or_else(|e| e.into_inner()) = Some(cms.clone());
install(cms);
+78
View File
@@ -34,6 +34,17 @@ type Xf = Option<Arc<TransformF32Executor>>;
/// generation only lets the cache drop what reloads left behind.
type DirectKey = (u64, u64, usize, bool);
/// The transforms of [`IccProfile::to_same_model_of`]: into ProPhoto RGB's primaries with linear
/// tone curves, where black-point compensation is a linear remap, and from there into the
/// destination.
struct SameModel {
into: Arc<TransformF32Executor>,
out_of: Arc<TransformF32Executor>,
/// The source's and the destination's black as luminance in the linear space (both 0 without
/// compensation).
black: [f32; 2],
}
/// A parsed ICC profile plus lazily-built transforms to/from sRGB.
pub struct IccProfile {
pub name: String,
@@ -49,6 +60,9 @@ pub struct IccProfile {
/// Direct transforms into other RGB profiles ([`Self::to_rgb_of`]), by destination, intent
/// and black-point compensation.
direct: Mutex<Vec<(DirectKey, Option<Arc<Direct>>)>>,
/// Transforms into other profiles of the same model ([`Self::to_same_model_of`]), keyed as
/// [`Self::direct`] is.
same_model: Mutex<Vec<(DirectKey, Option<Arc<SameModel>>)>>,
}
/// A transform from one profile into an RGB profile without passing through sRGB: into the
@@ -99,6 +113,7 @@ impl IccProfile {
to_srgb: Default::default(),
from_srgb: Default::default(),
direct: Mutex::new(Vec::new()),
same_model: Mutex::new(Vec::new()),
})
}
@@ -264,6 +279,69 @@ impl IccProfile {
Some(Direct { to_linear, to_dest: Some(to_dest), black })
}
/// Device values of this profile → device values of `dest`, a profile of the same model,
/// converted with `intent` through ProPhoto RGB rather than sRGB, so colours outside sRGB
/// keep their place. With `bpc` (and an intent other than absolute), this profile's black
/// maps onto the destination's black (black-point compensation). `None` when `dest` is of
/// another model or no transform can be built.
pub fn to_same_model_of(&self, dest: &IccProfile, v: &[f32], intent: Intent, bpc: bool) -> Option<Vec<f32>> {
if dest.kind != self.kind || v.len() != self.channels() {
return None;
}
let bpc = bpc && intent != Intent::AbsoluteColorimetric;
let generation = profiles_generation();
let key = (dest.id, generation, intent_index(intent), bpc);
let t = {
let mut cache = self.same_model.lock().unwrap_or_else(|e| e.into_inner());
cache.retain(|(k, _)| k.1 == generation);
match cache.iter().find(|(k, _)| *k == key) {
Some((_, t)) => t.clone(),
None => {
let t = self.build_same_model(dest, intent, bpc).map(Arc::new);
cache.push((key, t.clone()));
t
}
}
}?;
let mut lin = [0.0f32; 3];
t.into.transform(v, &mut lin).ok()?;
let [from, to] = t.black;
if from > 0.0 || to > 0.0 {
lin = lin.map(|x| to + (x - from) * (1.0 - to) / (1.0 - from));
}
let lin = lin.map(|x| x.clamp(0.0, 1.0));
let mut out = vec![0.0f32; dest.channels()];
t.out_of.transform(&lin, &mut out).ok()?;
Some(out.into_iter().map(|x| x.clamp(0.0, 1.0)).collect())
}
fn build_same_model(&self, dest: &IccProfile, intent: Intent, bpc: bool) -> Option<SameModel> {
let mut linear = rgb_space(super::PROPHOTO_RGB)?;
for trc in [&mut linear.red_trc, &mut linear.green_trc, &mut linear.blue_trc] {
*trc = Some(ToneReprCurve::Parametric(vec![1.0]));
}
linear.cicp = None;
let into = self.profile.create_transform_f32(self.layout(), &linear, Layout::Rgb, Self::opts(intent)).ok()?;
let out_of = linear.create_transform_f32(Layout::Rgb, &dest.profile, dest.layout(), Self::opts(intent)).ok()?;
let black = if bpc {
// Each profile's black: the darkest colour it reproduces, found as `build_direct`
// finds it, as luminance in the linear space.
let luminance = |p: &IccProfile, xf: &Arc<TransformF32Executor>| -> Option<f32> {
let inks = p.from_srgb([0.0; 3], Intent::RelativeColorimetric)?;
let mut lin = [0.0f32; 3];
xf.transform(&inks, &mut lin).ok()?;
let y = |c: &moxcms::Xyzd| c.y as f32;
let lum = lin[0] * y(&linear.red_colorant) + lin[1] * y(&linear.green_colorant) + lin[2] * y(&linear.blue_colorant);
Some(lum.clamp(0.0, 0.5))
};
let back = dest.profile.create_transform_f32(dest.layout(), &linear, Layout::Rgb, Self::opts(intent)).ok()?;
[luminance(self, &into)?, luminance(dest, &back)?]
} else {
[0.0; 2]
};
Some(SameModel { into, out_of, black })
}
/// Whether transforms can be built for this profile (checked at registration).
pub fn usable(&self) -> bool {
self.to_xf(Intent::RelativeColorimetric).is_some() && self.inverse_xf(Intent::RelativeColorimetric).is_some()
+36
View File
@@ -442,3 +442,39 @@ fn a_document_gets_the_same_context_for_the_same_profiles() {
let unknown = for_document(Some("no such profile"), Some(SRGB));
assert_eq!(unknown.settings(), &working_settings(), "an unknown profile or one of the other model leaves the working space");
}
/// The color management policies parse from their ids, Preserve Numbers is a CMYK policy only, and
/// settings saved before the policies existed read with the default policy.
#[test]
fn policies_parse_and_default_in_saved_settings() {
for p in Policy::CMYK {
assert_eq!(Policy::parse(p.id()), Some(p));
}
assert_eq!(Policy::parse("bogus"), None);
assert!(!Policy::RGB.contains(&Policy::PreserveNumbers));
let old: ColorSettings =
serde_json::from_str(r#"{"rgb": "sRGB IEC61966-2.1", "cmyk": "Device CMYK (uncalibrated)", "intent": "perceptual", "bpc": false}"#).unwrap();
assert_eq!((old.rgb_policy, old.cmyk_policy), (Policy::PreserveEmbedded, Policy::PreserveEmbedded));
let saved = serde_json::to_value(ColorSettings { cmyk_policy: Policy::PreserveNumbers, ..Default::default() }).unwrap();
assert_eq!((saved["rgbPolicy"].as_str(), saved["cmykPolicy"].as_str()), (Some("preserveEmbedded"), Some("preserveNumbers")));
}
/// Convert to Working Space keeps a color's appearance: an RGB color of a wide space converts into
/// sRGB, and a CMYK color of one CMYK space into another, with numbers that look the same. Grey and
/// Lab colors stay as they are.
#[test]
fn convert_into_keeps_the_appearance() {
let wide = Cms::new(&ColorSettings { rgb: WIDE_GAMUT_RGB.into(), cmyk: DEVICE_CMYK.into(), ..Default::default() }).unwrap();
let working = cms();
let rgb = Color::Rgb { r: 0.25, g: 0.4, b: 0.6 };
let moved = wide.convert_into(&working, &rgb, Intent::RelativeColorimetric);
assert_ne!(moved, rgb, "the numbers change");
assert!(de(wide.display_rgb(&rgb), working.display_rgb(&moved)) < 1.0, "{rgb:?} → {moved:?}");
let ink = Color::Cmyk { c: 0.2, m: 0.4, y: 0.6, k: 0.1 };
let moved = wide.convert_into(&working, &ink, Intent::RelativeColorimetric);
assert!(matches!(moved, Color::Cmyk { .. }) && moved != ink, "{ink:?} → {moved:?}");
assert!(delta_e2000(wide.lab(&ink), working.lab(&moved)) < 3.0, "{ink:?} → {moved:?}");
for c in [Color::Gray { k: 0.3 }, Color::Lab { l: 50.0, a: 10.0, b: -20.0 }] {
assert_eq!(wide.convert_into(&working, &c, Intent::RelativeColorimetric), c);
}
}
+52
View File
@@ -274,3 +274,55 @@ fn settings_built_before_a_reload_dont_leave_the_old_transform_to_the_new_ones()
assert!(in_srgb.iter().zip(want).any(|(a, b)| (a - b).abs() > 0.05), "sRGB {in_srgb:?} vs ProPhoto {want:?}");
assert!(in_prophoto.iter().zip(want).all(|(a, b)| (a - b).abs() < 1e-4), "after the reload {in_prophoto:?}, want {want:?}");
}
/// A CMYK profile converts into another CMYK profile, ink for ink, and refuses a profile
/// of another model or inks of the wrong count.
#[test]
fn cmyk_profiles_convert_into_each_other_directly() {
let generic = IccProfile::from_bytes(Some("A".into()), &icc_bytes(GENERIC_CMYK).unwrap()).unwrap();
let device = IccProfile::from_bytes(Some("B".into()), &icc_bytes(DEVICE_CMYK).unwrap()).unwrap();
let srgb = IccProfile::from_bytes(None, &icc_bytes(SRGB).unwrap()).unwrap();
let inks = [0.2, 0.4, 0.6, 0.1];
let out = generic.to_same_model_of(&device, &inks, Intent::RelativeColorimetric, false).unwrap();
assert_eq!(out.len(), 4);
assert!(out.iter().zip(inks).any(|(a, b)| (a - b).abs() > 0.01), "{out:?}");
assert!(generic.to_same_model_of(&srgb, &inks, Intent::RelativeColorimetric, false).is_none());
assert!(generic.to_same_model_of(&device, &inks[..3], Intent::RelativeColorimetric, false).is_none());
}
/// With black-point compensation, a CMYK profile's darkest ink converts into an ink as dark as a
/// darker profile's darkest ink, and a dark grey of a darker profile converts lighter into a
/// profile whose black is lighter; absolute colorimetric never compensates.
#[test]
fn cmyk_to_cmyk_compensates_the_black_point() {
let generic = IccProfile::from_bytes(Some("A".into()), &icc_bytes(GENERIC_CMYK).unwrap()).unwrap();
let device = IccProfile::from_bytes(Some("B".into()), &icc_bytes(DEVICE_CMYK).unwrap()).unwrap();
let rel = Intent::RelativeColorimetric;
let lightness = |p: &IccProfile, inks: &[f32]| lab::srgb_to_lab(p.to_srgb(inks, rel).unwrap()).l;
let black = |p: &IccProfile| p.from_srgb([0.0; 3], rel).unwrap();
// Generic CMYK's black is a dark grey, the uncalibrated profile's is black.
let (light, dark) = (lightness(&generic, &black(&generic)), lightness(&device, &black(&device)));
assert!(light > dark + 5.0, "{light} vs {dark}");
let into_device = |bpc| lightness(&device, &generic.to_same_model_of(&device, &black(&generic), rel, bpc).unwrap());
let (on, off) = (into_device(true), into_device(false));
assert!((on - dark).abs() < 1.5 && off > dark + 5.0, "with {on}, without {off}, the black {dark}");
let grey = device.from_srgb([0.2; 3], rel).unwrap();
let into_generic = |bpc| lightness(&generic, &device.to_same_model_of(&generic, &grey, rel, bpc).unwrap());
let (on, off) = (into_generic(true), into_generic(false));
assert!(on > off + 1.0, "with {on}, without {off}");
for (from, to, inks) in [(&generic, &device, black(&generic)), (&device, &generic, grey)] {
let abs = |bpc| from.to_same_model_of(to, &inks, Intent::AbsoluteColorimetric, bpc).unwrap();
assert_eq!(abs(true), abs(false));
}
// Convert to Working Space (Cms::convert_into) compensates as Color Settings say.
let device_name = register_icc(&icc_bytes(DEVICE_CMYK).unwrap(), Some("Test ICC Device CMYK".into())).unwrap().name;
let rich = Color::Cmyk { c: 0.9, m: 0.8, y: 0.8, k: 1.0 };
let convert = |bpc| {
let dest = Cms::new(&ColorSettings { cmyk: device_name.clone(), bpc, ..ColorSettings::default() }).unwrap();
icc_cmyk(bpc).convert_into(&dest, &rich, rel)
};
assert_ne!(convert(true), convert(false));
// White is untouched.
let white = generic.to_same_model_of(&device, &[0.0; 4], rel, true).unwrap();
assert!(white.iter().all(|v| *v < 0.01), "{white:?}");
}
+158 -7
View File
@@ -525,8 +525,8 @@ fn planar<R: std::io::Read + std::io::Seek>(d: &mut tiff::decoder::Decoder<R>) -
}
/// The samples of the `n` pixels of the TIFF `d` reads, `per` to a pixel (the number its color
/// type has), pixel by pixel and 8 bits each (16-bit samples to their high byte), whether the file
/// stores each pixel's samples together or one plane per channel. `None` when they don't decode to
/// type has), pixel by pixel and 8 bits each (16-bit samples rounded to the nearest), whether the
/// file stores each pixel's samples together or one plane per channel. `None` when they don't decode to
/// that many samples, which includes images whose planes together need more than the decoder's
/// limit (256 MiB of samples), and, before any decoding, files with more than
/// [`MAX_EXTRA_SAMPLES_PIXEL`] samples a pixel, files whose samples aren't unsigned integers (a
@@ -535,7 +535,7 @@ fn planar<R: std::io::Read + std::io::Seek>(d: &mut tiff::decoder::Decoder<R>) -
pub(crate) fn read_samples(d: &mut tiff::decoder::Decoder<Cursor<&[u8]>>, n: usize, per: usize) -> Option<Vec<u8>> {
Some(match read_samples_at_depth(d, n, per)? {
Samples::Eight(v) => v,
Samples::Sixteen(v) => v.iter().map(|s| (s >> 8) as u8).collect(),
Samples::Sixteen(v) => v.iter().map(|&s| u8::try_from((u32::from(s) + 128) / 257).unwrap_or(u8::MAX)).collect(),
})
}
@@ -618,13 +618,25 @@ pub fn icc_color_space(icc: &[u8]) -> Option<[u8; 4]> {
icc.get(16..20)?.try_into().ok()
}
/// The ICC profile of TIFF tag 34675 (ICCProfile) of the image `d` reads; `None` without one.
/// The most bytes of an ICC profile a TIFF's tag 34675 is read with (16 MiB).
const MAX_TIFF_ICC: u64 = 16 << 20;
/// The ICC profile of TIFF tag 34675 (ICCProfile, of type UNDEFINED or BYTE) of the image `d`
/// reads, as its bytes; `None` without one and for one over [`MAX_TIFF_ICC`] bytes.
fn tiff_icc(d: &mut tiff::decoder::Decoder<Cursor<&[u8]>>) -> Option<Vec<u8>> {
d.get_tag_u8_vec(tiff::tags::Tag::IccProfile).ok().filter(|icc| !icc.is_empty())
use tiff::tags::{Tag, Type, ValueBuffer};
let mut ifd = d.image_ifd();
let entry = ifd.find_entry(Tag::IccProfile)?;
if !matches!(entry.field_type(), Type::UNDEFINED | Type::BYTE) || entry.count() == 0 || entry.count() > MAX_TIFF_ICC {
return None;
}
let mut buf = ValueBuffer::empty(Type::UNDEFINED);
ifd.find_tag_buf(Tag::IccProfile, &mut buf).ok()??;
Some(buf.as_bytes().to_vec())
}
/// The ICC profile of TIFF `bytes`' first image (tag 34675), read with the `tiff` decoder's own
/// limits, whatever the size of the image; `None` for other images and TIFFs without one.
/// The ICC profile of TIFF `bytes`' first image (tag 34675), up to [`MAX_TIFF_ICC`] bytes whatever
/// the size of the image; `None` for other images, TIFFs without one and a longer one.
pub fn tiff_icc_profile(bytes: &[u8]) -> Option<Vec<u8>> {
let (_, mut d) = tiff(bytes)?;
tiff_icc(&mut d)
@@ -638,6 +650,61 @@ fn jpeg_icc(bytes: &[u8]) -> Option<Vec<u8>> {
d.icc_profile().filter(|icc| !icc.is_empty())
}
/// The bytes after an `ICC_PROFILE` APP2 segment's length: its name, then the segment's number and
/// the count of segments.
const ICC_SEGMENT: &[u8; 12] = b"ICC_PROFILE\0";
/// JPEG `bytes` carrying `icc` in place of the ICC profile they carry, if any: every
/// `ICC_PROFILE` APP2 segment before the scan taken out, `icc` written as numbered APP2 segments
/// after the leading application segments, and the other segments and the scan as they are. `None`
/// for bytes whose segments before the scan can't be read, an empty `icc`, and one longer than 255
/// segments hold.
pub fn jpeg_with_icc(bytes: &[u8], icc: &[u8]) -> Option<Vec<u8>> {
// A segment's length counts itself (2 bytes) and the name, number and count (14).
const CHUNK: usize = 0xFFFF - 2 - ICC_SEGMENT.len() - 2;
let chunks: Vec<&[u8]> = icc.chunks(CHUNK).collect();
let count = u8::try_from(chunks.len()).ok().filter(|n| *n > 0)?;
if !bytes.starts_with(&[0xFF, 0xD8]) {
return None;
}
let mut segments = Vec::with_capacity(icc.len() + 18 * chunks.len());
for (i, chunk) in chunks.iter().enumerate() {
let len = u16::try_from(chunk.len() + 2 + ICC_SEGMENT.len() + 2).ok()?;
segments.extend_from_slice(&[0xFF, 0xE2]);
segments.extend_from_slice(&len.to_be_bytes());
segments.extend_from_slice(ICC_SEGMENT);
segments.extend_from_slice(&[u8::try_from(i + 1).ok()?, count]);
segments.extend_from_slice(chunk);
}
let mut out = Vec::with_capacity(bytes.len() + segments.len());
out.extend_from_slice(&[0xFF, 0xD8]);
let mut icc_segments = Some(segments);
let mut at = 2;
// The segments before the scan (SOS), each with its length; a marker without one ends them.
// Fill bytes (0xFF) before a marker are left out.
loop {
while bytes.get(at..at + 2) == Some(&[0xFF, 0xFF]) {
at += 1;
}
let (Some(&0xFF), Some(&marker)) = (bytes.get(at), bytes.get(at + 1)) else { return None };
let app = (0xE0..=0xEF).contains(&marker);
if !app && let Some(s) = icc_segments.take() {
out.extend_from_slice(&s);
}
if matches!(marker, 0xDA | 0xD8 | 0xD9 | 0x01 | 0xD0..=0xD7) {
break;
}
let len = usize::from(u16::from_be_bytes(bytes.get(at + 2..at + 4)?.try_into().ok()?));
let segment = bytes.get(at..(at + 2).checked_add(len)?).filter(|_| len >= 2)?;
if !(marker == 0xE2 && segment.get(4..16) == Some(ICC_SEGMENT.as_slice())) {
out.extend_from_slice(segment);
}
at += segment.len();
}
out.extend_from_slice(bytes.get(at..)?);
Some(out)
}
/// `icc` when it is a profile of CMYK colors, which the samples of a CMYK image are in.
fn cmyk_icc(icc: Option<Vec<u8>>) -> Option<Vec<u8>> {
icc.filter(|icc| icc_color_space(icc) == Some(*b"CMYK"))
@@ -746,6 +813,14 @@ impl ImageBlob {
Some(Self::new("image/tiff", tiff))
}
/// [`ImageBlob::cmyk_tiff`], carrying `icc` as tag 34675 (ICCProfile) when it is a profile of
/// CMYK colors; any other profile is left out. The caller checks the rest of the profile.
pub fn cmyk_tiff_with_icc(inks: &Inks, icc: Option<&[u8]>) -> Option<Self> {
let icc = icc.filter(|icc| icc_color_space(icc) == Some(*b"CMYK"));
let tiff = write_cmyk_tiff(inks.width, inks.height, SampleSlice::Eight(&inks.data), None, icc)?;
Some(Self::new("image/tiff", tiff))
}
/// Does the image hold CMYK samples (a four-component JPEG or a CMYK TIFF)? Reads only its
/// header.
pub fn is_cmyk(&self) -> bool {
@@ -1968,6 +2043,35 @@ mod tests {
assert_eq!(rgb.get_pixel(1, 0).0, [0, 255, 255]);
}
/// A CMYK TIFF written with a CMYK profile carries it byte for byte as tag 34675; a profile of
/// other colors, and none, leave the TIFF untagged. The inks are the same either way.
#[test]
fn a_cmyk_tiff_carries_the_cmyk_profile_it_is_given() {
let profile = |space: &[u8; 4]| {
let mut icc = vec![0u8; 128];
icc[0..4].copy_from_slice(&128u32.to_be_bytes());
icc[16..20].copy_from_slice(space);
icc[36..40].copy_from_slice(b"acsp");
icc
};
let cmyk = profile(b"CMYK");
let b = ImageBlob::cmyk_tiff_with_icc(&inks(), Some(&cmyk)).unwrap();
assert_eq!(b.cmyk_icc_profile(), Some(cmyk), "the profile, byte for byte");
assert_eq!(b.cmyk(), Some(inks()));
// Read back up to 16 MiB; a longer one isn't read.
for (len, read) in [(16 << 20, true), ((16 << 20) + 1, false)] {
let mut big = profile(b"CMYK");
big.resize(len, 0);
let b = ImageBlob::cmyk_tiff_with_icc(&inks(), Some(&big)).unwrap();
assert_eq!(b.cmyk_icc_profile().is_some_and(|p| p == big), read, "{len} bytes");
}
for icc in [Some(profile(b"RGB ")), None] {
let b = ImageBlob::cmyk_tiff_with_icc(&inks(), icc.as_deref()).unwrap();
assert_eq!(tiff_icc_profile(&b.bytes), None, "{icc:?}");
assert_eq!(b.cmyk(), Some(inks()));
}
}
#[test]
fn a_header_gives_the_size_without_the_samples() {
assert_eq!(ImageBlob::cmyk_tiff(&inks()).unwrap().header_size(), Some((3, 2)));
@@ -2031,6 +2135,53 @@ mod tests {
}
}
/// A CMYK JPEG given a profile carries it in place of its own, its samples as they were; a
/// profile longer than 255 segments hold, an empty one and bytes that aren't a JPEG give none.
#[test]
fn a_cmyk_jpeg_carries_the_profile_it_is_given() {
let profile = |len: usize, mark: u8| {
let mut icc = vec![mark; len];
icc[16..20].copy_from_slice(b"CMYK");
icc
};
let px: Vec<u8> = (0..16 * 16).flat_map(|_| [10u8, 40, 100, 20]).collect();
let mut jpg = Vec::new();
let mut e = jpeg_encoder::Encoder::new(&mut jpg, 100);
e.add_icc_profile(&profile(300_000, 1)).unwrap();
e.encode(&px, 16, 16, jpeg_encoder::ColorType::Cmyk).unwrap();
let inks = ImageBlob::new("image/jpeg", jpg.clone()).cmyk().unwrap();
// One segment holds 65,519 bytes of a profile.
for len in [128, 65_519, 65_520, 300_000, 1 << 20] {
let icc = profile(len, 2);
let b = ImageBlob::new("image/jpeg", jpeg_with_icc(&jpg, &icc).unwrap());
assert!(b.cmyk_icc_profile() == Some(icc), "{len} bytes");
assert!(b.cmyk() == Some(inks.clone()), "{len} bytes: the samples as they were");
}
// A profile segment after the quantization table is taken out too.
let dqt = jpg.windows(2).position(|w| w == [0xFF, 0xDB]).unwrap();
let after = dqt + 2 + usize::from(u16::from_be_bytes([jpg[dqt + 2], jpg[dqt + 3]]));
let mut late = jpg.clone();
let stale = profile(128, 3);
let segment = [&[0xFF, 0xE2, 0, 144][..], ICC_SEGMENT, &[1, 1], &stale].concat();
late.splice(after..after, segment);
let icc = profile(128, 2);
let b = jpeg_with_icc(&late, &icc).unwrap();
assert_eq!(b.windows(ICC_SEGMENT.len()).filter(|w| *w == ICC_SEGMENT).count(), 1, "one profile segment");
assert!(ImageBlob::new("image/jpeg", b).cmyk_icc_profile() == Some(icc));
// Fill bytes before the quantization table's marker are passed over.
let mut filled = jpg.clone();
filled.splice(dqt..dqt, [0xFF; 3]);
assert!(ImageBlob::new("image/jpeg", filled.clone()).cmyk() == Some(inks.clone()), "the decoder reads it");
let icc = profile(128, 2);
let b = ImageBlob::new("image/jpeg", jpeg_with_icc(&filled, &icc).unwrap());
assert!(b.cmyk_icc_profile() == Some(icc), "fill bytes");
assert!(b.cmyk() == Some(inks.clone()), "fill bytes: the samples as they were");
assert!(jpeg_with_icc(&jpg, &profile(255 * 65_519, 2)).is_some(), "255 segments");
assert!(jpeg_with_icc(&jpg, &profile(255 * 65_519 + 1, 2)).is_none(), "256 segments");
assert!(jpeg_with_icc(&jpg, &[]).is_none(), "empty");
assert!(jpeg_with_icc(&ImageBlob::cmyk_tiff(&inks).unwrap().bytes, &profile(128, 2)).is_none(), "a TIFF");
}
#[test]
fn other_images_are_not_cmyk() {
let mut png = Vec::new();
+256 -64
View File
@@ -266,6 +266,7 @@ pub fn hit_test_visible_skipping(doc: &Document, p: Point, opt: HitOptions, skip
/// (`opt.scope`) or on a locked artboard. On a layer whose Preview is off it counts by its
/// outline, as a click there hits it.
pub fn selected_hidden_at(doc: &Document, p: Point, opt: HitOptions, selected: &[NodeId]) -> Option<NodeId> {
let _walk = ArtWalk::open();
let symbols = doc.symbol_boxes();
let locks = doc.locked_artboards();
selected.iter().rev().copied().find(|&id| {
@@ -322,6 +323,7 @@ fn path_options(nodes: &[&Node], chain: &[NodeId], opt: HitOptions) -> Option<Hi
/// The objects under `p` that [`Hit::top_object`] picks in `scope` (isolation mode), topmost
/// first: what clicks there select, one below the other (Cmd/Ctrl-click selects behind).
pub fn objects_at(doc: &Document, p: Point, opt: HitOptions, scope: Option<NodeId>) -> Vec<NodeId> {
let walk = ArtWalk::open();
let opt = HitOptions { scope: scope.or(opt.scope), ..opt };
// Normally one hit test per *object* suffices: layers and sublayers aren't selectable,
// while a group (including a clipping group) is selected as one object. Repeatedly
@@ -337,6 +339,7 @@ pub fn objects_at(doc: &Document, p: Point, opt: HitOptions, scope: Option<NodeI
for layer in doc.layers.iter().rev() {
// Malformed files may contain non-layer roots; let the general traversal handle them.
if !layer.is_layer() {
walk.restart();
return objects_at_repeated(doc, p, opt, scope);
}
if !layer.visible || layer.locked || layer.is_template() || doc.pattern_edit.as_ref().is_some_and(|e| e.layer != layer.id) {
@@ -344,7 +347,9 @@ pub fn objects_at(doc: &Document, p: Point, opt: HitOptions, scope: Option<NodeI
}
if !objects_at_layer(layer, p, opt, &symbols, &mut tops, &mut seen) {
// An envelope in Edit Contents may expose objects *within* a top-level group.
// The repeated traversal handles that mixed hierarchy precisely.
// The repeated traversal handles that mixed hierarchy precisely, with the whole
// limit of symbol art to walk.
walk.restart();
return objects_at_repeated(doc, p, opt, scope);
}
}
@@ -429,6 +434,7 @@ pub const MAX_LOCKED_AT: usize = 20;
/// out. While a pattern is edited only its tile layer is searched, and in isolation mode
/// (`opt.scope`) only what is inside the isolated group or object.
pub fn locked_objects_at(doc: &Document, p: Point, opt: HitOptions) -> Vec<NodeId> {
let _walk = ArtWalk::open();
let symbols = doc.symbol_boxes();
let mut out = Vec::new();
if let Some(scope) = opt.scope
@@ -504,8 +510,9 @@ fn object_hit(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes, editab
/// [`hit_test`] passing over the nodes `skip` names (and their contents), and with
/// `editable_only` over the objects of layers and sublayers on a locked artboard, and over all of
/// an isolated group on one ([`crate::LockedArtboards::holds`]). Symbol instances are measured by their art
/// ([`Node::geometric_bounds_with`]).
/// ([`Node::geometric_bounds_with`]), and are also hit outside that box where their art paints.
fn hit_test_skipping(doc: &Document, p: Point, opt: HitOptions, skip: &dyn Fn(NodeId) -> bool, editable_only: bool) -> Option<Hit> {
let _walk = ArtWalk::open();
let finish = |mut h: Hit| {
h.contents_of = h.ancestry.iter().rev().copied().find(|a| doc.node(*a).is_some_and(edits_contents));
h.layers = h.ancestry.iter().take_while(|a| doc.node(**a).is_some_and(Node::is_layer)).count();
@@ -1055,9 +1062,18 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Optio
AppearanceItem::Fill(_) => (!opt.path_only && fill_contains(bp, *rule, p)).then_some(HitKind::Fill),
})
}
NodeKind::Text(_) | NodeKind::PlacedDocument(_) | NodeKind::SymbolInstance { .. } => {
NodeKind::Text(_) | NodeKind::PlacedDocument(_) => {
n.geometric_bounds_with(symbols).filter(|b| b.inflate(opt.tol, opt.tol).contains(p)).map(|_| HitKind::Bounds)
}
// A symbol instance is hit within its bounds, and outside them where its art paints
// ([`instance_art_hit`]). In the art of another instance walked outside that instance's
// bounds, it is hit only where its own art paints ([`ArtOnly`]).
NodeKind::SymbolInstance { .. } => {
if !ArtOnly::on() && n.geometric_bounds_with(symbols).is_some_and(|b| b.inflate(opt.tol, opt.tol).contains(p)) {
return Some(HitKind::Bounds);
}
instance_art_hit(n, p, opt, symbols)
}
// A blend or an envelope is hit within its bounds, and outside them where its evaluated
// art paints: a blend's keys and steps on a spine can reach past the keys and the spine,
// and the strokes of an envelope's distorted contents past its bounds.
@@ -1164,6 +1180,15 @@ impl<'a> InstanceArt<'a> {
whole.or_else(|| self.map.as_ref()?.one_cell_with(n, &self.boxes))
}
/// How many objects placing the whole art makes: the objects of the art with their opacity
/// masks' art ([`Node::count_with_masks`]), or, through the guides, the most that
/// [`crate::NineSliceMap::most_placed_with`] counts.
fn size(&self) -> u64 {
self.map
.as_ref()
.map_or_else(|| u64::try_from(self.art.count_with_masks()).unwrap_or(u64::MAX), |m| m.most_placed_with(&self.art, &self.boxes))
}
/// Object `n` of the art placed on the canvas as it is drawn, clip groups included: moved by
/// `whole` when an object around it is moved as one piece ([`Self::whole`]). `None` when
/// placing it through the guides could make more than [`crate::MAX_SYMBOL_NODES`] objects, as
@@ -1211,64 +1236,198 @@ fn moved(n: &Node, xf: vectorcraft_geom::Affine) -> Node {
/// layer, is tested in outline ([`HitOptions::outline`]), as the canvas draws it. `None` when `inst` is not a symbol instance or
/// no editable object of its art is under `p`.
pub fn symbol_part_at(doc: &Document, inst: &Node, p: Point, opt: HitOptions) -> Option<Vec<u32>> {
struct Find<'a> {
art: &'a InstanceArt<'a>,
p: Point,
opt: HitOptions,
symbols: &'a SymbolBoxes<'a>,
}
impl Find<'_> {
fn hit(&self, n: &Node, whole: Option<vectorcraft_geom::Affine>, opt: HitOptions) -> bool {
self.art.place(n, whole).is_some_and(|n| hit_any(&n, self.p, opt, self.symbols).is_some())
}
fn skips_guide(&self, n: &Node) -> bool {
self.opt.skip_guides && matches!(n.kind, NodeKind::Path { guide: true, .. })
}
/// `outline`: the view is in Outline, or a layer around `n` has Preview off.
fn find(&self, n: &Node, path: &mut Vec<u32>, whole: Option<vectorcraft_geom::Affine>, outline: bool) -> bool {
if !n.visible || n.locked || n.is_template() || self.skips_guide(n) {
return false;
}
let outline = outline || previews_off(n);
let opt = HitOptions { outline, ..self.opt };
let (NodeKind::Group { children, .. } | NodeKind::Layer { children, .. }) = &n.kind else { return self.hit(n, whole, opt) };
if path.len() >= crate::symbol_overrides::MAX_PATH_DEPTH {
return self.hit(n, whole, opt);
}
let whole = self.art.whole(n, whole);
if n.clips()
&& let Some(first) = children.first()
{
let Some(clip) = self.art.place_unclipped(first, whole) else { return false };
if self.opt.clip_outlines && first.visible && !first.locked && !self.skips_guide(first) && clip_outline(&clip, self.p, opt).is_some()
{
path.push(0);
return true;
}
if !clip_contains(&clip, self.p, outline) {
return false;
}
}
for (i, c) in children.iter().enumerate().rev() {
// A Shaper group's original shapes are not drawn; symbol art has no isolation scope.
if shaper_hides(n, i, None) {
continue;
}
let Ok(i) = u32::try_from(i) else { continue };
path.push(i);
if self.find(c, path, whole, outline) {
return true;
}
path.pop();
}
false
}
}
let _walk = ArtWalk::open();
let art = InstanceArt::of(doc, inst)?;
let symbols = doc.symbol_boxes();
let mut path = vec![];
let outline = opt.outline || below_preview_off(doc, inst, opt.scope);
Find { art: &art, p, opt, symbols: &symbols }.find(&art.art, &mut path, None, outline).then_some(path)
let find = FindPart { art: &art, p, opt, symbols: &symbols, whole_instance: false };
find.find(&art.art, &mut path, None, outline).map(|_| path)
}
/// The walk of a symbol instance's placed art that [`symbol_part_at`] and [`instance_art_hit`] make.
struct FindPart<'a> {
art: &'a InstanceArt<'a>,
p: Point,
opt: HitOptions,
symbols: &'a SymbolBoxes<'a>,
/// For [`instance_art_hit`]: locked and template objects of the art count, as the canvas draws
/// them, and clipping paths are not picked by their outline.
whole_instance: bool,
}
impl FindPart<'_> {
fn hit(&self, n: &Node, whole: Option<vectorcraft_geom::Affine>, opt: HitOptions) -> Option<HitKind> {
self.art.place(n, whole).and_then(|n| hit_any(&n, self.p, opt, self.symbols))
}
fn skips_guide(&self, n: &Node) -> bool {
self.opt.skip_guides && matches!(n.kind, NodeKind::Path { guide: true, .. })
}
/// What a click at `p` hits on object `n` of the art, with `path` the child indices that lead
/// to what it hits. `outline`: the view is in Outline, or a layer around `n` has Preview off.
fn find(&self, n: &Node, path: &mut Vec<u32>, whole: Option<vectorcraft_geom::Affine>, outline: bool) -> Option<HitKind> {
if !n.visible || !self.whole_instance && (n.locked || n.is_template()) || self.skips_guide(n) || !ArtWalk::take(1) {
return None;
}
let outline = outline || previews_off(n);
let opt = HitOptions { outline, ..self.opt };
let (NodeKind::Group { children, .. } | NodeKind::Layer { children, .. }) = &n.kind else { return self.hit(n, whole, opt) };
if path.len() >= crate::symbol_overrides::MAX_PATH_DEPTH {
return self.hit(n, whole, opt);
}
let whole = self.art.whole(n, whole);
if n.clips()
&& let Some(first) = children.first()
{
let clip = self.art.place_unclipped(first, whole)?;
if !self.whole_instance
&& self.opt.clip_outlines
&& first.visible
&& !first.locked
&& !self.skips_guide(first)
&& let Some(kind) = clip_outline(&clip, self.p, opt)
{
path.push(0);
return Some(kind);
}
if !clip_contains(&clip, self.p, outline) {
return None;
}
}
for (i, c) in children.iter().enumerate().rev() {
// A Shaper group's original shapes are not drawn; symbol art has no isolation scope.
if shaper_hides(n, i, None) {
continue;
}
let Ok(i) = u32::try_from(i) else { continue };
path.push(i);
if let Some(kind) = self.find(c, path, whole, outline) {
return Some(kind);
}
path.pop();
}
None
}
}
/// What a click at `p` hits of symbol instance `n`'s art where the canvas draws it (`None` in
/// Outline, [`HitOptions::outline`]): its strokes, and the art that the live geometry effects of a
/// clipping path in it show past the art's box. `None` for an instance of a missing symbol, of a
/// symbol that holds an instance of itself or nests too deep ([`SymbolBoxes::art`]), and of art past
/// the 9-slice object limit ([`InstanceArt::of`]), when `p` is outside the instance's reach
/// ([`Node::reach_bounds_with`]), and once the click has visited as many objects of symbol art as
/// [`ArtWalk`] allows.
fn instance_art_hit(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Option<HitKind> {
let NodeKind::SymbolInstance { symbol, .. } = &n.kind else { return None };
if opt.outline || symbols.art(symbol).is_none() || !n.reach_bounds_with(symbols).is_some_and(|b| b.inflate(opt.tol, opt.tol).contains(p)) {
return None;
}
let _walk = ArtWalk::open();
if ArtWalk::used_up() {
return None;
}
let art = InstanceArt::of(symbols.document(), n)?;
#[cfg(test)]
ARTS_WALKED.with(|c| c.set(c.get() + 1));
let opt = HitOptions { clip_outlines: false, ..opt };
let _art_only = ArtOnly::set();
FindPart { art: &art, p, opt, symbols, whole_instance: true }.find(&art.art, &mut vec![], None, false)
}
thread_local! {
/// Whether a symbol instance's art is being walked outside the instance's box ([`ArtOnly`]).
static ART_ONLY: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
/// While a guard lives, a symbol instance is hit ([`hit_leaf`]) and touched by a marquee
/// ([`marquee_with`]) only where its own art is, by the same rule at every level, and not anywhere
/// in its box. [`instance_art_hit`] and the marquee's walk of an instance's art hold one: they walk
/// the art outside the instance's box, where the box of an instance nested in the art can reach
/// past it over empty space.
struct ArtOnly {
was: bool,
}
impl ArtOnly {
fn set() -> Self {
Self { was: ART_ONLY.replace(true) }
}
fn on() -> bool {
ART_ONLY.get()
}
}
impl Drop for ArtOnly {
fn drop(&mut self) {
ART_ONLY.set(self.was);
}
}
thread_local! {
/// How many more objects of symbol art the click or marquee under way may visit ([`ArtWalk`]),
/// `None` outside one.
static ART_WALK_LEFT: std::cell::Cell<Option<u64>> = const { std::cell::Cell::new(None) };
}
#[cfg(test)]
thread_local! {
/// The number of objects [`ArtWalk::open`] allows on this thread in place of
/// [`crate::MAX_SYMBOL_NODES`].
static ART_WALK_LIMIT: std::cell::Cell<Option<u64>> = const { std::cell::Cell::new(None) };
/// How many symbol instances' art clicks and marquees walked on this thread.
static ARTS_WALKED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// The objects of symbol art that one click or one marquee may visit: [`crate::MAX_SYMBOL_NODES`]
/// in all, the art of the instances nested in that art included, at every level. The first
/// [`ArtWalk::open`] of a click or a marquee sets the limit, and the walks opened while its guard
/// lives share it. Once the limit is used up, an instance is no longer hit or touched outside its
/// box; inside its box it still is.
struct ArtWalk {
outermost: bool,
}
impl ArtWalk {
fn open() -> Self {
let walk = Self { outermost: ART_WALK_LEFT.get().is_none() };
walk.restart();
walk
}
/// For the outermost guard, sets what is left back to the whole limit.
fn restart(&self) {
if self.outermost {
#[cfg(test)]
let limit = ART_WALK_LIMIT.get().unwrap_or(crate::MAX_SYMBOL_NODES);
#[cfg(not(test))]
let limit = crate::MAX_SYMBOL_NODES;
ART_WALK_LEFT.set(Some(limit));
}
}
/// Is nothing left?
fn used_up() -> bool {
ART_WALK_LEFT.get() == Some(0)
}
/// Takes `n` objects from what is left. `false` when fewer are left, and nothing is left after.
fn take(n: u64) -> bool {
let Some(left) = ART_WALK_LEFT.get() else { return true };
let rest = left.checked_sub(n);
ART_WALK_LEFT.set(Some(rest.unwrap_or(0)));
rest.is_some()
}
}
impl Drop for ArtWalk {
fn drop(&mut self) {
if self.outermost {
ART_WALK_LEFT.set(None);
}
}
}
/// Is `n` below a layer whose Preview is off, so that the canvas draws it in outline? As for clicks
@@ -1435,7 +1594,8 @@ pub fn compound_part_at(n: &Node, p: Point, tol: f64) -> Option<NodeId> {
/// Objects (children of layers, or of `scope` in isolation mode) touched by a marquee rect, leaving
/// out those on a locked artboard ([`crate::LockedArtboards::holds_id`]). Symbol instances are measured
/// by their art ([`Node::geometric_bounds_with`]). The art of a clip group or of a layer with a
/// by their art ([`Node::geometric_bounds_with`]), and outside that box, except in outline, they are
/// touched where the live geometry effects of a clipping path in their art show it. The art of a clip group or of a layer with a
/// clipping mask counts only when the marquee meets the region its clipping path clips to, and then
/// only within that region's bounds. In isolation mode the clipping mask of the isolated container,
/// or of the parent of the single object isolated, does not limit the marquee, as it does not limit
@@ -1463,6 +1623,7 @@ fn previews_off(n: &Node) -> bool {
}
fn marquee_with(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool, whole_compounds: bool, outline: bool) -> Vec<NodeId> {
let _walk = ArtWalk::open();
let mut out = Vec::new();
// The objects the marquee may touch ([`Top`]).
let tops: Vec<Top> = match scope.and_then(|s| doc.node(s)) {
@@ -1573,14 +1734,45 @@ fn marquee_with(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool, wh
}
NodeKind::Compound { children, .. } => children.iter().any(|c| c.visible && touches(c, s, r, scope, whole_compounds, outline)),
NodeKind::Repeat(spec) => spec.expand(Some(s.document())).iter().any(|copy| touches(copy, s, r, scope, whole_compounds, outline)),
// A box with no width or no height (an instance of a symbol that draws a straight
// line) is touched where the marquee crosses it.
_ => n.geometric_bounds_with(s).is_some_and(|b| {
let crosses = (b.width() == 0.0 || b.height() == 0.0) && b.x0 <= r.x1 && r.x0 <= b.x1 && b.y0 <= r.y1 && r.y0 <= b.y1;
b.intersect(r).area() > 0.0 || r.contains(b.origin()) || crosses
}),
// A symbol instance is touched by its box, and outside it where the live geometry
// effects of a clipping path in its art show the art. In the art of another instance
// walked outside that instance's box, it is touched only by its own art ([`ArtOnly`]).
NodeKind::SymbolInstance { .. } => !ArtOnly::on() && box_touches(n, s, r) || !outline && instance_art_touches(n, s, r, whole_compounds),
_ => box_touches(n, s, r),
}
}
/// Does the marquee `r` touch the box of `n`? A box with no width or no height (an instance of
/// a symbol that draws a straight line) is touched where the marquee crosses it.
fn box_touches(n: &Node, s: &SymbolBoxes, r: Rect) -> bool {
n.geometric_bounds_with(s).is_some_and(|b| {
let crosses = (b.width() == 0.0 || b.height() == 0.0) && b.x0 <= r.x1 && r.x0 <= b.x1 && b.y0 <= r.y1 && r.y0 <= b.y1;
b.intersect(r).area() > 0.0 || r.contains(b.origin()) || crosses
})
}
/// Does the marquee `r` touch the art of symbol instance `n` placed as the canvas draws it, as
/// it touches a group of that art? Only for art whose clipping paths have live geometry effects
/// ([`SymbolBoxes::art`]), or for any art while [`ArtOnly`] is on, and only when `r` meets the
/// instance's reach ([`Node::reach_bounds_with`]): elsewhere the art lies within the instance's
/// box. Not once the marquee has placed as many objects of symbol art as [`ArtWalk`] allows.
fn instance_art_touches(n: &Node, s: &SymbolBoxes, r: Rect, whole_compounds: bool) -> bool {
let NodeKind::SymbolInstance { symbol, .. } = &n.kind else { return false };
let meets = |b: Rect| b.x0 <= r.x1 && r.x0 <= b.x1 && b.y0 <= r.y1 && r.y0 <= b.y1;
if s.art(symbol).is_none_or(|a| a.reshaped.is_none() && !ArtOnly::on()) || !n.reach_bounds_with(s).is_some_and(meets) {
return false;
}
let _walk = ArtWalk::open();
if ArtWalk::used_up() {
return false;
}
let Some(art) = InstanceArt::of(s.document(), n) else { return false };
if !ArtWalk::take(art.size()) {
return false;
}
#[cfg(test)]
ARTS_WALKED.with(|c| c.set(c.get() + 1));
let _art_only = ArtOnly::set();
art.place(&art.art, None).is_some_and(|placed| placed.visible && touches(&placed, s, r, None, whole_compounds, false))
}
fn collect_leaves(n: &Node, s: &SymbolBoxes, r: Rect, scope: Option<NodeId>, whole_compounds: bool, outline: bool, out: &mut Vec<NodeId>) {
match &n.kind {
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Intertwine { children } => {
+288
View File
@@ -1852,6 +1852,294 @@ fn a_symbol_part_follows_the_region_its_clipping_layers_effects_give_it() {
assert!(symbol_part_outline(&d, n, &[0, 1]).unwrap().elements().is_empty(), "nothing is drawn, so nothing is outlined");
}
/// A symbol instance whose art holds a clip group or a clipping layer with a live effect that moves
/// its clipping path is clicked, and touched by a marquee, where the moved region shows the art past
/// the art's box: inside a group, nested in another symbol's art, moved and scaled. In Outline view,
/// below a layer whose Preview is off, where the art is hidden and where no region is left, only the
/// box counts.
#[test]
fn a_symbol_instance_is_hit_where_a_moved_clipping_path_in_its_art_shows_the_art() {
use vectorcraft_geom::Affine;
set_clip_region(moved_clip_region);
// `effect` on the clipping path of a clip group (a clipping layer with `layer`), whose art is
// shown when `shown`; `xf` places the instance, which with `nest` shows a symbol whose art holds
// an instance of the clipped symbol. The instance is in a group: the group, the instance.
let build = |effect: &str, xf: Affine, nest: bool, shown: bool, layer: bool| {
let mut d = Document::new(600.0, 400.0);
let l = d.layers[0].id;
let (mut mask, mut art) = half_clipped(&mut d);
mask.appearance.effects.push(crate::Effect { id: effect.into(), ..test_move() });
art.visible = shown;
let mut clip = if layer {
let mut layer = Node::layer(d.alloc_id(), "Masked", crate::LayerColor::Preset(2));
if let NodeKind::Layer { children, .. } = &mut layer.kind {
*children = vec![Arc::new(mask), Arc::new(art)];
}
layer
} else {
Node::group(d.alloc_id(), vec![Arc::new(mask), Arc::new(art)])
};
clip.set_clips(true);
let root = Node::group(d.alloc_id(), vec![Arc::new(clip)]);
d.symbols.push(crate::Symbol { dynamic: false, name: "Clipped".into(), art: Arc::new(root) });
let mut symbol = "Clipped";
if nest {
let inner = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Clipped".into(), xf: Affine::IDENTITY });
let root = Node::group(d.alloc_id(), vec![Arc::new(inner)]);
d.symbols.push(crate::Symbol { dynamic: false, name: "Outer".into(), art: Arc::new(root) });
symbol = "Outer";
}
let (g, inst) = (d.alloc_id(), d.alloc_id());
let instance = Node::new(inst, NodeKind::SymbolInstance { symbol: symbol.into(), xf });
d.insert(Some(l), 0, Node::group(g, vec![Arc::new(instance)])).unwrap();
(d, g, inst)
};
let opt = HitOptions::default();
let outline = HitOptions { outline: true, ..opt };
let at = |d: &Document, x: f64, y: f64, opt: HitOptions| hit_test(d, Point::new(x, y), opt).map(|h| h.leaf);
// Across the art's top edge in the moved region, and past the art's right end.
let (edge, past) = (Rect::new(140.0, -5.0, 160.0, 5.0), Rect::new(240.0, -5.0, 260.0, 5.0));
for layer in [false, true] {
let (d, g, inst) = build("test.move", Affine::IDENTITY, false, true, layer);
assert_eq!(at(&d, 150.0, 50.0, opt), Some(inst), "in the moved region, layer {layer}");
assert_eq!(hit_test(&d, Point::new(150.0, 50.0), opt).map(|h| h.top_object(None)), Some(g));
assert_eq!(at(&d, 50.0, 50.0, opt), Some(inst), "in the box");
assert_eq!(at(&d, 250.0, 50.0, opt), None, "past the art");
assert_eq!(at(&d, 150.0, 150.0, opt), None, "below the art");
assert_eq!(at(&d, 150.0, 50.0, outline), None, "Outline view draws the plain clipping path");
assert_eq!(marquee(&d, edge, None, false), [g], "layer {layer}");
assert_eq!(marquee(&d, edge, None, true), [inst], "layer {layer}");
assert!(marquee(&d, past, None, false).is_empty());
assert!(marquee_in_view(&d, edge, None, false, true).is_empty(), "Outline view");
}
// Preview off for the layer the instance is on.
let (mut d, _, _) = build("test.move", Affine::IDENTITY, false, true, false);
if let NodeKind::Layer { preview, .. } = &mut Arc::make_mut(&mut d.layers[0]).kind {
*preview = false;
}
assert_eq!(at(&d, 150.0, 50.0, opt), None, "Preview off");
assert!(marquee(&d, edge, None, false).is_empty(), "Preview off");
// Moved, scaled (the region moves 100 pt from the placed clipping path) and nested.
let (d, _, inst) = build("test.move", Affine::translate((10.0, 20.0)), false, true, false);
assert_eq!(at(&d, 160.0, 70.0, opt), Some(inst), "moved");
let (d, _, inst) = build("test.move", Affine::scale(2.0), false, true, false);
assert_eq!(at(&d, 250.0, 100.0, opt), Some(inst), "scaled");
assert_eq!(at(&d, 350.0, 100.0, opt), None, "scaled, past the region");
let (d, g, inst) = build("test.move", Affine::IDENTITY, true, true, false);
assert_eq!(at(&d, 150.0, 50.0, opt), Some(inst), "nested");
assert_eq!(marquee(&d, edge, None, false), [g], "nested");
// Hidden art, and an effect that leaves no region: clicks find only the box. The marquee still
// touches the moved clipping path on the edge of its region, as in a clip group, unless no
// region is left.
for (effect, shown) in [("test.move", false), ("test.empty", true)] {
let (d, g, inst) = build(effect, Affine::IDENTITY, false, shown, false);
assert_eq!(at(&d, 150.0, 50.0, opt), None, "{effect}, shown {shown}");
assert_eq!(at(&d, 50.0, 50.0, opt), Some(inst), "{effect}, shown {shown}: in the box");
let touched: &[NodeId] = if shown { &[] } else { &[g] };
assert_eq!(marquee(&d, edge, None, false), touched, "{effect}, shown {shown}");
}
}
/// A click on the stroke of a symbol's art where it paints past the instance's box selects the
/// instance; past the stroke, and in Outline view, it does not.
#[test]
fn a_symbol_instance_is_hit_on_the_stroke_of_its_art_past_its_box() {
let mut d = Document::new(300.0, 200.0);
let l = d.layers[0].id;
// A 20 pt stroke reaches 10 pt past the 0..100 square.
let look = Appearance::basic(Paint::solid(Color::BLACK), Paint::solid(Color::BLACK), 20.0);
let square = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), look);
let art = Node::group(d.alloc_id(), vec![Arc::new(square)]);
d.symbols.push(crate::Symbol { dynamic: false, name: "Stroked".into(), art: Arc::new(art) });
let inst = d.alloc_id();
let xf = vectorcraft_geom::Affine::IDENTITY;
d.insert(Some(l), 0, Node::new(inst, NodeKind::SymbolInstance { symbol: "Stroked".into(), xf })).unwrap();
let at = |x: f64, opt: HitOptions| hit_test(&d, Point::new(x, 50.0), opt).map(|h| h.leaf);
assert_eq!(at(108.0, HitOptions::default()), Some(inst), "on the stroke past the box");
assert_eq!(at(50.0, HitOptions::default()), Some(inst), "in the box");
assert_eq!(at(116.0, HitOptions::default()), None, "past the stroke");
assert_eq!(at(108.0, HitOptions { outline: true, ..HitOptions::default() }), None, "Outline view");
}
/// Outside a symbol instance's box, an instance nested in its art counts where its own art is, not
/// anywhere in its box: a click or a marquee on empty space in the nested box, where a moved
/// clipping path shows the art past the outer box, selects nothing, and on art drawn there it
/// selects the outer instance.
#[test]
fn a_nested_symbol_instances_box_past_the_outer_box_is_not_a_hit() {
set_clip_region(moved_clip_region);
// `Two` draws squares at the corners of 0..200 × 0..100, and with `middle` one at 140..160 ×
// 40..60. The outer symbol clips an instance of it to 0..100 × 0..100, which `test.move` moves
// to 100..200: the outer box is 0..100, the nested box 0..200.
let build = |middle: bool| {
let mut d = Document::new(600.0, 400.0);
let l = d.layers[0].id;
let square = |d: &mut Document, r: Rect| Arc::new(Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::default_art()));
let mut squares = vec![square(&mut d, Rect::new(0.0, 0.0, 10.0, 10.0)), square(&mut d, Rect::new(190.0, 90.0, 200.0, 100.0))];
if middle {
squares.push(square(&mut d, Rect::new(140.0, 40.0, 160.0, 60.0)));
}
group_symbol(&mut d, "Two", squares);
let (mut mask, _) = half_clipped(&mut d);
mask.appearance.effects.push(test_move());
let nested = instance_of(&mut d, "Two");
let mut clip = Node::group(d.alloc_id(), vec![Arc::new(mask), nested]);
clip.set_clips(true);
group_symbol(&mut d, "Outer", vec![Arc::new(clip)]);
let inst = d.alloc_id();
d.insert(Some(l), 0, Node::new(inst, NodeKind::SymbolInstance { symbol: "Outer".into(), xf: vectorcraft_geom::Affine::IDENTITY })).unwrap();
(d, inst)
};
let at = |d: &Document, x: f64, y: f64| hit_test(d, Point::new(x, y), HitOptions::default()).map(|h| h.leaf);
let inside = Rect::new(145.0, 45.0, 155.0, 55.0);
let (d, inst) = build(false);
assert_eq!(at(&d, 150.0, 50.0), None, "empty space in the nested box");
assert!(marquee(&d, inside, None, false).is_empty(), "marquee on empty space in the nested box");
assert_eq!(at(&d, 195.0, 95.0), Some(inst), "on the nested art past the outer box");
assert_eq!(at(&d, 50.0, 50.0), Some(inst), "in the outer box");
let (d, inst) = build(true);
assert_eq!(at(&d, 150.0, 50.0), Some(inst), "on the nested art");
// Across the middle square's left edge.
assert_eq!(marquee(&d, Rect::new(135.0, 45.0, 145.0, 55.0), None, false), [inst], "marquee on the nested art");
}
/// Adds the symbol `Clipped`: a clip group whose clipping path's region `test.move` moves to
/// 100..200 × 0..100, past the 0..100 box, over a 0..200 × 0..100 rectangle shown when `shown`.
/// A click at (150, 50) hits an instance of it on the rectangle, or, with the rectangle hidden,
/// walks its art and finds nothing.
fn clipped_symbol(d: &mut Document, shown: bool) {
set_clip_region(moved_clip_region);
let (mut mask, mut art) = half_clipped(d);
mask.appearance.effects.push(test_move());
art.visible = shown;
let mut clip = Node::group(d.alloc_id(), vec![Arc::new(mask), Arc::new(art)]);
clip.set_clips(true);
let root = Node::group(d.alloc_id(), vec![Arc::new(clip)]);
d.symbols.push(crate::Symbol { dynamic: false, name: "Clipped".into(), art: Arc::new(root) });
}
/// A symbol named `name` whose art is a group of `members`.
fn group_symbol(d: &mut Document, name: &str, members: Vec<Arc<Node>>) {
let root = Node::group(d.alloc_id(), members);
d.symbols.push(crate::Symbol { dynamic: false, name: name.into(), art: Arc::new(root) });
}
/// An instance of the symbol named `symbol`, not moved.
fn instance_of(d: &mut Document, symbol: &str) -> Arc<Node> {
Arc::new(Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: symbol.into(), xf: vectorcraft_geom::Affine::IDENTITY }))
}
/// 30 small squares at the origin, which a click at (150, 50) passes by.
fn far_squares(d: &mut Document) -> Vec<Arc<Node>> {
(0..30).map(|_| Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::default_art()))).collect()
}
/// Runs `f` with [`ArtWalk`] allowing `limit` objects, and returns its result with how many
/// leaves [`hit_leaf`] tested and how many instances' art was walked meanwhile.
fn with_art_limit<R>(limit: u64, f: impl FnOnce() -> R) -> (R, usize, usize) {
ART_WALK_LIMIT.with(|c| c.set(Some(limit)));
LEAVES_TESTED.with(|c| c.set(0));
ARTS_WALKED.with(|c| c.set(0));
let r = f();
ART_WALK_LIMIT.with(|c| c.set(None));
(r, LEAVES_TESTED.with(std::cell::Cell::get), ARTS_WALKED.with(std::cell::Cell::get))
}
/// One click visits at most the art walk's limit of objects in a chain of nested symbol
/// instances, though each level's art alone is well within it. A click on the art of an
/// instance nested in another is still found.
#[test]
fn a_click_walks_a_chain_of_nested_symbol_instances_within_one_limit() {
// Level 0 is `Clipped`; each level above holds an instance of the level below and 30 squares:
// 32 objects to visit at each level.
let chain = |shown: bool| {
let mut d = Document::new(600.0, 400.0);
let l = d.layers[0].id;
clipped_symbol(&mut d, shown);
for level in 1..12 {
let below = if level == 1 { "Clipped".to_string() } else { format!("Level {}", level - 1) };
let mut members = vec![instance_of(&mut d, &below)];
members.extend(far_squares(&mut d));
group_symbol(&mut d, &format!("Level {level}"), members);
}
let inst = instance_of(&mut d, "Level 11");
let id = inst.id;
d.insert(Some(l), 0, Arc::unwrap_or_clone(inst)).unwrap();
(d, id)
};
let at = |d: &Document, x: f64| hit_test(d, Point::new(x, 50.0), HitOptions::default()).map(|h| h.leaf);
// Without one limit for the click, the miss walks the art of all 12 levels.
let (d, inst) = chain(false);
let (hit, _, arts) = with_art_limit(100, || at(&d, 150.0));
assert_eq!(hit, None);
assert!(arts <= 4, "{arts} levels walked");
assert_eq!(with_art_limit(100, || at(&d, 50.0)).0, Some(inst), "in the box");
// Past the limit, a click that the art would take finds only the box.
let (d, inst) = chain(true);
assert_eq!(with_art_limit(100, || at(&d, 150.0)).0, None, "past the limit");
assert_eq!(at(&d, 150.0), Some(inst), "within the limit");
// Two levels: a click on the inner instance's art is found within the limit.
let mut d = Document::new(600.0, 400.0);
let l = d.layers[0].id;
clipped_symbol(&mut d, true);
let inner = instance_of(&mut d, "Clipped");
group_symbol(&mut d, "Outer", vec![inner]);
let inst = instance_of(&mut d, "Outer");
let id = inst.id;
d.insert(Some(l), 0, Arc::unwrap_or_clone(inst)).unwrap();
assert_eq!(with_art_limit(100, || at(&d, 150.0)).0, Some(id), "nested");
}
/// One click, and one marquee, visit at most the art walk's limit of objects in art that repeats
/// one nested instance many times, though the art of each instance alone is well within it. A
/// click on one of the nested instances' art is still found.
#[test]
fn a_click_and_a_marquee_walk_repeated_nested_symbol_instances_within_one_limit() {
// 50 instances of `Clipped` in one symbol's art, its rectangle shown when `shown`.
let repeated = |shown: bool| {
let mut d = Document::new(600.0, 400.0);
let l = d.layers[0].id;
clipped_symbol(&mut d, shown);
let members = (0..50).map(|_| instance_of(&mut d, "Clipped")).collect();
group_symbol(&mut d, "Repeated", members);
let inst = instance_of(&mut d, "Repeated");
let id = inst.id;
d.insert(Some(l), 0, Arc::unwrap_or_clone(inst)).unwrap();
(d, id)
};
let (d, inst) = repeated(false);
// Without one limit for the click, the miss walks the art of all 51 instances. The outer art
// takes 1 object of the 100, and each nested instance with its art 4.
let (hit, _, arts) = with_art_limit(100, || hit_test(&d, Point::new(150.0, 50.0), HitOptions::default()).map(|h| h.leaf));
assert_eq!(hit, None);
assert!(arts <= 26, "{arts} instances' art walked");
// Select Behind over 60 instances of `Clipped` on the layer shares one limit too: each walk
// takes 3 objects of the 100.
let mut stack = Document::new(600.0, 400.0);
let l = stack.layers[0].id;
clipped_symbol(&mut stack, false);
for _ in 0..60 {
let inst = instance_of(&mut stack, "Clipped");
stack.insert(Some(l), 0, Arc::unwrap_or_clone(inst)).unwrap();
}
let (behind, _, arts) = with_art_limit(100, || objects_at(&stack, Point::new(150.0, 50.0), HitOptions::default(), None));
assert!(behind.is_empty());
assert!(arts <= 34, "Select Behind: {arts} instances' art walked");
// Without one limit for the marquee, it places the art of all 51 instances, inside the moved
// region (within each instance's reach and outside its box). Placing the outer art takes 51
// objects of the 100, and each nested art 4.
let inside = Rect::new(140.0, 40.0, 160.0, 60.0);
let (touched, _, arts) = with_art_limit(100, || marquee(&d, inside, None, false));
assert!(touched.is_empty());
assert!(arts <= 14, "{arts} instances' art placed");
assert_eq!(with_art_limit(100, || marquee(&d, Rect::new(40.0, 40.0, 60.0, 60.0), None, false)).0, [inst], "in the box");
// A click and a marquee on the nested instances' art are found within the limit.
let (d, inst) = repeated(true);
assert_eq!(with_art_limit(100, || hit_test(&d, Point::new(150.0, 50.0), HitOptions::default()).map(|h| h.leaf)).0, Some(inst), "click");
let edge = Rect::new(140.0, -5.0, 160.0, 5.0);
assert_eq!(with_art_limit(100, || marquee(&d, edge, None, false)).0, [inst], "marquee across the art's edge");
}
/// In a dynamic symbol's art that placing through its 9-slice guides could make more than
/// [`crate::MAX_SYMBOL_NODES`] objects of, Direct Selection picks and outlines nothing, as the
/// canvas draws nothing of it. At the art's natural size the same art is picked.
+34 -4
View File
@@ -80,16 +80,22 @@ pub fn spot_color(doc: &Document, name: &str) -> Option<Color> {
/// A colour's swatch link and tint (`(swatch name, tint 0..1)`), as the colour visitors pass it.
pub type Link<'a> = Option<(&'a str, f32)>;
/// An ink amount clamped to 0..1; an amount that is not a finite number prints no ink.
fn ink_amount(v: f32) -> f32 {
if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }
}
/// Separate one colour into inks. A colour linked to a spot swatch prints on that plate only, at
/// its tint; one linked to the Registration swatch prints its tint on every plate.
/// its tint; one linked to the Registration swatch prints its tint on every plate. A tint that is
/// not a finite number prints no ink.
pub fn inks(doc: &Document, c: &Cms, color: &Color, link: Link, intent: Intent) -> Inks {
if let Some((REGISTRATION, tint)) = link {
return Inks { cmyk: [tint.clamp(0.0, 1.0); 4], spot: None, registration: true };
return Inks { cmyk: [ink_amount(tint); 4], spot: None, registration: true };
}
if let Some((name, tint)) = link
&& doc.swatch(name).is_some_and(|s| s.spot && s.paint.color().is_some())
{
return Inks { cmyk: [0.0; 4], spot: Some((name.to_string(), tint.clamp(0.0, 1.0))), registration: false };
return Inks { cmyk: [0.0; 4], spot: Some((name.to_string(), ink_amount(tint))), registration: false };
}
Inks { cmyk: c.to_cmyk(color, intent), spot: None, registration: false }
}
@@ -104,7 +110,7 @@ pub type StopPlates = Option<[f32; 4]>;
pub fn stop_inks(doc: &Document, c: &Cms, color: &Color, link: Link, plates: StopPlates, intent: Intent) -> Inks {
let mut ink = inks(doc, c, color, link, intent);
if let (Some(p), Some(_)) = (plates, &ink.spot) {
ink.cmyk = p.map(|v| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 });
ink.cmyk = p.map(ink_amount);
}
ink
}
@@ -227,6 +233,30 @@ mod tests {
}
}
#[test]
fn a_spot_or_registration_tint_that_is_not_a_finite_number_prints_no_ink() {
let d = gold_doc();
let c = vectorcraft_color::cms::active();
let look = Color::cmyk(0.25, 0.1, 0.8, 0.05);
let ink = |link: Link| inks(&d, &c, &look, link, Intent::RelativeColorimetric);
for t in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
let spot = ink(Some(("Gold", t)));
assert_eq!(spot, Inks { cmyk: [0.0; 4], spot: Some(("Gold".into(), 0.0)), registration: false }, "{t}");
assert_eq!((spot.on_plate("Gold"), spot.on_plate("Cyan")), (0.0, 0.0), "{t}");
let reg = ink(Some((REGISTRATION, t)));
assert_eq!(reg, Inks { cmyk: [0.0; 4], spot: None, registration: true }, "{t}");
assert_eq!((reg.on_plate("Gold"), reg.on_plate("Black")), (0.0, 0.0), "{t}");
// A stop's process plates still print beside a spot tint that prints nothing.
let stop = stop_inks(&d, &c, &look, Some(("Gold", t)), Some([0.25, 0.0, 0.4, 0.0]), Intent::RelativeColorimetric);
assert_eq!((stop.cmyk, stop.on_plate("Gold")), ([0.25, 0.0, 0.4, 0.0], 0.0), "{t}");
}
// Finite tints outside 0..1 are clamped.
assert_eq!(ink(Some(("Gold", 2.0))).on_plate("Gold"), 1.0);
assert_eq!(ink(Some(("Gold", -1.0))).on_plate("Gold"), 0.0);
assert_eq!(ink(Some((REGISTRATION, 2.0))).cmyk, [1.0; 4]);
assert_eq!(ink(Some((REGISTRATION, -1.0))).cmyk, [0.0; 4]);
}
#[test]
fn gradient_stops_pass_their_plates_and_freeform_points_are_visited() {
let mut stop = GradientStop::new(0.0, Color::cmyk(0.25, 0.1, 0.8, 0.05));
+1 -1
View File
@@ -128,7 +128,7 @@ pub use text::{
RubyPosition, RubySpan, ScriptMetrics, SingleWordJustify, TabAlign, TabStop, TextFlow, TextKind, TextObject, TextRun, TextStyleDef, TextWrap,
VerticalAlign, WrapShape,
};
pub use text::{CharClass, CompositeFont, CompositePart, CompositeParts};
pub use text::{CharClass, CompositeClass, CompositeFont, CompositePart, CompositeParts, CustomCharSet};
pub use trace::TraceView;
pub use variables::{DataSet, DataValue, Variable, VariableKind, Variables};
pub use vectorcraft_color as color;
+37 -4
View File
@@ -105,8 +105,13 @@ impl Document {
n += shared(&self.para_styles, &newer.para_styles, styles);
n += shared(&self.composite_fonts, &newer.composite_fonts, |c| {
let part = |p: &crate::CompositePart| p.font.capacity() + p.style.capacity();
let custom = |f: &crate::CompositeFont| {
vec_bytes(&f.custom) + f.custom.iter().map(|s| s.name.capacity() + s.characters.capacity() + part(&s.part)).sum::<usize>()
};
vec_bytes(c)
+ c.iter().map(|f| f.name.capacity() + crate::CharClass::ALL.into_iter().map(|k| part(f.parts.get(k))).sum::<usize>()).sum::<usize>()
+ c.iter()
.map(|f| f.name.capacity() + crate::CharClass::ALL.into_iter().map(|k| part(f.parts.get(k))).sum::<usize>() + custom(f))
.sum::<usize>()
});
n += shared(&self.saved_selections, &newer.saved_selections, |s| {
vec_bytes(s) + s.iter().map(|s: &SavedSelection| s.name.capacity() + vec_bytes(&s.objects)).sum::<usize>()
@@ -276,7 +281,13 @@ fn node_bytes(n: &Node, other: Option<&Node>, counted: &mut HashSet<usize>) -> u
});
}
b += match &n.kind {
NodeKind::Path { path, live, .. } => path_bytes(path) + boxed(live),
NodeKind::Path { path, live, .. } => {
let was = other.and_then(|o| match &o.kind {
NodeKind::Path { path, .. } => Some(path),
_ => None,
});
path_bytes_besides(path, was) + boxed(live)
}
NodeKind::Layer { children, .. }
| NodeKind::Group { children, .. }
| NodeKind::Compound { children, .. }
@@ -293,8 +304,30 @@ fn node_bytes(n: &Node, other: Option<&Node>, counted: &mut HashSet<usize>) -> u
}
fn path_bytes(p: &PathData) -> usize {
let anchors: usize =
p.subpaths.iter().map(|s| if s.anchors.capacity() == 0 { 0 } else { ALLOC + s.anchors.capacity() * size_of::<Anchor>() }).sum();
path_bytes_besides(p, None)
}
/// [`path_bytes`] without the anchor chunks that `other` (another version of the path) holds too:
/// a version made by changing one anchor shares every chunk but the one it changed.
fn path_bytes_besides(p: &PathData, other: Option<&PathData>) -> usize {
let mut shared: Vec<usize> =
other.map(|o| o.subpaths.iter().flat_map(|s| s.anchors.chunk_allocations()).map(|(id, _)| id).collect()).unwrap_or_default();
shared.sort_unstable();
let chunk = ALLOC + ARC + size_of::<Vec<Anchor>>() + ALLOC;
let anchors: usize = p
.subpaths
.iter()
.map(|s| {
let table = s.anchors.table_bytes();
let own: usize = s
.anchors
.chunk_allocations()
.filter(|(id, _)| shared.binary_search(id).is_err())
.map(|(_, capacity)| chunk + capacity * size_of::<Anchor>())
.sum();
if table == 0 { own } else { ALLOC + table + own }
})
.sum();
anchors + if p.subpaths.capacity() == 0 { 0 } else { ALLOC + p.subpaths.capacity() * size_of::<SubPath>() }
}
+29 -1
View File
@@ -228,7 +228,11 @@ impl Node {
/// grown by the farthest a stroke in the art can reach. For an instance placed through 9-slice
/// guides, that box also holds the art box placed by each cell's map
/// ([`crate::NineSliceMap::placement_bounds`]). An instance whose art holds an instance of a
/// symbol with 9-slice guides at any depth is measured as its art placed by its transform.
/// symbol with 9-slice guides at any depth is measured as its art placed by its transform. An
/// instance whose art holds a clipping path with live geometry effects also takes in the region
/// they give it ([`crate::hit::set_clip_region`]): moved with the instance when it only moves its
/// art, otherwise placed by it and grown by as far as the region reaches past the art, times √2
/// and the larger of 1 and the Frobenius norm of the transform's linear part.
pub fn reach_bounds_with(&self, symbols: &SymbolBoxes) -> Option<Rect> {
symbols.quickly(|| self.reach(Some(symbols)))
}
@@ -268,6 +272,30 @@ impl Node {
NodeKind::Layer { children, clip: true, .. } | NodeKind::Group { children, clip: true } => {
vectorcraft_geom::union_opt(self.visual(symbols), children.first().and_then(|c| crate::hit::reshaped_clip_box(c)))
}
// The live geometry effects of the clipping paths in the art can draw it past its box.
NodeKind::SymbolInstance { symbol, xf } => {
let Some((s, art, region)) = symbols.and_then(|s| {
let a = s.art(symbol)?;
Some((s, a, a.reshaped?))
}) else {
return self.visual(symbols);
};
let map = s.document().symbol_slice_map_of(symbol, art.geometric, *xf);
let placed = if moves_only(*xf) && map.is_none() {
xf.transform_rect_bbox(region)
} else {
// An effect can reach a fixed distance past the art or a distance in proportion
// to its size, as `SymbolBoxes::effects_reach` estimates it.
let g = art.geometric;
let all = region.union(g);
let past = [g.x0 - region.x0, region.x1 - g.x1, g.y0 - region.y0, region.y1 - g.y1].into_iter().fold(0.0_f64, f64::max);
let [a, b, c, d, _, _] = xf.as_coeffs();
let grow = past * std::f64::consts::SQRT_2 * (a * a + b * b + c * c + d * d).sqrt().max(1.0);
map.map_or_else(|| xf.transform_rect_bbox(all), |m| m.placement_bounds(all)).inflate(grow, grow)
};
let o = self.appearance.outset();
vectorcraft_geom::union_opt(self.visual(symbols), self.projected(Some(placed)).map(|b| b.inflate(o, o)))
}
_ => self.visual(symbols),
}
}
+59 -2
View File
@@ -49,6 +49,13 @@ pub struct Selection {
/// it when no object is selected to align to it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key_guide: Option<NodeId>,
/// The key anchor for Align: one of two or more anchor points selected with Direct Selection,
/// the last one a Shift-click added (path, anchor in [`Self::anchors`]). Align and Distribute
/// move the other selected points relative to it, and it stays in place. Never set with `key`
/// or `key_guide`; [`Self::prune`] clears it when the point is no longer a selected anchor, or
/// fewer than two points are selected.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key_anchor: Option<(NodeId, AnchorRef)>,
/// The new art on a locked artboard that this selection keeps editable
/// ([`Document::new_art`]). Undo, Redo and a failed batch restore it with the selection, and
/// the engine then sets the document's from it. Editing state, never saved.
@@ -98,6 +105,7 @@ impl Selection {
self.slices.clear();
self.guides.clear();
self.key_guide = None;
self.key_anchor = None;
self.symbol_part = None;
self.on_path_member = None;
}
@@ -113,9 +121,15 @@ impl Selection {
self.set([instance]);
self.symbol_part = Some(SymbolPart { instance, path });
}
/// Is there a key for Align: an object or a guide?
/// Is there a key for Align: an object, a guide or an anchor point?
pub fn has_key(&self) -> bool {
self.key.is_some() || self.key_guide.is_some()
self.key.is_some() || self.key_guide.is_some() || self.key_anchor.is_some()
}
/// Where the key anchor ([`Self::key_anchor`]) is. `None` without one, or when the point is
/// gone from its path.
pub fn key_anchor_point(&self, doc: &Document) -> Option<vectorcraft_geom::Point> {
let (id, (si, ai)) = self.key_anchor?;
Some(doc.node(id)?.path_data()?.subpaths.get(si)?.anchors.get(ai)?.p)
}
pub fn set(&mut self, ids: impl IntoIterator<Item = NodeId>) {
self.clear();
@@ -143,6 +157,9 @@ impl Selection {
if self.key == Some(id) {
self.key = None;
}
if self.key_anchor.is_some_and(|(k, _)| k == id) {
self.key_anchor = None;
}
}
/// [`Self::remove`] for each of `ids`, going through the selected objects once.
pub fn remove_all(&mut self, ids: impl IntoIterator<Item = NodeId>) {
@@ -162,6 +179,9 @@ impl Selection {
if self.key.is_some_and(|k| ids.contains(&k)) {
self.key = None;
}
if self.key_anchor.is_some_and(|(k, _)| ids.contains(&k)) {
self.key_anchor = None;
}
}
/// Select slices `ids` (see [`Selection::slices`]) and nothing else.
pub fn set_slices(&mut self, ids: impl IntoIterator<Item = NodeId>) {
@@ -271,6 +291,40 @@ impl Selection {
if self.key.is_some() || self.objects.is_empty() || self.key_guide.is_some_and(|k| !self.guides.contains(&k)) {
self.key_guide = None;
}
// A key anchor needs its point on its path and selected as an anchor point (not as a
// selected segment's end), with another point selected too: another anchor, or a path
// selected whole (Align and Distribute act on path points only). It takes the place of a
// key object or key guide.
let path_of = |o: &NodeId| found.get(o).and_then(|p| doc.node_at(p)).and_then(|n| n.path_data());
let whole_path = |o: &NodeId| !self.anchors.contains_key(o) && path_of(o).is_some_and(|path| path.anchors().next().is_some());
// Selected anchor points that are on their paths, counted up to two.
let points = || {
self.anchors
.iter()
.flat_map(|(o, set)| {
let path = path_of(o);
set.iter().filter(move |&&(si, ai)| path.is_some_and(|p| p.subpaths.get(si).is_some_and(|sp| ai < sp.anchors.len())))
})
.take(2)
.count()
};
let anchor_kept = self.key_anchor.is_some_and(|(id, (si, ai))| {
self.objects.contains(&id)
&& !self.segments.contains_key(&id)
&& self.anchors.get(&id).is_some_and(|set| set.contains(&(si, ai)))
&& found
.get(&id)
.and_then(|p| doc.node_at(p))
.and_then(|n| n.path_data())
.is_some_and(|path| path.subpaths.get(si).is_some_and(|sp| ai < sp.anchors.len()))
&& (points() >= 2 || self.objects.iter().any(whole_path))
});
if anchor_kept {
self.key = None;
self.key_guide = None;
} else {
self.key_anchor = None;
}
// A selected part needs its instance selected alone, with no guides, its symbol dynamic
// and the object still in the symbol's art as a path, compound path or type, where neither
// it nor a group or layer around it is hidden, locked or a template.
@@ -348,6 +402,9 @@ impl Selection {
if self.key.is_some_and(|k| gone.contains(&k)) {
self.key = None;
}
if self.key_anchor.is_some_and(|(k, _)| gone.contains(&k)) {
self.key_anchor = None;
}
}
/// Target `id` (see [`Selection::target`]): a layer gets its visible, unlocked art selected
/// (the art of its sublayers too), anything else is selected itself.
+17 -1
View File
@@ -47,6 +47,11 @@ pub(crate) struct ArtBox {
/// Whether the art holds an instance of a symbol with 9-slice guides, or of a symbol whose art
/// holds one at any depth.
pub(crate) sliced: bool,
/// The box of the regions that the live geometry effects of the clipping paths in the art give
/// them ([`crate::hit::set_clip_region`]), and of the instances in the art whose art has such
/// regions, each measured by [`Node::reach_bounds_with`]. `None` when nothing in the art has
/// one.
pub(crate) reshaped: Option<Rect>,
}
/// The art boxes of a document's symbols, which [`Node::geometric_bounds_with`],
@@ -562,7 +567,18 @@ fn art_box(art: &Arc<Node>, symbols: &SymbolBoxes) -> Option<ArtBox> {
sliced |= symbols.doc.symbol_nine_slice(symbol).is_some() || symbols.art(symbol).is_some_and(|a| a.sliced);
}
});
Some(ArtBox { geometric, visual, reach, effects, brush, sliced })
let mut reshaped = None;
if effects {
art.walk(&mut |n| {
let b = match &n.kind {
NodeKind::SymbolInstance { symbol, .. } if symbols.art(symbol).is_some_and(|a| a.reshaped.is_some()) => n.reach(Some(symbols)),
_ if n.clips() => n.children().and_then(|c| c.first()).and_then(|c| crate::hit::reshaped_clip_box(c)),
_ => None,
};
reshaped = vectorcraft_geom::union_opt(reshaped, b.filter(finite));
});
}
Some(ArtBox { geometric, visual, reach, effects, brush, sliced, reshaped })
}
#[cfg(test)]
+2 -2
View File
@@ -22,8 +22,8 @@ pub use char_style::{
WARICHU_LINES, WARICHU_SCALE, WarichuAlign,
};
pub use composite::{
COMPOSITE_BASELINE, COMPOSITE_FONTS_MAX, COMPOSITE_NAME_MAX, COMPOSITE_SCALE, COMPOSITE_SIZE, CharClass, CompositeFont, CompositePart,
CompositeParts,
COMPOSITE_BASELINE, COMPOSITE_FONTS_MAX, COMPOSITE_NAME_MAX, COMPOSITE_SCALE, COMPOSITE_SIZE, CUSTOM_CHARS_MAX, CUSTOM_SETS_MAX, CharClass,
CompositeClass, CompositeClasses, CompositeFont, CompositePart, CompositeParts, CustomCharSet,
};
pub use para_style::{
Burasagari, Composer, FirstBaseline, HyphenationOptions, JustificationOptions, Justify, Kashida, Kinsoku, KinsokuShoriType, LeadingModel,
+247 -26
View File
@@ -1,11 +1,16 @@
//! Type › Composite Fonts…: a font made of other fonts, one for each class of character (the
//! Kanji font is the base; kana, punctuation, symbols, Roman letters and numerals each take a font
//! of their own, sized, raised and scaled against it). Type set in a composite font names it as its
//! family; the layout sets each character in its class's font.
//! of their own, sized, raised and scaled against it). Custom character sets give characters the
//! user picks a font of their own too, ahead of their class; of two sets that list a character,
//! the later one sets it. Type set in a composite font names it as its family; the layout sets each
//! character in its set's or its class's font.
//!
//! Behavior reference (public documentation):
//! <https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf#page=347>.
//! Which characters fall in which class, the ranges and the defaults are VectorCraft's own.
//! <https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf#page=347>,
//! custom character sets:
//! <https://web.archive.org/web/20220911143322/https://helpx.adobe.com/pdf/cs6/illustrator_reference.pdf#page=348>.
//! Which characters fall in which class, the ranges, the limits and the defaults are VectorCraft's
//! own.
use serde::{Deserialize, Serialize};
@@ -179,12 +184,76 @@ impl CompositeParts {
}
}
/// A custom character set of a composite font: characters chosen by the user, set in a part of
/// their own instead of their class's part.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct CustomCharSet {
/// The set's name, unique in its composite font in any case.
pub name: String,
/// The set's characters, each once, in the order they were added.
#[serde(default)]
pub characters: String,
/// The font, style, size, baseline and scales its characters take.
#[serde(flatten)]
pub part: CompositePart,
}
impl CustomCharSet {
/// An empty set named `name`, its characters set in `part`.
pub fn new(name: impl Into<String>, part: CompositePart) -> Self {
Self { name: name.into(), characters: String::new(), part }
}
/// Whether `c` can be in a custom character set: not a control character, and in the Basic
/// Multilingual Plane (U+0000 to U+FFFF).
pub fn takes(c: char) -> bool {
!c.is_control() && (c as u32) <= 0xFFFF
}
/// The character a Unicode code names: hexadecimal digits, optionally after `U+` or `0x`
/// (`3042`, `U+3042`). `None` for anything else or a code [`Self::takes`] refuses.
pub fn char_from_code(code: &str) -> Option<char> {
let code = code.trim();
let hex = ["U+", "u+", "0x", "0X"].iter().find_map(|p| code.strip_prefix(p)).unwrap_or(code);
if hex.is_empty() || hex.len() > 6 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
u32::from_str_radix(hex, 16).ok().and_then(char::from_u32).filter(|&c| Self::takes(c))
}
}
/// Where a composite font sets a character: the part of one of its six classes, or the part of
/// one of its custom character sets (an index into [`CompositeFont::custom`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum CompositeClass {
Fixed(CharClass),
Custom(usize),
}
/// The custom character set each character of a composite font's sets falls in
/// ([`CompositeFont::classes`]): the last set that lists it.
#[derive(Clone, Debug, Default)]
pub struct CompositeClasses {
custom: std::collections::HashMap<char, usize>,
}
impl CompositeClasses {
/// Where the composite font sets `c`: the last custom set that lists it, else its class.
pub fn of(&self, c: char) -> CompositeClass {
self.custom.get(&c).map_or(CompositeClass::Fixed(CharClass::of(c)), |&i| CompositeClass::Custom(i))
}
}
/// A composite font (Type › Composite Fonts…), saved with the document. Its name is the family
/// that type set in it names.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct CompositeFont {
pub name: String,
pub parts: CompositeParts,
/// Custom character sets, checked before the classes. A character two sets list is set in the
/// later set's part.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub custom: Vec<CustomCharSet>,
}
/// The size a part can take, in percent of the type's size.
@@ -197,11 +266,40 @@ pub const COMPOSITE_SCALE: std::ops::RangeInclusive<f64> = 10.0..=400.0;
pub const COMPOSITE_NAME_MAX: usize = 255;
/// The most composite fonts a document or a file keeps.
pub const COMPOSITE_FONTS_MAX: usize = 256;
/// The most custom character sets a composite font keeps.
pub const CUSTOM_SETS_MAX: usize = 64;
/// The most characters a custom character set keeps.
pub const CUSTOM_CHARS_MAX: usize = 8192;
impl CompositeFont {
/// A composite font named `name` setting every class in `font` and `style`.
pub fn new(name: impl Into<String>, font: impl Into<String>, style: impl Into<String>) -> Self {
Self { name: name.into(), parts: CompositeParts::all(CompositePart::new(font, style)) }
Self { name: name.into(), parts: CompositeParts::all(CompositePart::new(font, style)), custom: Vec::new() }
}
/// The part that sets the characters of `class`, `None` for a custom set the font doesn't have.
pub fn part(&self, class: CompositeClass) -> Option<&CompositePart> {
match class {
CompositeClass::Fixed(c) => Some(self.parts.get(c)),
CompositeClass::Custom(i) => self.custom.get(i).map(|s| &s.part),
}
}
/// Where this font sets character `c`: the last custom set that lists it, else its class. For
/// many characters, [`Self::classes`] looks each up faster.
pub fn class_of(&self, c: char) -> CompositeClass {
self.custom.iter().rposition(|s| s.characters.contains(c)).map_or(CompositeClass::Fixed(CharClass::of(c)), CompositeClass::Custom)
}
/// The custom set each character of this font's sets falls in, to look up many characters.
pub fn classes(&self) -> CompositeClasses {
let mut custom = std::collections::HashMap::new();
for (i, s) in self.custom.iter().enumerate() {
for c in s.characters.chars() {
custom.insert(c, i);
}
}
CompositeClasses { custom }
}
/// Do `a` and `b` name the same composite font: equal in any case?
@@ -223,36 +321,50 @@ impl CompositeFont {
}
/// Why this composite font can't be used, if it can't: a name [`Self::clean_name`] refuses, a
/// part without a font, a value out of its range, or a scale or Scale at Center on a class
/// that has none.
/// part without a font, a value out of its range, a scale or Scale at Center on a class that
/// has none, or a custom character set [`Self::check_custom`] refuses.
pub fn check(&self) -> Result<(), String> {
if Self::clean_name(&self.name).as_deref() != Some(self.name.as_str()) {
return Err(format!("`{}` can't name a composite font", self.name));
}
for class in CharClass::ALL {
let p = self.parts.get(class);
let k = class.key();
if p.font.trim().is_empty() {
return Err(format!("{k}: give a font"));
check_part(self.parts.get(class), class.key(), class.scales(), class == CharClass::Kana)?;
}
self.check_custom()
}
/// Why this font's custom character sets can't be used, if they can't: more than
/// [`CUSTOM_SETS_MAX`], a name [`Self::clean_name`] refuses or two sets share (in any case), a
/// character [`CustomCharSet::takes`] refuses or a set lists twice, more than
/// [`CUSTOM_CHARS_MAX`] characters in a set, or a part [`Self::check`] would refuse. A set's
/// characters can be scaled across and up; only kana are scaled at the center.
pub fn check_custom(&self) -> Result<(), String> {
if self.custom.len() > CUSTOM_SETS_MAX {
return Err(format!("a composite font keeps at most {CUSTOM_SETS_MAX} character sets"));
}
let mut names = std::collections::HashSet::new();
for s in &self.custom {
let name = &s.name;
if Self::clean_name(name).as_deref() != Some(name.as_str()) {
return Err(format!("`{name}` can't name a character set"));
}
let within = |v: f64, r: &std::ops::RangeInclusive<f64>| v.is_finite() && r.contains(&v);
if !within(p.size, &COMPOSITE_SIZE) {
return Err(format!("{k}: size must be {} to {}%", COMPOSITE_SIZE.start(), COMPOSITE_SIZE.end()));
if !names.insert(name.to_lowercase()) {
return Err(format!("two character sets are named `{name}`"));
}
if !within(p.baseline, &COMPOSITE_BASELINE) {
return Err(format!("{k}: baseline must be {} to {}%", COMPOSITE_BASELINE.start(), COMPOSITE_BASELINE.end()));
}
for v in [p.h_scale, p.v_scale] {
if !within(v, &COMPOSITE_SCALE) {
return Err(format!("{k}: scale must be {} to {}%", COMPOSITE_SCALE.start(), COMPOSITE_SCALE.end()));
let mut seen = std::collections::HashSet::new();
for (n, c) in s.characters.chars().enumerate() {
if n == CUSTOM_CHARS_MAX {
return Err(format!("`{name}`: a character set keeps at most {CUSTOM_CHARS_MAX} characters"));
}
if v != 100.0 && !class.scales() {
return Err(format!("{k}: only kana, roman and numerals can be scaled"));
let code = c as u32;
if !CustomCharSet::takes(c) {
return Err(format!("`{name}`: U+{code:04X} is a control character or past U+FFFF"));
}
if !seen.insert(c) {
return Err(format!("`{name}`: U+{code:04X} is listed twice"));
}
}
if p.scale_at_center && class != CharClass::Kana {
return Err(format!("{k}: only kana can be scaled at the center"));
}
check_part(&s.part, name, true, false)?;
}
Ok(())
}
@@ -264,9 +376,41 @@ impl CompositeFont {
let mut names = std::collections::HashSet::new();
let mut kept: Vec<CompositeFont> =
fonts.iter().filter(|f| f.check().is_ok() && names.insert(f.name.to_lowercase())).take(COMPOSITE_FONTS_MAX).cloned().collect();
kept.retain(|f| CharClass::ALL.iter().all(|&c| !names.contains(&f.parts.get(c).font.to_lowercase())));
kept.retain(|f| f.part_fonts().all(|font| !names.contains(&font.to_lowercase())));
(kept.len() != fonts.len()).then_some(kept)
}
/// The fonts of the parts, each class's then each custom set's.
pub fn part_fonts(&self) -> impl Iterator<Item = &str> {
CharClass::ALL.into_iter().map(|c| self.parts.get(c).font.as_str()).chain(self.custom.iter().map(|s| s.part.font.as_str()))
}
}
/// Why part `p` (of the class or set named `k`) can't be used, if it can't: no font, a value out
/// of its range, a scale when `scales` is false, or Scale at Center when `center` is false.
fn check_part(p: &CompositePart, k: &str, scales: bool, center: bool) -> Result<(), String> {
if p.font.trim().is_empty() {
return Err(format!("{k}: give a font"));
}
let within = |v: f64, r: &std::ops::RangeInclusive<f64>| v.is_finite() && r.contains(&v);
if !within(p.size, &COMPOSITE_SIZE) {
return Err(format!("{k}: size must be {} to {}%", COMPOSITE_SIZE.start(), COMPOSITE_SIZE.end()));
}
if !within(p.baseline, &COMPOSITE_BASELINE) {
return Err(format!("{k}: baseline must be {} to {}%", COMPOSITE_BASELINE.start(), COMPOSITE_BASELINE.end()));
}
for v in [p.h_scale, p.v_scale] {
if !within(v, &COMPOSITE_SCALE) {
return Err(format!("{k}: scale must be {} to {}%", COMPOSITE_SCALE.start(), COMPOSITE_SCALE.end()));
}
if v != 100.0 && !scales {
return Err(format!("{k}: only kana, roman and numerals can be scaled"));
}
}
if p.scale_at_center && !center {
return Err(format!("{k}: only kana can be scaled at the center"));
}
Ok(())
}
#[cfg(test)]
@@ -341,4 +485,81 @@ mod tests {
spaced.name = " Mixed".into();
assert!(spaced.check().is_err());
}
/// A character a custom set lists falls in that set ahead of its class, the later of two sets
/// that list it wins, and the quick lookup agrees with the one-character one.
#[test]
fn custom_sets_take_their_characters_before_the_classes() {
let mut f = CompositeFont::new("Mixed", "Inter", "Regular");
let mut a = CustomCharSet::new("Marks", CompositePart::new("Source Sans 3", "Bold"));
a.characters = "※A".into();
let mut b = CustomCharSet::new("Letters", CompositePart::new("Inter", "Italic"));
b.characters = "AB".into();
f.custom = vec![a, b];
let classes = f.classes();
for (c, want) in [
('※', CompositeClass::Custom(0)),
('A', CompositeClass::Custom(1)),
('B', CompositeClass::Custom(1)),
('C', CompositeClass::Fixed(CharClass::Roman)),
('漢', CompositeClass::Fixed(CharClass::Kanji)),
] {
assert_eq!(f.class_of(c), want, "{c:?}");
assert_eq!(classes.of(c), want, "{c:?}");
}
assert_eq!(f.part(CompositeClass::Custom(0)).map(|p| p.style.as_str()), Some("Bold"));
assert_eq!(f.part(CompositeClass::Custom(2)), None);
assert_eq!(f.check(), Ok(()));
// Read and written with the part's fields beside the set's name and characters.
let json = serde_json::to_value(&f).unwrap();
assert_eq!(json["custom"][1]["characters"], "AB");
assert_eq!(json["custom"][1]["style"], "Italic");
assert_eq!(serde_json::from_value::<CompositeFont>(json).unwrap(), f);
// A font without sets is written as before.
assert!(serde_json::to_value(CompositeFont::new("Plain", "Inter", "Regular")).unwrap().get("custom").is_none());
}
/// Set names, characters and limits are checked; sets scale like Roman letters.
#[test]
fn custom_sets_check_their_names_and_characters() {
let ok = CompositeFont::new("Mixed", "Inter", "Regular");
let with = |sets: Vec<(&str, &str)>, edit: fn(&mut CompositePart)| {
let mut f = ok.clone();
for (name, chars) in sets {
let mut s = CustomCharSet::new(name, CompositePart::new("Inter", "Regular"));
s.characters = chars.into();
edit(&mut s.part);
f.custom.push(s);
}
f.check()
};
let none = |_: &mut CompositePart| {};
assert_eq!(with(vec![("Empty", "")], none), Ok(()));
assert_eq!(with(vec![("Wide", "AB")], |p| p.h_scale = 80.0), Ok(()));
assert!(with(vec![("Wide", "AB")], |p| p.scale_at_center = true).is_err());
assert!(with(vec![("Big", "A")], |p| p.size = 401.0).is_err());
assert!(with(vec![("Same", "A"), ("same", "B")], none).unwrap_err().contains("two character sets"));
assert!(with(vec![(" Pad", "A")], none).is_err());
assert!(with(vec![("Twice", "ABA")], none).unwrap_err().contains("U+0041 is listed twice"));
assert!(with(vec![("Tab", "A\t")], none).unwrap_err().contains("U+0009"));
assert!(with(vec![("Astral", "𠀋")], none).unwrap_err().contains("U+2000B"));
let many: String = (0x4E00u32..).filter_map(char::from_u32).take(CUSTOM_CHARS_MAX + 1).collect();
let mut f = ok.clone();
f.custom.push(CustomCharSet { name: "Many".into(), characters: many, part: CompositePart::new("Inter", "Regular") });
assert!(f.check().unwrap_err().contains("at most"));
let mut f = ok.clone();
f.custom = (0..=CUSTOM_SETS_MAX).map(|i| CustomCharSet::new(format!("S{i}"), CompositePart::new("Inter", "Regular"))).collect();
assert!(f.check().is_err());
}
/// Codes are hexadecimal, with or without `U+` or `0x`, inside the Basic Multilingual Plane.
#[test]
fn custom_set_codes_name_characters() {
assert_eq!(CustomCharSet::char_from_code("3042"), Some('あ'));
assert_eq!(CustomCharSet::char_from_code(" U+203B "), Some('※'));
assert_eq!(CustomCharSet::char_from_code("0x41"), Some('A'));
for bad in ["", "U+", "zz", "20000B", "D800", "0009", "1F600", "1234567", "+41"] {
assert_eq!(CustomCharSet::char_from_code(bad), None, "{bad:?}");
}
}
}
+34 -4
View File
@@ -17,22 +17,52 @@ pub enum Justify {
JustifyAll,
}
/// Paragraph composer (Paragraph panel menu): how lines are broken.
/// Paragraph composer (Paragraph panel menu): how lines are broken, and whether complex scripts
/// (Indic, Arabic, Hebrew) are shaped. `SingleLine` and `EveryLine` are the Middle Eastern & South
/// Asian composers: they shape complex scripts with the font's layout tables, as all type is set
/// here, and are what documents saved before the four composers existed use. The `Standard`
/// composers break lines the same way but set a complex script glyph by glyph as the font maps its
/// characters, with no conjuncts, marks placed or joining forms: what fonts that keep a script's
/// glyphs on Latin character codes (legacy ANSI Indic fonts) need.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Composer {
/// Break each line as soon as it is full.
/// Middle Eastern & South Asian Single-line Composer: break each line as soon as it is full.
SingleLine,
/// Total fit over the whole paragraph (Illustrator's default): justified lines get even word
/// spacing, ragged lines an even rag.
/// Middle Eastern & South Asian Every-line Composer: total fit over the whole paragraph
/// (Illustrator's default): justified lines get even word spacing, ragged lines an even rag.
#[default]
EveryLine,
/// Standard Single-line Composer: [`Composer::SingleLine`]'s breaks, complex scripts not shaped.
StandardSingleLine,
/// Standard Every-line Composer: [`Composer::EveryLine`]'s breaks, complex scripts not shaped.
StandardEveryLine,
}
impl Composer {
fn is_default(&self) -> bool {
*self == Self::default()
}
/// Does this composer break lines one at a time (as opposed to a total fit)?
pub fn is_single_line(self) -> bool {
matches!(self, Self::SingleLine | Self::StandardSingleLine)
}
/// Does this composer shape complex scripts ([`Composer::SingleLine`], [`Composer::EveryLine`])?
pub fn shapes_complex_scripts(self) -> bool {
matches!(self, Self::SingleLine | Self::EveryLine)
}
/// The same shaping with the other line breaking (`single_line`: one line at a time).
pub fn with_breaking(self, single_line: bool) -> Self {
match (single_line, self.shapes_complex_scripts()) {
(true, true) => Self::SingleLine,
(true, false) => Self::StandardSingleLine,
(false, true) => Self::EveryLine,
(false, false) => Self::StandardEveryLine,
}
}
}
/// A paragraph's hyphenation options (Paragraph panel menu › Hyphenation…): which words automatic
+7 -7
View File
@@ -210,22 +210,22 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
CLASSIC_EXTRUDE,
"Extrude & Bevel (Classic)…",
&["Effect", "3D and Materials", "3D (Classic)"],
"{depth: pt (0..2000, 50), cap: bool (true: solid; false: hollow), bevel: none|classic|rounded|cove|step|ogee (none), bevelHeight: pt (1..100, 4; the bevel is as wide as it is tall), bevelInside: bool (false: added around the shape; true: carved into it), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), surface: wireframe|none|diffuse|plastic (plastic; wireframe draws the edges of every face, hidden ones included, and fills none; none keeps the paint unlit; diffuse lights the faces softly; plastic adds a glossy highlight), lights: null or array of 1..10 objects (intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, behind: bool; overrides the single-light fields; the lights add up; color and softness are not used), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 100), ambient: % (0..100, 50; Ambient Light, every face whichever way it faces), highlightIntensity: % (0..100, 60; plastic only), highlightSize: % (0..100, 90; 100 broad, 0 a small spot; plastic only), blendSteps: integer (1..256, 25; how many levels the shading and the highlight are rounded to), shadingColor: black|custom|none (black; the color the faces darken toward; none keeps the paint and adds only the highlight), shadingCustom: #rrggbb (#000000; the color shadingColor custom darkens toward), drawHiddenFaces: bool (false; also draws the faces turned away, behind the others)}. The front face is the art, extruded backwards about the center of its bounds; a solid is built for the top fill and for each stroke, each in its own paint. The defaults are VectorCraft's own.",
json!({"depth":50.0,"cap":true,"bevel":"none","bevelHeight":4.0,"bevelInside":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"surface":"plastic","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":100.0,"ambient":50.0,"highlightIntensity":60.0,"highlightSize":90.0,"blendSteps":25,"shadingColor":"black","shadingCustom":"#000000","drawHiddenFaces":false}),
"{depth: pt (0..2000, 50), cap: bool (true: solid; false: hollow), bevel: none|classic|rounded|cove|step|ogee (none), bevelHeight: pt (1..100, 4; the bevel is as wide as it is tall), bevelInside: bool (false: added around the shape; true: carved into it), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), surface: wireframe|none|diffuse|plastic (plastic; wireframe draws the edges of every face, hidden ones included, and fills none; none keeps the paint unlit; diffuse lights the faces softly; plastic adds a glossy highlight), lights: null or array of 1..10 objects (intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, behind: bool; overrides the single-light fields; the lights add up; color and softness are not used), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 100), ambient: % (0..100, 50; Ambient Light, every face whichever way it faces), highlightIntensity: % (0..100, 60; plastic only), highlightSize: % (0..100, 90; 100 broad, 0 a small spot; plastic only), blendSteps: integer (1..256, 25; how many levels the shading and the highlight are rounded to), shadingColor: black|custom|none (black; the color the faces darken toward; none keeps the paint and adds only the highlight), shadingCustom: #rrggbb (#000000; the color shadingColor custom darkens toward), preserveSpotColors: bool (false; a color linked to a spot or global swatch stays a tint of that swatch, lighter where the highlight is, with black multiplied and overprinting where the faces darken, so a spot color keeps its plate; it does nothing while shadingColor is custom), drawHiddenFaces: bool (false; also draws the faces turned away, behind the others)}. The front face is the art, extruded backwards about the center of its bounds; a solid is built for the top fill and for each stroke, each in its own paint. The defaults are VectorCraft's own.",
json!({"depth":50.0,"cap":true,"bevel":"none","bevelHeight":4.0,"bevelInside":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"surface":"plastic","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":100.0,"ambient":50.0,"highlightIntensity":60.0,"highlightSize":90.0,"blendSteps":25,"shadingColor":"black","shadingCustom":"#000000","preserveSpotColors":false,"drawHiddenFaces":false}),
),
g(
CLASSIC_REVOLVE,
"Revolve (Classic)…",
&["Effect", "3D and Materials", "3D (Classic)"],
"{angle: degrees (0..360, 360; how far the profile sweeps round the axis), cap: bool (true: solid, the open ends closed; false: hollow), offset: pt (0..1000, 0; the axis's distance from the profile, a ring past 0), edge: left|right (left; the axis is at the left or the right edge of the profile's bounds), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), surface: wireframe|none|diffuse|plastic (plastic; wireframe draws the edges of every face and fills none; none keeps the paint unlit; diffuse lights the faces softly; plastic adds a glossy highlight), lights: null or array of 1..10 objects (intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, behind: bool; overrides the single-light fields; the lights add up; color and softness are not used), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 100), ambient: % (0..100, 50; Ambient Light, every face whichever way it faces), highlightIntensity: % (0..100, 60; plastic only), highlightSize: % (0..100, 90; 100 broad, 0 a small spot; plastic only), blendSteps: integer (1..256, 25; how many levels the shading and the highlight are rounded to), shadingColor: black|custom|none (black; the color the faces darken toward; none keeps the paint and adds only the highlight), shadingCustom: #rrggbb (#000000; the color shadingColor custom darkens toward)}. The profile sweeps round a vertical axis, as Revolve's does, painted with the top fill (else the top stroke); every face is drawn, back to front. The defaults are VectorCraft's own.",
json!({"angle":360.0,"cap":true,"offset":0.0,"edge":"left","rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"surface":"plastic","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":100.0,"ambient":50.0,"highlightIntensity":60.0,"highlightSize":90.0,"blendSteps":25,"shadingColor":"black","shadingCustom":"#000000"}),
"{angle: degrees (0..360, 360; how far the profile sweeps round the axis), cap: bool (true: solid, the open ends closed; false: hollow), offset: pt (0..1000, 0; the axis's distance from the profile, a ring past 0), edge: left|right (left; the axis is at the left or the right edge of the profile's bounds), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), surface: wireframe|none|diffuse|plastic (plastic; wireframe draws the edges of every face and fills none; none keeps the paint unlit; diffuse lights the faces softly; plastic adds a glossy highlight), lights: null or array of 1..10 objects (intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, behind: bool; overrides the single-light fields; the lights add up; color and softness are not used), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 100), ambient: % (0..100, 50; Ambient Light, every face whichever way it faces), highlightIntensity: % (0..100, 60; plastic only), highlightSize: % (0..100, 90; 100 broad, 0 a small spot; plastic only), blendSteps: integer (1..256, 25; how many levels the shading and the highlight are rounded to), shadingColor: black|custom|none (black; the color the faces darken toward; none keeps the paint and adds only the highlight), shadingCustom: #rrggbb (#000000; the color shadingColor custom darkens toward), preserveSpotColors: bool (false; a color linked to a spot or global swatch stays a tint of that swatch, lighter where the highlight is, with black multiplied and overprinting where the faces darken, so a spot color keeps its plate; it does nothing while shadingColor is custom)}. The profile sweeps round a vertical axis, as Revolve's does, painted with the top fill (else the top stroke); every face is drawn, back to front. The defaults are VectorCraft's own.",
json!({"angle":360.0,"cap":true,"offset":0.0,"edge":"left","rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"surface":"plastic","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":100.0,"ambient":50.0,"highlightIntensity":60.0,"highlightSize":90.0,"blendSteps":25,"shadingColor":"black","shadingCustom":"#000000","preserveSpotColors":false}),
),
g(
CLASSIC_ROTATE,
"Rotate (Classic)…",
&["Effect", "3D and Materials", "3D (Classic)"],
"{rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), surface: none|diffuse (diffuse; none keeps the paint unlit; diffuse lights the art softly), lights: null or array of 1..10 objects (intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, behind: bool; overrides the single-light fields; the lights add up; color and softness are not used), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 100), ambient: % (0..100, 50; Ambient Light, the art whichever way it faces), blendSteps: integer (1..256, 25; how many levels the shading is rounded to), shadingColor: black|custom|none (black; the color the art darkens toward; none keeps the paint), shadingCustom: #rrggbb (#000000; the color shadingColor custom darkens toward)}. Every fill and stroke turns as one flat plane about the center of the object's bounds, as Rotate's do, each in its own paint, and is seen from either side. The defaults are VectorCraft's own.",
json!({"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"surface":"diffuse","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":100.0,"ambient":50.0,"blendSteps":25,"shadingColor":"black","shadingCustom":"#000000"}),
"{rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), surface: none|diffuse (diffuse; none keeps the paint unlit; diffuse lights the art softly), lights: null or array of 1..10 objects (intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, behind: bool; overrides the single-light fields; the lights add up; color and softness are not used), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 100), ambient: % (0..100, 50; Ambient Light, the art whichever way it faces), blendSteps: integer (1..256, 25; how many levels the shading is rounded to), shadingColor: black|custom|none (black; the color the art darkens toward; none keeps the paint), shadingCustom: #rrggbb (#000000; the color shadingColor custom darkens toward), preserveSpotColors: bool (false; a color linked to a spot or global swatch stays a tint of that swatch, with black multiplied and overprinting where the art darkens, so a spot color keeps its plate; it does nothing while shadingColor is custom)}. Every fill and stroke turns as one flat plane about the center of the object's bounds, as Rotate's do, each in its own paint, and is seen from either side. The defaults are VectorCraft's own.",
json!({"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"surface":"diffuse","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":100.0,"ambient":50.0,"blendSteps":25,"shadingColor":"black","shadingCustom":"#000000","preserveSpotColors":false}),
),
g(
"convertToShape.rectangle",
@@ -859,7 +859,7 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
crate::SVG_FILTER,
"SVG Filter",
&["Effect", "SVG Filters"],
"{name: string (the filter's id), xml: string (one SVG <filter> element with an id and 1..64 primitives, at most 64 KB), scale: number 0.001..1000 (1; points per unit of the filter's user space), origin: [x, y] pt ([0, 0]; where the filter's user space starts in the document)} runs the SVG filter on the object's pixels, its lengths in points times scale, at the Document Raster Effects resolution or finer; svgFilter.apply applies one of the document's filters (svgFilter.list)",
"{name: string (the filter's id), xml: string (one SVG <filter> element with an id and 1..64 primitives, at most 64 KB), scale: number 0.001..1000 (1; points per unit of the filter's user space), origin: [x, y] pt ([0, 0]; where the filter's user space starts in the document), axes?: [a, b, c, d] (absent or [1, 0, 0, 1]: the document's axes, and not null; the document directions of one unit along the filter's x axis [a, b] and y axis [c, d] before scale, each -1000..1000, with a·d - b·c at least 0.001 from 0; a turned, skewed or unevenly scaled user space, as SVG import gives a filtered element under a rotate, skew or uneven scale; the filter's offsets, blurs and region run along these axes; moving, turning or skewing the object moves origin and leaves axes)} runs the SVG filter on the object's pixels, its lengths in points times scale, at the Document Raster Effects resolution or finer; svgFilter.apply applies one of the document's filters (svgFilter.list)",
json!({"scale": 1.0, "origin": [0.0, 0.0]}),
));
v
+30 -45
View File
@@ -742,45 +742,28 @@ fn item_art(id: NodeId, item: &AppearanceItem, shapes: &[(PathData, FillRule)],
}
/// The members of a container that [`take_held_guides`] visits, by index, each with the same for
/// its own members. A ruler guide's entry is empty; a container's is not.
/// its own members. A ruler guide's entry is empty, whether its container is a layer or a group; a
/// container's is not.
#[derive(Default)]
struct HeldGuides(Vec<(usize, HeldGuides)>);
/// The members of `n` holding or leading to the ruler guides of sublayers, found by looking at each
/// member at every depth once: the ruler guides (`is_guide`) of `n` when it is a layer, each
/// sublayer holding one directly or in its sublayers, and each member of another kind with such a
/// sublayer among its members or its groups' members. With them, whether `n` is a layer holding a
/// ruler guide directly or in its sublayers. The other members of a layer holding none are not
/// looked into, unless the layer is the one whose sublayers are gathered (`top`).
fn held_guides(n: &Node, top: bool, is_guide: &impl Fn(&Node) -> bool) -> (bool, HeldGuides) {
let layer = n.is_layer();
let (mut holds, mut held, mut other) = (false, vec![], vec![]);
/// The members of `n` that are ruler guides (`is_guide`), and those holding one at any depth, in
/// order. Every member at every depth is looked at once: a sublayer or group with no guide of its
/// own is still looked into, and a guide directly in a group counts as one in a layer does. A
/// member holding no guide gets no entry.
fn held_guides(n: &Node, is_guide: &impl Fn(&Node) -> bool) -> HeldGuides {
let mut held = vec![];
for (i, c) in n.children().into_iter().flatten().enumerate() {
if is_guide(c) {
if layer {
holds = true;
held.push((i, HeldGuides::default()));
}
} else if c.is_layer() {
let (sub_holds, inner) = held_guides(c, false, is_guide);
if sub_holds {
holds = true;
held.push((i, inner));
}
} else {
other.push((i, c));
held.push((i, HeldGuides::default()));
continue;
}
let inner = held_guides(c, is_guide);
if !inner.0.is_empty() {
held.push((i, inner));
}
}
if !layer || holds || top {
for (i, c) in other {
let (_, inner) = held_guides(c, false, is_guide);
if !inner.0.is_empty() {
held.push((i, inner));
}
}
held.sort_unstable_by_key(|(i, _)| *i);
}
(layer && holds, HeldGuides(held))
HeldGuides(held)
}
/// Move the ruler guides of `n` and of its members that [`held_guides`] found (`held`) to `out`:
@@ -818,15 +801,14 @@ fn take_held_guides(n: &mut Node, held: HeldGuides, out: &mut Vec<Arc<Node>>, hi
out.append(&mut deeper);
}
/// Move the ruler guides of the sublayers among the members of `n`, and among its groups'
/// members, to `out`, in order: a sublayer's own guides, then those of its members. A guide taken
/// from a hidden or locked sublayer, or from one inside such a sublayer or group, is hidden or
/// locked itself. Each member at every depth is looked at once at most (`is_guide`), and only
/// containers holding one are copied.
/// Move the ruler guides of `n` and those held at any depth by the sublayers and groups among its
/// members to `out`, in order: a container's own guides, then those of its members. A sublayer or
/// group with no guide of its own still gives up those of its members. A guide taken from a hidden
/// or locked sublayer or group, or from one inside such a container, is hidden or locked itself.
/// Each member at every depth is looked at once (`is_guide`), and only containers holding a guide
/// are copied.
fn take_sublayer_guides(n: &mut Node, out: &mut Vec<Arc<Node>>, is_guide: &impl Fn(&Node) -> bool) {
let (_, mut held) = held_guides(n, true, is_guide);
// The ruler guides of `n` itself stay where they are.
held.0.retain(|(_, inner)| !inner.0.is_empty());
let held = held_guides(n, is_guide);
take_held_guides(n, held, out, false, false);
}
@@ -850,12 +832,15 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option<Node> {
return None;
}
let mut m = n.clone();
// A layer's ruler guides take no part: they go back above the evaluated art at the end.
let mut guides = m.children_mut().map(|ch| ch.split_off(Node::guide_run_start(ch))).unwrap_or_default();
// So do its sublayers' guides, which would otherwise go with the art the effects replace or
// copy: they stay on this layer, hidden and locked as their sublayers were.
if m.is_layer() && (pathfinder || !fx.is_empty()) {
// Its ruler guides take no part: they go back above the evaluated art at the end. With effects
// that replace or copy the art, so do its guides among the art and those in its sublayers and
// groups, which would otherwise go with that art: they stay on `n`, in order, hidden and
// locked as their containers were.
let mut guides = vec![];
if pathfinder || !fx.is_empty() {
take_sublayer_guides(&mut m, &mut guides, &Node::is_ruler_guide);
} else if let Some(ch) = m.children_mut() {
guides = ch.split_off(Node::guide_run_start(ch));
}
if pathfinder {
let hook = |c: &Node| crate::outline_object(doc, c);
+142 -7
View File
@@ -1808,7 +1808,7 @@ fn a_sublayers_ruler_guide_in_a_hidden_or_locked_group_stays_hidden_or_locked()
}
/// Gathering the ruler guides of nested sublayers, and of a sublayer inside nested groups, looks
/// at each member at every depth once at most, and takes the same guides, in the same order and
/// at each member at every depth once, and takes the same guides, in the same order and
/// state, as a layer's effects keep.
#[test]
fn gathering_sublayer_ruler_guides_looks_at_each_member_once() {
@@ -1840,7 +1840,7 @@ fn gathering_sublayer_ruler_guides_looks_at_each_member_once() {
groups.locked = depth == 3;
}
let free = Arc::new(Node::group(NodeId(500), vec![sq(501, 0.0, Color::BLACK), sq(502, 5.0, Color::BLACK)]));
// A sublayer with no guide: the art in its group is not looked at.
// A sublayer with no guide, its group holding none either.
let quiet = Node::group(NodeId(601), vec![sq(602, 0.0, Color::BLACK), sq(603, 5.0, Color::BLACK)]);
let quiet = Arc::new(layer(600, vec![Arc::new(quiet)]));
let mut root = layer(10, vec![sq(1, 0.0, Color::rgb(1.0, 0.0, 0.0)), Arc::new(chain), Arc::new(groups), free.clone(), quiet.clone(), guide(20)]);
@@ -1858,15 +1858,16 @@ fn gathering_sublayer_ruler_guides_looks_at_each_member_once() {
};
let mut taken = vec![];
take_sublayer_guides(&mut m, &mut taken, &is_guide);
assert_eq!(looked.get(), members(&root) - 2, "each member is looked at once, none in the quiet sublayer's group");
assert_eq!(state(&taken), expected, "a sublayer's own guides, then its sublayers', each once");
assert!(Arc::ptr_eq(&taken[0], &first_guide), "a guide with nothing to change is not copied");
assert!(!Arc::ptr_eq(&taken[1], &second_guide), "a guide made hidden is copied");
assert_eq!(looked.get(), members(&root), "each member is looked at once, those of guide-free containers included");
assert_eq!(taken[0].id, NodeId(20), "the layer's own guide comes first");
assert_eq!(state(&taken[1..]), expected, "a sublayer's own guides, then its sublayers', each once");
assert!(Arc::ptr_eq(&taken[1], &first_guide), "a guide with nothing to change is not copied");
assert!(!Arc::ptr_eq(&taken[2], &second_guide), "a guide made hidden is copied");
assert!(second_guide.visible, "the guide as it was stays as it was");
let ch = m.children().unwrap();
assert_eq!(ch.len(), 5, "the layer's own guide is taken");
assert!(Arc::ptr_eq(&ch[3], &free), "a member with no sublayer guide is not copied");
assert!(Arc::ptr_eq(&ch[4], &quiet), "a sublayer with no guide is not copied");
assert!(ch[5].is_ruler_guide(), "the layer's own guide stays where it is");
let ids = |n: &Node| n.children().into_iter().flatten().map(|c| c.id).collect::<Vec<_>>();
assert_eq!(ids(&ch[1]), [NodeId(102), NodeId(301)], "the sublayer's art stays, in order");
assert_eq!(ids(&root.children().unwrap()[1]), [NodeId(102), NodeId(301), NodeId(201)], "the source keeps its guides");
@@ -1892,6 +1893,140 @@ fn gathering_sublayer_ruler_guides_looks_at_each_member_once() {
}
}
/// Where each ruler guide in `n` is, at every depth, with its position, visibility and lock.
fn ruler_guides_in(n: &Node, out: &mut Vec<(NodeId, NodeKind, bool, bool)>) {
for c in n.children().into_iter().flatten() {
if c.is_ruler_guide() {
out.push((c.id, c.kind.clone(), c.visible, c.locked));
}
ruler_guides_in(c, out);
}
}
/// A ruler guide in a layer inside a group inside a sublayer that holds no guide of its own, at
/// any depth of that pattern, survives its layer's Pathfinder effect or Transform copies once, last
/// in the layer, hidden or locked when any container on its way was.
#[test]
fn a_ruler_guide_below_a_guide_free_sublayer_and_a_group_survives_its_layers_effects() {
use vectorcraft_doc::LayerColor;
let layer = |id, children: Vec<Arc<Node>>, (hidden, locked): (bool, bool)| {
let mut l = Node::layer(NodeId(id), "Sub", LayerColor::Preset(1));
if let Some(ch) = l.children_mut() {
*ch = children;
}
(l.visible, l.locked) = (!hidden, locked);
Arc::new(l)
};
let group = |id, children: Vec<Arc<Node>>, (hidden, locked): (bool, bool)| {
let mut g = Node::group(NodeId(id), children);
(g.visible, g.locked) = (!hidden, locked);
Arc::new(g)
};
let kind = NodeKind::RulerGuide { vertical: false, pos: 7.0, artboard: None };
let states = [(false, false), (true, false), (false, true)];
for (effect, params) in [("pathfinder.add", serde_json::json!({})), ("distort.transform", serde_json::json!({"moveH": 30, "copies": 2}))] {
for depth in 1..=3_u64 {
for sub in states {
for grp in states {
for inner in states {
// Layer 10 → sublayer → group → … → layer holding the guide, the pattern
// repeated `depth` times; only the innermost layer holds a guide.
let mut n = layer(900, vec![sq(901, 0.0, Color::BLACK), Arc::new(Node::new(NodeId(20), kind.clone()))], inner);
for d in (0..depth).rev() {
let (s, g) = if d == 0 { (sub, grp) } else { ((false, false), (false, false)) };
n = group(700 + d, vec![sq(710 + d, 5.0, Color::BLACK), n], g);
n = layer(800 + d, vec![n, sq(810 + d, 2.0, Color::BLACK)], s);
}
let mut root = Node::layer(NodeId(10), "Layer 1", LayerColor::Preset(0));
if let Some(ch) = root.children_mut() {
*ch = vec![sq(1, 0.0, Color::rgb(1.0, 0.0, 0.0)), n];
}
root.appearance.effects.push(Effect { id: effect.into(), params: params.clone(), visible: true });
let art = evaluate_container(None, &root).unwrap();
let (hidden, locked) = (sub.0 || grp.0 || inner.0, sub.1 || grp.1 || inner.1);
let mut all = vec![];
ruler_guides_in(&art, &mut all);
let case = format!("{effect}, depth {depth}, sublayer {sub:?}, group {grp:?}, layer {inner:?}");
assert_eq!(all, [(NodeId(20), kind.clone(), !hidden, locked)], "{case}: the guide, once, unchanged");
let last = art.children().and_then(|c| c.last()).unwrap();
assert_eq!(last.id, NodeId(20), "{case}: last in the layer");
}
}
}
}
}
}
/// A ruler guide directly in a group, in a layer or in a group with a Pathfinder effect or
/// Transform copies, survives that effect once, last in the container, hidden or locked as its
/// groups were. So does one among a group's art rather than after it.
#[test]
fn a_ruler_guide_in_a_group_survives_its_containers_effects() {
use vectorcraft_doc::LayerColor;
let kind = NodeKind::RulerGuide { vertical: true, pos: 4.0, artboard: None };
let guide = || Arc::new(Node::new(NodeId(20), kind.clone()));
for (effect, params) in [("pathfinder.add", serde_json::json!({})), ("distort.transform", serde_json::json!({"moveH": 30, "copies": 2}))] {
for (hidden, locked) in [(false, false), (true, false), (false, true)] {
let mut inner = Node::group(NodeId(12), vec![sq(2, 5.0, Color::BLACK), guide(), sq(3, 8.0, Color::BLACK)]);
(inner.visible, inner.locked) = (!hidden, locked);
let outer = Arc::new(Node::group(NodeId(11), vec![Arc::new(inner)]));
let fx = Effect { id: effect.into(), params: params.clone(), visible: true };
let mut l = Node::layer(NodeId(10), "Layer 1", LayerColor::Preset(0));
if let Some(ch) = l.children_mut() {
*ch = vec![sq(1, 0.0, Color::rgb(1.0, 0.0, 0.0)), outer.clone()];
}
l.appearance.effects.push(fx.clone());
let mut g = Node::group(NodeId(13), vec![sq(1, 0.0, Color::rgb(1.0, 0.0, 0.0)), outer]);
g.appearance.effects.push(fx.clone());
for (what, n) in [("layer", l), ("group", g)] {
let art = evaluate_container(None, &n).unwrap();
let mut all = vec![];
ruler_guides_in(&art, &mut all);
let case = format!("{effect} on a {what}, hidden {hidden}, locked {locked}");
assert_eq!(all, [(NodeId(20), kind.clone(), !hidden, locked)], "{case}: the guide, once, unchanged");
assert_eq!(art.children().and_then(|c| c.last()).map(|c| c.id), Some(NodeId(20)), "{case}: last");
}
}
// A group's guide placed before its art.
let mut g = Node::group(NodeId(13), vec![guide(), sq(1, 0.0, Color::BLACK), sq(2, 5.0, Color::BLACK)]);
g.appearance.effects.push(Effect { id: effect.into(), params: params.clone(), visible: true });
let art = evaluate_container(None, &g).unwrap();
let mut all = vec![];
ruler_guides_in(&art, &mut all);
assert_eq!(all, [(NodeId(20), kind.clone(), true, false)], "{effect}: a group's leading guide, once");
assert_eq!(art.children().and_then(|c| c.last()).map(|c| c.id), Some(NodeId(20)), "{effect}: moved last");
}
}
/// The ruler guides of a layer or group with a Pathfinder effect or Transform copies, one among
/// its art and one after it, go after the evaluated art in the order they were in.
#[test]
fn a_containers_ruler_guides_keep_their_order_through_its_effects() {
use vectorcraft_doc::LayerColor;
let guide = |id| Arc::new(Node::new(NodeId(id), NodeKind::RulerGuide { vertical: false, pos: 3.0, artboard: None }));
for (effect, params) in [("pathfinder.add", serde_json::json!({})), ("distort.transform", serde_json::json!({"moveH": 30, "copies": 2}))] {
let members = || vec![sq(1, 0.0, Color::BLACK), guide(20), sq(2, 5.0, Color::BLACK), guide(21)];
let fx = Effect { id: effect.into(), params: params.clone(), visible: true };
let mut l = Node::layer(NodeId(10), "Layer 1", LayerColor::Preset(0));
if let Some(ch) = l.children_mut() {
*ch = members();
}
l.appearance.effects.push(fx.clone());
let mut g = Node::group(NodeId(11), members());
g.appearance.effects.push(fx);
for (what, n) in [("layer", l), ("group", g)] {
let art = evaluate_container(None, &n).unwrap();
let mut all = vec![];
ruler_guides_in(&art, &mut all);
let ids = all.iter().map(|(id, ..)| *id).collect::<Vec<_>>();
assert_eq!(ids, [NodeId(20), NodeId(21)], "{effect} on a {what}: the guides, in order");
let ch = art.children().unwrap();
let tail = ch[ch.len() - 2..].iter().map(|c| c.id).collect::<Vec<_>>();
assert_eq!(tail, [NodeId(20), NodeId(21)], "{effect} on a {what}: last, in order");
}
}
}
/// A group's own Fill + Offset Path below its contents follows the outline of a clip group among
/// its members, its clipping path, not the clipped art it hides outside that path (#7289).
#[test]
+4 -1
View File
@@ -81,7 +81,10 @@ pub use lighting_presets::{LIGHTING_PRESETS, LightingPreset, lighting_preset_par
pub use live::{expand_live, expand_live_deep, expanded_live_group, outline_object, text_outliner};
pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks};
pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace};
pub use raster::{RASTERIZE, RasterFx, Rasterize, color_param, follow_items, item_effects_in, outset, raster_effects, without_geometry};
pub use raster::{
RASTERIZE, RasterFx, Rasterize, color_param, follow_items, item_effects_in, outset, raster_effects, rasterize_stage, stage_map, staged_effects,
without_geometry,
};
pub use reshape::{
SYMBOL_ART_MAX_NODES, expand_outlined, follows_image_pixels, gives_image_shape, has_gradient_copies, has_instance_row_raster, has_item_geometry,
has_painted_effects, has_transform_art, has_transform_copies, has_transform_options, image_paint, needs_outline, outer_effects, outline_art,
+54 -1
View File
@@ -606,9 +606,62 @@ fn transform_step(e: &Effect) -> Option<Affine> {
(copies == 0 && !flag(&p, "random", false)).then(|| map.with_translation(Vec2::ZERO))
}
/// The Transform effects of `effects` that move the image of its last visible Rasterize effect
/// instead of the art it is made from: the indices of the visible Transform effects listed after
/// it that move the art, when from the first visible Transform effect after it on every visible
/// effect is a Transform effect that moves the art once (no copies, not at random). Effects apply
/// in the order they are listed, so the image is made of the art before those Transform effects
/// and moves with them, its pixel grid and room around the art included. `None` when there is no
/// such Transform effect, or the effects after the Rasterize effect hold anything else from there
/// on.
pub fn rasterize_stage(effects: &[Effect]) -> Option<Vec<usize>> {
let r = effects.iter().rposition(|e| e.visible && e.id == RASTERIZE)?;
let first = r + 1 + effects.get(r + 1..)?.iter().position(|e| e.visible && e.id == "distort.transform")?;
let mut at = vec![];
for (i, e) in effects.iter().enumerate().skip(first).filter(|(_, e)| e.visible) {
if e.id != "distort.transform" || transform_step(e).is_none() {
return None;
}
if crate::distort::transform_map(Rect::ZERO, &merged_params(&e.id, &e.params), 0).0 != Affine::IDENTITY {
at.push(i);
}
}
(!at.is_empty()).then_some(at)
}
/// `effects` with the Transform effects at `at` hidden ([`rasterize_stage`]): what the image of
/// the Rasterize effect before them is made from.
pub fn staged_effects(effects: &[Effect], at: &[usize]) -> Vec<Effect> {
let mut out = effects.to_vec();
for &i in at {
if let Some(e) = out.get_mut(i) {
e.visible = false;
}
}
out
}
/// The map the Transform effects at `at` of `effects` ([`rasterize_stage`]) move the Rasterize
/// image by, for `staged`, the geometry the effects before them make: each one's map about its
/// reference point on the bounds of the geometry it takes, as the geometry pass applies it, the
/// last one outermost. `None` when `staged` is empty or the map is not a number.
pub fn stage_map(effects: &[Effect], at: &[usize], staged: &vectorcraft_geom::PathData) -> Option<Affine> {
let mut cur = staged.clone();
let mut b = staged.bounds()?;
let mut map = Affine::IDENTITY;
for &i in at {
let e = effects.get(i)?;
let (m, _) = crate::distort::transform_map(b, &merged_params(&e.id, &e.params), 0);
map = m * map;
cur = cur.transformed(m);
b = cur.bounds().unwrap_or(b);
}
map.is_finite().then_some(map)
}
/// Does raster effect `id` follow the Transform effects listed after it ([`followed`])? An SVG
/// filter does not, and neither does Rasterize, whose image keeps its own pixel grid and room
/// around the art.
/// around the art: the image moves with those Transform effects instead ([`rasterize_stage`]).
pub(crate) fn follows(id: &str) -> bool {
crate::is_raster(id) && id != crate::SVG_FILTER && id != RASTERIZE
}
+56 -7
View File
@@ -171,7 +171,7 @@ pub(crate) fn validate_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value)
}
}
}
let casting = shapes.iter().filter(|s| surface_item(&s.items).is_some_and(|i| paint_of(i).1 * s.opacity > 0.0));
let casting = shapes.iter().filter(|s| surface_item(&s.items).is_some_and(|i| crate::solids::shadow::casts(paint_of(i).1 * s.opacity)));
return check_shadow(p, casting.map(|s| &s.path), b, options);
}
let (path, _) = object_profile(n, "Revolve")?;
@@ -350,8 +350,8 @@ fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData {
/// The shadow of Revolve `fx` cast by the surface of `path` in `item`'s paint about the axis of
/// `b`, revolved with `options`, as dark as `item` is opaque and in its blending mode; `None` with
/// the shadow off, when `item` is fully transparent or when it casts nothing, and an error when
/// the surface it is cast from is past the limits.
/// the shadow off, when `item` is fully transparent or its opacity is not a finite number, or when
/// it casts nothing, and an error when the surface it is cast from is past the limits.
///
/// `kept` holds the faces the surface drawn kept for its shadow ([`surface`]); without them the
/// surface is swept again for its shadow alone.
@@ -366,7 +366,7 @@ fn shadow_of(
) -> Result<Option<Node>, String> {
let Some(settings) = crate::solids::shadow::options(&merged_params(&fx.id, &fx.params)) else { return Ok(None) };
let (_, opacity, blend) = paint_of(item);
if opacity <= 0.0 {
if !crate::solids::shadow::casts(opacity) {
return Ok(None);
}
let mut caster = vectorcraft_three_d::ShadowCaster::new();
@@ -377,7 +377,7 @@ fn shadow_of(
/// Does Revolve `fx` cast a shadow, so a surface in paint as opaque as `opacity` keeps the faces
/// its shadow is cast from ([`surface`])?
fn casts(fx: &Effect, opacity: f32) -> bool {
opacity > 0.0 && crate::solids::shadow::options(&merged_params(&fx.id, &fx.params)).is_some()
crate::solids::shadow::casts(opacity) && crate::solids::shadow::options(&merged_params(&fx.id, &fx.params)).is_some()
}
/// Add the surface of `path` about the axis of `b` to `caster`: the faces it kept when it was
@@ -420,9 +420,9 @@ fn group_shadow(
for s in shapes {
let kept = kept.next().flatten();
let Some((_, opacity, blend)) = surface_item(&s.items).map(paint_of) else { continue };
// A member whose surface is fully transparent casts nothing.
// A member whose surface is fully transparent, or whose opacity is not a finite number, casts nothing.
let ink = crate::solids::shadow::Ink { opacity: opacity * s.opacity, blend };
if ink.opacity <= 0.0 {
if !ink.casts() {
continue;
}
let faces = cast_surface(&mut caster, &s.path, b, options, kept)?;
@@ -1861,6 +1861,55 @@ mod tests {
}
}
}
/// A surface whose fill, or whose member, is at NaN or infinite opacity casts no shadow, as one
/// at 0 % does, whether the shadow is cast from the surface drawn or swept for it alone; the
/// member beside it still casts its part.
#[test]
fn a_surface_whose_opacity_is_not_a_finite_number_casts_no_shadow() {
let fx = crate::new_effect(REVOLVE, &json!({"shadow": true})).unwrap();
let group = |opacity: f32| {
let mut other = profile_node();
other.id = NodeId(2);
other.transform(Affine::translate((120.0, 0.0)), false);
other.opacity = opacity;
let mut g = Node::group(NodeId(9), vec![Arc::new(profile_node()), Arc::new(other)]);
g.appearance.effects.push(fx.clone());
g
};
let group_shadows = |g: &Node| {
let shapes = members(g, "").unwrap();
let b = union_bounds(&shapes).unwrap();
let options = shared_options(&shapes, fx_options(&fx)).unwrap();
let kept: Vec<Option<SolidFaces>> = shapes
.iter()
.map(|s| {
let (paint, opacity, blend) = paint_of(surface_item(&s.items).unwrap());
let cast = casts(&fx, opacity * s.opacity);
surface(None, g, &s.path, b, options, &fx, paint, opacity, blend, false, &mut Copies::default(), cast).unwrap().2
})
.collect();
[group_shadow(g.id, &fx, &shapes, b, options, kept).unwrap(), group_shadow(g.id, &fx, &shapes, b, options, vec![]).unwrap()]
.map(|art| format!("{art:?}"))
};
let expected = group_shadows(&group(0.0));
for bad in [f32::NAN, f32::INFINITY] {
let mut n = profile_node();
if let AppearanceItem::Fill(f) = &mut n.appearance.items[0] {
f.opacity = bad;
}
let (path, _) = profile(&n).unwrap();
let b = path.bounds().unwrap();
let item = surface_item(&n.appearance.items).unwrap();
assert!(!casts(&fx, bad), "{bad}: the surface keeps no faces for a shadow");
let (_, kept) = object_surface(None, &n, &path, &fx, item, false, &mut Copies::default()).unwrap();
assert_eq!(shadow_of(n.id, &fx, &path, item, b, fx_options(&fx), kept).unwrap(), None, "{bad} drawn");
assert_eq!(shadow_of(n.id, &fx, &path, item, b, fx_options(&fx), None).unwrap(), None, "{bad} alone");
let shadows = group_shadows(&group(bad));
assert!(!expected[0].is_empty() && expected[0] != "[]", "the opaque member casts");
assert_eq!(shadows, expected, "{bad}: only the opaque member casts");
}
}
}
#[cfg(test)]
+48 -1
View File
@@ -23,7 +23,7 @@ const RANGES: [(&str, f64, f64, bool); 13] = [
("blendSteps", 1.0, 256.0, true),
];
const FLAGS: [&str; 2] = ["cap", "lightBehind"];
const FLAGS: [&str; 3] = ["cap", "lightBehind", "preserveSpotColors"];
/// The surface Revolve (Classic)'s parameters `p` sweep: Revolve's angle, cap, offset, edge,
/// rotation and perspective, built unshaded, since the classic surfaces light its faces.
@@ -118,6 +118,52 @@ mod tests {
}
}
/// With Preserve Spot Colors, a fill linked to a spot swatch stays linked on every face: the
/// highlight lowers its tint, and the shadow is black multiplied and overprinting over it.
#[test]
fn preserve_spot_colors_keeps_the_swatch_on_every_face() {
let ink = Color::cmyk(0.0, 1.0, 0.0, 0.2);
let spot = |params: Value| {
let mut n = profile();
*n.appearance = Appearance::basic(Paint::Solid { color: ink, swatch: Some("Ink".into()), tint: 0.5 }, Paint::None, 0.0);
n.appearance.effects.push(crate::new_effect(CLASSIC_REVOLVE, &params).unwrap());
crate::revolve_art(&n).unwrap()
};
let art = spot(json!({"preserveSpotColors": true}));
let mut darkened = 0;
for face in faces(&art) {
let mut fills = face.appearance.items.iter().filter_map(|i| match i {
AppearanceItem::Fill(f) => Some(f),
AppearanceItem::Stroke(_) => None,
});
match fills.next().map(|f| &f.paint) {
Some(Paint::Solid { color, swatch: Some(name), tint }) => {
assert_eq!(name, "Ink");
assert!(*tint > 0.0 && *tint <= 0.5, "{tint}");
assert_eq!(*color, ink.tinted(*tint / 0.5), "the color is the swatch at the face's tint");
}
other => panic!("the face keeps its swatch: {other:?}"),
}
if let Some(shadow) = fills.next() {
assert!(matches!(shadow.paint.color(), Some(Color::Gray { .. })), "{:?}", shadow.paint);
assert_eq!((shadow.blend, shadow.overprint), (vectorcraft_doc::color::BlendMode::Multiply, true));
darkened += 1;
}
}
assert!(darkened > 0, "faces turned from the light are darkened");
// Off, and with a custom shading color, the faces are process mixes again.
for params in [json!({}), json!({"preserveSpotColors": true, "shadingColor": "custom", "shadingCustom": "#336699"})] {
let art = spot(params.clone());
let linked = faces(&art)
.iter()
.filter(|f| {
f.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Fill(f) if matches!(f.paint, Paint::Solid { swatch: Some(_), .. })))
})
.count();
assert!(linked < faces(&art).len(), "{params}: shaded faces lose the swatch");
}
}
#[test]
fn angle_offset_edge_and_cap_shape_the_surface() {
let full = revolved(json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0})).visual_bounds().unwrap();
@@ -207,6 +253,7 @@ mod tests {
(json!({"rotationY": -181}), "`rotationY` must be between -180 and 180"),
(json!({"blendSteps": 2.5}), "`blendSteps` must be a whole number"),
(json!({"lightBehind": 1}), "`lightBehind` must be true or false"),
(json!({"preserveSpotColors": "on"}), "`preserveSpotColors` must be true or false"),
(json!({"surface": "glossy"}), "`surface` must be one of"),
(json!({"shadingColor": "white"}), "`shadingColor` must be one of"),
(json!({"shadingCustom": "red"}), "`shadingCustom` must be a #rrggbb color"),
+10 -10
View File
@@ -1474,8 +1474,8 @@ fn cast_shadow<'a>(id: &str, p: &Value, shapes: &'a [Profile]) -> Result<Option<
let view = view_options(p);
let mut caster = vectorcraft_three_d::ShadowCaster::new();
// The faces each solid of visible art added, with its member's scopes and its ink (opacity
// and blending mode), in order. A fully transparent fill or stroke (or member) casts nothing
// and does not place the plane.
// and blending mode), in order. A fill or stroke (or member) that is fully transparent, or whose
// opacity is not a finite number, casts nothing and does not place the plane ([`shadow::Ink::casts`]).
let mut cast: Vec<shadow::Cast> = vec![];
let ink = |item: &AppearanceItem, member: f32| {
let (_, opacity, blend) = paint_of(item, member);
@@ -1483,7 +1483,7 @@ fn cast_shadow<'a>(id: &str, p: &Value, shapes: &'a [Profile]) -> Result<Option<
};
if id == ROTATE {
for s in shapes {
for item in s.items.iter().filter(|i| ink(i, s.opacity).opacity > 0.0) {
for item in s.items.iter().filter(|i| ink(i, s.opacity).casts()) {
let (area, rule) = region(&s.path, s.rule, item);
#[cfg(test)]
SOLIDS_BUILT.with(|n| n.set(n.get() + 1));
@@ -1497,7 +1497,7 @@ fn cast_shadow<'a>(id: &str, p: &Value, shapes: &'a [Profile]) -> Result<Option<
// Inflate's eye is placed for every shape together, as the solids are.
let added: Vec<Result<std::ops::Range<usize>, String>> = if id == INFLATE {
let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule, _, _)| (area, *rule)).collect();
let casts: Vec<bool> = inks.iter().map(|i| i.opacity > 0.0).collect();
let casts: Vec<bool> = inks.iter().map(|i| i.casts()).collect();
#[cfg(test)]
SOLIDS_BUILT.with(|n| n.set(n.get() + shapes.len()));
caster.inflate(&shapes, &casts, bounds, inflate_options(p), view)
@@ -1505,13 +1505,13 @@ fn cast_shadow<'a>(id: &str, p: &Value, shapes: &'a [Profile]) -> Result<Option<
let mut added = vec![];
for ((_, _, area, rule, _, _), ink) in parts.iter().zip(&inks) {
#[cfg(test)]
SOLIDS_BUILT.with(|n| n.set(n.get() + usize::from(ink.opacity > 0.0)));
added.push(if ink.opacity > 0.0 { caster.extrude(area, *rule, bounds, extrude_options(p), view) } else { Ok(0..0) });
SOLIDS_BUILT.with(|n| n.set(n.get() + usize::from(ink.casts())));
added.push(if ink.casts() { caster.extrude(area, *rule, bounds, extrude_options(p), view) } else { Ok(0..0) });
}
added
};
for (((item, _, _, _, scopes, _), ink), faces) in parts.iter().zip(inks).zip(added) {
if ink.opacity <= 0.0 {
if !ink.casts() {
continue;
}
// A stroke whose own solid is past the limits is left out; past the shadow's limit for
@@ -1552,7 +1552,7 @@ fn cast_built<'a>(id: &str, p: &Value, shapes: &'a [Profile], built: Vec<Option<
}
for ((s, item), kept) in items.into_iter().zip(built) {
let ink = ink(item, s.opacity);
if ink.opacity <= 0.0 {
if !ink.casts() {
continue;
}
let faces = match kept {
@@ -1570,11 +1570,11 @@ fn cast_built<'a>(id: &str, p: &Value, shapes: &'a [Profile], built: Vec<Option<
}
let (parts, bounds) = solid_parts(shapes, b);
let inks: Vec<shadow::Ink> = parts.iter().map(|(item, member, ..)| ink(item, *member)).collect();
if parts.len() != built.len() || (id == INFLATE && inks.iter().zip(&built).any(|(ink, kept)| ink.opacity > 0.0 && kept.is_none())) {
if parts.len() != built.len() || (id == INFLATE && inks.iter().zip(&built).any(|(ink, kept)| ink.casts() && kept.is_none())) {
return cast_shadow(id, p, shapes);
}
for (((item, _, area, rule, scopes, _), ink), kept) in parts.iter().zip(inks).zip(built) {
if ink.opacity <= 0.0 {
if !ink.casts() {
continue;
}
let faces = match kept {
+38 -3
View File
@@ -102,7 +102,7 @@ const RANGES: [(&str, f64, f64, bool); 13] = [
("blendSteps", 1.0, 256.0, true),
];
const FLAGS: [&str; 4] = ["cap", "bevelInside", "lightBehind", "drawHiddenFaces"];
const FLAGS: [&str; 5] = ["cap", "bevelInside", "lightBehind", "drawHiddenFaces", "preserveSpotColors"];
/// Check Extrude & Bevel (Classic)'s parameters `p` (its defaults filled in): every key is one of
/// its parameters, numbers are in range, flags are true or false, and `surface`, `shadingColor` and
@@ -127,7 +127,7 @@ const ROTATE_RANGES: [(&str, f64, f64, bool); 9] = [
/// Check Rotate (Classic)'s parameters `p` (its defaults filled in) as [`validate`] checks Extrude &
/// Bevel (Classic)'s, with `surface` No Shading or Diffuse only.
pub fn validate_rotate(p: &Value) -> Result<(), String> {
validate_classic(CLASSIC_ROTATE, p, &ROTATE_RANGES, &["lightBehind"])?;
validate_classic(CLASSIC_ROTATE, p, &ROTATE_RANGES, &["lightBehind", "preserveSpotColors"])?;
if p.get("surface").is_some_and(|v| !v.as_str().is_some_and(|s| ROTATE_SURFACES.contains(&s))) {
return Err(format!("`surface` must be one of {}", ROTATE_SURFACES.join(", ")));
}
@@ -192,6 +192,35 @@ fn shaded(c: Color, tone: f64, spec: f64, shade: Option<Color>) -> Color {
Color::rgb(r, g, b)
}
/// Whether Preserve Spot Colors applies with parameters `p`: `preserveSpotColors` is on and the
/// shading color is not Custom, which cannot keep a spot color.
fn preserves_spot_colors(p: &Value) -> bool {
flag(p, "preserveSpotColors", false) && text(p, "shadingColor", "black") != "custom"
}
/// A face of `paint`, a color linked to a swatch, with Preserve Spot Colors: the swatch at a lower
/// tint for the highlight `spec`, its link kept so a spot color stays on its own plate, and, where
/// tone `tone` is below full light and the faces darken (`shade`), black over it at `1 - tone`,
/// multiplied and overprinting, so it prints on the black plate. `seam` is the width of the seam
/// stroke each layer draws around the face.
fn linked_face(paint: &Paint, tone: f64, spec: f64, shade: Option<Color>, seam: f64) -> Vec<AppearanceItem> {
let Paint::Solid { color, swatch, tint } = paint else { return vec![] };
let keep = (1.0 - spec).clamp(0.0, 1.0) as f32;
let fill = Paint::Solid { color: color.tinted(keep), swatch: swatch.clone(), tint: tint * keep };
let mut items = vec![AppearanceItem::Fill(FillLayer::new(fill.clone())), AppearanceItem::Stroke(crate::lighting::seam_stroke(fill, seam))];
if shade.is_some() && tone < 1.0 {
let black = Paint::solid(Color::gray((1.0 - tone).clamp(0.0, 1.0) as f32));
let mut layer = FillLayer::new(black.clone());
layer.blend = BlendMode::Multiply;
layer.overprint = true;
let mut edge = crate::lighting::seam_stroke(black, seam);
edge.blend = BlendMode::Multiply;
edge.overprint = true;
items.extend([AppearanceItem::Fill(layer), AppearanceItem::Stroke(edge)]);
}
items
}
/// The width of the stroke [`surface`] draws around `face` with parameters `p`: a wire edge on
/// Wireframe, else the seam between faces.
pub(crate) fn stroke_width(face: &Facet, p: &Value) -> f64 {
@@ -200,12 +229,14 @@ pub(crate) fn stroke_width(face: &Facet, p: &Value) -> f64 {
/// One solid's faces in paint `paint`, as a group with the paint's opacity and blend mode, one child
/// for each facet: the edges of the faces on Wireframe, else each face filled in its paint shaded by
/// its tone and highlight. A gradient with transparent stops is shaded opaque and then masked by
/// its tone and highlight (with Preserve Spot Colors, a color linked to a swatch as [`linked_face`]
/// draws it). A gradient with transparent stops is shaded opaque and then masked by
/// its own transparency, so the shading follows the gradient's coverage.
pub(crate) fn surface(n: &Node, facets: Vec<Facet>, source: &Paint, opacity: f32, blend: BlendMode, p: &Value) -> Node {
let shading = classic_shading(p);
let lights = lights(p);
let shade = shading_color(p);
let preserve = preserves_spot_colors(p);
let wire = shading.surface == ClassicSurface::Wireframe;
let (paint, mask) = if wire { (source.clone(), None) } else { crate::lighting::opaque_paint(source) };
let paint = &paint;
@@ -229,6 +260,10 @@ pub(crate) fn surface(n: &Node, facets: Vec<Facet>, source: &Paint, opacity: f32
AppearanceItem::Stroke(crate::lighting::seam_stroke(paint.clone(), seam)),
]
}
// Preserve Spot Colors: a color linked to a swatch stays a tint of that swatch.
Some(_) if preserve && matches!(paint, Paint::Solid { swatch: Some(_), .. }) && !paint.is_registration() => {
linked_face(paint, tone, spec, shade, seam)
}
Some(c) => {
let fill = Paint::solid(shaded(c, tone, spec, shade));
vec![AppearanceItem::Fill(FillLayer::new(fill.clone())), AppearanceItem::Stroke(crate::lighting::seam_stroke(fill, seam))]
+13
View File
@@ -50,6 +50,19 @@ pub(crate) struct Ink {
pub(crate) blend: BlendMode,
}
impl Ink {
/// Whether art in this ink casts part of a shadow ([`casts`]).
pub(crate) fn casts(self) -> bool {
casts(self.opacity)
}
}
/// Whether art at `opacity` casts part of a shadow: its opacity is a finite number above zero.
/// Art at zero, negative, NaN or infinite opacity casts nothing.
pub(crate) fn casts(opacity: f32) -> bool {
opacity.is_finite() && opacity > 0.0
}
/// The shadow of the faces in `caster` as a path of object `id`, cast by art in `ink`: filled
/// black at [`SHADOW_OPACITY`] times its opacity, in its blending mode, with the nonzero rule;
/// `None` when it casts nothing.
+59 -1
View File
@@ -1,5 +1,5 @@
use serde_json::json;
use vectorcraft_doc::{Document, NodeId, StrokeLayer};
use vectorcraft_doc::{Document, FillLayer, NodeId, StrokeLayer};
use vectorcraft_geom::{Affine, BezPath, Point, Shape};
use super::*;
@@ -2015,6 +2015,62 @@ fn a_member_in_a_transparent_gradient_keeps_the_shared_face_order() {
}
}
/// The fills of each face of `art`: the first is the face's paint, a second the shadow over it.
fn face_fills(art: &Node) -> Vec<Vec<FillLayer>> {
faces(art)
.iter()
.map(|f| {
f.appearance
.items
.iter()
.filter_map(|i| match i {
AppearanceItem::Fill(f) => Some(f.clone()),
AppearanceItem::Stroke(_) => None,
})
.collect()
})
.collect()
}
#[test]
fn classic_preserve_spot_colors_keeps_a_linked_fill_on_every_face() {
let ink = Color::cmyk(0.0, 1.0, 0.0, 0.2);
let art = |params: Value| {
let mut n = classic(params);
n.appearance.items = vec![AppearanceItem::Fill(FillLayer::new(Paint::Solid { color: ink, swatch: Some("Ink".into()), tint: 1.0 }))];
solid_art(&n).unwrap()
};
let lit = json!({"preserveSpotColors": true, "highlightIntensity": 100, "highlightSize": 100, "lightHeight": 60});
let (mut highlighted, mut shaded) = (false, false);
for layers in face_fills(&art(lit)) {
let Paint::Solid { color, swatch: Some(name), tint } = &layers[0].paint else { panic!("the swatch is kept: {layers:?}") };
assert_eq!(name, "Ink");
assert_eq!(*color, ink.tinted(*tint), "the color is the swatch at the face's tint");
highlighted |= *tint < 1.0;
if let Some(shadow) = layers.get(1) {
let Some(Color::Gray { k }) = shadow.paint.color() else { panic!("black shading: {:?}", shadow.paint) };
assert!(k > 0.0 && k <= 1.0, "{k}");
assert_eq!((shadow.blend, shadow.overprint, shadow.opacity), (BlendMode::Multiply, true, 1.0));
shaded = true;
}
assert!(layers.len() <= 2, "{layers:?}");
}
assert!(highlighted && shaded, "the highlight lowers the tint and the shadow adds black");
// No shading color: the highlight only, as tints of the swatch.
for layers in face_fills(&art(json!({"preserveSpotColors": true, "shadingColor": "none"}))) {
assert!(matches!(&layers[..], [f] if matches!(&f.paint, Paint::Solid { swatch: Some(_), .. })), "{layers:?}");
}
// Off, or with a custom shading color, shaded faces are process mixes, as before.
for params in [json!({}), json!({"preserveSpotColors": true, "shadingColor": "custom", "shadingCustom": "#3366cc"})] {
let unlinked = face_fills(&art(params.clone())).iter().filter(|l| matches!(l[0].paint, Paint::Solid { swatch: None, .. })).count();
assert!(unlinked > 0, "{params}");
}
// An unlinked fill is shaded as it is with the option off.
let off: Vec<[f32; 3]> = classic_faces(json!({})).iter().map(fill_rgb).collect();
let on: Vec<[f32; 3]> = classic_faces(json!({"preserveSpotColors": true})).iter().map(fill_rgb).collect();
assert_eq!(off, on);
}
#[test]
fn classic_parameters_are_checked() {
let n = classic(json!({}));
@@ -2034,6 +2090,7 @@ fn classic_parameters_are_checked() {
(json!({"highlightSize": "big"}), "`highlightSize` must be a number"),
(json!({"cap": "yes"}), "`cap` must be true or false"),
(json!({"drawHiddenFaces": 1}), "`drawHiddenFaces` must be true or false"),
(json!({"preserveSpotColors": "yes"}), "`preserveSpotColors` must be true or false"),
(json!({"bevel": "fancy"}), "bevel must be none or one of"),
(json!({"twist": 10}), "unknown parameter `twist`"),
(json!({"lights": []}), "lights must contain one to ten lights"),
@@ -2110,6 +2167,7 @@ fn classic_rotate_parameters_are_checked() {
(json!({"lightIntensity": 101}), "`lightIntensity` must be between 0 and 100 (got 101)"),
(json!({"blendSteps": 0}), "`blendSteps` must be a whole number between 1 and 256 (got 0)"),
(json!({"lightBehind": 1}), "`lightBehind` must be true or false"),
(json!({"preserveSpotColors": null}), "`preserveSpotColors` must be true or false"),
(json!({"highlightSize": 50}), "unknown parameter `highlightSize`"),
(json!({"drawHiddenFaces": true}), "unknown parameter `drawHiddenFaces`"),
(json!({"shadow": true}), "unknown parameter `shadow`"),
+60
View File
@@ -312,3 +312,63 @@ fn the_shadow_cast_from_the_solids_drawn_is_the_shadow_of_solids_built_for_it()
}
}
}
/// The opacity of each part of the shadow of `n` (its 3D effect's shadow on) and the shadow's art,
/// as [`cast_shadow`] casts it from solids built for the shadow alone and as [`cast_built`] casts
/// it from the faces the solids drawn kept.
fn cast_parts(n: &Node) -> [(Vec<f32>, String); 2] {
let (_, fx) = solid(&n.appearance.effects).unwrap();
let shapes = if is_container(n) {
members(n, "").unwrap()
} else {
let (path, rule) = profile(n).unwrap();
vec![Profile { path, rule, items: solid_paint_sources(n).1, opacity: 1.0, scopes: vec![] }]
};
let p = crate::merged_params(&fx.id, &fx.params);
let (_, built) = surfaces_for(None, n, fx, &shapes, true).unwrap();
[cast_shadow(&fx.id, &p, &shapes), cast_built(&fx.id, &p, &shapes, built)].map(|c| {
let (caster, settings, cast) = c.unwrap().unwrap();
let inks = cast.iter().map(|(_, ink, _)| ink.opacity).collect();
(inks, format!("{:?}", shadow::scoped_art(n.id, &caster, settings, cast, has_masks(&shapes))))
})
}
/// A fill, a stroke or a member whose opacity is NaN or infinite casts no part of the shadow, as one
/// at 0 % does, whether the shadow is cast from solids built for it or from the solids drawn; the
/// art beside it still casts its part.
#[test]
fn art_whose_opacity_is_not_a_finite_number_casts_no_shadow() {
// The art with opacity `opacity` in one place: a square's stroke, the fill of a group's second
// member, and a group's second member.
let objects = |opacity: f32| {
let mut fill = square_node(2, 200.0);
for item in &mut fill.appearance.items {
if let AppearanceItem::Fill(f) = item {
f.opacity = opacity;
}
}
let mut member = square_node(2, 200.0);
member.opacity = opacity;
[
stroked_square(1, 0.0, opacity),
Node::group(NodeId(9), vec![Arc::new(square_node(1, 0.0)), Arc::new(fill)]),
Node::group(NodeId(9), vec![Arc::new(square_node(1, 0.0)), Arc::new(member)]),
]
};
for id in [EXTRUDE, INFLATE, ROTATE] {
for bad in [f32::NAN, f32::INFINITY] {
for (i, (mut n, mut clear)) in objects(bad).into_iter().zip(objects(0.0)).enumerate() {
n.appearance.effects.push(crate::new_effect(id, &json!({"shadow": true})).unwrap());
clear.appearance.effects.push(crate::new_effect(id, &json!({"shadow": true})).unwrap());
let expected = cast_parts(&clear);
for (k, (inks, shadow)) in cast_parts(&n).into_iter().enumerate() {
assert_eq!(inks, vec![1.0], "{id} {bad} object {i} cast {k}: only the opaque art casts");
assert_eq!(shadow, expected[k].1, "{id} {bad} object {i} cast {k}");
}
let mut opacities = vec![];
art(&n, id).unwrap().walk(&mut |c| opacities.extend(shadow_opacity(c)));
assert_eq!(opacities, vec![0.5], "{id} {bad} object {i}");
}
}
}
}
+218 -34
View File
@@ -1,7 +1,8 @@
//! Effect › SVG Filters (`svg.filter`): an SVG `<filter>` element applied as a live raster effect.
//! The filter runs on the object's pixels as SVG 1.1 describes it
//! (<https://www.w3.org/TR/SVG11/filters.html>), drawn by resvg. Its lengths are in points times
//! the effect's `scale`, its user space starts at the effect's `origin`, its object bounding box is the box of the drawn pixels, and it runs at the
//! the effect's `scale`, its user space starts at the effect's `origin`, its axes point as the effect's `axes` say (a turned or
//! skewed user space), its object bounding box is the box of the drawn pixels, and it runs at the
//! Document Raster Effects resolution when the view shows the object at a finer one.
use std::sync::Arc;
@@ -1224,13 +1225,16 @@ fn region_length(v: Option<&str>, default: f64, user: bool) -> Length {
}
/// An SVG filter effect: the filter's XML, how many points one of its units is, where its user
/// space's origin lies in the document, and its region.
/// space's origin lies in the document, which way its axes point, and its region.
#[derive(Clone, Debug, PartialEq)]
pub struct SvgFilterFx {
/// The filter as it is drawn and written ([`rebuilt`]).
xml: Arc<str>,
scale: f64,
origin: Vec2,
/// The directions of the user space's axes in the document, before `scale` ([`axes_param`]):
/// the identity unless the user space is turned, skewed or scaled unevenly.
axes: Affine,
/// Its region (filter effects region): x, y, width, height.
region: [Length; 4],
/// Is the region in user space with a length measured against the object's box? It is then
@@ -1250,11 +1254,92 @@ fn origin_param(p: &Value) -> Vec2 {
Vec2::new(at(0), at(1))
}
/// The largest magnitude of an entry of an SVG filter effect's `axes`.
pub const MAX_AXIS: f64 = 1000.0;
/// The smallest magnitude of the determinant of an SVG filter effect's `axes`.
pub const MIN_AXES_DET: f64 = 0.001;
/// The `axes` of an SVG filter effect's parameters `p`: `[a, b, c, d]`, the document directions
/// of one unit along the filter's x axis (`[a, b]`) and its y axis (`[c, d]`), before `scale`.
/// The identity when absent; `None` when it isn't four numbers from -[`MAX_AXIS`] to [`MAX_AXIS`]
/// whose determinant is at least [`MIN_AXES_DET`] across ([`valid_axes`]).
fn axes_param(p: &Value) -> Option<Affine> {
match p.get("axes") {
None | Some(Value::Null) => Some(Affine::IDENTITY),
Some(v) => valid_axes(v),
}
}
/// `v` as the linear map of an SVG filter effect's `axes` ([`axes_param`]), when it is one.
pub fn valid_axes(v: &Value) -> Option<Affine> {
let n: Vec<f64> = v.as_array()?.iter().map(Value::as_f64).collect::<Option<_>>()?;
axes_of(<[f64; 4]>::try_from(n).ok()?)
}
/// `[a, b, c, d]` as the linear map of an SVG filter effect's `axes`, when the effect takes it ([`valid_axes`]).
fn axes_of([a, b, c, d]: [f64; 4]) -> Option<Affine> {
let det = a * d - b * c;
([a, b, c, d].iter().all(|v| v.is_finite() && v.abs() <= MAX_AXIS) && det.abs() >= MIN_AXES_DET).then(|| Affine::new([a, b, c, d, 0.0, 0.0]))
}
/// A filter's user space as the SVG filter effect stores it ([`user_space`]).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct UserSpace {
/// The effect's `scale`.
pub scale: f64,
/// The effect's `origin`.
pub origin: [f64; 2],
/// The effect's `axes`; `None` when the axes are the document's.
pub axes: Option<[f64; 4]>,
}
/// `ts`, a user space mapped to the document, as the SVG filter effect's `scale` (the square root
/// of its area scale), `origin` and, when it turns, skews or scales unevenly, `axes`. `None` when
/// it can't be stored: not finite, `scale` outside 0.001..1000, or `axes` the effect refuses
/// ([`valid_axes`]).
pub fn user_space(ts: Affine) -> Option<UserSpace> {
let [a, b, c, d, e, f] = ts.as_coeffs();
if ![a, b, c, d, e, f].iter().all(|v| v.is_finite()) {
return None;
}
let scale = ts.determinant().abs().sqrt();
if !(0.001..=1000.0).contains(&scale) {
return None;
}
let even = b == 0.0 && c == 0.0 && a > 0.0 && (a - d).abs() <= 1e-9 * a.max(1.0);
if even {
return Some(UserSpace { scale: a, origin: [e, f], axes: None });
}
let axes = [a, b, c, d].map(|v| v / scale);
axes_of(axes)?;
Some(UserSpace { scale, origin: [e, f], axes: Some(axes) })
}
/// The longest a unit vector becomes under the linear part of `a` (its largest singular value).
fn stretch(a: Affine) -> f64 {
let [a, b, c, d, _, _] = a.as_coeffs();
let sum = a * a + b * b + c * c + d * d;
let det = a * d - b * c;
(0.5 * (sum + (sum * sum - 4.0 * det * det).max(0.0).sqrt())).sqrt()
}
/// The share of a raster pixel, `space.px` points wide, that a pixel of the Document Raster Effects
/// resolution is (at most 1).
fn raster_resolution(space: &PixelSpace) -> f64 {
if space.line.is_finite() && space.line > 0.0 && space.px.is_finite() && space.px > 0.0 { (space.px / space.line).min(1.0) } else { 1.0 }
}
/// How many times the raster's pixels a turned filter's canvas may hold ([`SvgFilterFx::turned_canvas`]).
const TURNED_ROOM: f64 = 4.0;
impl SvgFilterFx {
/// The effect's parameters `p` (defaults merged in): `xml`, the `<filter>` element, `scale`
/// and `origin`. `None` when `xml` isn't a filter [`check`] takes, however the effect came to
/// hold it (an opened file, a script): the object is then drawn, printed and exported without it.
/// The effect's parameters `p` (defaults merged in): `xml`, the `<filter>` element, `scale`,
/// `origin` and `axes`. `None` when `xml` isn't a filter [`check`] takes, or `axes` isn't
/// valid ([`axes_param`]), however the effect came to hold it (an opened file, a script): the
/// object is then drawn, printed and exported without it.
pub(crate) fn parse(p: &Value) -> Option<Self> {
let axes = axes_param(p)?;
let xml = checked(text(p, "xml", ""))?;
let (wrapped, _) = wrap(&xml);
let doc = roxmltree::Document::parse(&wrapped).ok()?;
@@ -1273,7 +1358,17 @@ impl SvgFilterFx {
let (window, blur) = (largest(&["radius", "kernelUnitLength"]), largest(&["stdDeviation"]));
let box_units = f.attribute("primitiveUnits").is_some_and(|u| u.trim() == "objectBoundingBox");
let scale = num(p, "scale", 1.0).clamp(1e-3, 1e3);
Some(Self { xml, scale, origin: origin_param(p), region, measured, window, blur, box_units })
Some(Self { xml, scale, origin: origin_param(p), axes, region, measured, window, blur, box_units })
}
/// The filter's user space → the document: moved to `origin`, scaled by `scale`, along `axes`.
pub fn to_doc(&self) -> Affine {
Affine::translate(self.origin) * Affine::scale(self.scale) * self.axes
}
/// The box of `bounds` (document units) in the filter's user space.
fn user_box(&self, bounds: Rect) -> Rect {
self.to_doc().inverse().transform_rect_bbox(bounds)
}
/// The filter's region in its user space for an object whose box there is `b`: lengths in user
@@ -1308,11 +1403,11 @@ impl SvgFilterFx {
with_attributes(xml, &[("x", r.x0), ("y", r.y0), ("width", r.width()), ("height", r.height())])
}
/// The filter as SVG output writes it (one unit a point, [`Self::in_points`]) for an object
/// whose box in the document is `bounds`: in its SVG 1.1 form ([`svg11`]), `None` when SVG 1.1
/// can't hold it ([`Self::writes_as_svg11`]).
pub fn written_xml(&self, bounds: Rect) -> Option<String> {
let out = self.with_region(&self.svg11_form()?, (bounds - self.origin).scale_from_origin(1.0 / self.scale))?;
/// The filter as SVG output writes it, in its user space ([`Self::to_doc`]), for an object
/// whose box in that user space is `user_box`: in its SVG 1.1 form ([`svg11`]), `None` when SVG
/// 1.1 can't hold it ([`Self::writes_as_svg11`]).
pub fn written_xml(&self, user_box: Rect) -> Option<String> {
let out = self.with_region(&self.svg11_form()?, user_box)?;
(written_len(&out) <= MAX_WRITTEN_XML).then_some(out)
}
@@ -1345,10 +1440,11 @@ impl SvgFilterFx {
if !(space.px.is_finite() && space.px > 0.0) {
return 1.0;
}
let mut s: f64 = if space.line.is_finite() && space.line > 0.0 { (space.px / space.line).min(1.0) } else { 1.0 };
// A length spans length × scale / px raster pixels, or a fraction of the box.
let mut s = raster_resolution(space);
// A length spans at most length × scale × the axes' stretch / px raster pixels, or a fraction of the box.
let reach = self.scale * stretch(self.axes);
for (length, most) in [(self.window, MAX_WINDOW_PX), (self.blur, MAX_BLUR_PX)] {
let px = if self.box_units { length * box_px.max(0.0) } else { length * self.scale / space.px };
let px = if self.box_units { length * box_px.max(0.0) } else { length * reach / space.px };
if px * s > most {
s = most / px;
}
@@ -1374,15 +1470,21 @@ impl SvgFilterFx {
self.measured
}
/// Does one unit of the filter's user space measure one point (its `scale` is 1)?
pub fn in_points(&self) -> bool {
self.scale == 1.0
/// Is the filter's user space the document's, only moved: one unit a point, its axes the
/// document's (`scale` 1, no `axes`)?
pub fn moves_only(&self) -> bool {
self.scale == 1.0 && self.axes == Affine::IDENTITY
}
/// Can SVG import store the filter's user space ([`Self::to_doc`]) again ([`user_space`])? Its
/// `scale` and `axes` together can scale it past what one of them holds alone.
pub fn opens_again(&self) -> bool {
user_space(self.to_doc()).is_some()
}
/// How far beyond `bounds` (document units) the filter can draw: its region's reach past them.
pub fn outset(&self, bounds: Rect) -> f64 {
let b = (bounds - self.origin).scale_from_origin(1.0 / self.scale);
let region = self.region_in(b).scale_from_origin(self.scale) + self.origin;
let region = self.to_doc().transform_rect_bbox(self.region_in(self.user_box(bounds)));
let reach = [bounds.x0 - region.x0, bounds.y0 - region.y0, region.x1 - bounds.x1, region.y1 - bounds.y1].into_iter().fold(0.0, f64::max);
if reach.is_finite() { reach.min(MAX_OUTSET) } else { 0.0 }
}
@@ -1397,20 +1499,40 @@ impl SvgFilterFx {
}
// Art that draws no pixels still has a box: the whole raster stands for it.
let (x0, y0, x1, y1) = drawn_box(data, w, h).unwrap_or((0, 0, w, h));
let s = self.resolution(space, (x1 - x0).max(y1 - y0) as f64);
let (sw, sh) = ((w as f64 * s).ceil().max(1.0) as u32, (h as f64 * s).ceil().max(1.0) as u32);
let Some(png) = png_of(data, w, x0, y0, x1, y1).map(Arc::new) else { return };
let k = self.scale;
// Filter user space → the small canvas, and the image's pixels → filter user space.
let outer = Affine::scale(s) * space.to_doc.inverse() * Affine::translate(self.origin) * Affine::scale(k);
let place = Affine::scale(1.0 / k) * Affine::translate(-self.origin) * space.to_doc * Affine::translate(Vec2::new(x0 as f64, y0 as f64));
let b = place.transform_rect_bbox(Rect::new(0.0, 0.0, (x1 - x0) as f64, (y1 - y0) as f64));
let user = self.to_doc();
// Filter user space → the small canvas, and the small canvas → the raster's pixels.
let (sw, sh, outer, back) = if self.axes == Affine::IDENTITY {
let s = self.resolution(space, (x1 - x0).max(y1 - y0) as f64);
let (sw, sh) = ((w as f64 * s).ceil().max(1.0) as u32, (h as f64 * s).ceil().max(1.0) as u32);
(sw, sh, Affine::scale(s) * space.to_doc.inverse() * user, Affine::scale(1.0 / s))
} else {
// The canvas measures the costly lengths in its own pixels.
let drawn = Rect::new(x0 as f64, y0 as f64, x1 as f64, y1 as f64);
let Some(canvas) = self.turned_canvas(w, h, raster_resolution(space), drawn, space) else { return };
canvas
};
// The source as an image, its size, and the map from its pixels to filter user space. The
// object's box in user space is the image's box there, which resvg measures the region and
// a primitive's lengths in the object's box against. In a turned or skewed user space the
// source is first drawn onto the canvas's pixels, whose rows and columns run along the user
// space's axes, and cut to the pixels it draws there: the image then lies along those axes,
// and its box is the box of the art in the user space, not of the raster's box turned into it.
let source = if self.axes == Affine::IDENTITY {
let place = user.inverse() * space.to_doc * Affine::translate(Vec2::new(x0 as f64, y0 as f64));
png_of(data, w, x0, y0, x1, y1).map(|png| (png, x1 - x0, y1 - y0, place))
} else {
on_canvas(data, w, h, sw, sh, outer * user.inverse() * space.to_doc)
.map(|(png, cx, cy, iw, ih)| (png, iw, ih, outer.inverse() * Affine::translate(Vec2::new(cx as f64, cy as f64))))
};
let Some((png, iw, ih, place)) = source else { return };
let png = Arc::new(png);
let b = place.transform_rect_bbox(Rect::new(0.0, 0.0, iw as f64, ih as f64));
let Some(drawn) = self.drawn_xml(b).and_then(|xml| with_id(&xml, DRAWN_ID)) else { return };
let svg = format!(
"<svg xmlns=\"{SVG_NS}\" xmlns:xlink=\"{XLINK_NS}\" width=\"{sw}\" height=\"{sh}\" viewBox=\"0 0 {sw} {sh}\"><defs>{drawn}</defs><g transform=\"{}\"><g filter=\"url(#{DRAWN_ID})\"><image width=\"{}\" height=\"{}\" preserveAspectRatio=\"none\" transform=\"{}\" xlink:href=\"data:image/png;base64,{}\"/></g></g></svg>",
matrix(outer),
x1 - x0,
y1 - y0,
iw,
ih,
matrix(place),
base64(&png)
);
@@ -1420,19 +1542,65 @@ impl SvgFilterFx {
}
let Some(mut small) = tiny_skia::Pixmap::new(sw, sh) else { return };
resvg::render(&tree, tiny_skia::Transform::identity(), &mut small.as_mut());
let out = if s < 1.0 {
let out = if back == Affine::IDENTITY {
small
} else {
let Some(mut full) = tiny_skia::Pixmap::new(w as u32, h as u32) else { return };
let paint = tiny_skia::PixmapPaint { quality: tiny_skia::FilterQuality::Bilinear, ..Default::default() };
let up = (1.0 / s) as f32;
full.draw_pixmap(0, 0, small.as_ref(), &paint, tiny_skia::Transform::from_scale(up, up), None);
let [a, b, c, d, e, f] = back.as_coeffs().map(|v| v as f32);
full.draw_pixmap(0, 0, small.as_ref(), &paint, tiny_skia::Transform::from_row(a, b, c, d, e, f), None);
full
} else {
small
};
if out.data().len() == data.len() {
data.copy_from_slice(out.data());
}
}
/// The canvas a filter whose user space is turned, skewed or scaled unevenly runs on, for a
/// `w` × `h` raster lying in the document as `space` says, at resolution `s`
/// ([`raster_resolution`]), whose art draws within `drawn` (raster pixels): its axes are the user
/// space's, so the filter's offsets, blurs and other lengths run along them as SVG 1.1 measures
/// them, and it covers what the raster shows. Along each of its axes its pixels are about as
/// large as the raster's at `s`, and coarser when it would hold more than [`TURNED_ROOM`] times
/// as many pixels as the raster does at `s`, and when a morphology radius or kernel unit would
/// span more than 32 of its pixels or a blur's standard deviation more than 512 (a length in the
/// object's box measured against the art's box on the canvas). Returns its size, the map from
/// user space to it, and the map from it to the raster's pixels; `None` when the spaces can't
/// be measured.
fn turned_canvas(&self, w: usize, h: usize, s: f64, drawn: Rect, space: &PixelSpace) -> Option<(u32, u32, Affine, Affine)> {
let to_px = space.to_doc.inverse() * self.to_doc();
let from_px = to_px.inverse();
let ub = from_px.transform_rect_bbox(Rect::new(0.0, 0.0, w as f64, h as f64));
let most = ((w as f64 * s).ceil().max(1.0) * (h as f64 * s).ceil().max(1.0) * TURNED_ROOM).max(1.0);
// Canvas pixels a unit along each axis of the user space: as many as the raster's at `s`.
let [a, b, c, d, _, _] = to_px.as_coeffs();
let (mut qx, mut qy) = (s * a.hypot(b), s * c.hypot(d));
let pixels = ub.width() * qx * ub.height() * qy;
if pixels > most {
let k = (most / pixels).sqrt();
(qx, qy) = (qx * k, qy * k);
}
// A length along an axis spans length × q canvas pixels, or length × the art's box on the
// canvas along that axis: both shrink with q.
let art = from_px.transform_rect_bbox(drawn);
for (length, cap) in [(self.window, MAX_WINDOW_PX), (self.blur, MAX_BLUR_PX)] {
let px = if self.box_units { length * (art.width() * qx).max(art.height() * qy) } else { length * qx.max(qy) };
if px > cap {
let k = cap / px;
(qx, qy) = (qx * k, qy * k);
}
}
if !(qx.is_finite() && qx > 0.0 && qy.is_finite() && qy > 0.0 && ub.is_finite()) {
return None;
}
let (cw, ch) = ((ub.width() * qx).ceil().max(1.0), (ub.height() * qy).ceil().max(1.0));
// Rounding up adds at most a row and a column.
if cw * ch > most + cw + ch + 1.0 {
return None;
}
let outer = Affine::scale_non_uniform(qx, qy) * Affine::translate(Vec2::new(-ub.x0, -ub.y0));
Some((cw as u32, ch as u32, outer, to_px * outer.inverse()))
}
}
/// The farthest a filter's region reaches beyond the object, in points.
@@ -1450,6 +1618,22 @@ fn drawn_box(data: &[u8], w: usize, h: usize) -> Option<(usize, usize, usize, us
(x1 > x0 && y1 > y0).then_some((x0, y0, x1, y1))
}
/// The pixels of `data` (premultiplied RGBA8, `w` × `h`) drawn onto a `cw` × `ch` canvas through
/// `to_canvas` (the raster's pixels → the canvas's), cut to the box of the canvas pixels that aren't
/// transparent (the whole canvas when all are): a PNG of them, the box's corner on the canvas, and
/// its size.
fn on_canvas(data: &[u8], w: usize, h: usize, cw: u32, ch: u32, to_canvas: Affine) -> Option<(Vec<u8>, usize, usize, usize, usize)> {
let size = tiny_skia::IntSize::from_wh(u32::try_from(w).ok()?, u32::try_from(h).ok()?)?;
let raster = tiny_skia::Pixmap::from_vec(data.to_vec(), size)?;
let mut canvas = tiny_skia::Pixmap::new(cw, ch)?;
let paint = tiny_skia::PixmapPaint { quality: tiny_skia::FilterQuality::Bilinear, ..Default::default() };
let [a, b, c, d, e, f] = to_canvas.as_coeffs().map(|v| v as f32);
canvas.draw_pixmap(0, 0, raster.as_ref(), &paint, tiny_skia::Transform::from_row(a, b, c, d, e, f), None);
let (cw, ch) = (cw as usize, ch as usize);
let (x0, y0, x1, y1) = drawn_box(canvas.data(), cw, ch).unwrap_or((0, 0, cw, ch));
Some((png_of(canvas.data(), cw, x0, y0, x1, y1)?, x0, y0, x1 - x0, y1 - y0))
}
/// The pixels of `data` (premultiplied RGBA8, `w` wide) in columns x0..x1 and rows y0..y1, as a PNG.
fn png_of(data: &[u8], w: usize, x0: usize, y0: usize, x1: usize, y1: usize) -> Option<Vec<u8>> {
use image::ImageEncoder;
+150 -1
View File
@@ -222,6 +222,154 @@ fn user_space_starts_at_the_origin() {
assert!((filter(json!({ "xml": half, "origin": [-10.0, 0.0] })).outset(b) - 10.0).abs() < 1e-9);
}
/// The opacity of `data` (`w` wide) after the filter `xml` with `params` runs on a blue square
/// over 10..20 pt of a 40 pt raster one point a pixel, at each of `points`.
fn turned_run(xml: &str, params: serde_json::Value, points: &[(usize, usize)]) -> Vec<u8> {
let w = 40;
let mut data = image(w, w, |x, y| if (10..20).contains(&x) && (10..20).contains(&y) { [0, 0, 255, 255] } else { [0; 4] });
let mut p = params;
p["xml"] = json!(xml);
filter(p).apply(&mut data, w, w, &space(w, w, 1.0, 1.0));
points.iter().map(|&(x, y)| at(&data, w, x, y)[3]).collect()
}
const SHIFT_X: &str = r#"<filter id="Shift" x="-1" y="-1" width="3" height="3"><feOffset dx="4"/></filter>"#;
/// With `axes`, the filter runs in its turned or skewed user space: an offset along its x axis
/// moves the art along that axis in the document (SVG 1.1 measures a primitive's lengths in the
/// user space of the element the filter is on).
#[test]
fn a_turned_user_space_moves_the_art_along_its_own_axes() {
// The square spans 10..20 pt; moved 4 pt along x it covers 14..24 across.
let points = [(22, 15), (15, 22), (12, 11)];
assert_eq!(turned_run(SHIFT_X, json!({}), &points), [255, 0, 0], "the document's axes: to the right");
// Turned a quarter turn: the filter's x axis points down the page.
let down = turned_run(SHIFT_X, json!({ "axes": [0.0, 1.0, -1.0, 0.0] }), &points);
assert_eq!(down, [0, 255, 0], "moved down, not to the right");
// Skewed: one unit along y is one across and one down, so dy 4 moves the art 4 pt each way.
let shift_y = r#"<filter id="Shift" x="-1" y="-1" width="3" height="3"><feOffset dy="4"/></filter>"#;
let skewed = turned_run(shift_y, json!({ "axes": [1.0, 0.0, 1.0, 1.0] }), &[(22, 22), (11, 22), (11, 11)]);
assert!(skewed[0] > 200 && skewed[1] < 60 && skewed[2] < 60, "moved across and down: {skewed:?}");
}
#[test]
fn axes_the_effect_cannot_measure_are_not_drawn() {
for axes in [json!([0, 0, 0, 0]), json!([1, 2]), json!([1, 0, 0, "1"]), json!([2000, 0, 0, 1]), json!([0.01, 0, 0, 0.01]), json!("turned")] {
let e = Effect { id: SVG_FILTER.into(), params: json!({ "xml": SHIFT_X, "axes": axes }), visible: true };
assert!(raster_effects(&[e]).is_empty(), "{axes}: drawn without it");
assert!(valid_axes(&axes).is_none(), "{axes}");
}
assert_eq!(valid_axes(&json!([0, 1, -1, 0])), Some(Affine::new([0.0, 1.0, -1.0, 0.0, 0.0, 0.0])));
}
/// A region measured against the object's box is measured against its box in the turned user
/// space: a diamond turned an eighth of a turn is a square there, and a flood over 0..100% of it
/// covers the diamond alone.
#[test]
fn a_turned_region_is_measured_against_the_box_in_its_user_space() {
let w = 40;
let diamond = |x: usize, y: usize| (x as f64 - 19.5).abs() + (y as f64 - 19.5).abs() <= 10.0;
let mut data = image(w, w, |x, y| if diamond(x, y) { [0, 0, 255, 255] } else { [0; 4] });
let flood = r##"<filter id="F" filterUnits="userSpaceOnUse" x="0%" y="0%" width="100%" height="100%"><feFlood flood-color="#ff0000"/></filter>"##;
let h = std::f64::consts::FRAC_1_SQRT_2;
filter(json!({ "xml": flood, "axes": [h, h, -h, h] })).apply(&mut data, w, w, &space(w, w, 1.0, 1.0));
assert_eq!(at(&data, w, 20, 20), [255, 0, 0, 255], "inside the diamond");
assert!(at(&data, w, 12, 12)[3] < 40, "outside the diamond, inside its box on the page: {:?}", at(&data, w, 12, 12));
}
/// In a turned user space, a region in the object's bounding box (the default `filterUnits`) and a
/// primitive's lengths in it (`primitiveUnits="objectBoundingBox"`) are measured against the
/// object's box there: on a diamond turned an eighth of a turn, a flood over 0..100% of the box
/// covers the diamond alone.
#[test]
fn a_turned_filter_measures_box_units_against_the_box_in_its_user_space() {
let w = 40;
let h = std::f64::consts::FRAC_1_SQRT_2;
let region = r##"<filter id="F" x="0" y="0" width="1" height="1"><feFlood flood-color="#ff0000"/></filter>"##;
let primitive = r##"<filter id="F" filterUnits="userSpaceOnUse" x="-1000" y="-1000" width="2000" height="2000" primitiveUnits="objectBoundingBox"><feFlood flood-color="#ff0000" x="0" y="0" width="1" height="1"/></filter>"##;
for xml in [region, primitive] {
let diamond = |x: usize, y: usize| (x as f64 - 19.5).abs() + (y as f64 - 19.5).abs() <= 10.0;
let mut data = image(w, w, |x, y| if diamond(x, y) { [0, 0, 255, 255] } else { [0; 4] });
filter(json!({ "xml": xml, "axes": [h, h, -h, h] })).apply(&mut data, w, w, &space(w, w, 1.0, 1.0));
assert_eq!(at(&data, w, 20, 20), [255, 0, 0, 255], "{xml}: inside the diamond");
assert!(at(&data, w, 12, 12)[3] < 40, "{xml}: outside the diamond, inside its box on the page: {:?}", at(&data, w, 12, 12));
}
}
/// SVG import stores a filter's user space again only when `scale` and `axes` together stay within
/// what it holds: each alone can be valid while their product is not.
#[test]
fn a_user_space_opens_again_only_within_what_import_stores() {
let opens = |scale: f64, axes: serde_json::Value| filter(json!({ "xml": SHIFT_X, "scale": scale, "axes": axes })).opens_again();
assert!(opens(1000.0, json!(null)));
assert!(opens(2.0, json!([0.0, 1.0, -1.0, 0.0])));
assert!(!opens(1000.0, json!([2.0, 0.0, 0.0, 2.0])), "scale 2000 across");
assert!(!opens(0.001, json!([0.5, 0.0, 0.0, 0.5])), "scale 0.0005 across");
let space = user_space(Affine::new([0.0, 2.0, -2.0, 0.0, 5.0, 6.0])).unwrap();
assert_eq!((space.scale, space.origin, space.axes), (2.0, [5.0, 6.0], Some([0.0, 1.0, -1.0, 0.0])));
}
/// The region of a turned filter reaches as far as its turned box does.
#[test]
fn a_turned_region_reaches_along_the_turned_axes() {
let b = Rect::new(0.0, 0.0, 50.0, 50.0);
let user = r#"<filter id="u" filterUnits="userSpaceOnUse" x="-30" y="-5" width="100" height="100"><feOffset/></filter>"#;
assert!((filter(json!({ "xml": user })).outset(b) - 45.0).abs() < 1e-9, "45 pt below the box");
// A quarter turn: user y runs to the left of the page, so -5..95 of it lies 95 pt left of the box.
assert!((filter(json!({ "xml": user, "axes": [0.0, 1.0, -1.0, 0.0] })).outset(b) - 95.0).abs() < 1e-9);
}
/// A filter skewed as far as `axes` allows runs on a canvas no larger than four times the raster's
/// pixels, however far the skew spreads the raster's box in its user space.
#[test]
fn a_skewed_filter_runs_within_its_pixel_budget() {
let (w, h) = (64, 48);
let f = filter(json!({ "xml": SHIFT_X, "axes": [1.0, 0.0, 999.0, 1.0] }));
let all = Rect::new(0.0, 0.0, w as f64, h as f64);
let (cw, ch, ..) = f.turned_canvas(w, h, 1.0, all, &space(w, h, 1.0, 1.0)).unwrap();
let (cw, ch) = (f64::from(cw), f64::from(ch));
assert!(cw * ch <= TURNED_ROOM * (w * h) as f64 + cw + ch + 1.0, "{cw} × {ch}");
// It still draws.
let mut data = image(w, h, |x, y| if (20..40).contains(&x) && (10..30).contains(&y) { [0, 0, 255, 255] } else { [0; 4] });
f.apply(&mut data, w, h, &space(w, h, 1.0, 1.0));
// A plain quarter turn keeps the raster's own pixel size.
let turned = filter(json!({ "xml": SHIFT_X, "axes": [0.0, 1.0, -1.0, 0.0] }));
let (cw, ch, outer, _) = turned.turned_canvas(w, h, 1.0, all, &space(w, h, 1.0, 1.0)).unwrap();
assert_eq!((cw, ch), (48, 64));
assert!((outer.determinant() - 1.0).abs() < 1e-9);
// Stretched 100 times across and squeezed 100 times down: each canvas axis keeps the raster's
// detail along it, not one column of it.
let stretched = filter(json!({ "xml": SHIFT_X, "axes": [100.0, 0.0, 0.0, 0.01] }));
let (cw, ch, ..) = stretched.turned_canvas(100, 100, 1.0, Rect::new(0.0, 0.0, 100.0, 100.0), &space(100, 100, 1.0, 1.0)).unwrap();
assert_eq!((cw, ch), (100, 100));
}
/// On a far skewed canvas, a radius or a standard deviation in the object's box spans no more
/// canvas pixels than the caps allow: it is measured against the art's box on the canvas, which the
/// skew spreads far wider than the art's box in the raster.
#[test]
fn a_skewed_filter_keeps_box_lengths_within_the_caps_on_its_canvas() {
let (w, h) = (64, 48);
let sp = space(w, h, 1.0, 1.0);
let drawn = Rect::new(20.0, 10.0, 40.0, 30.0);
for (xml, length, cap) in [
("<filter id=\"m\" primitiveUnits=\"objectBoundingBox\"><feMorphology radius=\"1.5\"/></filter>", 1.5, MAX_WINDOW_PX),
("<filter id=\"b\" primitiveUnits=\"objectBoundingBox\"><feGaussianBlur stdDeviation=\"20\"/></filter>", 20.0, MAX_BLUR_PX),
] {
let f = filter(json!({ "xml": xml, "axes": [1.0, 0.0, 999.0, 1.0] }));
let (_, _, outer, _) = f.turned_canvas(w, h, 1.0, drawn, &sp).unwrap();
// The art's box on the canvas: the drawn pixels taken into user space, then onto the canvas.
let art = (outer * f.to_doc().inverse() * sp.to_doc).transform_rect_bbox(drawn);
let px = length * art.width().max(art.height());
assert!(px <= cap + 1e-6, "{xml}: {px} canvas pixels");
}
// A user-space radius spans no more than 32 canvas pixels along either axis.
let f = filter(json!({ "xml": "<filter id=\"u\"><feMorphology radius=\"100\"/></filter>", "scale": 1000.0, "axes": [1.0, 0.0, 999.0, 1.0] }));
let (_, _, outer, _) = f.turned_canvas(w, h, 1.0, drawn, &sp).unwrap();
let [qx, _, _, qy, _, _] = outer.as_coeffs();
assert!(100.0 * qx.max(qy) <= MAX_WINDOW_PX + 1e-6, "{qx} {qy}");
}
#[test]
fn runs_no_finer_than_the_raster_effects_resolution() {
let w = 16;
@@ -448,7 +596,8 @@ fn a_region_measured_against_the_box_is_written_out() {
let far = r#"<filter id="r" filterUnits="userSpaceOnUse" x="-50%" y="0" width="200%" height="10"><feOffset/></filter>"#;
let f = filter(json!({ "xml": far, "origin": [10.0, 0.0] }));
assert_eq!(
f.written_xml(Rect::new(10.0, 0.0, 30.0, 10.0)).as_deref(),
// The object over 10..30 pt of the page: 0..20 of the user space that starts at the origin.
f.written_xml(Rect::new(0.0, 0.0, 20.0, 10.0)).as_deref(),
Some(r#"<filter id="r" filterUnits="userSpaceOnUse" x="-10" y="0" width="40" height="10"><feOffset/></filter>"#)
);
let plain = filter(json!({ "xml": RED_FLOOD }));
+59 -1
View File
@@ -117,13 +117,71 @@ fn a_hidden_shadow_follows_the_transform_it_loses() {
assert!(close(drawn_shadow(&[shown]), (20.0, 20.0, 8.0)));
}
/// Rasterize keeps its room around the art: its image keeps its own pixel grid.
/// Rasterize keeps its room around the art: its image keeps its own pixel grid, and the Transform
/// effect moves the image instead ([`crate::rasterize_stage`]).
#[test]
fn rasterize_before_a_transform_keeps_its_options() {
let rasterize = fx(crate::RASTERIZE, json!({"addAround": 6}));
let effects = [rasterize.clone(), transform(json!({"scaleH": 200, "scaleV": 200}))];
assert_eq!(crate::raster::followed(&effects)[0], rasterize);
assert_eq!(crate::raster::without_geometry(&effects), vec![rasterize]);
assert_eq!(crate::rasterize_stage(&effects), Some(vec![1]));
}
/// The Transform effects after the last Rasterize effect move its image when, from the first of
/// them on, every visible effect is a Transform effect that moves the art once. Hidden ones, and
/// ones with Transform Objects off, leave the image where it is.
#[test]
fn rasterize_following_ignores_hidden_and_objects_off() {
let rasterize = || fx(crate::RASTERIZE, json!({}));
let scale = || transform(json!({"scaleH": 200, "scaleV": 200}));
let mut hidden = scale();
hidden.visible = false;
let off = transform(json!({"scaleH": 200, "objects": false}));
let roughen = fx("distort.roughen", json!({}));
let shadow = shadow(5.0, 5.0, 2.0);
let cases: Vec<(Vec<Effect>, Option<Vec<usize>>)> = vec![
(vec![rasterize(), hidden.clone()], None),
(vec![rasterize(), off.clone()], None),
(vec![rasterize(), hidden, off, scale()], Some(vec![3])),
(vec![rasterize(), transform(json!({"moveH": 10})), scale()], Some(vec![1, 2])),
// Effects before the first Transform effect go into the image; anything after it holds
// the image back.
(vec![rasterize(), roughen.clone(), shadow.clone(), scale()], Some(vec![3])),
(vec![rasterize(), scale(), roughen], None),
(vec![rasterize(), scale(), shadow], None),
(vec![rasterize(), transform(json!({"scaleH": 200, "copies": 2}))], None),
(vec![rasterize(), transform(json!({"scaleH": 200, "random": true}))], None),
// The Transform effect before the Rasterize effect moves the art.
(vec![scale(), rasterize()], None),
(vec![scale(), rasterize(), rasterize(), scale()], Some(vec![3])),
];
for (effects, want) in cases {
let ids: Vec<&str> = effects.iter().map(|e| e.id.as_str()).collect();
assert_eq!(crate::rasterize_stage(&effects), want, "{ids:?}");
}
// What the image is made from hides those Transform effects; the rest stay as they are.
let effects = [rasterize(), scale()];
let staged = crate::staged_effects(&effects, &[1]);
assert_eq!((staged[0].visible, staged[1].visible), (true, false));
}
/// The map that moves the image is each Transform effect's map about its reference point on the
/// bounds of the geometry it takes, in order.
#[test]
fn the_image_moves_by_each_transform_about_the_art_it_takes() {
let staged = shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0));
let effects = [fx(crate::RASTERIZE, json!({})), transform(json!({"scaleH": 200, "scaleV": 200})), transform(json!({"moveH": 5}))];
let map = crate::stage_map(&effects, &[1, 2], &staged).unwrap();
// Twice the size about the center (20, 20), then 5 pt to the right.
let want = Affine::translate((5.0, 0.0)) * Affine::translate((20.0, 20.0)) * Affine::scale(2.0) * Affine::translate((-20.0, -20.0));
assert!(map.as_coeffs().iter().zip(want.as_coeffs()).all(|(a, b)| (a - b).abs() < 1e-9), "{map:?}");
// The second Transform effect turns about the center of what the first one made.
let effects = [fx(crate::RASTERIZE, json!({})), transform(json!({"moveH": 100})), transform(json!({"rotate": 90}))];
let map = crate::stage_map(&effects, &[1, 2], &staged).unwrap();
let c = map * Point::new(20.0, 20.0);
assert!((c.x - 120.0).abs() < 1e-9 && (c.y - 20.0).abs() < 1e-9, "{c:?}");
assert_eq!(crate::stage_map(&effects, &[1, 2], &PathData::default()), None);
}
/// An SVG filter keeps its options: it paints in its own user space.
+77 -17
View File
@@ -9,8 +9,9 @@
//! text. Each action is an `/action-N` block with a hexadecimal `/name`; each step is an `/event-N`
//! block inside one, with its `/internalName` in parentheses, the `/localizedName` it is shown with
//! as hexadecimal text, and `/isOn` 0 when it is left out of playback; each value a step was
//! recorded with is a `/parameter-N` block inside it, which is counted, and whose hexadecimal
//! `/value` is checked against its byte count. The set's `/actionCount`, an action's `/eventCount`
//! recorded with is a `/parameter-N` block inside it, which is counted, whose `/key` (a number),
//! `/type` (text in parentheses) and `/value` (a number, text in parentheses or hexadecimal text) are
//! kept, and whose hexadecimal `/value` is checked against its byte count. The set's `/actionCount`, an action's `/eventCount`
//! and a step's `/parameterCount` give how many actions, steps and recorded values follow.
//! The layout follows the README of Inventsable's Redesigning-Illustrator-Actions-in-Scripting
//! (https://github.com/Inventsable/Redesigning-Illustrator-Actions-in-Scripting), the forum post
@@ -40,6 +41,8 @@ const MAX_STEPS: usize = 10_000;
/// The most steps read in the set: a small text can hold hundreds of thousands of empty steps, and
/// the answer holds an object for each.
const MAX_SET_STEPS: usize = 100_000;
/// The most recorded values kept in the set; the others are counted but not kept.
const MAX_VALUES: usize = 100_000;
/// The longest name kept (bytes).
const MAX_NAME: usize = 1024;
/// The most tokens listed as unread.
@@ -56,7 +59,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Read Action Set File",
[],
None,
"{path? | data?: the file's text | dataBase64?, name?: the file's name (default: the path's)} read an action set another app saved as text (.aia, starting \"/version\") without changing anything → {set: the set's /name (else the file's name without its extension, else empty), version, actions: [{name, steps: [{name: the step's /internalName, label: its /localizedName, or null when it has none or it is blank, on: false when /isOn is 0, params: how many /parameter blocks it has, line}]}], unread: [{line, text}] (tokens that aren't read, at most 1000), truncated: true when a cap stopped the reading (8 MiB, 16 open blocks, 1,000 actions, 10,000 steps in an action, 100,000 steps in the set; names past 1,024 bytes are cut), when the text ends inside a block or a value or after a field's name, when /actionCount, /eventCount or a step's /parameterCount differs from the actions, steps or recorded values read (a negative count, one too large for a number and one that isn't a number always differ), or when a hexadecimal name, label or recorded /value isn't as many bytes as its count says}. Give exactly one of path, data and dataBase64. Text that doesn't start with /version and a number is an error",
"{path? | data?: the file's text | dataBase64?, name?: the file's name (default: the path's)} read an action set another app saved as text (.aia, starting \"/version\") without changing anything → {set: the set's /name (else the file's name without its extension, else empty), version, actions: [{name, steps: [{name: the step's /internalName, label: its /localizedName, or null when it has none or it is blank, on: false when /isOn is 0, params: how many /parameter blocks it has, values: [{key: the block's /key, or null, type: its /type, or null, value: its /value when it is a number, or text that is UTF-8 (else null), bytes?: the text's length in bytes}] (the first 100,000 recorded values in the set), line}]}], unread: [{line, text}] (tokens that aren't read, at most 1000), truncated: true when a cap stopped the reading (8 MiB, 16 open blocks, 1,000 actions, 10,000 steps in an action, 100,000 steps in the set; names past 1,024 bytes are cut), when the text ends inside a block or a value or after a field's name, when /actionCount, /eventCount or a step's /parameterCount differs from the actions, steps or recorded values read (a negative count, one too large for a number and one that isn't a number always differ), or when a hexadecimal name, label or recorded /value isn't as many bytes as its count says}. Give exactly one of path, data and dataBase64. Text that doesn't start with /version and a number is an error",
always,
read
)]
@@ -73,12 +76,33 @@ pub struct AiaStep {
pub on: Option<i64>,
/// How many `/parameter-N` blocks it has.
pub params: usize,
/// Its `/parameter-N` blocks, in order, while the set keeps at most [`MAX_VALUES`].
pub values: Vec<AiaValue>,
/// Its `/parameterCount`, if it has one.
pub declared: Option<i64>,
/// The line its block opens on (from 1).
pub line: usize,
}
/// A value a step was recorded with: a `/parameter-N` block.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct AiaValue {
/// Its `/key`, if it has one that fits an `i64`.
pub key: Option<i64>,
/// Its `/type`, read as UTF-8 with replacement characters, if it has one.
pub kind: Option<String>,
/// Its `/value`, if it is a number that fits an `i64` or text.
pub value: Option<AiaData>,
}
/// A recorded `/value` that is kept.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AiaData {
Int(i64),
/// The bytes of text in parentheses or of hexadecimal text.
Text(Vec<u8>),
}
/// An action: an `/action-N` block.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct AiaAction {
@@ -106,6 +130,8 @@ pub struct AiaSet {
pub truncated: bool,
/// How many steps were read in all the actions.
steps: usize,
/// How many recorded values were kept in all the steps.
values: usize,
}
/// A token of the text.
@@ -252,7 +278,8 @@ fn hex_bytes(digits: &[u8]) -> Option<Vec<u8>> {
enum Kind {
Action,
Step,
Param,
/// A recorded value, and whether it is kept.
Param(bool),
/// A block that isn't read, or one past a cap.
Other,
}
@@ -315,12 +342,18 @@ fn open_block(set: &mut AiaSet, stack: &[Kind], name: &[u8], line: usize) -> Kin
}
},
Some(Kind::Step) if numbered(name, b"parameter-") => {
let mut kept = false;
if let Some(s) = set.actions.last_mut().and_then(|a| a.steps.last_mut()) {
s.params += 1;
if set.values < MAX_VALUES {
s.values.push(AiaValue::default());
set.values += 1;
kept = true;
}
}
Kind::Param
Kind::Param(kept)
}
Some(Kind::Param | Kind::Other) => Kind::Other,
Some(Kind::Param(_) | Kind::Other) => Kind::Other,
_ => {
unread(set, line, format!("/{} {{", String::from_utf8_lossy(name)));
Kind::Other
@@ -355,12 +388,23 @@ fn set_hex(set: &mut AiaSet, kind: Option<&Kind>, name: Option<&[u8]>, bytes: Op
}
}
/// Mark `set` truncated when the hexadecimal `/value` on `line` isn't as many bytes as its count
/// says.
fn check_value(set: &mut AiaSet, bytes: Option<Vec<u8>>, line: usize) {
if bytes.is_none() {
/// Set the field `name` of the recorded value open on `line` to `token`: its `/key`, `/type` or
/// `/value`, when the value is `kept`. A hexadecimal `/value` that isn't as many bytes as its count
/// says marks `set` truncated, kept or not.
fn set_param(set: &mut AiaSet, kept: bool, name: Option<&[u8]>, token: Token, line: usize) {
if name == Some(b"value".as_slice()) && matches!(token, Token::Hex(None)) {
set.truncated = true;
unread(set, line, BAD_HEX);
return;
}
let value = set.actions.last_mut().and_then(|a| a.steps.last_mut()).and_then(|s| s.values.last_mut());
let Some(v) = value.filter(|_| kept) else { return };
match (name, token) {
(Some(b"key"), Token::Int(key)) => v.key = key,
(Some(b"type"), Token::Text(t)) => v.kind = Some(String::from_utf8_lossy(&t).into_owned()),
(Some(b"value"), Token::Int(Some(n))) => v.value = Some(AiaData::Int(n)),
(Some(b"value"), Token::Text(t) | Token::Hex(Some(t))) => v.value = Some(AiaData::Text(t)),
_ => {}
}
}
@@ -448,13 +492,13 @@ pub fn read_aia(bytes: &[u8]) -> std::result::Result<AiaSet, String> {
unread(&mut set, line, "}");
}
}
Token::Hex(bytes) if matches!(stack.last(), Some(Kind::Param)) => {
if field(&mut pending).as_deref() == Some(b"value".as_slice()) {
check_value(&mut set, bytes, line);
}
_ if matches!(stack.last(), Some(Kind::Param(_))) => {
let kept = matches!(stack.last(), Some(Kind::Param(true)));
let name = field(&mut pending);
set_param(&mut set, kept, name.as_deref(), token, line);
}
// The other values inside a recorded value, or a block that isn't read.
_ if matches!(stack.last(), Some(Kind::Param | Kind::Other)) => {
// The values inside a block that isn't read.
_ if matches!(stack.last(), Some(Kind::Other)) => {
pending = None;
}
Token::Hex(bytes) => {
@@ -521,6 +565,21 @@ pub fn read_aia(bytes: &[u8]) -> std::result::Result<AiaSet, String> {
Ok(set)
}
/// A recorded value in `actionSet.read`'s answer: `{key, type, value, bytes?}`, the value a number,
/// text that is UTF-8 (else null) with its length in bytes, or null.
fn value_json(v: &AiaValue) -> Value {
let mut out = json!({ "key": v.key, "type": v.kind, "value": null });
match &v.value {
Some(AiaData::Int(n)) => out["value"] = json!(n),
Some(AiaData::Text(t)) => {
out["value"] = json!(std::str::from_utf8(t).ok());
out["bytes"] = json!(t.len());
}
None => {}
}
out
}
fn read(_: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "actionSet.read";
let (path, data, b64) = (text_param(p, "path", C)?, text_param(p, "data", C)?, text_param(p, "dataBase64", C)?);
@@ -544,7 +603,8 @@ fn read(_: &mut Session, p: &Value) -> Result<Value> {
.iter()
.map(|s| {
let label = s.label.clone().filter(|l| !l.trim().is_empty());
json!({ "name": s.name, "label": label, "on": s.on != Some(0), "params": s.params, "line": s.line })
let values: Vec<Value> = s.values.iter().map(value_json).collect();
json!({ "name": s.name, "label": label, "on": s.on != Some(0), "params": s.params, "values": values, "line": s.line })
})
.collect();
json!({ "name": a.name, "steps": steps })
+114 -41
View File
@@ -9,7 +9,7 @@ mod tests;
mod text;
use serde_json::{Map, Value, json};
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
use vectorcraft_doc::{Document, LockedArtboards, Node, NodeId, NodeKind};
use vectorcraft_geom::{PathData, SubPath};
use vectorcraft_pathops::addons as po;
@@ -37,7 +37,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Simplify Anchors (Keep Shape)…",
["Object", "Plug-ins", "Artwork Cleanup"],
None,
"{tolerance?: 1..100 (20; higher removes more anchors and lets the outline move further from where it was, up to tolerance × 0.02 pt), protectCorners?: bool (false; anchors where the path turns by more than 30° stay), ids?: [id…] (default: the selection)} remove the anchors each path under the objects can do without, refitting the handles beside each so the outline keeps its shape, as one undo step; open ends stay, and locked and hidden paths are left out → {ids, before, after} anchor counts",
"{tolerance?: 1..100 (20; higher removes more anchors and lets the outline move further from where it was, up to tolerance × 0.02 pt), protectCorners?: bool (false; anchors where the path turns by more than 30° stay), ids?: [id…] (default: the selection)} remove the anchors each path under the objects can do without, refitting the handles beside each so the outline keeps its shape, as one undo step when a path changes; open ends stay; from the selection, locked and hidden paths are left out, and named by `ids`, the paths of the objects, groups and layers it names are taken even when locked or hidden, but not those on a locked artboard; a changed path loses its selected anchors and segments → {ids, before, after} anchor counts",
has_selection,
simplify
),
@@ -73,7 +73,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Deep Ungroup…",
["Object", "Plug-ins", "Artwork Cleanup"],
None,
"{includeClipGroups?: bool (false: clipping groups, and the art inside them, stay as they are; true: they are ungrouped too and their clipping paths become plain paths), ids?: [id…] (default: the selection)} ungroup every level of the groups among the objects, as one undo step; each object keeps the opacity of the groups it was in; locked and hidden groups stay whole, and locked and hidden objects stay locked or hidden and are not selected → {ids (the objects selected: those left, or with no group to take apart the selection as it was), ungrouped (groups)}",
"{includeClipGroups?: bool (false: clipping groups, and the art inside them, stay as they are; true: they are ungrouped too and their clipping paths become plain paths), ids?: [id…] (default: the selection)} ungroup every level of the groups among the objects, as one undo step when a group goes; each object keeps the opacity of the groups it was in; from the selection, locked and hidden groups stay whole; named by `ids`, the groups it names and the groups in the groups and layers it names are ungrouped even when locked or hidden (a named layer stays), but not those on a locked artboard, and their objects become hidden or locked when a group was; locked and hidden objects stay locked or hidden and are not selected → {ids (the objects selected: those left, or with no group to take apart the selection as it was), ungrouped (groups)}",
has_selection,
deep_ungroup
),
@@ -82,7 +82,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Rejoin Path Pieces…",
["Object", "Plug-ins", "Artwork Cleanup"],
None,
"{gap?: pt 0..6 (0.1), ids?: [id…] (default: the selection)} weld the open paths under the objects whose ends lie within the gap of each other back into paths: an end joins one other end, the one the path turns least to go on into, pieces may run either way, and a chain whose ends meet closes; each path takes the place and paint of its topmost piece, as one undo step → {ids, joined (paths made), removed (pieces merged into them)}",
"{gap?: pt 0..6 (0.1), ids?: [id…] (default: the selection)} weld the open paths under the objects whose ends lie within the gap of each other back into paths: an end joins one other end, the one the path turns least to go on into, pieces may run either way, and a chain whose ends meet closes; each path takes the place and paint of its topmost piece, as one undo step when a path is made; from the selection, locked and hidden paths are left out, and named by `ids`, they are taken, but not those on a locked artboard; only pieces alike in being hidden (by themselves or a group or layer above them) and in being locked join; locked and hidden paths made are not selected → {ids, joined (paths made), removed (pieces merged into them)}",
has_selection,
rejoin
),
@@ -91,9 +91,28 @@ pub fn specs() -> Vec<CommandSpec> {
v
}
/// The paths (not guides) under `roots`, compound path members included; locked and hidden
/// paths, and those in locked or hidden groups, stay out.
fn paths_under(d: &Document, roots: &[NodeId]) -> Vec<NodeId> {
/// Whether a request names its objects with `ids` (present and not null) rather than taking the
/// selection.
fn named(p: &Value) -> bool {
p.get("ids").is_some_and(|v| !v.is_null())
}
/// Keep the items of `items` whose object (`id`) a command acts on: when `named`, every one not on
/// a locked artboard, locked and hidden ones (and ones in a locked or hidden group or layer)
/// included; else only those that can be edited.
fn retain_targets<T>(d: &Document, items: &mut Vec<T>, named: bool, id: impl Fn(&T) -> NodeId) {
if named {
let locks = d.locked_artboards();
items.retain(|t| !locks.holds_id(id(t)));
} else {
let editable = d.editable();
items.retain(|t| editable(id(t)));
}
}
/// The paths (not guides) under `roots`, compound path members included, that a command acts on
/// ([`retain_targets`]).
fn paths_under(d: &Document, roots: &[NodeId], named: bool) -> Vec<NodeId> {
let mut out = vec![];
for n in roots.iter().filter_map(|id| d.node(*id)) {
n.walk(&mut |c| {
@@ -102,8 +121,7 @@ fn paths_under(d: &Document, roots: &[NodeId]) -> Vec<NodeId> {
}
});
}
let editable = d.editable();
out.retain(|id| editable(*id));
retain_targets(d, &mut out, named, |id| *id);
out
}
@@ -116,7 +134,7 @@ fn simplify(s: &mut Session, p: &Value) -> Result<Value> {
let protect = switch_or(p, "protectCorners", false, C)?;
let roots = checked_ids_or_selection(s, p, C)?;
let d = &s.doc()?.doc;
let ids = paths_under(d, &roots);
let ids = paths_under(d, &roots, named(p));
let paths: Vec<(NodeId, &PathData)> = ids.iter().filter_map(|id| Some((*id, d.node(*id)?.path_data()?))).collect();
let before: usize = paths.iter().map(|(_, p)| p.anchor_count()).sum();
if before > MAX_ANCHORS {
@@ -258,10 +276,11 @@ fn ungroupable(n: &Node, clips: bool) -> bool {
}
/// `n` with every level of grouping Deep Ungroup takes apart removed, into `out`; `count` counts
/// the groups. Members take on the opacity of the groups they were in, and a clipping path
/// becomes a plain path. A locked or hidden group stays whole.
fn flatten(n: Node, clips: bool, out: &mut Vec<Node>, count: &mut usize) {
if !ungroupable(&n, clips) || n.locked || !n.visible {
/// the groups. Members take on the opacity of the groups they were in, are hidden when one of them
/// was hidden and locked when one of them was locked, and a clipping path becomes a plain path. A
/// locked or hidden group stays whole unless `named`.
fn flatten(n: Node, clips: bool, named: bool, out: &mut Vec<Node>, count: &mut usize) {
if !ungroupable(&n, clips) || (!named && (n.locked || !n.visible)) {
out.push(n);
return;
}
@@ -276,26 +295,57 @@ fn flatten(n: Node, clips: bool, out: &mut Vec<Node>, count: &mut usize) {
*clipping = false;
}
c.opacity *= n.opacity;
flatten(c, clips, out, count);
c.visible &= n.visible;
c.locked |= n.locked;
flatten(c, clips, named, out, count);
}
}
/// The outermost groups Deep Ungroup takes apart in layer `n`, its sublayers included, in paint
/// order, leaving out those on a locked artboard.
fn groups_in_layer(d: &Document, n: &Node, clips: bool, out: &mut Vec<NodeId>) {
let locks = d.locked_artboards();
fn visit(n: &Node, clips: bool, locks: &LockedArtboards, out: &mut Vec<NodeId>) {
for c in n.children().into_iter().flatten() {
if c.is_layer() {
visit(c, clips, locks, out);
} else if ungroupable(c, clips) && !locks.holds_id(c.id) {
out.push(c.id);
}
}
}
visit(n, clips, &locks, out);
}
fn deep_ungroup(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.cleanup.deepUngroup";
let clips = switch_or(p, "includeClipGroups", false, C)?;
let roots = checked_ids_or_selection(s, p, C)?;
let named = named(p);
let mut roots = checked_ids_or_selection(s, p, C)?;
let d = &s.doc()?.doc;
// Locked and hidden objects, and those in locked or hidden groups, stay as they are.
let roots: Vec<NodeId> = {
let editable = d.editable();
roots.into_iter().filter(|id| editable(*id)).collect()
};
// From the selection, locked and hidden objects, and those in locked or hidden groups, stay as
// they are; named, only those on a locked artboard do.
retain_targets(d, &mut roots, named, |id| *id);
// A named layer stays: the groups in it, and in its sublayers, are taken apart.
let mut groups = vec![];
for id in roots {
match d.node(id) {
Some(n) if named && n.is_layer() => {
let at = groups.len();
groups_in_layer(d, n, clips, &mut groups);
if groups.len() == at {
groups.push(id);
}
}
_ => groups.push(id),
}
}
let (mut items, mut selected, mut count) = (vec![], vec![], 0);
for id in &roots {
for id in &groups {
match d.node(*id).filter(|n| ungroupable(n, clips)) {
Some(n) => {
let mut out = vec![];
flatten(n.clone(), clips, &mut out, &mut count);
flatten(n.clone(), clips, named, &mut out, &mut count);
selected.extend(out.iter().map(|n| n.id));
items.push((*id, out));
}
@@ -304,7 +354,7 @@ fn deep_ungroup(s: &mut Session, p: &Value) -> Result<Value> {
}
// With no group to take apart, the document, the selection and the undo history stay as they
// are, and the reply lists the selection.
if items.is_empty() {
if count == 0 {
selected = s.doc()?.selection.objects.to_vec();
} else {
selected = s.edit("Deep Ungroup", move |d, sel| {
@@ -319,26 +369,39 @@ fn deep_ungroup(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "ids": selected.iter().map(|i| i.0).collect::<Vec<_>>(), "ungrouped": count }))
}
/// One path Rejoin Path Pieces takes: its id, its subpath, and whether it and every group and layer
/// above it are visible, and whether they are all unlocked.
type Piece = (NodeId, SubPath, (bool, bool));
/// The open single-subpath paths under `roots` that Rejoin Path Pieces takes, in paint order
/// (compound path members, clipping paths and guides stay out).
fn pieces(d: &Document, roots: &[NodeId]) -> Vec<(NodeId, SubPath)> {
fn visit(n: &Node, out: &mut Vec<(NodeId, SubPath)>) {
/// (compound path members, clipping paths and guides stay out; [`retain_targets`]).
fn pieces(d: &Document, roots: &[NodeId], named: bool) -> Vec<Piece> {
fn visit(n: &Node, above: (bool, bool), out: &mut Vec<Piece>) {
let state = (above.0 && n.visible, above.1 && !n.locked);
match &n.kind {
NodeKind::Path { path, guide: false, clipping: false, .. } => {
if let [sp] = path.subpaths.as_slice()
&& !sp.closed
{
out.push((n.id, sp.clone()));
out.push((n.id, sp.clone(), state));
}
}
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => children.iter().for_each(|c| visit(c, out)),
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => children.iter().for_each(|c| visit(c, state, out)),
_ => {}
}
}
let mut out = vec![];
roots.iter().filter_map(|id| d.node(*id)).for_each(|n| visit(n, &mut out));
let editable = d.editable();
out.retain(|(id, _)| editable(*id));
for (id, n) in roots.iter().filter_map(|id| Some((*id, d.node(*id)?))) {
// The groups and layers above the root, and their state.
let above = d.ancestry(id).unwrap_or_default();
let above = above.split_last().map_or(&[][..], |(_, up)| up);
if above.last().and_then(|i| d.node(*i)).is_some_and(|parent| matches!(parent.kind, NodeKind::Compound { .. })) {
continue;
}
let state = above.iter().filter_map(|i| d.node(*i)).fold((true, true), |(v, u), a| (v && a.visible, u && !a.locked));
visit(n, state, &mut out);
}
retain_targets(d, &mut out, named, |piece| piece.0);
out
}
@@ -349,18 +412,26 @@ fn rejoin(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, "`gap` must be from 0 to 6 pt"));
}
let roots = checked_ids_or_selection(s, p, C)?;
let found = pieces(&s.doc()?.doc, &roots);
let found = pieces(&s.doc()?.doc, &roots, named(p));
if found.len() > MAX_OBJECTS {
return Err(EngineError::Other("Rejoin Path Pieces: too many paths to rejoin at once".into()));
}
let subpaths: Vec<SubPath> = found.iter().map(|(_, sp)| sp.clone()).collect();
// Each chain becomes its topmost piece (the last in paint order); the others go.
// Only pieces alike in being shown and in being unlocked join, so that a joined path neither
// shows hidden art nor hides shown art, nor locks or unlocks a piece.
let (mut kept, mut gone) = (vec![], vec![]);
for j in po::rejoin(&subpaths, gap) {
let ids: Vec<NodeId> = j.members.iter().filter_map(|&m| found.get(m).map(|f| f.0)).collect();
let Some(&top) = j.members.iter().max().and_then(|&m| found.get(m).map(|f| &f.0)) else { continue };
gone.extend(ids.into_iter().filter(|id| *id != top));
kept.push((top, j.path));
for state in [(true, true), (true, false), (false, true), (false, false)] {
let part: Vec<&Piece> = found.iter().filter(|f| f.2 == state).collect();
if part.is_empty() {
continue;
}
let subpaths: Vec<SubPath> = part.iter().map(|f| f.1.clone()).collect();
// Each chain becomes its topmost piece (the last in paint order); the others go.
for j in po::rejoin(&subpaths, gap) {
let ids: Vec<NodeId> = j.members.iter().filter_map(|&m| part.get(m).map(|f| f.0)).collect();
let Some(top) = j.members.iter().max().and_then(|&m| part.get(m).map(|f| f.0)) else { continue };
gone.extend(ids.into_iter().filter(|id| *id != top));
kept.push((top, j.path));
}
}
if !kept.is_empty() {
let ids: Vec<NodeId> = kept.iter().map(|k| k.0).collect();
@@ -369,7 +440,9 @@ fn rejoin(s: &mut Session, p: &Value) -> Result<Value> {
set_path(d, *id, PathData::single(sp.clone()));
}
d.remove_many(&gone)?;
sel.set(ids);
// Locked and hidden paths, and those in locked or hidden groups, are not selected.
let editable = d.editable();
sel.set(ids.into_iter().filter(|id| editable(*id)));
Ok(())
})?;
}
+268 -12
View File
@@ -3,7 +3,7 @@
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, Node, NodeId, NodeKind};
use vectorcraft_doc::{Appearance, LayerColor, Node, NodeId, NodeKind};
use vectorcraft_geom::{FillRule, Rect};
use crate::Session;
@@ -123,18 +123,100 @@ fn simplify_leaves_locked_and_hidden_paths_alone() {
assert_eq!(r["before"], 16);
assert!(anchors(&s, ids[0]) < 16);
assert_eq!((anchors(&s, ids[1]), anchors(&s, ids[2])), (16, 16));
// Named by `ids`, or in a locked group, a path stays too, with no undo step.
let steps = undo_steps(&s);
s.execute("addon.cleanup.simplify", &json!({"ids": [ids[1].0, ids[2].0]})).unwrap();
assert_eq!((anchors(&s, ids[1]), anchors(&s, ids[2])), (16, 16));
assert_eq!(undo_steps(&s), steps);
// In a selected locked group, a path stays too, with no undo step.
change(&mut s, ids[1], |n| n.locked = false);
change(&mut s, g, |n| n.locked = true);
let steps = undo_steps(&s);
let r = s.execute("addon.cleanup.simplify", &json!({"ids": [g.0]})).unwrap();
let r = s.execute("addon.cleanup.simplify", &json!({})).unwrap();
assert_eq!((r["before"].as_u64(), r["after"].as_u64()), (Some(0), Some(0)));
assert_eq!(anchors(&s, ids[1]), 16);
assert_eq!(undo_steps(&s), steps);
// Named by `ids`, the locked group's paths, its hidden one included, are simplified.
let r = s.execute("addon.cleanup.simplify", &json!({"ids": [g.0]})).unwrap();
assert_eq!(r["ids"], json!([ids[0].0, ids[1].0, ids[2].0]));
assert!(anchors(&s, ids[1]) < 16 && anchors(&s, ids[2]) < 16);
assert_eq!(undo_steps(&s), steps + 1);
}
/// A path of 16 anchors at (`x`, `y`): an ellipse with anchors added twice.
fn ellipse16(s: &mut Session, x: f64, y: f64) -> NodeId {
let e = id_of(&s.execute("shape.ellipse", &json!({"x": x, "y": y, "width": 50, "height": 50})).unwrap());
select(s, &[e]);
s.execute("object.path.addAnchorPoints", &json!({})).unwrap();
s.execute("object.path.addAnchorPoints", &json!({})).unwrap();
e
}
/// Simplify Anchors takes the locked and hidden paths `ids` names, those in the locked or hidden
/// groups and layers it names (their locked and hidden members too) and a hidden compound path's
/// member, as one undo step, without selecting them; from the selection it leaves them alone, and a
/// path on a locked artboard stays even when named.
#[test]
fn simplify_named_locked_and_hidden_paths() {
let mut s = session();
let locked = ellipse16(&mut s, 10.0, 10.0);
let hidden = ellipse16(&mut s, 70.0, 10.0);
change(&mut s, locked, |n| n.locked = true);
change(&mut s, hidden, |n| n.visible = false);
// A locked group with a hidden member, and a hidden group with a locked member.
let in_locked = ellipse16(&mut s, 130.0, 10.0);
let locked_group = wrap(&mut s, in_locked, |c| NodeKind::Group { children: c, clip: false });
change(&mut s, locked_group, |n| n.locked = true);
change(&mut s, in_locked, |n| n.visible = false);
let in_hidden = ellipse16(&mut s, 190.0, 10.0);
let hidden_group = wrap(&mut s, in_hidden, |c| NodeKind::Group { children: c, clip: false });
change(&mut s, hidden_group, |n| n.visible = false);
change(&mut s, in_hidden, |n| n.locked = true);
// A member of a hidden compound path.
let member = ellipse16(&mut s, 250.0, 10.0);
let compound = wrap(&mut s, member, |c| NodeKind::Compound { children: c, rule: FillRule::NonZero });
change(&mut s, compound, |n| n.visible = false);
// A path in a locked layer, named itself.
let layer = id_of(&s.execute("layer.new", &json!({})).unwrap());
let in_layer = ellipse16(&mut s, 10.0, 100.0);
let on_board = ellipse16(&mut s, 10.0, 200.0);
change(&mut s, layer, |n| n.locked = true);
let all = [locked, hidden, in_locked, in_hidden, member, in_layer];
select(&mut s, &[locked, hidden, locked_group, hidden_group, compound]);
let steps = undo_steps(&s);
let r = s.execute("addon.cleanup.simplify", &json!({})).unwrap();
assert_eq!(r["ids"], json!([]), "from the selection, none is taken");
assert_eq!(undo_steps(&s), steps);
assert!(all.iter().all(|id| anchors(&s, *id) == 16));
select(&mut s, &[]);
s.edit("Select Anchors", |_, sel| {
sel.anchors.insert(locked, [(0, 1)].into());
Ok(())
})
.unwrap();
let steps = undo_steps(&s);
let r = s.execute("addon.cleanup.simplify", &json!({"ids": [locked.0, hidden.0, locked_group.0, hidden_group.0, member.0, in_layer.0]})).unwrap();
assert_eq!(r["ids"], json!(all.iter().map(|i| i.0).collect::<Vec<_>>()));
assert_eq!(r["before"], 16 * 6);
assert_eq!(r["after"].as_u64(), Some(all.iter().map(|id| anchors(&s, *id) as u64).sum::<u64>()));
assert!(all.iter().all(|id| anchors(&s, *id) < 16), "{r}");
assert_eq!(undo_steps(&s), steps + 1);
let sel = &s.doc().unwrap().selection;
assert!(sel.objects.is_empty() && sel.anchors.is_empty(), "nothing is selected, and the changed path's anchors are not");
let n = node(&s, in_hidden).unwrap();
assert!(n.locked && n.visible && !node(&s, hidden_group).unwrap().visible);
s.execute("edit.undo", &json!({})).unwrap();
assert!(all.iter().all(|id| anchors(&s, *id) == 16));
s.execute("edit.redo", &json!({})).unwrap();
assert!(all.iter().all(|id| anchors(&s, *id) < 16));
// Nothing left to remove: no undo step.
let steps = undo_steps(&s);
s.execute("addon.cleanup.simplify", &json!({"ids": [locked.0]})).unwrap();
assert_eq!(undo_steps(&s), steps);
// On a locked artboard, a named path stays as it is.
s.edit("Lock Artboard", |d, _| {
d.artboards[0].locked = true;
Ok(())
})
.unwrap();
let steps = undo_steps(&s);
let r = s.execute("addon.cleanup.simplify", &json!({"ids": [on_board.0]})).unwrap();
assert_eq!((r["ids"].clone(), anchors(&s, on_board), undo_steps(&s)), (json!([]), 16, steps));
}
/// Each kind: what makes the issue in a fresh document (beside a clean rectangle).
@@ -610,14 +692,15 @@ fn deep_ungroup_keeps_hidden_art_hidden_and_locked_art_locked() {
assert!(!d.node(hidden).unwrap().visible && d.node(b).unwrap().visible);
assert!(d.node(locked).unwrap().locked && d.node(e).unwrap().locked);
assert_eq!(s.doc().unwrap().selection.objects, [a]);
// Named by `ids`, a locked group stays as it is, with no undo step.
// Named by `ids`, a locked group is taken apart as one undo step; its member becomes locked
// and is not selected.
let steps = undo_steps(&s);
let r = s.execute("addon.cleanup.deepUngroup", &json!({"ids": [locked.0]})).unwrap();
assert_eq!(r["ungrouped"], 0);
assert!(node(&s, locked).is_some());
assert_eq!(undo_steps(&s), steps);
assert_eq!(r["ungrouped"], 1);
assert!(node(&s, locked).is_none() && node(&s, c).unwrap().locked);
assert_eq!(undo_steps(&s), steps + 1);
assert_eq!(r["ids"], json!([]));
// With nothing ungrouped, the selection stays and the reply lists it.
assert_eq!(r["ids"], json!([a.0]));
let f = rect(&mut s, 130.0, 10.0, 20.0, 20.0);
select(&mut s, &[a]);
let r = s.execute("addon.cleanup.deepUngroup", &json!({"ids": [f.0]})).unwrap();
@@ -625,6 +708,108 @@ fn deep_ungroup_keeps_hidden_art_hidden_and_locked_art_locked() {
assert_eq!(s.doc().unwrap().selection.objects, [a]);
}
/// Put `ids` inside a new container that `kind` makes of its members.
fn wrap_all(s: &mut Session, ids: &[NodeId], kind: fn(Vec<std::sync::Arc<Node>>) -> NodeKind) -> NodeId {
s.edit("t", |d, _| {
let w = d.alloc_id();
Ok(d.wrap(ids, |c| Node::new(w, kind(c)))?)
})
.unwrap()
}
fn plain_group(c: Vec<std::sync::Arc<Node>>) -> NodeKind {
NodeKind::Group { children: c, clip: false }
}
fn sublayer(c: Vec<std::sync::Arc<Node>>) -> NodeKind {
NodeKind::Layer { color: LayerColor::Preset(0), template: false, printable: true, children: c, clip: false, preview: true, dim_images: None }
}
/// Named by `ids`, Deep Ungroup takes apart locked and hidden groups, those nested in a group it
/// names and those in a layer it names (its sublayers included, the layer staying), as one undo
/// step; their members become locked or hidden as the groups were, keep the groups' opacity and are
/// not selected. From the selection those groups stay whole, as do a clipping group and a group on a
/// locked artboard even when named.
#[test]
fn deep_ungroup_named_locked_and_hidden_groups() {
let mut s = session();
let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0);
let b = rect(&mut s, 40.0, 10.0, 20.0, 20.0);
let locked = wrap_all(&mut s, &[a], plain_group);
let hidden = wrap_all(&mut s, &[b], plain_group);
change(&mut s, locked, |n| n.locked = true);
change(&mut s, hidden, |n| {
n.visible = false;
n.opacity = 0.5;
});
select(&mut s, &[locked, hidden]);
let steps = undo_steps(&s);
let r = s.execute("addon.cleanup.deepUngroup", &json!({})).unwrap();
assert_eq!((r["ungrouped"].clone(), undo_steps(&s)), (json!(0), steps), "from the selection, they stay whole");
assert!(node(&s, locked).is_some() && node(&s, hidden).is_some());
let r = s.execute("addon.cleanup.deepUngroup", &json!({"ids": [locked.0, hidden.0]})).unwrap();
assert_eq!((r["ungrouped"].clone(), r["ids"].clone()), (json!(2), json!([])));
assert_eq!(undo_steps(&s), steps + 1);
assert!(node(&s, locked).is_none() && node(&s, hidden).is_none());
let (na, nb) = (node(&s, a).unwrap(), node(&s, b).unwrap());
assert!(na.locked && na.visible && !nb.visible && !nb.locked);
assert_eq!(nb.opacity, 0.5);
assert!(s.doc().unwrap().selection.objects.is_empty());
s.execute("edit.undo", &json!({})).unwrap();
assert!(node(&s, locked).is_some() && !node(&s, a).unwrap().locked && node(&s, b).unwrap().visible);
s.execute("edit.redo", &json!({})).unwrap();
assert!(node(&s, locked).is_none() && node(&s, a).unwrap().locked);
// Locked and hidden groups nested in a named group that can be edited.
let c = rect(&mut s, 70.0, 10.0, 20.0, 20.0);
let e = rect(&mut s, 100.0, 10.0, 20.0, 20.0);
let f = rect(&mut s, 130.0, 10.0, 20.0, 20.0);
let inner_locked = wrap_all(&mut s, &[e], plain_group);
let inner_hidden = wrap_all(&mut s, &[f], plain_group);
change(&mut s, inner_locked, |n| n.locked = true);
change(&mut s, inner_hidden, |n| n.visible = false);
let outer = wrap_all(&mut s, &[c, inner_locked, inner_hidden], plain_group);
let r = s.execute("addon.cleanup.deepUngroup", &json!({"ids": [outer.0]})).unwrap();
assert_eq!((r["ungrouped"].clone(), r["ids"].clone()), (json!(3), json!([c.0])));
assert!(node(&s, e).unwrap().locked && !node(&s, f).unwrap().visible);
assert_eq!(s.doc().unwrap().selection.objects, [c]);
// In a named layer and its sublayer; the layers and a clipping group stay.
let layer = id_of(&s.execute("layer.new", &json!({})).unwrap());
let g = rect(&mut s, 10.0, 100.0, 20.0, 20.0);
let h = rect(&mut s, 40.0, 100.0, 20.0, 20.0);
let layer_locked = wrap_all(&mut s, &[g], plain_group);
change(&mut s, layer_locked, |n| n.locked = true);
let layer_hidden = wrap_all(&mut s, &[h], plain_group);
change(&mut s, layer_hidden, |n| n.visible = false);
let sub = wrap_all(&mut s, &[layer_hidden], sublayer);
let clipped = rect(&mut s, 100.0, 100.0, 10.0, 10.0);
let clip = rect(&mut s, 95.0, 95.0, 30.0, 30.0);
select(&mut s, &[clipped, clip]);
s.execute("object.clippingMask.make", &json!({})).unwrap();
let clip_group = s.doc().unwrap().selection.objects[0];
let steps = undo_steps(&s);
let r = s.execute("addon.cleanup.deepUngroup", &json!({"ids": [layer.0]})).unwrap();
assert_eq!((r["ungrouped"].clone(), undo_steps(&s)), (json!(2), steps + 1));
assert!(node(&s, layer_locked).is_none() && node(&s, layer_hidden).is_none());
assert!(node(&s, layer).is_some() && node(&s, clip_group).is_some());
assert!(node(&s, g).unwrap().locked && !node(&s, h).unwrap().visible);
assert_eq!(s.doc().unwrap().doc.parent_of(h), Some(sub));
// On a locked artboard, a named group stays whole, with the selection as it was.
let k = rect(&mut s, 10.0, 200.0, 20.0, 20.0);
let on_board = wrap_all(&mut s, &[k], plain_group);
let off_board = rect(&mut s, 500.0, 10.0, 20.0, 20.0);
s.edit("Lock Artboard", |d, _| {
d.artboards[0].locked = true;
Ok(())
})
.unwrap();
select(&mut s, &[off_board]);
let steps = undo_steps(&s);
let r = s.execute("addon.cleanup.deepUngroup", &json!({"ids": [on_board.0]})).unwrap();
assert_eq!((r["ungrouped"].clone(), r["ids"].clone(), undo_steps(&s)), (json!(0), json!([off_board.0]), steps));
assert!(node(&s, on_board).is_some());
assert_eq!(s.doc().unwrap().selection.objects, [off_board]);
}
#[test]
fn rejoin_joins_pieces_within_the_gap_only() {
let mut s = session();
@@ -646,3 +831,74 @@ fn rejoin_joins_pieces_within_the_gap_only() {
assert!(s.execute("addon.cleanup.rejoin", &bad).is_err(), "{bad}");
}
}
/// Named by `ids`, Rejoin Path Pieces takes locked and hidden pieces (in a locked group or layer
/// too) and joins only pieces alike in being hidden and in being locked, whichever is topmost, as one
/// undo step; a locked or hidden path it makes is not selected. From the selection it takes only
/// pieces that can be edited, and compound path members and pieces on a locked artboard stay out.
#[test]
fn rejoin_named_locked_and_hidden_pieces() {
let mut s = session();
let a = line(&mut s, &[(10.0, 10.0), (50.0, 10.0)]);
let b = line(&mut s, &[(50.05, 10.0), (90.0, 10.0)]);
let g = wrap_all(&mut s, &[a, b], plain_group);
change(&mut s, g, |n| n.locked = true);
select(&mut s, &[g]);
let steps = undo_steps(&s);
let r = s.execute("addon.cleanup.rejoin", &json!({})).unwrap();
assert_eq!((r["joined"].clone(), undo_steps(&s)), (json!(0), steps), "from the selection, none is taken");
let r = s.execute("addon.cleanup.rejoin", &json!({"ids": [g.0]})).unwrap();
assert_eq!((r["joined"].clone(), r["removed"].clone(), r["ids"].clone()), (json!(1), json!(1), json!([b.0])));
assert_eq!(undo_steps(&s), steps + 1);
assert!(node(&s, a).is_none() && anchors(&s, b) == 3);
assert!(s.doc().unwrap().selection.objects.is_empty(), "the locked path is not selected");
s.execute("edit.undo", &json!({})).unwrap();
assert!(node(&s, a).is_some() && anchors(&s, b) == 2);
s.execute("edit.redo", &json!({})).unwrap();
assert!(node(&s, a).is_none() && anchors(&s, b) == 3);
// A shown piece and two hidden ones, the hidden on top: only the hidden pair joins.
let c = line(&mut s, &[(10.0, 40.0), (50.0, 40.0)]);
let e = line(&mut s, &[(50.05, 40.0), (90.0, 40.0)]);
let h = line(&mut s, &[(90.05, 40.0), (130.0, 40.0)]);
change(&mut s, e, |n| n.visible = false);
change(&mut s, h, |n| n.visible = false);
let r = s.execute("addon.cleanup.rejoin", &json!({"ids": [c.0, e.0, h.0]})).unwrap();
assert_eq!((r["joined"].clone(), r["removed"].clone(), r["ids"].clone()), (json!(1), json!(1), json!([h.0])));
assert!(node(&s, e).is_none() && anchors(&s, c) == 2 && anchors(&s, h) == 3);
assert!(!node(&s, h).unwrap().visible && node(&s, c).unwrap().visible);
assert!(s.doc().unwrap().selection.objects.is_empty(), "the hidden path is not selected");
// An unlocked piece under a locked one, and a hidden piece under a shown one: none joins.
let k = line(&mut s, &[(10.0, 70.0), (50.0, 70.0)]);
let m = line(&mut s, &[(50.05, 70.0), (90.0, 70.0)]);
let n = line(&mut s, &[(10.0, 100.0), (50.0, 100.0)]);
let o = line(&mut s, &[(50.05, 100.0), (90.0, 100.0)]);
change(&mut s, m, |x| x.locked = true);
change(&mut s, n, |x| x.visible = false);
let steps = undo_steps(&s);
let r = s.execute("addon.cleanup.rejoin", &json!({"ids": [k.0, m.0, n.0, o.0]})).unwrap();
assert_eq!((r["joined"].clone(), r["removed"].clone(), undo_steps(&s)), (json!(0), json!(0), steps));
assert!([k, m, n, o].iter().all(|id| anchors(&s, *id) == 2));
// Named pieces in a locked layer join; members of a compound path stay out.
let layer = id_of(&s.execute("layer.new", &json!({})).unwrap());
let p = line(&mut s, &[(10.0, 130.0), (50.0, 130.0)]);
let q = line(&mut s, &[(50.05, 130.0), (90.0, 130.0)]);
let u = line(&mut s, &[(10.0, 160.0), (50.0, 160.0)]);
let v = line(&mut s, &[(50.05, 160.0), (90.0, 160.0)]);
wrap_all(&mut s, &[u, v], |c| NodeKind::Compound { children: c, rule: FillRule::NonZero });
let w = line(&mut s, &[(10.0, 200.0), (50.0, 200.0)]);
let x = line(&mut s, &[(50.05, 200.0), (90.0, 200.0)]);
change(&mut s, layer, |l| l.locked = true);
let r = s.execute("addon.cleanup.rejoin", &json!({"ids": [p.0, q.0, u.0, v.0]})).unwrap();
assert_eq!((r["joined"].clone(), r["ids"].clone()), (json!(1), json!([q.0])));
assert!(node(&s, p).is_none() && anchors(&s, u) == 2 && anchors(&s, v) == 2);
// On a locked artboard, named pieces stay as they are.
s.edit("Lock Artboard", |d, _| {
d.artboards[0].locked = true;
Ok(())
})
.unwrap();
let steps = undo_steps(&s);
let r = s.execute("addon.cleanup.rejoin", &json!({"ids": [w.0, x.0]})).unwrap();
assert_eq!((r["joined"].clone(), undo_steps(&s)), (json!(0), steps));
assert!(node(&s, w).is_some() && node(&s, x).is_some());
}
@@ -1225,7 +1225,7 @@ fn expand_dashes_keeps_the_fill_below_and_follows_aligned_corners() {
let mut centred = 0;
for id in ids(&r) {
let sp = path(&s, id).subpaths[0].clone();
if let [a, b, c] = sp.anchors.as_slice()
if let [a, b, c] = &*sp.anchors.to_vec()
&& corners.iter().any(|k| b.p.distance(*k) < 1e-6)
{
assert!((a.p.distance(b.p) - 5.0).abs() < 1e-6 && (c.p.distance(b.p) - 5.0).abs() < 1e-6, "{sp:?}");
+6 -6
View File
@@ -14,8 +14,9 @@ use vectorcraft_doc::{LockedArtboards, Node, NodeId, NodeKind};
use vectorcraft_geom::Point;
use super::super::links::{Status, statuses};
use super::super::panelcmds::listed_artboards;
use super::super::select::{is_selectable, on_artboard, selectable};
use super::super::{CommandSpec, bad, checked_ids_param, cmd, has_doc, point_indices, switch_or, text_param};
use super::super::{CommandSpec, bad, checked_ids_param, cmd, has_doc, switch_or, text_param};
use crate::{DocState, Result, Session};
/// How Select by Artboards relates an object to the chosen artboards (`relation`).
@@ -33,7 +34,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Select by Artboards",
["Object", "Plug-ins", "Selection Tools"],
None,
"{artboards: [index…] (0-based, at least one), relation?: \"overlap\" (default: on at least one of them, as select.allOnArtboard takes it: the bounds overlap the artboard or the centre is on it) | \"inside\" (the bounds lie wholly within one of them, edges included) | \"outside\" (on none of them)} select those objects among the ones Select All takes (in isolation mode, the isolated container's); hidden and locked objects, objects on hidden, locked or template layers and art held by a locked artboard are left out. Bounds are geometric, a symbol instance's measured by its art → {count}",
"{artboards: [index…] (0-based whole numbers, at least one), relation?: \"overlap\" (default: on at least one of them, as select.allOnArtboard takes it: the bounds overlap the artboard or the centre is on it) | \"inside\" (the bounds lie wholly within one of them, edges included) | \"outside\" (on none of them)} select those objects among the ones Select All takes (in isolation mode, the isolated container's); hidden and locked objects, objects on hidden, locked or template layers and art held by a locked artboard are left out. Bounds are geometric, a symbol instance's measured by its art → {count}",
has_doc,
by_artboards
),
@@ -58,13 +59,12 @@ fn by_artboards(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, "`relation` must be overlap, inside or outside"));
}
let listed = p.get("artboards").ok_or_else(|| bad(C, "missing `artboards`"))?;
let indices = point_indices(listed).ok_or_else(|| bad(C, "`artboards` must be a list of artboard indices"))?;
if indices.is_empty() {
if listed.as_array().is_some_and(Vec::is_empty) {
return Err(bad(C, "choose at least one artboard"));
}
let st = s.doc()?;
let rects =
indices.iter().map(|i| st.doc.artboards.get(*i).map(|a| a.rect)).collect::<Option<Vec<_>>>().ok_or_else(|| bad(C, "no such artboard"))?;
let rects: Vec<_> =
listed_artboards(&st.doc, listed, "artboards", C)?.into_iter().filter_map(|i| st.doc.artboards.get(i)).map(|a| a.rect).collect();
let symbols = st.doc.symbol_boxes();
let ids: Vec<NodeId> = selectable(&st.doc, st.isolation)
.into_iter()
@@ -108,6 +108,35 @@ fn bad_params_are_errors_and_leave_the_selection() {
}
}
/// A malformed `artboards` entry is refused as one, naming the parameter, before any entry is
/// looked up; a value that is not a list, an empty list, a left-out list and a whole number past
/// the last artboard each have their own error.
#[test]
fn artboard_errors_say_what_is_wrong() {
let mut s = three_boards();
let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0);
let entries = "invalid parameters for `addon.select.byArtboards`: `artboards` must contain whole numbers";
let not_a_list = "invalid parameters for `addon.select.byArtboards`: `artboards` must be a list of artboard indices";
for (p, want) in [
(json!({"artboards": ["1"]}), entries),
(json!({"artboards": [-1]}), entries),
(json!({"artboards": [1.5]}), entries),
(json!({"artboards": [true]}), entries),
(json!({"artboards": [9, "1"]}), entries),
(json!({"artboards": "0"}), not_a_list),
(json!({"artboards": null}), not_a_list),
(json!({"artboards": []}), "invalid parameters for `addon.select.byArtboards`: choose at least one artboard"),
(json!({}), "invalid parameters for `addon.select.byArtboards`: missing `artboards`"),
(json!({"artboards": [9]}), "no such artboard"),
(json!({"artboards": [0, 9]}), "no such artboard"),
] {
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
assert_eq!(s.execute(C, &p).unwrap_err().to_string(), want, "{p}");
assert_eq!(s.doc().unwrap().selection.objects, vec![a], "{p}");
}
assert_eq!(pick(&mut s, json!({"artboards": [0, 0]})), vec![a]);
}
#[test]
fn a_symbol_instance_is_measured_by_its_art() {
// The symbol's art, (100, 60) to (140, 80), drawn 50 pt up and left: (50, 10) to (90, 30), on
+3 -3
View File
@@ -19,8 +19,8 @@ use super::edit::{outermost, selected_roots};
use super::pathops::{remove_each, separate};
use super::swatch::{anchor, names_param};
use super::{
CommandSpec, Result, Session, bad, check_numbers, cmd, color_param, find_command, has_doc, has_selection, id_param, named_ids, number_or, ok,
str_param, switch_or,
CommandSpec, Result, Session, bad, check_numbers, cmd, color_param, find_command, has_doc, has_selection, named_ids, number_or, ok, str_param,
switch_or,
};
use crate::EngineError;
@@ -180,7 +180,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Paintbrush",
[],
None,
"{…params as path.freehand, brush?: name} paint a freehand stroke with a brush; without `brush` (or with null) the plain path is drawn; a `brush` that names no brush in the document, or a `style` other than \"pencil\" or \"brush\", is an error and nothing is drawn → {id}",
"{…params as path.freehand, brush?: name} paint a freehand stroke with a brush; without `brush` (or with null) the plain path is drawn; a `brush` that names no brush in the document, a `style` other than \"pencil\" or \"brush\", a `brush` for a path with no stroke (a continued or redrawn path without one, or a new filled \"pencil\" path while the stroke is None) and an unknown key are errors and nothing is drawn; the path and its brush are one undo step → {id}",
has_doc,
brush_freehand
),
+7 -14
View File
@@ -1,7 +1,6 @@
use super::{
AppearanceItem, Arc, BTreeSet, Bristle, Brush, BrushKind, Color, Document, EngineError, Node, NodeId, NodeKind, Paint, Result, Session, Value,
anchor, bad, brush, check_numbers, find_command, id_param, ids_json, json, name_param, names_param, ok, roots, selection_art, set_unknown,
str_param,
anchor, bad, brush, check_numbers, find_command, ids_json, json, name_param, names_param, ok, roots, selection_art, set_unknown, str_param,
};
// ---------- brushes ----------
@@ -729,9 +728,10 @@ pub(super) fn expand_brush(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "ids": ids_json(&out) }))
}
/// Paints a `path.freehand` stroke with the brush `brush` names. A `brush` left out or null draws
/// the plain path; a `brush` that is not a string or names no brush in the document, and a `style`
/// other than "pencil" or "brush", is an error before anything is drawn.
/// Paints a `path.freehand` stroke with the brush `brush` names, in one undo step with the path.
/// A `brush` left out or null draws the plain path; a `brush` that is not a string or names no
/// brush in the document, a `style` other than "pencil" or "brush", and a `brush` for a path with
/// no stroke are errors before anything is drawn.
pub(super) fn brush_freehand(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "brush.freehand";
let name = match crate::cmd::text_param(p, "brush", C)? {
@@ -745,13 +745,6 @@ pub(super) fn brush_freehand(s: &mut Session, p: &Value) -> Result<Value> {
Some(v) => return Err(bad(C, format!("`style` must be \"pencil\" or \"brush\", not {v}"))),
}
let spec = find_command("path.freehand").ok_or_else(|| EngineError::UnknownCommand("path.freehand".into()))?;
let r = crate::cmd::run_checked(s, spec, p)?;
let (Some(name), Some(id)) = (name, id_param(&r, "id")) else { return Ok(r) };
s.edit("Paintbrush", |d, _| {
if let Some(st) = d.node_mut(id).and_then(|n| n.appearance.stroke_mut()) {
st.brush = Some(name.clone());
}
Ok(())
})?;
Ok(r)
let checked = crate::schemas::check(spec, p, s.replaying())?;
crate::cmd::draw2::freehand_brushed(s, checked.as_ref().unwrap_or(p), name.as_deref())
}
+3 -3
View File
@@ -117,7 +117,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Live Paint Bucket",
[],
None,
"{group?: id | ids?|id?: [id…] (paths to make live first, as livePaint.make does), point: [x,y], points?: [[x,y]…] (a drag polyline starting at point; 1 to 4096 finite canvas points; fills every crossed face of the same group), continuation?: [[[x,y]…]…] (further path chunks of 1 to 4096 finite canvas points each; each starts at the preceding chunk's last point; requires points), scope?: \"one\" (default: the face alone) | \"connected\" (a double-click: also each face reached across edges that are hidden or draw no stroke, as a flood from the face; a stroked edge, a hidden face or a Gap Detection gap stops it) | \"samePaint\" (a triple-click: also every face of the group whose fill is match; \"connected\" and \"samePaint\" are refused with points), match?: paint (as the document stores it, the `fill` of a face's appearance; scope samePaint only; default: the face's fill), color?|swatch?|gradient?|none? (default: current fill; a color in #rrggbbaa or #rgba is refused), tint?: 0..100, keepModel?: bool (both as paint.setFill)} fill the face containing point, skipping hidden faces; when every face it reaches already has the paint, nothing changes (no undo step, and the recent colors stay as they are) → {group, face, count (faces reached)}",
"{group?: id | ids?|id?: [id…] (paths to make live first, as livePaint.make does), point: [x,y], points?: [[x,y]…] (a drag polyline starting at point; 1 to 4096 finite canvas points; fills every crossed face of the same group), continuation?: [[[x,y]…]…] (further path chunks of 1 to 4096 finite canvas points each; each starts at the preceding chunk's last point; requires points), scope?: \"one\" (default: the face alone) | \"connected\" (a double-click: also each face reached across edges that are hidden or draw no stroke, as a flood from the face; a stroked edge, a hidden face or a Gap Detection gap stops it) | \"samePaint\" (a triple-click: also every face of the group whose fill is match; \"connected\" and \"samePaint\" are refused with points), match?: paint (as the document stores it, the `fill` of a face's appearance; scope samePaint only; default: the face's fill), color?|swatch?|gradient?|none? (default: current fill; a color in #rrggbbaa or #rgba is refused), tint?: 0..100, keepModel?: bool (both as paint.setFill)} fill the face containing point, skipping hidden faces: each face reached has its fills replaced by one fill with the paint, shown, fully opaque and blending normally, in the place of its topmost fill (none leaves one fill of none), and its strokes and its own opacity stay as they are; when every face it reaches already has only that one fill, nothing changes (no undo step, and the recent colors stay as they are) → {group, face, count (faces reached)}",
has_doc,
lp_fill
),
@@ -126,7 +126,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Live Paint Bucket (Stroke)",
[],
None,
"{group: id, point: [x,y], color?|swatch?|none? (default: current stroke), width?: pt (default: current weight), tolerance?: pt (4), scope?: \"one\" (default: the edge alone) | \"connected\" (a double-click: also each edge joined to it, end to end, through edges that draw with stroke paint match) | \"samePaint\" (a triple-click: also every edge of the group that draws with stroke paint match), match?: paint (as the document stores it; scope connected or samePaint only; default: the stroke paint the edge draws with; an edge draws with the paint of its topmost stroke that is shown, with paint, above 0 pt and not fully transparent, and with none when the edge is fully transparent or has no such stroke), tint?: 0..100, keepModel?: bool (both as paint.setFill)} paint the edge nearest point, skipping hidden edges: each edge reached takes the paint and width in its topmost stroke that is shown, or in a new stroke above the others when it has none; when every edge it reaches already has the paint and the width there, nothing changes (no undo step, and the recent colors stay as they are) → {group, edge, count (edges reached)}",
"{group: id, point: [x,y], color?|swatch?|none? (default: current stroke), width?: pt (default: current weight), tolerance?: pt (4), scope?: \"one\" (default: the edge alone) | \"connected\" (a double-click: also each edge joined to it, end to end, through edges that draw with stroke paint match) | \"samePaint\" (a triple-click: also every edge of the group that draws with stroke paint match), match?: paint (as the document stores it; scope connected or samePaint only; default: the stroke paint the edge draws with; an edge draws with the paint of its topmost stroke that is shown, with paint, above 0 pt and not fully transparent, and with none when the edge is fully transparent or has no such stroke), tint?: 0..100, keepModel?: bool (both as paint.setFill)} paint the edge nearest point, skipping hidden edges: each edge reached has its strokes replaced by one stroke with the paint and width, shown, fully opaque and blending normally, in the place of its topmost stroke and with the dashes, caps and arrowheads of its topmost shown stroke (a new stroke above the fills when it has none; none leaves one stroke of none), and its fills stay as they are; when every edge it reaches already has only that one stroke (at any width for none), nothing changes (no undo step, and the recent colors stay as they are) → {group, edge, count (edges reached)}",
has_doc,
lp_stroke_edge
),
@@ -144,7 +144,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Make",
["Object", "Image Trace"],
None,
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), savedPreset?: {settings} (a saved preset's settings, which the journal records so a replay traces with them after the preset is changed, renamed or deleted; used in place of the saved preset `preset` names), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), colorGroup?: name (color mode, documentLibrary with library \"document\": the colors of that document color group instead of every swatch; \"\" is every swatch), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), ignoreBackground: false (with ignoreWhite: only the areas of the ignored color that reach the image's edge are left out), keepColors?: [\"#rrggbb\"…] (not mode logo: only the palette colors within about 30 levels of a listed color are traced; empty: every color), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, at most ten; white is its own color, and a named color with every channel 245 or more is taken as that white, except with logoColors named, logoWhite false and no ignoreWhite; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white while white is its own color, and stay holes with ignoreWhite or without a white of its own; fills/strokes are ignored; gradients and photos are refused, judged by the image's own colors also when logoColors are named), logoWhite: true (mode logo with logoColors named; false traces the named colors only: white areas take the nearest of them, a named white is kept and enclosed transparent areas stay holes; with ignoreWhite, white areas are left out either way)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down; Flat Logo names only the size it traced a larger image at; or a keepColors color matches none the trace found, or nothing was traced)}",
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), savedPreset?: {settings} (a saved preset's settings, which the journal records so a replay traces with them after the preset is changed, renamed or deleted; used in place of the saved preset `preset` names), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), colorGroup?: name (color mode, documentLibrary with library \"document\": the colors of that document color group instead of every swatch; \"\" is every swatch), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\" (abutting with fills, not mode logo: a \"Seam Fills\" group under the paths holds 1.5 px strokes along the edges two filled paths share, in the color of the lower path, so that no light hairline shows along those edges; the paths are not changed), ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), ignoreBackground: false (with ignoreWhite: only the areas of the ignored color that reach the image's edge are left out), keepColors?: [\"#rrggbb\"…] (not mode logo: only the palette colors within about 30 levels of a listed color are traced; empty: every color), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, at most ten; white is its own color, and a named color with every channel 245 or more is taken as that white, except with logoColors named, logoWhite false and no ignoreWhite; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white while white is its own color, and stay holes with ignoreWhite or without a white of its own; fills/strokes are ignored; gradients and photos are refused, judged by the image's own colors also when logoColors are named), logoWhite: true (mode logo with logoColors named; false traces the named colors only: white areas take the nearest of them, a named white is kept and enclosed transparent areas stay holes; with ignoreWhite, white areas are left out either way)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down; Flat Logo names only the size it traced a larger image at; or a keepColors color matches none the trace found, or nothing was traced)}",
has_selection_or_ids,
|s, p| trace_make(s, p, false)
),
+79 -39
View File
@@ -525,16 +525,17 @@ pub(super) fn lp_fill(s: &mut Session, p: &Value) -> Result<Value> {
Some(gid) => {
let (fid, targets) = targets_in(&s.doc()?.doc, gid)?;
let count = targets.len();
// Faces that already have the paint stay as they are: when every face has it, nothing
// changes and no undo step is recorded.
// Faces that already have only the fill the bucket would leave ([`bucket_keeps`]) stay as
// they are: when every face does, nothing changes and no undo step is recorded.
let d = &s.doc()?.doc;
let unpainted: Vec<NodeId> = targets.into_iter().filter(|t| d.node(*t).is_none_or(|f| f.appearance.fill_paint() != paint)).collect();
let unpainted: Vec<NodeId> =
targets.into_iter().filter(|t| d.node(*t).is_none_or(|f| !bucket_keeps(&f.appearance, true, &paint, 0.0))).collect();
if unpainted.is_empty() {
return Ok(json!({ "group": gid.0, "face": fid.0, "count": count }));
}
s.edit("Live Paint Bucket", |d, _| {
for target in unpainted {
d.node_mut(target).ok_or(EngineError::NoNode(target))?.appearance.set_fill(paint.clone());
bucket_paint(&mut d.node_mut(target).ok_or(EngineError::NoNode(target))?.appearance, true, &paint, 0.0);
}
Ok((gid, fid, count))
})?
@@ -544,7 +545,7 @@ pub(super) fn lp_fill(s: &mut Session, p: &Value) -> Result<Value> {
let (fid, targets) = targets_in(d, gid)?;
let count = targets.len();
for target in targets {
d.node_mut(target).ok_or(EngineError::NoNode(target))?.appearance.set_fill(paint.clone());
bucket_paint(&mut d.node_mut(target).ok_or(EngineError::NoNode(target))?.appearance, true, &paint, 0.0);
}
Ok((gid, fid, count))
})?,
@@ -580,18 +581,10 @@ pub(super) fn lp_stroke_edge(s: &mut Session, p: &Value) -> Result<Value> {
.collect(),
};
let count = targets.len();
// An edge whose shown stroke already has the paint and the width (or that has no shown stroke
// and gets none) stays as it is; when every edge does, nothing changes and no undo step is
// recorded.
let changed: Vec<NodeId> = targets
.into_iter()
.filter(|t| {
d.node(*t).is_none_or(|e| match shown_stroke(e) {
Some(st) => !(st.paint == paint && st.width == width),
None => !paint.is_none(),
})
})
.collect();
// An edge that already has only the stroke the bucket would leave ([`bucket_keeps`]) stays
// as it is; when every edge does, nothing changes and no undo step is recorded.
let changed: Vec<NodeId> =
targets.into_iter().filter(|t| d.node(*t).is_none_or(|e| !bucket_keeps(&e.appearance, false, &paint, width))).collect();
(eid, count, changed)
};
if changed.is_empty() {
@@ -599,14 +592,7 @@ pub(super) fn lp_stroke_edge(s: &mut Session, p: &Value) -> Result<Value> {
}
s.edit("Live Paint Bucket", |d, _| {
for target in &changed {
let e = d.node_mut(*target).ok_or(EngineError::NoNode(*target))?;
match shown_stroke_mut(e) {
Some(st) => {
st.paint = paint.clone();
st.width = width;
}
None => e.appearance.items.push(super::AppearanceItem::Stroke(super::StrokeLayer::new(paint.clone(), width))),
}
bucket_paint(&mut d.node_mut(*target).ok_or(EngineError::NoNode(*target))?.appearance, false, &paint, width);
}
Ok(())
})?;
@@ -614,22 +600,76 @@ pub(super) fn lp_stroke_edge(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "group": g.0, "edge": eid.0, "count": count }))
}
/// The stroke `livePaint.strokeEdge` paints on Live Paint edge `e`: its topmost stroke that is
/// shown. None when it has no stroke or every stroke is hidden; the command then adds a stroke
/// above the others.
fn shown_stroke(e: &Node) -> Option<&super::StrokeLayer> {
e.appearance.items.iter().rev().find_map(|i| match i {
super::AppearanceItem::Stroke(s) if s.visible => Some(s),
_ => None,
})
/// Whether the Live Paint Bucket leaves appearance `a` as it is when it paints its fills (`fill`)
/// or strokes with `paint` (a stroke at `width`): it has exactly one row of that kind, shown, fully
/// opaque, blending normally and with the paint (a stroke also with the width, unless the paint is
/// none), or it has no row of that kind and the paint is none.
fn bucket_keeps(a: &Appearance, fill: bool, paint: &super::Paint, width: f64) -> bool {
let mut rows = a.items.iter().filter(|i| i.is_fill() == fill);
match (rows.next(), rows.next()) {
(None, _) => paint.is_none(),
(Some(row), None) => {
row.visible()
&& row.opacity() == 1.0
&& row.blend() == vectorcraft_color::BlendMode::Normal
&& row.paint() == paint
&& match row {
AppearanceItem::Fill(_) => true,
AppearanceItem::Stroke(st) => paint.is_none() || st.width == width,
}
}
(Some(_), Some(_)) => false,
}
}
/// [`shown_stroke`], to change.
fn shown_stroke_mut(e: &mut Node) -> Option<&mut super::StrokeLayer> {
e.appearance.items.iter_mut().rev().find_map(|i| match i {
super::AppearanceItem::Stroke(s) if s.visible => Some(s),
_ => None,
})
/// Paint the fills (`fill`) or strokes of appearance `a` with `paint` (a stroke at `width`), as the
/// Live Paint Bucket does: every row of that kind is replaced by one row, shown, fully opaque and
/// blending normally, in the place of the topmost row of that kind. The new row keeps the other
/// settings (a stroke's dashes, caps and arrowheads, a row's effects) of the topmost row of that
/// kind that is shown, or of the topmost one when every row is hidden. Rows of the other kind stay
/// as they are. With no row of that kind, a fill is added below the strokes and a stroke above the
/// fills.
fn bucket_paint(a: &mut Appearance, fill: bool, paint: &super::Paint, width: f64) {
let of_kind = |i: &AppearanceItem| i.is_fill() == fill;
let Some(top) = a.items.iter().rposition(of_kind) else {
if fill {
a.set_fill(paint.clone());
} else {
a.items.push(AppearanceItem::Stroke(super::StrokeLayer::new(paint.clone(), width)));
}
return;
};
let base = a.items.iter().rposition(|i| of_kind(i) && i.visible()).unwrap_or(top);
let Some(mut row) = a.items.get(base).cloned() else { return };
match &mut row {
AppearanceItem::Fill(f) => {
f.paint = paint.clone();
f.opacity = 1.0;
f.blend = vectorcraft_color::BlendMode::Normal;
f.visible = true;
}
AppearanceItem::Stroke(st) => {
st.paint = paint.clone();
st.width = width;
st.opacity = 1.0;
st.blend = vectorcraft_color::BlendMode::Normal;
st.visible = true;
}
}
let mut row = Some(row);
a.items = std::mem::take(&mut a.items)
.into_iter()
.enumerate()
.filter_map(|(i, item)| {
if i == top {
row.take()
} else if of_kind(&item) {
None
} else {
Some(item)
}
})
.collect();
}
/// Every fill and stroke row of face or edge `n`, bottom first, as `livePaint.info` lists them. A
+127 -18
View File
@@ -12,6 +12,7 @@ use super::{
thread_local! {
static BOX_TESTS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
static CANDIDATE_VISITS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
static TRACES_ON_THIS_THREAD: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
// ---------- Image Trace ----------
@@ -22,6 +23,22 @@ fn is_trace_group(n: &Node) -> bool {
&& n.children().is_some_and(|c| c.first().is_some_and(|i| matches!(i.kind, NodeKind::Image(_))))
}
/// The name of the group that holds an Abutting trace's seam fills (see [`tr::TraceResult::seams`]).
const SEAM_FILLS_NAME: &str = "Seam Fills";
/// Whether `n` can be a trace's seam fills group: a group named as one, or a group of stroked paths
/// without fills, so that it is still found after it is renamed or its strokes are edited. (Auto
/// Grouping groups hold filled paths.)
fn is_seam_fills(n: &Node) -> bool {
let stroke_only = |m: &Arc<Node>| {
matches!(m.kind, NodeKind::Path { .. } | NodeKind::Compound { .. })
&& m.appearance.fill_paint() == Paint::None
&& m.appearance.stroke_paint() != Paint::None
};
matches!(n.kind, NodeKind::Group { clip: false, .. })
&& (n.name.as_deref() == Some(SEAM_FILLS_NAME) || n.children().is_some_and(|c| !c.is_empty() && c.iter().all(stroke_only)))
}
const LIVE_PATHFINDER_BY_COLOR: &str = "byColor can't regroup a trace with a live Pathfinder effect: expand without byColor";
/// The `view` param ([`TraceView`] id), if given.
@@ -393,11 +410,15 @@ fn by_color_param(cmd: &str, p: &Value) -> Result<bool> {
/// (by the boxes of everything they paint, effects included, within one point); otherwise it
/// starts another group of that paint, which keeps the stacking order of those items. Items
/// farther apart can still share antialiased pixels at small view or raster-export scales, so
/// their edge pixels may change. A group among `items` (Auto Grouping) is refused.
/// their edge pixels may change. The seam fills' group, when it is the lowest item, stays as it is,
/// under every other group. Any other group among `items` (Auto Grouping) is refused.
fn color_groups(cmd: &str, d: &mut Document, items: Vec<Arc<Node>>) -> Result<Vec<Arc<Node>>> {
use std::collections::{BTreeMap, HashMap};
use std::ops::Bound::{Excluded, Unbounded};
use vectorcraft_color::tint_percent;
// The seam fills come first, under every path: kept there, they move under nothing.
let mut items = items;
let seams: Vec<Arc<Node>> = if items.first().is_some_and(|n| is_seam_fills(n)) { items.drain(..1).collect() } else { vec![] };
// Grouped by the paint: unlinked (its #rrggbb), linked (the swatch and its exact tint), a
// gradient, or another paint (its label). The name is apart from it: a swatch may be named as
// a color's `#rrggbb` or as "Gradients".
@@ -517,25 +538,31 @@ fn color_groups(cmd: &str, d: &mut Document, items: Vec<Arc<Node>>) -> Result<Ve
}
}
}
Ok(groups
let groups: Vec<Arc<Node>> = groups
.into_iter()
.map(|run| {
let mut g = Node::group(d.alloc_id(), run.members);
g.name = Some(run.name);
Arc::new(g)
})
.collect())
.collect();
Ok(seams.into_iter().chain(groups).collect())
}
/// What a trace depends on: the document it is made in, the object traced (an image, or an Image
/// Trace object traced again), its image, the image's encoded bytes, 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 in the same document.
/// What a trace depends on: the document it is made in and the content it was reverted to, the
/// object traced (an image, or an Image Trace object traced again), its image, the image's encoded
/// bytes, 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 in the same
/// document until File › Revert replaces its content.
#[derive(Clone, Debug, PartialEq)]
struct TraceInputs {
/// The [`DocState::uid`](crate::DocState::uid) of the document traced in. A trace made in one
/// document never stands in for one in another, however alike their images and settings.
doc: u64,
/// The document's [`DocState::reverts`](crate::DocState::reverts) when the trace was prepared.
/// A trace prepared before File › Revert never stands in for one after it, even when the
/// restored image, pixels, object and settings are the same.
reverts: u64,
target: NodeId,
image: Box<ImageObject>,
/// The settings traced with, as JSON.
@@ -604,21 +631,25 @@ fn prepare(s: &Session, c: &str, p: &Value) -> Result<Prepared> {
let bytes = Arc::clone(&blob.bytes);
let library = trace_library(c, s, &mut params)?;
let traced_with = serde_json::to_value(&params).map_err(|e| EngineError::Other(e.to_string()))?;
let inputs = TraceInputs { doc: s.doc()?.uid, target, image, params: traced_with, swatches: params.swatches.clone(), bytes };
let st = s.doc()?;
let inputs = TraceInputs { doc: st.uid, reverts: st.reverts, target, image, params: traced_with, swatches: params.swatches.clone(), bytes };
Ok(Prepared { inputs, trace: params, view, params_json, preset_label, library })
}
/// What a trace made of an image: the size in pixels it was decoded at, and the paths.
/// What a trace made of an image: the size in pixels it was decoded at, the paths, and their seam
/// fills ([`seams_within`]).
pub struct Traced {
inputs: TraceInputs,
raster: (u32, u32),
res: tr::TraceResult,
seams: Vec<tr::TracedPath>,
}
impl Traced {
/// Hand it to the next run of its command with the same inputs ([`TraceRun::params`]) in the
/// document it was made in, which applies it as one undo step instead of tracing again. Any
/// other trace drops it, a trace in another document included.
/// other trace drops it and traces again, a trace in another document included, and so does
/// the command after File › Revert replaced the document's content.
pub fn hand_in(self, s: &mut Session) {
s.trace_hand_off = Some(self);
}
@@ -627,6 +658,8 @@ impl Traced {
/// Decode the image bytes of `inputs` and trace them 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, steps: &(dyn Fn(u64, u64) -> bool + Sync)) -> Result<Traced> {
#[cfg(test)]
TRACES_ON_THIS_THREAD.with(|n| n.set(n.get() + 1));
// An image over the tracer's size is traced scaled down (the paths are scaled back).
let raster = tr::Raster::decode_to_fit(&inputs.bytes).map_err(|e| EngineError::Other(e.to_string()))?;
if !steps(0, 0) {
@@ -636,7 +669,18 @@ fn trace_now(inputs: &TraceInputs, params: &tr::TraceParams, steps: &(dyn Fn(u64
tr::TraceError::Cancelled => EngineError::Cancelled,
e => EngineError::Other(e.to_string()),
})?;
Ok(Traced { inputs: inputs.clone(), raster: (raster.width, raster.height), res })
let seams = seams_within(&res, params, MAX_TRACE_ANCHORS);
Ok(Traced { inputs: inputs.clone(), raster: (raster.width, raster.height), res, seams })
}
/// The seam fills of `res`, traced with `params` ([`tr::seam_fills`]), while the paths and the seam
/// fills together stay within `max` anchors: the seam fills have at most as many anchors as the
/// paths, so a trace whose paths have more than half of `max` gets none.
fn seams_within(res: &tr::TraceResult, params: &tr::TraceParams, max: usize) -> Vec<tr::TracedPath> {
if res.anchor_count() > max / 2 {
return vec![];
}
tr::seam_fills(&res.paths, params)
}
/// An Image Trace made away from its command, off the UI thread (#3098). [`TraceRun::prepare`]
@@ -691,17 +735,30 @@ impl TraceRun {
}
/// Does it still apply: would the command trace the same object, image, pixels and settings
/// now, in the document it was prepared in?
/// now, in the document it was prepared in, with no File › Revert since?
pub fn current(&self, s: &Session) -> bool {
prepare(s, self.command, &self.params).is_ok_and(|now| now.inputs == self.prepared.inputs)
}
/// Has File › Revert replaced the content of the document it was prepared in since then? False
/// when that document is no longer open.
pub fn reverted(&self, s: &Session) -> bool {
let TraceInputs { doc, reverts, .. } = self.prepared.inputs;
s.documents().iter().any(|st| st.uid == doc && st.reverts != reverts)
}
/// 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, steps)
}
/// How many images this thread has decoded and traced so far.
#[cfg(test)]
pub(crate) fn traces_on_this_thread() -> usize {
TRACES_ON_THIS_THREAD.with(std::cell::Cell::get)
}
}
pub(super) fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
@@ -716,7 +773,7 @@ pub(super) fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Val
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() {
let Traced { raster, res, seams: seam_fills, .. } = match s.trace_hand_off.take() {
Some(traced) if traced.inputs == inputs => traced,
_ => trace_now(&inputs, &trace, &|_, _| true)?,
};
@@ -729,6 +786,13 @@ pub(super) fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Val
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();
// Seam fills: document units per raster pixel along the direction the image is stretched most,
// so that a stroke covers its pixels across an edge at any angle.
let seam_unit = {
let [a, b, c, d, _, _] = xf.as_coeffs();
let (t, det) = (a * a + b * b + c * c + d * d, a * d - b * c);
((t + (t * t - 4.0 * det * det).max(0.0).sqrt()) / 2.0).sqrt()
};
let (npaths, anchors, colors) = (res.paths.len(), res.anchor_count(), res.palette.len());
let label = if expand { "Image Trace (Make and Expand)" } else { "Image Trace" };
// A library color fills with the swatch's displayed color (its model kept), unlinked.
@@ -754,8 +818,38 @@ pub(super) fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Val
hidden.visible = false;
children.push(Arc::new(hidden));
}
let paint_of = |tp: &tr::TracedPath| {
let solid =
by_color.get(&tp.color).map_or_else(|| Paint::solid(Color::rgb8(tp.color[0], tp.color[1], tp.color[2])), |paint| (*paint).clone());
// Gradients: a path the tracer found to be a linear gradient is filled with it.
tp.gradient.as_ref().map_or(solid, |g| traced_gradient(g, xf))
};
// Abutting: the seam fills, in a group of their own under every path.
let seams: Vec<Arc<Node>> = seam_fills
.iter()
.filter_map(|tp| {
let width = tp.stroke?;
let mut n = shape_node(d, tp.path.transformed(xf), None);
n.appearance = Appearance {
items: vec![AppearanceItem::Stroke(StrokeLayer {
cap: LineCap::Butt,
join: LineJoin::Round,
..StrokeLayer::new(paint_of(tp), width * seam_unit)
})],
..Default::default()
}
.into();
Some(Arc::new(n))
})
.collect();
if !seams.is_empty() {
let mut g = Node::group(d.alloc_id(), seams);
g.name = Some(SEAM_FILLS_NAME.into());
children.push(Arc::new(g));
}
let mut traced = Vec::with_capacity(res.paths.len());
for tp in res.paths {
let paint = paint_of(&tp);
let path = tp.path.transformed(xf);
let mut n = shape_node(d, path, None);
// Shapes: a path the tracer found to be a shape is that live shape.
@@ -764,10 +858,6 @@ pub(super) fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Val
*path = shape.to_path();
*live = Some(Box::new(shape));
}
let solid =
by_color.get(&tp.color).map_or_else(|| Paint::solid(Color::rgb8(tp.color[0], tp.color[1], tp.color[2])), |paint| (*paint).clone());
// Gradients: a path the tracer found to be a linear gradient is filled with it.
let paint = tp.gradient.as_ref().map_or(solid, |g| traced_gradient(g, xf));
n.appearance = match tp.stroke {
// Traced lines: round corners, and ends round or square as the tracer found them.
Some(width) => Appearance {
@@ -1103,9 +1193,28 @@ mod color_group_tests {
#[cfg(test)]
mod trace_limit_tests {
use super::{MAX_TRACE_ANCHORS, keep_warnings, trace_warnings};
use super::{MAX_TRACE_ANCHORS, keep_warnings, seams_within, trace_warnings};
use vectorcraft_trace as tr;
#[test]
fn seam_fills_count_against_the_anchor_limit() {
// Two colors side by side, the edge between them anti-aliased: one seam fill along it.
let img = tr::Raster::from_fn(24, 16, |x, _| {
let share = (10.4 - f64::from(x)).clamp(0.0, 1.0);
let mix = |a: f64, b: f64| (a * share + b * (1.0 - share)).round() as u8;
[mix(200.0, 30.0), mix(40.0, 90.0), mix(50.0, 200.0), 255]
});
let params = tr::TraceParams { mode: tr::Mode::Color, colors: 2, method: tr::Method::Abutting, ..tr::TraceParams::default() };
let res = tr::trace(&img, &params);
let all = tr::seam_fills(&res.paths, &params);
assert!(!all.is_empty(), "{res:?}");
let anchors = res.anchor_count();
// Twice the paths' anchors leaves room for any seam fills.
assert_eq!(seams_within(&res, &params, 2 * anchors), all);
// One anchor short of that: none.
assert_eq!(seams_within(&res, &params, 2 * anchors - 1), []);
}
#[test]
fn keep_warnings_name_matched_colors_that_produce_no_paths() {
let params = tr::TraceParams { keep_colors: vec!["#ff0000".into()], ..tr::TraceParams::default() };
+129 -3
View File
@@ -16,7 +16,7 @@ use std::collections::BTreeMap;
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::cms::{self, ColorSettings, Intent, Model, ProofTarget};
use vectorcraft_color::cms::{self, ColorSettings, Intent, Model, Policy, ProofTarget};
use vectorcraft_color::recolor::Neutral;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::ColorMode;
@@ -37,7 +37,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Color Settings…",
["Edit"],
Some("Cmd+Shift+K"),
"{rgb?: profile, cmyk?: profile, intent?: \"perceptual\"|\"relative\"|\"saturation\"|\"absolute\", bpc?: bool} set the working spaces → {rgb, cmyk, intent, bpc, profiles: [{name, kind, builtin}]}. The desktop app and vectorcraft-cli keep them in `Color Settings.json` next to the preferences and use them again at the next start (not with VECTORCRAFT_NO_PREFS); saved settings naming a profile that is gone leave the defaults",
"{rgb?: profile, cmyk?: profile, intent?: \"perceptual\"|\"relative\"|\"saturation\"|\"absolute\", bpc?: bool, rgbPolicy?: \"off\"|\"preserveEmbedded\"|\"convert\", cmykPolicy?: \"off\"|\"preserveEmbedded\"|\"convert\"|\"preserveNumbers\"} set the working spaces and the color management policies → {rgb, cmyk, intent, bpc, rgbPolicy, cmykPolicy, profiles: [{name, kind, builtin}]}. A policy decides what opening a document (document.open, file.newFromTemplate, file.revert) does with an RGB or CMYK profile it is tagged with that is not the working space: preserveEmbedded (default) keeps it; off and preserveNumbers take it off and keep the color numbers; convert converts the colors of that model into the working space with the intent and bpc and takes it off. The open reply's warnings name each profile taken off or converted, and a document a policy changed opens with unsaved changes. The desktop app and vectorcraft-cli keep them in `Color Settings.json` next to the preferences and use them again at the next start (not with VECTORCRAFT_NO_PREFS); saved settings naming a profile that is gone leave the defaults",
always,
color_settings
),
@@ -163,12 +163,26 @@ fn on_param(p: &Value, key: &str, cmd: &str) -> Result<Option<bool>> {
switch_or(p, key, false, cmd).map(Some)
}
/// The policy `p` gives for `key`, one of `allowed`, or `None` when `p` leaves it out.
fn policy_param(p: &Value, key: &str, allowed: &[Policy], cmd: &str) -> Result<Option<Policy>> {
let Some(v) = text_param(p, key, cmd)? else { return Ok(None) };
match Policy::parse(v).filter(|x| allowed.contains(x)) {
Some(x) => Ok(Some(x)),
None => {
let ids: Vec<&str> = allowed.iter().map(Policy::id).collect();
Err(bad(cmd, format!("`{key}` must be one of {}", ids.join(", "))))
}
}
}
fn settings_json(st: &ColorSettings) -> Value {
json!({
"rgb": st.rgb,
"cmyk": st.cmyk,
"intent": st.intent.id(),
"bpc": st.bpc,
"rgbPolicy": st.rgb_policy.id(),
"cmykPolicy": st.cmyk_policy.id(),
"profiles": cms::profiles().iter().map(|p| json!({"name": p.name, "kind": p.kind, "builtin": p.builtin})).collect::<Vec<_>>(),
})
}
@@ -205,6 +219,12 @@ fn color_settings(s: &mut Session, p: &Value) -> Result<Value> {
st.intent = i;
}
st.bpc = switch_or(p, "bpc", st.bpc, C)?;
if let Some(v) = policy_param(p, "rgbPolicy", &Policy::RGB, C)? {
st.rgb_policy = v;
}
if let Some(v) = policy_param(p, "cmykPolicy", &Policy::CMYK, C)? {
st.cmyk_policy = v;
}
if st != before {
cms::set_active(&st).map_err(|e| bad(C, e.to_string()))?;
// Saved for the next start when the app attached a settings file (#6335).
@@ -530,7 +550,12 @@ pub(crate) fn set_color_mode(d: &mut vectorcraft_doc::Document, mode: ColorMode,
if !convert {
return 0;
}
let conv = mode_converter(mode, intent, grays);
convert_colors(d, &mode_converter(mode, intent, grays))
}
/// Every color of `d` that [`set_color_mode`] converts, converted with `conv` → how many colors of
/// the art changed, as [`set_color_mode`] counts them.
fn convert_colors(d: &mut vectorcraft_doc::Document, conv: &dyn Fn(&Color) -> Color) -> usize {
for sw in d.swatches_iter_mut() {
match &mut sw.paint {
Paint::Solid { color, .. } => *color = conv(color),
@@ -538,6 +563,11 @@ pub(crate) fn set_color_mode(d: &mut vectorcraft_doc::Document, mode: ColorMode,
for st in &mut g.gradient.stops {
st.color = conv(&st.color);
}
if let Some(freeform) = &mut g.freeform {
for point in &mut freeform.points {
point.color = conv(&point.color);
}
}
}
_ => {}
}
@@ -621,6 +651,102 @@ pub(crate) fn set_color_mode(d: &mut vectorcraft_doc::Document, mode: ColorMode,
in_art
}
/// Apply the color management policies of Edit → Color Settings to `d`, a document being opened:
/// each RGB or CMYK profile it is tagged with that is not the working space of its model is kept
/// (Preserve Embedded Profiles), taken off with the color numbers kept (Off, Preserve Numbers), or
/// taken off after the colors of that model were converted from it into the working space with the
/// working intent (Convert to Working Space). A profile that is not available here is taken off
/// by Off and Preserve Numbers and kept by Convert to Working Space, which has nothing to convert
/// from. → a note for each profile, and whether `d` changed.
pub(crate) fn apply_open_policies(d: &mut vectorcraft_doc::Document) -> (Vec<String>, bool) {
let working = cms::working_settings();
let (rgb, cmyk) = doc_profiles(d);
let mut notes = Vec::new();
// What each model's policy does with its profile: `None` keeps it; `Some` takes it off, after
// converting the colors from the profile it holds.
let mut decide = |label: &str, tag: Option<String>, working: &str, policy: Policy, kind: cms::ProfileKind| -> Option<Option<String>> {
let tag = tag.filter(|t| t != working)?;
let available = cms::profile(&tag).is_some_and(|p| p.kind == kind);
match policy {
Policy::PreserveEmbedded => None,
Policy::Off | Policy::PreserveNumbers => {
notes.push(format!(
"the {label} profile “{tag}” the file is tagged with was not used: Color Settings' {label} policy is {}",
policy.label()
));
Some(None)
}
Policy::Convert if available => {
notes.push(format!(
"the {label} colors were converted from the profile “{tag}” the file is tagged with into the working space “{working}”"
));
Some(Some(tag))
}
Policy::Convert => {
notes.push(format!("the {label} profile “{tag}” the file is tagged with is not available, so its colors were not converted"));
None
}
}
};
let rgb_do = decide("RGB", rgb, &working.rgb, working.rgb_policy, cms::ProfileKind::Rgb);
let cmyk_do = decide("CMYK", cmyk, &working.cmyk, working.cmyk_policy, cms::ProfileKind::Cmyk);
if rgb_do.is_none() && cmyk_do.is_none() {
return (notes, false);
}
let (from_rgb, from_cmyk) = (rgb_do.clone().flatten(), cmyk_do.clone().flatten());
if from_rgb.is_some() || from_cmyk.is_some() {
let source = cms::for_document(from_rgb.as_deref(), from_cmyk.as_deref());
let dest = cms::working();
let intent = working.intent;
let (rgb_on, cmyk_on) = (from_rgb.is_some(), from_cmyk.is_some());
let conv = |c: &Color| match c {
Color::Rgb { .. } if rgb_on => source.convert_into(&dest, c, intent),
Color::Cmyk { .. } if cmyk_on => source.convert_into(&dest, c, intent),
_ => *c,
};
convert_colors(d, &conv);
convert_setting_colors(d, &conv);
}
if rgb_do.is_some() {
d.color_profiles.rgb = None;
}
if cmyk_do.is_some() {
d.color_profiles.cmyk = None;
}
(notes, true)
}
/// The colors of `d` outside its art that [`convert_colors`] leaves, converted with `conv`: each
/// artboard's background, the transparency grid and simulated paper colors of Document Setup, and
/// the fill and stroke of the character and paragraph styles.
fn convert_setting_colors(d: &mut vectorcraft_doc::Document, conv: &dyn Fn(&Color) -> Color) {
for c in d.artboards.iter_mut().filter_map(|a| a.color.as_mut()) {
*c = conv(c);
}
for c in &mut d.setup.grid_colors {
*c = conv(c);
}
// A tint of a global swatch is the converted swatch's color at its tint, which
// [`convert_colors`] already gave it.
let bases = global_bases(d);
let mut f = |c: &Color, link: proof::Link| mode_color(c, link, &bases, conv);
for styles in [&mut d.char_styles, &mut d.para_styles] {
if !styles.iter().any(|s| s.attrs.contains_key("fill") || s.attrs.contains_key("stroke")) {
continue;
}
for def in styles.iter_mut() {
for key in ["fill", "stroke"] {
let Some(value) = def.attrs.get_mut(key) else { continue };
let Ok(mut paint) = serde_json::from_value::<Paint>(value.clone()) else { continue };
map_paint_colors(&mut paint, &mut f);
if let Ok(v) = serde_json::to_value(&paint) {
*value = v;
}
}
}
}
}
/// [`proof::map_node_colors`] over `n`'s subtree, then [`map_more_colors`].
fn map_art_colors(n: &mut vectorcraft_doc::Node, f: &mut dyn FnMut(&Color, proof::Link) -> Color) {
proof::map_node_colors(n, f);
+67 -15
View File
@@ -1,15 +1,18 @@
//! Type › Composite Fonts…: the document's composite fonts ([`vectorcraft_doc::CompositeFont`]),
//! which set each class of character (Kanji, Kana, Punctuation, Symbols, Roman, Numerals) in a
//! font of its own. Type names a composite font as its family, and the font menus list them first.
//! The font database holds the active document's composite fonts ([`sync`]); a file exported here
//! carries them to another document.
//! font of its own, and the characters of each custom character set in the set's font. Type names
//! a composite font as its family, and the font menus list them first. The font database holds the
//! active document's composite fonts ([`sync`]); a file exported here carries them to another
//! document.
//!
//! Behavior reference (public documentation):
//! <https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf#page=347>.
//! <https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf#page=347>,
//! custom character sets:
//! <https://web.archive.org/web/20220911143322/https://helpx.adobe.com/pdf/cs6/illustrator_reference.pdf#page=348>.
use serde_json::{Map, Value, json};
use vectorcraft_doc::text::{COMPOSITE_FONTS_MAX, COMPOSITE_NAME_MAX};
use vectorcraft_doc::{CharClass, CompositeFont, CompositePart, CompositeParts};
use vectorcraft_doc::text::{COMPOSITE_FONTS_MAX, COMPOSITE_NAME_MAX, CUSTOM_CHARS_MAX, CUSTOM_SETS_MAX};
use vectorcraft_doc::{CharClass, CompositeFont, CompositePart, CompositeParts, CustomCharSet};
use vectorcraft_text::FontDb;
use super::fileio::{read_file_head, write_file};
@@ -26,6 +29,10 @@ const MAX_FILE_BYTES: usize = 4 << 20;
/// The keys a part may give.
const PART_KEYS: [&str; 7] = ["font", "style", "size", "baseline", "hScale", "vScale", "scaleAtCenter"];
/// The keys a custom character set may give: its name and characters, then its part's values
/// (a set is never scaled at the center).
const SET_KEYS: [&str; 8] = ["name", "characters", "font", "style", "size", "baseline", "hScale", "vScale"];
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
@@ -33,7 +40,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Composite Fonts",
[],
None,
"{} the active document's composite fonts → {fonts: [{name, parts: {kanji, kana, punctuation, symbols, roman, numerals: {font, style, size: % of the type size, baseline: % of the type size (positive raises), hScale: %, vScale: %, scaleAtCenter?: bool}}}]}",
"{} the active document's composite fonts → {fonts: [{name, parts: {kanji, kana, punctuation, symbols, roman, numerals: {font, style, size: % of the type size, baseline: % of the type size (positive raises), hScale: %, vScale: %, scaleAtCenter?: bool}}, custom?: [{name, characters, font, style, size, baseline, hScale, vScale}]}]}",
has_doc,
list
),
@@ -42,7 +49,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Composite Fonts…",
["Type"],
None,
"{fonts: [{name, parts: {kanji: {font, style?: \"Regular\", size?: % 10..400 = 100, baseline?: % -100..100 = 0}, kana?, punctuation?, symbols?, roman?, numerals?: {font, style?, size?, baseline?, hScale?, vScale?: % 10..400 = 100, scaleAtCenter?: bool = false}}}]} replace the active document's composite fonts (Type › Composite Fonts…'s OK), as one undo step, none when nothing changes. A composite font sets each class of character in its part's font: kanji (the base: ideographs, full-width letters and digits and anything no other class takes), kana (hiragana and katakana, both widths), punctuation (East Asian and full-width punctuation, quotes and dashes), symbols (arrows, math, shapes and other symbols), roman (half-width letters, their punctuation and spaces) and numerals (the digits 0 to 9); a part left out is the kanji part. size and baseline are percent of the type's size, hScale and vScale scale kana, roman and numerals only, and scaleAtCenter (kana only) scales them from the em box's center instead of the baseline. Names are trimmed, unique in any case, at most 255 characters, and may not name an installed font; a part's font may not be a composite font; at most 256. Type set in a composite font names it as its family (text.setStyle {font: name}), and the font menus list composite fonts first. A font taken out stays named by its type, which then shows as missing → {fonts}",
"{fonts: [{name, parts: {kanji: {font, style?: \"Regular\", size?: % 10..400 = 100, baseline?: % -100..100 = 0}, kana?, punctuation?, symbols?, roman?, numerals?: {font, style?, size?, baseline?, hScale?, vScale?: % 10..400 = 100, scaleAtCenter?: bool = false}}, custom?: [{name, characters?: string = \"\", font?, style?, size?, baseline?, hScale?, vScale?}]}]} replace the active document's composite fonts (Type › Composite Fonts…'s OK), as one undo step, none when nothing changes. A composite font sets each class of character in its part's font: kanji (the base: ideographs, full-width letters and digits and anything no other class takes), kana (hiragana and katakana, both widths), punctuation (East Asian and full-width punctuation, quotes and dashes), symbols (arrows, math, shapes and other symbols), roman (half-width letters, their punctuation and spaces) and numerals (the digits 0 to 9); a part left out is the kanji part. size and baseline are percent of the type's size, hScale and vScale scale kana, roman and numerals only, and scaleAtCenter (kana only) scales them from the em box's center instead of the baseline. custom lists custom character sets: each sets the characters it lists in its own font, style, size, baseline and scales (values left out are the kanji part's), ahead of their class; a character two sets list is set by the later one. A set's name is trimmed and unique in the font in any case; its characters are each listed once, at most 8192, none a control character or past U+FFFF; at most 64 sets. Names are trimmed, unique in any case, at most 255 characters, and may not name an installed font; a part's font may not be a composite font; at most 256. Type set in a composite font names it as its family (text.setStyle {font: name}), and the font menus list composite fonts first. A font taken out stays named by its type, which then shows as missing → {fonts}",
has_doc,
set
),
@@ -51,7 +58,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export Composite Fonts",
[],
None,
"{path?: .vccomposite file, names?: [name] (default: all), fonts?: [{name, parts}] (as type.compositeFonts.set takes them)} write the active document's composite fonts (all, or those named, any case), or the fonts given, to a file type.compositeFonts.import reads; without path the file's text comes back as data → {path, count} | {data, count}",
"{path?: .vccomposite file, names?: [name] (default: all), fonts?: [{name, parts}] (as type.compositeFonts.set takes them)} write the active document's composite fonts (all, or those named, any case), or the fonts given, to a file type.compositeFonts.import reads (a file over 4 MB is refused); without path the file's text comes back as data → {path, count} | {data, count}",
has_doc,
export
),
@@ -121,8 +128,8 @@ fn list(s: &mut Session, _: &Value) -> Result<Value> {
/// copy of the Kanji part, and keys or values a part can't take refused.
fn parse_font(v: &Value, c: &str) -> Result<CompositeFont> {
let o = v.as_object().ok_or_else(|| bad(c, "each font must be {name, parts}"))?;
if let Some(k) = o.keys().find(|k| !["name", "parts"].contains(&k.as_str())) {
return Err(bad(c, format!("unknown key `{k}`; a font takes name and parts")));
if let Some(k) = o.keys().find(|k| !["name", "parts", "custom"].contains(&k.as_str())) {
return Err(bad(c, format!("unknown key `{k}`; a font takes name, parts and custom")));
}
let raw = o.get("name").and_then(Value::as_str).ok_or_else(|| bad(c, "each font needs a name"))?;
let name = CompositeFont::clean_name(raw)
@@ -140,7 +147,17 @@ fn parse_font(v: &Value, c: &str) -> Result<CompositeFont> {
serde_json::from_value(p.clone()).map(Some).map_err(|e| bad(c, format!("`{name}`: {}: {e}", class.key())))
};
let kanji = part(CharClass::Kanji)?.ok_or_else(|| bad(c, format!("`{name}`: give the kanji part")))?;
let mut f = CompositeFont { name: name.clone(), parts: CompositeParts::all(kanji) };
let custom = match o.get("custom") {
None => Vec::new(),
Some(Value::Array(sets)) => {
if sets.len() > CUSTOM_SETS_MAX {
return Err(bad(c, format!("`{name}`: a composite font keeps at most {CUSTOM_SETS_MAX} character sets")));
}
sets.iter().map(|s| parse_set(s, &name, &kanji, c)).collect::<Result<Vec<_>>>()?
}
Some(_) => return Err(bad(c, format!("`{name}`: custom must be a list of character sets"))),
};
let mut f = CompositeFont { name: name.clone(), parts: CompositeParts::all(kanji), custom };
for class in CharClass::ALL.into_iter().skip(1) {
if let Some(p) = part(class)? {
*f.parts.get_mut(class) = p;
@@ -150,6 +167,38 @@ fn parse_font(v: &Value, c: &str) -> Result<CompositeFont> {
Ok(f)
}
/// Custom character set `v` of composite font `font` from command parameters or a file: its name
/// trimmed, the part values it leaves out those of `kanji`, and keys or values it can't take
/// refused. [`CompositeFont::check`] checks its characters and values.
fn parse_set(v: &Value, font: &str, kanji: &CompositePart, c: &str) -> Result<CustomCharSet> {
let o = v.as_object().ok_or_else(|| bad(c, format!("`{font}`: each character set must be {{name, characters, font…}}")))?;
if let Some(k) = o.keys().find(|k| !SET_KEYS.contains(&k.as_str())) {
return Err(bad(c, format!("`{font}`: unknown key `{k}` in a character set")));
}
let raw = o.get("name").and_then(Value::as_str).ok_or_else(|| bad(c, format!("`{font}`: each character set needs a name")))?;
let name = CompositeFont::clean_name(raw)
.ok_or_else(|| bad(c, format!("`{raw}` can't name a character set (1 to {COMPOSITE_NAME_MAX} characters, no control characters)")))?;
let characters = match o.get("characters") {
None => String::new(),
Some(Value::String(s)) => s.clone(),
Some(_) => return Err(bad(c, format!("`{font}`: `{name}`: characters must be a string"))),
};
// Checked here as well as by `check`, so a huge list is refused before it is copied again.
if characters.chars().nth(CUSTOM_CHARS_MAX).is_some() {
return Err(bad(c, format!("`{font}`: `{name}`: a character set keeps at most {CUSTOM_CHARS_MAX} characters")));
}
let mut part = serde_json::to_value(kanji).map_err(|e| bad(c, e.to_string()))?;
if let Some(p) = part.as_object_mut() {
for k in &SET_KEYS[2..] {
if let Some(given) = o.get(*k) {
p.insert(k.to_string(), given.clone());
}
}
}
let part: CompositePart = serde_json::from_value(part).map_err(|e| bad(c, format!("`{font}`: `{name}`: {e}")))?;
Ok(CustomCharSet { name, characters, part })
}
/// Error unless `fonts` can stand together in a document: names unique in any case, none naming
/// an installed font, no part set in a composite font, and at most [`COMPOSITE_FONTS_MAX`].
fn check_set(fonts: &[CompositeFont], c: &str) -> Result<()> {
@@ -167,10 +216,10 @@ fn check_set(fonts: &[CompositeFont], c: &str) -> Result<()> {
}
}
for f in fonts {
for class in CharClass::ALL {
let font = &f.parts.get(class).font;
let classes = CharClass::ALL.into_iter().map(|class| (class.key(), &f.parts.get(class).font));
for (part, font) in classes.chain(f.custom.iter().map(|s| (s.name.as_str(), &s.part.font))) {
if names.contains(&font.to_lowercase()) || db.composite_font(font).is_some() {
return Err(bad(c, format!("`{}`: {} is set in `{font}`, a composite font", f.name, class.key())));
return Err(bad(c, format!("`{}`: {} is set in `{font}`, a composite font", f.name, part)));
}
}
}
@@ -260,6 +309,9 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
let mut file = Map::new();
file.insert("compositeFonts".into(), json!(fonts));
let text = serde_json::to_string_pretty(&file).map_err(|e| bad(C, e.to_string()))?;
if text.len() > MAX_FILE_BYTES {
return Err(bad(C, "the composite fonts make a file larger than 4 MB, which Import can't read; export fewer of them"));
}
match path {
Some(path) => {
write_file(path, text.as_bytes())?;
@@ -856,3 +856,127 @@ fn composite_fonts_export_and_import() {
assert!(s.execute("type.compositeFonts.export", &json!({"path": " "})).is_err());
let _ = std::fs::remove_dir_all(&dir);
}
/// `font(name)` with two custom character sets: "Marks" (x and A) in Inter at 150%, and "A only"
/// (A) in Source Sans 3, which sets A since it comes later.
fn font_with_sets(name: &str) -> Value {
let mut v = font(name);
v["custom"] = json!([
{"name": " Marks ", "characters": "xA", "font": "Inter", "size": 150, "hScale": 90},
{"name": "A only", "characters": "A"},
]);
v
}
/// Custom character sets are set with the font as one undo step, take values they leave out from
/// the Kanji part, set the characters they list ahead of their class (the later set winning), are
/// saved with the document, and measure type again when they change.
#[test]
fn custom_character_sets_set_their_characters() {
let mut s = session();
let before = steps(&s);
let r = s.execute("type.compositeFonts.set", &json!({"fonts": [font_with_sets("CF Sets")]})).unwrap();
assert_eq!(r["fonts"][0]["custom"][0]["name"], "Marks");
assert_eq!(steps(&s), before + 1);
let f = s.doc().unwrap().doc.composite_fonts[0].clone();
assert_eq!(f.custom.len(), 2);
assert_eq!((f.custom[0].part.font.as_str(), f.custom[0].part.size, f.custom[0].part.h_scale), ("Inter", 150.0, 90.0));
assert_eq!((f.custom[1].part.font.as_str(), f.custom[1].part.size), ("Source Sans 3", 100.0));
// The same sets again change nothing.
s.execute("type.compositeFonts.set", &json!({"fonts": [font_with_sets("CF Sets")]})).unwrap();
assert_eq!(steps(&s), before + 1);
let back = vectorcraft_format::load(&vectorcraft_format::save_file(&s.doc().unwrap().doc)).unwrap();
assert_eq!(*back.composite_fonts, *s.doc().unwrap().doc.composite_fonts);
let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "xAB", "size": 20})).unwrap()["id"].as_u64().unwrap();
s.execute("text.setStyle", &json!({"ids": [id], "font": "CF Sets"})).unwrap();
let db = vectorcraft_text::FontDb::global();
let face = |family: &str| db.face(family, "Regular").unwrap().id();
let lay = vectorcraft_text::layout(db, &text(&s, id));
let fonts: Vec<u32> = lay.glyphs.iter().map(|g| g.font_id).collect();
assert_eq!(fonts, [face("Inter"), face("Source Sans 3"), face("JetBrains Mono")]);
// Taking x out of Marks sets it in its class's font again, measured in the same step.
let wide = text(&s, id).cached_bounds.unwrap().width();
let mut fewer = font_with_sets("CF Sets");
fewer["custom"][0]["characters"] = json!("A");
s.execute("type.compositeFonts.set", &json!({"fonts": [fewer]})).unwrap();
assert!(text(&s, id).cached_bounds.unwrap().width() < wide - 1.0);
s.execute("edit.undo", &json!({})).unwrap();
assert!((text(&s, id).cached_bounds.unwrap().width() - wide).abs() < 1e-6);
}
/// Bad custom character sets are refused with an error and no undo step.
#[test]
fn bad_custom_character_sets_are_refused() {
let mut s = session();
s.execute("type.compositeFonts.set", &json!({"fonts": [font_with_sets("CF Sets Base")]})).unwrap();
let before = steps(&s);
let with = |f: &dyn Fn(&mut Value)| {
let mut v = font_with_sets("CF Sets Refused");
f(&mut v);
json!({"fonts": [v]})
};
let many: String = (0x4E00u32..).filter_map(char::from_u32).take(8193).collect();
let sets: Vec<Value> = (0..65).map(|i| json!({"name": format!("S{i}")})).collect();
for bad in [
with(&|v| v["custom"] = json!("Marks")),
with(&|v| v["custom"] = Value::Null),
with(&|v| v["custom"][0]["characters"] = Value::Null),
with(&|v| v["custom"][0] = json!("Marks")),
with(&|v| v["custom"][0]["name"] = json!(" ")),
with(&|v| {
v["custom"][0].as_object_mut().unwrap().remove("name");
}),
with(&|v| v["custom"][1]["name"] = json!("marks")),
with(&|v| v["custom"][0]["characters"] = json!(["※"])),
with(&|v| v["custom"][0]["characters"] = json!("※※")),
with(&|v| v["custom"][0]["characters"] = json!("A\n")),
with(&|v| v["custom"][0]["characters"] = json!("😀")),
with(&|v| v["custom"][0]["characters"] = json!(many.clone())),
with(&|v| v["custom"] = json!(sets.clone())),
with(&|v| v["custom"][0]["size"] = json!(5)),
with(&|v| v["custom"][0]["size"] = json!("50")),
with(&|v| v["custom"][0]["scaleAtCenter"] = json!(true)),
with(&|v| v["custom"][0]["scaleAtCenter"] = json!(false)),
with(&|v| v["custom"][0]["font"] = json!("")),
with(&|v| v["custom"][0]["font"] = json!("CF Sets Base")),
with(&|v| v["custom"][0]["width"] = json!(3)),
] {
let e = s.execute("type.compositeFonts.set", &bad);
assert!(e.is_err(), "accepted {bad}");
assert_eq!(names(&s), ["CF Sets Base"]);
assert_eq!(steps(&s), before);
}
// A set without characters is kept: it sets nothing.
let ok = with(&|v| v["custom"][0]["characters"] = json!(""));
s.execute("type.compositeFonts.set", &ok).unwrap();
assert_eq!(s.doc().unwrap().doc.composite_fonts[0].custom[0].characters, "");
}
/// Export writes the custom character sets and Import reads them back; a file whose set is bad is
/// refused.
#[test]
fn custom_character_sets_export_and_import() {
let mut s = session();
s.execute("type.compositeFonts.set", &json!({"fonts": [font_with_sets("CF Sets IO")]})).unwrap();
let data = s.execute("type.compositeFonts.export", &json!({})).unwrap();
let mut t = session();
t.execute("type.compositeFonts.import", &json!({"data": data["data"]})).unwrap();
assert_eq!(*t.doc().unwrap().doc.composite_fonts, *s.doc().unwrap().doc.composite_fonts);
let text = data["data"].as_str().unwrap().replace("\"A only\"", "\"marks\"");
let mut u = session();
assert!(u.execute("type.compositeFonts.import", &json!({"data": text})).is_err());
assert!(names(&u).is_empty());
// Fonts whose file would be larger than Import reads are refused.
let full: String = (0x4E00u32..).filter_map(char::from_u32).take(8192).collect();
let sets: Vec<Value> = (0..64).map(|i| json!({"name": format!("S{i}"), "characters": full})).collect();
let big: Vec<Value> = (0..3)
.map(|i| {
let mut f = font(&format!("CF Sets Big {i}"));
f["custom"] = json!(sets);
f
})
.collect();
let e = s.execute("type.compositeFonts.export", &json!({"fonts": big})).unwrap_err();
assert!(e.to_string().contains("4 MB"), "{e}");
}
+24 -8
View File
@@ -53,7 +53,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Flare",
[],
None,
"{cx, cy, diameter=100, opacity=50 (%), brightness=30 (%), growth=20 (%), fuzziness=50 (%), rays=15, longest=300 (%), rayFuzziness=100 (%), rotation=0 (deg, turns the rays clockwise), x2?, y2? (ring end; else pathLength at direction), pathLength=300, rings=10, largest=50 (%), direction=45 (deg counter-clockwise from 3 o'clock: where the rings run from the centre), seed?} → {id}",
"{cx, cy, diameter=100, opacity=50 (%), brightness=30 (%), growth=20 (%), fuzziness=50 (%), rays=15, longest=300 (%), rayFuzziness=100 (%), rotation=0 (deg, turns the rays clockwise), x2?, y2? (ring end; else pathLength at direction), pathLength=300, rings=10, largest=50 (%), direction=45 (deg counter-clockwise from 3 o'clock: where the rings run from the centre), seed?, ringSeed? (whole number below 2^53: places the rings along the path anew, keeping their count and sizes)} → {id}",
has_doc,
flare
),
@@ -429,6 +429,7 @@ fn flare(s: &mut Session, p: &Value) -> Result<Value> {
"direction",
"pathLength",
"seed",
"ringSeed",
];
check_numbers(p, &numbers, "shape.flare")?;
let pct = |k: &str, d: f64| (f64_or(p, k, d) / 100.0).clamp(0.0, 10.0);
@@ -450,14 +451,28 @@ fn flare(s: &mut Session, p: &Value) -> Result<Value> {
c + vectorcraft_geom::Vec2::new(a.cos(), -a.sin()) * f64_or(p, "pathLength", 300.0)
}
};
// Deterministic pseudo-random sizes (same parameters → same flare).
let mut seed = p.get("seed").and_then(Value::as_u64).unwrap_or(0x2545_f491_4f6c_dd1d) | 1;
let mut rnd = move || {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
(seed >> 11) as f64 / (1u64 << 53) as f64
// Deterministic pseudo-random sizes (same parameters → same flare). `ringSeed` places the rings
// along the path from a sequence of its own; their count and sizes and the other parts stay.
let seed_of = |key: &str| match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(v) => v.as_u64().map(Some).ok_or_else(|| bad("shape.flare", format!("`{key}` must be a whole number of at least 0, not {v}"))),
};
// The generator's state is odd (never 0).
let random = |mut state: u64| {
move || {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
(state >> 11) as f64 / (1u64 << 53) as f64
}
};
let mut rnd = random(seed_of("seed")?.unwrap_or(0x2545_f491_4f6c_dd1d) | 1);
// Each `ringSeed` below 2^53 (what a JSON number holds exactly) gets a state of its own.
let ring_seed = match seed_of("ringSeed")? {
Some(r) if r >= 1 << 53 => return Err(bad("shape.flare", format!("`ringSeed` must be below 2^53, not {r}"))),
r => r,
};
let mut ring_at = ring_seed.map(|r| random((r << 1) | 1));
let stop = |offset: f32, opacity: f32| GradientStop { opacity, ..GradientStop::new(offset, vectorcraft_color::Color::WHITE) };
let radial = |centre: Point, radius: f64, stops: Vec<GradientStop>| {
let mut g = GradientPaint::new(Gradient::new(GradientKind::Radial, stops));
@@ -495,6 +510,7 @@ fn flare(s: &mut Session, p: &Value) -> Result<Value> {
// Rings along the path to the end point (Largest = % of the average ring size).
for _ in 0..rings {
let t = rnd();
let t = ring_at.as_mut().map_or(t, |next| next());
let at = c + (end - c) * t;
let rr = (r * largest * (0.25 + rnd() * 0.75)).max(0.5);
let solid = rnd() < 0.5;
+2 -1
View File
@@ -356,7 +356,8 @@ fn mirror_open(sp: &SubPath, axis: &Axis, m: Affine) -> Vec<SubPath> {
if !end_on {
run.reverse();
}
let Some((last, body)) = run.anchors.split_last() else { return vec![] };
let run = run.anchors.into_vec();
let Some((last, body)) = run.split_last() else { return vec![] };
let q = axis.project(last.p);
let mut anchors = body.to_vec();
anchors.push(Anchor::with_handles(q, last.h_in, m * last.h_in));
+3 -9
View File
@@ -10,6 +10,7 @@ use vectorcraft_doc::{CharStyle, Guide, Node, NodeId, NodeKind, SavedSelection,
use vectorcraft_geom::Vec2;
use super::edit::selected_roots;
use super::panelcmds::artboard_at;
use super::pathops::Beside;
use super::select::{first_selectable, is_selectable, selectable_ids, walk_selectable};
use super::{
@@ -201,7 +202,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add Guide",
[],
None,
"{vertical: bool, pos: pt (x for vertical, y for horizontal), artboard?: index (an artboard guide: it runs across that artboard only and moves, is copied and is deleted with it; default a canvas guide, across the whole canvas), layer?: id (the layer it is on, shown, hidden, locked and deleted with it; default the current layer)} → {index}",
"{vertical: bool, pos: pt (x for vertical, y for horizontal), artboard?: index (0-based whole number; an artboard guide: it runs across that artboard only and moves, is copied and is deleted with it; default, or null, a canvas guide, across the whole canvas), layer?: id (the layer it is on, shown, hidden, locked and deleted with it; default the current layer)} → {index}",
has_doc,
guide_add
),
@@ -767,15 +768,8 @@ fn clear_guides(s: &mut Session, _: &Value) -> Result<Value> {
fn guide_add(s: &mut Session, p: &Value) -> Result<Value> {
let pos = f64_req(p, "pos", "guide.add")?;
let vertical = switch_or(p, "vertical", false, "guide.add")?;
let artboard = match p.get("artboard").filter(|v| !v.is_null()) {
Some(v) => {
let boards = &s.doc()?.doc.artboards;
let ab = v.as_u64().and_then(|i| boards.get(usize::try_from(i).ok()?)).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
Some(ab.id)
}
None => None,
};
let st = s.doc()?;
let artboard = artboard_at(&st.doc, p, "artboard", "guide.add")?.and_then(|i| st.doc.artboards.get(i)).map(|a| a.id);
let layer = match id_param(p, "layer") {
Some(l) if st.doc.node(l).is_some_and(Node::is_layer) => Some(l),
Some(_) => return Err(bad("guide.add", "layer must name a layer or sublayer")),
+1
View File
@@ -33,3 +33,4 @@ mod smooth;
pub use commands::specs;
pub(super) use common::{ids_json, replace_with_pieces, usize_opt};
pub(super) use cut::{components, erase_outline};
pub(super) use freehand::freehand_brushed;
+76 -12
View File
@@ -46,7 +46,7 @@ fn fit_freehand(pts: &[Point], tol: f64, closed: bool) -> SubPath {
let sp = vectorcraft_tools::draw2::catmull_rom(&marked, false);
if sp.anchors.len() < 3 {
// Two anchors closed would run back over the line itself.
return SubPath::new(sp.anchors, closed && n > 2);
return SubPath::of(sp.anchors, closed && n > 2);
}
let fitted = po::simplify_with(
&PathData::single(sp.clone()),
@@ -62,7 +62,7 @@ fn fit_freehand(pts: &[Point], tol: f64, closed: bool) -> SubPath {
f.h_in = f.p;
}
}
SubPath::new(anchors, closed)
SubPath::of(anchors, closed)
}
/// The path of a freehand stroke: fitted at `tol` ([`fit_freehand`]), or with no `tol` the points
@@ -133,13 +133,13 @@ fn redraw_section(pd: &PathData, pts: &[Point], tol: Option<f64>, within: f64) -
*s0 = s1.p;
let (Some(e0), Some(e1)) = (stroke.last_mut(), sp.anchors.get(end)) else { return None };
*e0 = e1.p;
let fit = stroke_subpath(&stroke, tol, false).anchors;
let fit = stroke_subpath(&stroke, tol, false).anchors.into_vec();
let (Some(f0), Some(f1)) = (fit.first().copied(), fit.last().copied()) else { return None };
let inner = fit.get(1..fit.len().saturating_sub(1)).unwrap_or_default();
let (before, after): (Vec<Anchor>, Vec<Anchor>) = if sp.closed {
((0..=(start + n - end) % n).filter_map(|k| sp.anchors.get((end + k) % n).copied()).collect(), vec![])
} else {
(sp.anchors.get(..=start)?.to_vec(), sp.anchors.get(end..)?.to_vec())
(sp.anchors.get_range(..=start)?, sp.anchors.get_range(end..)?)
};
let mut anchors = before;
if let Some(a) = anchors.last_mut() {
@@ -157,6 +157,34 @@ fn redraw_section(pd: &PathData, pts: &[Point], tol: Option<f64>, within: f64) -
}
pub(super) fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
freehand_brushed(s, p, None)
}
/// The object a brush on path `id` paints: the compound path a member path is in, else the path.
fn brush_target(d: &Document, id: NodeId) -> NodeId {
d.parent_of(id).filter(|p| d.node(*p).is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. }))).unwrap_or(id)
}
/// Whether `ap` has a stroke a brush can paint: a stroke whose paint is not None.
fn can_brush(ap: &Appearance) -> bool {
ap.stroke().is_some_and(|st| !st.paint.is_none())
}
fn no_stroke(name: &str) -> EngineError {
bad("brush.freehand", format!("cannot apply brush `{name}`: path has no stroke"))
}
/// Sets brush `name` on the stroke of object `id` of `d`.
fn set_brush(d: &mut Document, id: NodeId, name: &str) {
if let Some(st) = d.node_mut(id).and_then(|n| n.appearance.stroke_mut()) {
st.brush = Some(name.to_string());
}
}
/// `path.freehand`, with the stroke painted with brush `brush_name` (one `brush.freehand` has
/// checked) in the same undo step as the path. A path without a stroke is an error before
/// anything changes.
pub(crate) fn freehand_brushed(s: &mut Session, p: &Value, brush_name: Option<&str>) -> Result<Value> {
const C: &str = "path.freehand";
let samples = samples_param(p, "points", C)?;
if samples.iter().any(|q| !within_input_bound(q.pt)) {
@@ -173,6 +201,8 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
let fit_tol = switch_or(p, "fit", true, C)?.then_some(tol);
let closed = switch_or(p, "closed", false, C)?;
let brush = str_param(p, "style") == Some("brush");
// The undo step's name: a stroke painted with a brush is a Paintbrush stroke, whatever its style.
let label = if brush || brush_name.is_some() { "Paintbrush" } else { "Pencil" };
let keep_selected = switch_or(p, "keepSelected", true, C)?;
let filled = switch_or(p, "fill", false, C)?;
if let Some(ext) = p.get("extend").filter(|v| !v.is_null()) {
@@ -180,6 +210,12 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
let at_start = str_param(ext, "end") == Some("start");
let (pd, _) = path_of(&s.doc()?.doc, id)?;
let si = pd.subpaths.iter().rposition(|s| !s.closed && !s.anchors.is_empty()).ok_or_else(|| bad(C, "path has no open end"))?;
let target = brush_target(&s.doc()?.doc, id);
if let Some(name) = brush_name
&& !s.doc()?.doc.node(target).is_some_and(|n| can_brush(&n.appearance))
{
return Err(no_stroke(name));
}
let mut sp = pd.subpaths[si].clone();
let old_len = subpath_len(&sp);
if at_start {
@@ -191,6 +227,15 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
let Some(p0) = pts.first_mut() else { return Err(bad(C, "needs points")) };
*p0 = end;
if pts.len() < 2 {
// No stroke to add: the brush alone changes, when it is not the path's already.
if let Some(name) = brush_name
&& s.doc()?.doc.node(target).and_then(|n| n.appearance.stroke()).is_some_and(|st| st.brush.as_deref() != Some(name))
{
s.edit("Paintbrush", |d, _| {
set_brush(d, target, name);
Ok(())
})?;
}
return Ok(json!({ "id": id.0 }));
}
// A stroke that ends on an open end of another path connects the two: (that path, its last
@@ -271,12 +316,12 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
// outgoing handle.
let mut other_len = 0.0;
if let Some((_, _, y, _)) = &connect
&& let Some((y0, rest)) = y.anchors.split_first()
&& let Some(y0) = y.anchors.first()
{
if let Some(last) = sp.anchors.last_mut() {
*last = Anchor::with_handles(y0.p, last.h_in + (y0.p - last.p), y0.h_out);
}
sp.anchors.extend_from_slice(rest);
sp.anchors.extend(y.anchors.iter().skip(1));
other_len = subpath_len(y);
}
// What the pen recorded along the path (in the direction it is continued in), followed by
@@ -290,7 +335,7 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
let drawn = old.extended(old_len, &pen, drawn_len - old_len);
if connect.is_some() { drawn.extended(drawn_len, &other, other_len) } else { drawn }
});
s.edit(if brush { "Paintbrush" } else { "Pencil" }, |d, sel| {
s.edit(label, |d, sel| {
let reverse = at_start && !sp.closed;
if reverse {
sp.reverse();
@@ -326,6 +371,9 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
{
if reverse { joined.reversed() } else { joined }.write(st);
}
if let Some(name) = brush_name {
set_brush(d, target, name);
}
if keep_selected {
sel.set([id]);
} else {
@@ -346,11 +394,19 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
// were, measured along the old path; fit them to the new length if strokes drawn with a
// pen get redrawn.
if let Some((si, sp)) = redraw_section(&pd, &pts, fit_tol, within) {
s.edit(if brush { "Paintbrush" } else { "Pencil" }, |d, sel| {
// A member path of a compound path is redrawn in it: the compound path is what is
// selected, and what a brush paints.
let whole = brush_target(&s.doc()?.doc, id);
if let Some(name) = brush_name
&& !s.doc()?.doc.node(whole).is_some_and(|n| can_brush(&n.appearance))
{
return Err(no_stroke(name));
}
s.edit(label, |d, sel| {
*path_mut(d, id)?.subpaths.get_mut(si).ok_or_else(|| bad(C, "no such subpath"))? = sp;
// A member path of a compound path is redrawn in it: the compound path is what is
// selected.
let whole = d.parent_of(id).filter(|p| d.node(*p).is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. }))).unwrap_or(id);
if let Some(name) = brush_name {
set_brush(d, whole, name);
}
if keep_selected {
sel.set([whole]);
} else {
@@ -373,7 +429,15 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(st) = look.appearance.stroke_mut() {
pen.write(st);
}
let out = insert_look(s, if brush { "Paintbrush" } else { "Pencil" }, path_kind(PathData::single(sp)), look, None, keep_selected)?;
if let Some(name) = brush_name {
if !can_brush(&look.appearance) {
return Err(no_stroke(name));
}
if let Some(st) = look.appearance.stroke_mut() {
st.brush = Some(name.to_string());
}
}
let out = insert_look(s, label, path_kind(PathData::single(sp)), look, None, keep_selected)?;
if !keep_selected {
// A new stroke with Keep selected off leaves nothing selected: Illustrator starts the stroke
// by dropping the earlier selection, it does not keep it (#5752).
+11 -11
View File
@@ -274,7 +274,7 @@ fn refit_run(anchors: &[Anchor], tol: f64, sigma: f64, free: [bool; 2]) -> Vec<A
for a in &mut out {
*a = Anchor::with_handles(a.p, a.h_in, a.h_out);
}
out
out.into_vec()
}
/// Distance (points) from an anchor within which a smoothed closed path starts at that anchor
@@ -549,7 +549,7 @@ fn smooth_subpath(sp: &SubPath, pts: &[Point], radius: f64, share: usize, tol: f
if sp.closed {
let start = sp.anchors[0].p;
let Some(k) = marked.iter().position(|m| !m) else {
let corners = smooth_corners(&sp.anchors, true, radius);
let corners = smooth_corners(&sp.anchors.contiguous(), true, radius);
let Some(c) = corners.iter().position(|c| *c) else {
// Everything is in range and nothing is a corner: smooth the whole closed path,
// across its start too.
@@ -557,7 +557,7 @@ fn smooth_subpath(sp: &SubPath, pts: &[Point], radius: f64, share: usize, tol: f
return f.subpaths.into_iter().next().map(|r| start_nearest(r, start, sp));
};
// Cut the path open at a corner, which then ends the last part as well as starting the first.
let mut open = sp.anchors.clone();
let mut open = sp.anchors.to_vec();
open.rotate_left(c);
let mut cut = corners;
cut.rotate_left(c);
@@ -576,15 +576,14 @@ fn smooth_subpath(sp: &SubPath, pts: &[Point], radius: f64, share: usize, tol: f
return Some(start_nearest(SubPath::new(res, true), start, sp));
};
// Rotate so an untouched anchor comes first, duplicate it at the end and treat as open.
let mut open = sp.clone();
open.anchors.rotate_left(k);
let mut open = sp.anchors.to_vec();
open.rotate_left(k);
let mut m = marked.clone();
m.rotate_left(k);
open.anchors.push(open.anchors[0]);
open.push(open[0]);
m.push(false);
open.closed = false;
let mut res = smooth_open(&open.anchors, &m, near, tol, sigma, radius, false);
if same_anchors(&res, &open.anchors) {
let mut res = smooth_open(&open, &m, near, tol, sigma, radius, false);
if same_anchors(&res, &open) {
return None;
}
let l = res.pop()?;
@@ -595,8 +594,9 @@ fn smooth_subpath(sp: &SubPath, pts: &[Point], radius: f64, share: usize, tol: f
let res = SubPath::new(res, true);
return Some(if k == 0 { res } else { start_nearest(res, start, sp) });
}
let res = smooth_open(&sp.anchors, &marked, near, tol, sigma, radius, true);
(!same_anchors(&res, &sp.anchors)).then(|| SubPath::new(res, false))
let anchors = sp.anchors.contiguous();
let res = smooth_open(&anchors, &marked, near, tol, sigma, radius, true);
(!same_anchors(&res, &anchors)).then(|| SubPath::new(res, false))
}
/// The segment `c`, which ends in the drag's reach (`near`) and starts outside it, split where it
+5 -1
View File
@@ -146,7 +146,11 @@ fn text(k: &str, v: &Value, multiline: bool) -> Result<String> {
return Err(bad(C, format!("{k} is too long (at most {MAX_TEXT} characters)")));
}
if s.chars().any(|c| c.is_control() && !(multiline && matches!(c, '\n' | '\r' | '\t'))) {
return Err(bad(C, format!("{k} has control characters{}", if multiline { "" } else { " or line breaks" })));
return Err(if multiline {
bad(C, format!("{k} has control characters"))
} else {
bad(C, format!("{k} has control characters or line breaks"))
});
}
Ok(s.to_string())
}
+6
View File
@@ -54,6 +54,7 @@ pub mod ppi;
pub mod progress;
mod psdlayers;
pub mod psdread;
mod psdslices;
mod pxrread;
mod save;
mod screens;
@@ -93,6 +94,9 @@ pub(crate) use native::preview_png;
pub use native::with_compression_pref;
pub(crate) use pdfimport::opened_page_document;
pub use pdfimport::{LoadOptions, page_document};
pub(crate) use psdlayers::import_active as psd_layers;
pub use psdlayers::{PsdInfo, info as psd_info};
pub(crate) use psdslices::over as psd_slices;
pub(crate) use save::job_for;
pub(crate) use save::save as save_document;
pub use save::{
@@ -240,6 +244,8 @@ mod tests_psd;
mod tests_psdlayers;
#[cfg(test)]
mod tests_psdread;
#[cfg(test)]
mod tests_psdslices;
#[cfg(test)]
mod tests_heifread;
File diff suppressed because one or more lines are too long
+69 -18
View File
@@ -528,17 +528,26 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
}
// A PSD or PSB file's layers become layers (`layers`, default on); without layers, or with
// `layers: false`, its merged image opens. Layers that can't be read leave the merged image.
"psd" | "psb" if opts.layers => {
let cms = vectorcraft_color::cms::active();
match super::psdlayers::import(bytes, opts.hidden_layers, MAX_RASTER_ALLOC, |c| cms.cmyk_to_srgb(c, false)) {
Ok(Some((doc, warnings))) => (doc, warnings, false),
Ok(None) => (raster_doc(&title, bytes)?, vec![], false),
Err(e) => (raster_doc(&title, bytes)?, vec![format!("the layers can't be read ({e}): the file opened as its merged image")], false),
}
}
"psd" | "psb" if opts.layers => match super::psdlayers::import_active(bytes, opts.hidden_layers) {
Ok(Some((doc, warnings))) => (doc, warnings, false),
Ok(None) => (raster_doc(&title, bytes)?, vec![], false),
Err(e) => (raster_doc(&title, bytes)?, vec![format!("the layers can't be read ({e}): the file opened as its merged image")], false),
},
_ if format.raster => (raster_doc(&title, bytes)?, vec![], false),
_ => return Err(err(format!("{} files can't be opened yet", format.label))),
};
// A PSD or PSB file's slices become user slices over its canvas (`importSlices`, default on),
// whether its layers or its merged image came in.
if matches!(format.id, "psd" | "psb")
&& opts.import_slices
&& let Some(canvas) = doc.artboards.first().map(|a| a.rect)
{
let (slices, notes) = super::psd_slices(bytes, canvas);
for (rect, options) in slices {
crate::cmd::slices::add_slice(&mut doc, rect, options);
}
warnings.extend(notes);
}
// A PDF, `.ai` or `.ait` read from its pages or its editing copy, not restored from the document
// it carries, opens with the File Info it gives (a PostScript `.ai` gives none).
if matches!(format.id, "pdf" | "ai" | "ait")
@@ -660,6 +669,17 @@ pub struct ReadFile {
path: Option<String>,
/// What became of its linked files.
links: Resolved,
/// Whether the color management policies changed the document ([`apply_policies`]).
policy_changed: bool,
}
/// Edit › Color Settings: the color management policies keep, take off or convert from the
/// profiles the document `loaded` holds is tagged with, and their notes go to its warnings →
/// whether they changed the document.
fn apply_policies(loaded: &mut Loaded) -> bool {
let (notes, changed) = crate::cmd::colormgmt::apply_open_policies(&mut loaded.doc);
loaded.warnings.extend(notes);
changed
}
#[cfg(test)]
@@ -680,17 +700,35 @@ impl ReadFile {
/// ([`super::importer`]).
pub fn read_for_open(name: &str, bytes: &[u8], path: Option<String>, p: &Value, update_links: bool, low_res_proxy_eps: bool) -> Result<ReadFile> {
let opts = LoadOptions::from_params("document.open", p)?;
let mut loaded = if opts.link_pages {
linked_pages(name, bytes, path.as_deref(), &opts)?
let (mut loaded, policy_changed) = if opts.link_pages {
let mut loaded = linked_pages(name, bytes, path.as_deref(), &opts)?;
let changed = apply_policies(&mut loaded);
(loaded, changed)
} else {
match super::importer::pick(name, bytes, p)? {
Some(plugin) => super::importer::load(name, bytes, &plugin, p)?,
None => load_with(name, bytes, &opts)?,
Some(plugin) => {
let mut loaded = super::importer::load(name, bytes, &plugin, p)?;
let changed = apply_policies(&mut loaded);
(loaded, changed)
}
None => {
// The color mode `p` asks for converts the colors after the policies did, through
// the profiles the document keeps.
let mut loaded = load_with(name, bytes, &LoadOptions { color_mode: None, ..opts.clone() })?;
let changed = apply_policies(&mut loaded);
if let Some(mode) = opts.color_mode.filter(|m| *m != loaded.doc.color_mode) {
let doc = &mut loaded.doc;
vectorcraft_color::cms::with_active(super::super::colormgmt::doc_cms(doc), || {
super::super::colormgmt::set_color_mode(doc, mode, true, None, opts.grays);
});
}
(loaded, changed)
}
}
};
let links = crate::cmd::links::resolve(&mut loaded.doc, crate::cmd::links::LinkBase::file(path.as_deref()), update_links, low_res_proxy_eps);
Session::refresh_text_bounds(&mut loaded.doc);
Ok(ReadFile { loaded, opts, path, links })
Ok(ReadFile { loaded, opts, path, links, policy_changed })
}
/// The PDF `name` (`bytes`, read from `path`) opened with `linkPages`: its pages as placed documents
@@ -705,7 +743,7 @@ fn linked_pages(name: &str, bytes: &[u8], path: Option<&str>, opts: &LoadOptions
/// The second half of [`open_bytes_with`]: open a file [`read_for_open`] read as the new active
/// document → `document.open`'s result.
pub fn open_read(s: &mut Session, file: ReadFile) -> Result<Value> {
open_loaded(s, file.loaded, file.links, file.path, &file.opts, false)
open_loaded(s, file.loaded, file.links, file.path, &file.opts, false, file.policy_changed)
}
/// File → New from Template: any readable file (at `path`, if it is one: its links are looked for
@@ -717,14 +755,24 @@ pub fn open_template(s: &mut Session, name: &str, bytes: &[u8], path: Option<&st
"only this Affinity file's embedded preview could be read: use File › Open to see it with its warning, or export SVG or PDF from Affinity first",
));
}
let policy_changed = apply_policies(&mut loaded);
let base = crate::cmd::links::LinkBase::file(path);
let links = crate::cmd::links::resolve(&mut loaded.doc, base, s.prefs.update_links == "automatically", s.prefs.low_res_proxy_eps);
open_loaded(s, loaded, links, path.map(str::to_string), &LoadOptions::default(), true)
open_loaded(s, loaded, links, path.map(str::to_string), &LoadOptions::default(), true, policy_changed)
}
/// Make a loaded file the new active document; `links` is what became of its linked files, and
/// `opts` are the options it was read with.
fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option<String>, opts: &LoadOptions, as_template: bool) -> Result<Value> {
/// Make a loaded file the new active document; `links` is what became of its linked files, `opts`
/// are the options it was read with, and `policy_changed` says whether the color management
/// policies changed it (it then opens with unsaved changes).
fn open_loaded(
s: &mut Session,
loaded: Loaded,
links: Resolved,
path: Option<String>,
opts: &LoadOptions,
as_template: bool,
policy_changed: bool,
) -> Result<Value> {
let Loaded { mut doc, format, warnings, restored, converted, native_pdf_compatible, .. } =
loaded.with_grid_prefs(&s.prefs).with_unit_prefs(&s.prefs);
// EPS now has an ordinary Save path. Its preview, partial-program and damaged-editing
@@ -765,6 +813,9 @@ fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option<St
let converted = saves_back && converted && s.prefs.append_converted;
let index = s.add_document(doc, path);
let st = s.doc_mut()?;
if policy_changed {
st.mark_unsaved();
}
if let Some(pdf) = native_pdf_compatible {
st.save_options.insert("pdfCompatible".into(), json!(pdf));
}
+6 -8
View File
@@ -236,15 +236,13 @@ pub(super) fn encode_to(cmd: &str, f: &Format, doc: &Document, p: &Value, w: imp
}
/// `doc` with its path geometry rounded to `decimals` places, as [`vectorcraft_format::save_to`]
/// writes it for `version`: placed documents become groups of their art for older versions.
/// writes it for `version`: placed documents, those in the stored brushes' art included, become
/// groups of their art for older versions.
fn rounded(doc: &Document, version: u32, decimals: u8) -> Document {
let mut d = if version < vectorcraft_format::VERSION && doc.has_placed() {
let mut d = doc.with_placed_art().into_owned();
d.placed_as_groups();
d
} else {
doc.clone()
};
let mut d = doc.clone();
if version < vectorcraft_format::VERSION {
vectorcraft_format::placed_as_groups(&mut d);
}
d.round_geometry(decimals.min(vectorcraft_format::MAX_PRECISION));
d
}
+9 -1
View File
@@ -36,6 +36,8 @@ pub struct LoadOptions {
pub layers: bool,
/// A PSD or PSB file's hidden layers come in, hidden (`hiddenLayers`; else they are left out).
pub hidden_layers: bool,
/// A PSD or PSB file's slices become user slices (`importSlices`; else they are left out).
pub import_slices: bool,
/// How a DXF drawing comes in (the `dxf` param, see [`super::dxfimport`]).
pub dxf: vectorcraft_cad::ImportOptions,
/// An Illustrator EPS or `.ai` that carries the editing copy of its art opens from it (its
@@ -67,6 +69,7 @@ impl Default for LoadOptions {
text_as: TextAs::default(),
layers: true,
hidden_layers: false,
import_slices: true,
dxf: Default::default(),
editing_data: true,
trust_editing_data: false,
@@ -81,7 +84,7 @@ impl LoadOptions {
/// From command params: `pages` (`"2-3, 5"`, a page number or `"all"`), `page` (one page, when
/// `pages` isn't given), `cropTo` (or `crop`), `password`, `colorMode` (`rgb` | `cmyk`) with `grays`,
/// `textAs` (`text` | `outlines`), `layers` (true | false), `hiddenLayers` (true | false),
/// `editingData` (true | false | `always`),
/// `importSlices` (true | false), `editingData` (true | false | `always`),
/// `legacyNameEncoding` (one of [`vectorcraft_eps::NameEncoding::LABELS`]), `linkPages` (true |
/// false) and `dxf` (the DXF import options).
pub fn from_params(cmd: &str, p: &Value) -> Result<Self> {
@@ -111,6 +114,10 @@ impl LoadOptions {
None => false,
Some(v) => v.as_bool().ok_or_else(|| bad(cmd, "hiddenLayers must be true or false"))?,
};
let import_slices = match p.get("importSlices").filter(|v| !v.is_null()) {
None => true,
Some(v) => v.as_bool().ok_or_else(|| bad(cmd, "importSlices must be true or false"))?,
};
let (editing_data, trust_editing_data) = match p.get("editingData").filter(|v| !v.is_null()) {
None => (true, false),
Some(Value::String(s)) if s.eq_ignore_ascii_case("always") => (true, true),
@@ -137,6 +144,7 @@ impl LoadOptions {
text_as,
layers,
hidden_layers,
import_slices,
dxf,
editing_data,
trust_editing_data,
+64
View File
@@ -43,6 +43,7 @@ use std::cell::Cell;
use std::sync::Arc;
use image::RgbaImage;
use serde_json::{Value, json};
use vectorcraft_color::BlendMode;
use vectorcraft_color::blend::{blend_rgb, composite};
use vectorcraft_color::cms::{Lab, lab::lab_to_srgb};
@@ -51,6 +52,7 @@ use vectorcraft_geom::Affine;
use super::load::{RasterImage, as_png};
use super::psdread::{Header, Reader, byte, header, layer_channel, layer_info, long_key, row_bytes, sample, to_rgb};
use crate::cmd::Session;
use crate::cmd::place::text::mac_roman;
/// Most layer groups inside one another.
@@ -1527,3 +1529,65 @@ pub(super) fn import(
doc.limit_depth_noting(&mut warnings);
Ok(Some((doc, warnings)))
}
/// [`import`] within the memory allowed for one image, CMYK converted with the active color
/// settings: what `document.open` and `file.place` read a PSD or PSB file's layers with.
pub(crate) fn import_active(bytes: &[u8], hidden: bool) -> Result<Option<(Document, Vec<String>)>, String> {
let cms = vectorcraft_color::cms::active();
import(bytes, hidden, super::MAX_RASTER_ALLOC, |c| cms.cmyk_to_srgb(c, false))
}
/// What a PSD or PSB file holds (`document.psdInfo`, the PSD Import Options dialog).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PsdInfo {
/// The large document format.
pub psb: bool,
/// The canvas, in pixels.
pub width: u32,
pub height: u32,
/// The layers, group markers left out.
pub layers: usize,
/// The layers among them that are hidden.
pub hidden: usize,
/// The layer groups.
pub groups: usize,
/// The slices that come in with `importSlices` (those generated automatically, and those
/// outside the canvas, left out).
pub slices: usize,
}
/// What PSD or PSB file `bytes` holds, read from its header, its slices and its layer records
/// without decoding any pixels. A file without layer records, or whose layer records can't be read, has no layers: it
/// opens and places as its merged image.
pub fn info(bytes: &[u8]) -> Result<PsdInfo, String> {
let mut r = Reader::new(bytes);
let h = header(&mut r)?;
let records = layer_info(h.layers, h.psb).filter(|info| info.len() > 2).and_then(|info| records(info, h.psb).ok()).unwrap_or_default();
let layers = records.iter().filter(|r| r.divider == 0);
Ok(PsdInfo {
psb: h.psb,
width: h.width,
height: h.height,
layers: layers.clone().count(),
hidden: layers.filter(|r| r.hidden).count(),
groups: records.iter().filter(|r| matches!(r.divider, 1 | 2)).count(),
slices: super::psdslices::count(bytes),
})
}
const INFO: &str = "document.psdInfo";
/// `document.psdInfo`.
pub(super) fn psd_info(_: &mut Session, p: &Value) -> crate::cmd::Result<Value> {
let bytes = super::source(p, INFO)?.bytes;
let i = info(&bytes).map_err(super::load::err)?;
Ok(json!({
"format": if i.psb { "psb" } else { "psd" },
"width": i.width,
"height": i.height,
"layers": i.layers,
"hiddenLayers": i.hidden,
"groups": i.groups,
"slices": i.slices,
}))
}
+2 -2
View File
@@ -105,7 +105,7 @@ pub(super) struct Header<'a> {
/// Colour mode data: an indexed image's palette (256 reds, then greens, then blues).
palette: &'a [u8],
/// The image resources.
resources: &'a [u8],
pub(super) resources: &'a [u8],
/// The layer and mask information.
pub(super) layers: &'a [u8],
}
@@ -172,7 +172,7 @@ pub fn resolution(bytes: &[u8]) -> Option<(f64, f64)> {
/// The image resource blocks of `resources`, in file order: each one's id, name (the bytes of its
/// Pascal string) and data. Stops at the first block that is cut short. With `signed`, only the
/// blocks whose signature is `8BIM` are given.
fn resources(resources: &[u8], signed: bool) -> impl Iterator<Item = (u16, &[u8], &[u8])> {
pub(super) fn resources(resources: &[u8], signed: bool) -> impl Iterator<Item = (u16, &[u8], &[u8])> {
let mut r = Reader::new(resources);
std::iter::from_fn(move || {
if r.rest().is_empty() {
+408
View File
@@ -0,0 +1,408 @@
//! The slices a Photoshop document (PSD or PSB) stores, for `document.open` and `file.place`
//! with `importSlices`: image resource 1050, read from the "Slices resource format" of Adobe's
//! public Photoshop File Formats Specification
//! (<https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/>). Version 6 stores the slices
//! as records; versions 7 and 8 store them in a descriptor. The descriptor's keys, and the meaning
//! of the origin, type and background values, are read as the MIT-licensed ag-psd reads them
//! (<https://github.com/Agamnentzar/ag-psd>, `src/imageResources.ts` and `src/descriptor.ts`),
//! which agrees with psd.rb (<https://github.com/layervault/psd.rb>,
//! `lib/psd/resources/slices.rb`) on the keys both read.
//!
//! A slice the file marks as generated automatically is left out: the document lays out its own
//! auto slices around the others. A slice that follows a layer comes in as a user slice over the
//! rectangle the file stores for it. Each slice keeps its name, its type (Image or No Image), its
//! link, target, message, alternative text, cell text and background color; text alignment isn't
//! read. The file is untrusted: every read is checked, and the slice count, the strings and the
//! descriptor's nesting and item count are capped.
use vectorcraft_doc::{SliceKind, SliceOptions};
use vectorcraft_geom::Rect;
use super::psdread::{Reader, TRUNCATED, header, resources};
/// The image resource that holds the slices.
const SLICES: u16 = 1050;
/// The most slices one file brings in.
const MAX_SLICES: usize = 10_000;
/// The deepest descriptor objects and lists nest.
const MAX_DEPTH: usize = 16;
/// The most values the descriptors of one slices resource hold together, nested ones included.
const MAX_VALUES: usize = 200_000;
/// The error of descriptors that hold more than [`MAX_VALUES`].
const TOO_MANY_VALUES: &str = "the slices' descriptor holds too many values";
/// One slice as the file stores it.
#[derive(Clone, Debug, PartialEq)]
struct PsdSlice {
/// Left, top, right and bottom, in the canvas's pixels.
rect: [i64; 4],
options: SliceOptions,
}
/// The slices a file stores, those generated automatically left out, and its canvas.
struct Stored {
slices: Vec<PsdSlice>,
/// The canvas's width and height, in pixels.
size: (u32, u32),
}
/// The slices PSD or PSB file `bytes` stores. `None` when it stores none (no `8BIM` resource 1050
/// before the image resources end or are cut short). An error when the file isn't a PSD or PSB
/// file, or its slices can't be read.
fn read(bytes: &[u8]) -> Result<Option<Stored>, String> {
let mut r = Reader::new(bytes);
let h = header(&mut r)?;
let Some((_, _, data)) = resources(h.resources, true).find(|(id, _, _)| *id == SLICES) else { return Ok(None) };
Ok(Some(Stored { slices: decode(data)?, size: (h.width, h.height) }))
}
/// The slices of resource 1050's `data`.
fn decode(data: &[u8]) -> Result<Vec<PsdSlice>, String> {
let mut r = Reader::new(data);
// The values every descriptor of the resource may hold together.
let mut budget = MAX_VALUES;
match r.u32()? {
6 => version_6(&mut r, &mut budget),
7 | 8 => {
if r.u32()? != 16 {
return Err("the slices' descriptor has a version other than 16".into());
}
let d = descriptor(&mut r, 0, &mut budget)?;
from_descriptor(&d)
}
v => Err(format!("the slices are stored in version {v}, which isn't read")),
}
}
/// Version 6: the bounds of all the slices, the group's name, the count, then each slice's record.
/// The descriptors that follow the records spend one `budget` of values between them.
fn version_6(r: &mut Reader<'_>, budget: &mut usize) -> Result<Vec<PsdSlice>, String> {
r.take(16)?;
unicode(r)?;
let count = r.u32()? as usize;
if count > MAX_SLICES {
return Err(format!("the file has {count} slices, more than the {MAX_SLICES} read"));
}
let mut out = vec![];
for _ in 0..count {
// The ID and group ID.
r.take(8)?;
let origin = r.u32()?;
// A slice that follows a layer gives the layer's ID.
if origin == 1 {
r.take(4)?;
}
let name = unicode(r)?;
let kind = if r.u32()? == 0 { SliceKind::NoImage } else { SliceKind::Image };
let mut rect = [0i64; 4];
for v in &mut rect {
*v = i64::from(r.u32()? as i32);
}
let (url, target, message, alt) = (unicode(r)?, unicode(r)?, unicode(r)?, unicode(r)?);
let html = r.u8()? != 0;
let cell = unicode(r)?;
// The horizontal and vertical alignment.
r.take(8)?;
let [a, red, green, blue] = r.array::<4>()?;
let background = if a == 0 && red == 0 && green == 0 && blue == 0 {
String::new()
} else if a == 0 {
"matte".into()
} else {
format!("#{red:02x}{green:02x}{blue:02x}")
};
// Photoshop 7 and later may follow a record with a descriptor (version 16); a record that
// doesn't start one is the next slice. A descriptor read that uses up the budget is an
// error.
if r.rest().starts_with(&16u32.to_be_bytes()) {
let mut ahead = Reader::new(r.rest());
ahead.u32()?;
match descriptor(&mut ahead, 0, budget) {
Ok(_) => {
let used = r.rest().len() - ahead.rest().len();
r.take(used)?;
}
Err(e) if e == TOO_MANY_VALUES => return Err(e),
Err(_) => {}
}
}
// Origin 0 is a slice generated automatically.
if origin != 0 {
let options = SliceOptions { kind, name, url, target, message, alt, text: cell_text(cell, html), background, ..Default::default() };
out.push(PsdSlice { rect, options: for_kind(options) });
}
}
Ok(out)
}
/// Versions 7 and 8: the descriptor's `slices` list, each slice an object.
fn from_descriptor(d: &[(String, Value)]) -> Result<Vec<PsdSlice>, String> {
let Some(Value::List(list)) = field(d, "slices") else { return Err("the slices' descriptor has no list of slices".into()) };
if list.len() > MAX_SLICES {
return Err(format!("the file has {} slices, more than the {MAX_SLICES} read", list.len()));
}
let mut out = vec![];
for s in list {
let Value::Object(s) = s else { return Err("the slices' descriptor lists something other than a slice".into()) };
if matches!(field(s, "origin"), Some(Value::Enum(_, v)) if v == "autoGenerated") {
continue;
}
let Some(Value::Object(b)) = field(s, "bounds") else { return Err("a slice in the slices' descriptor has no bounds".into()) };
let edge = |k: &str| number(field(b, k)).ok_or_else(|| "a slice's bounds in the slices' descriptor aren't numbers".to_string());
let rect = [edge("Left")?, edge("Top ")?, edge("Rght")?, edge("Btom")?];
let text = |k: &str| match field(s, k) {
Some(Value::Text(t)) => t.clone(),
_ => String::new(),
};
let kind = if matches!(field(s, "Type"), Some(Value::Enum(_, v)) if v == "noImage") { SliceKind::NoImage } else { SliceKind::Image };
let html = matches!(field(s, "cellTextIsHTML"), Some(Value::Bool(true)));
let background = match field(s, "bgColorType") {
Some(Value::Enum(_, v)) if v == "matte" => "matte".into(),
Some(Value::Enum(_, v)) if v == "Clr " => match field(s, "bgColor") {
Some(Value::Object(c)) => {
let channel = |k: &str| number(field(c, k)).map(|v| v.clamp(0, 255));
match (channel("Rd "), channel("Grn "), channel("Bl ")) {
(Some(r), Some(g), Some(b)) => format!("#{r:02x}{g:02x}{b:02x}"),
_ => String::new(),
}
}
_ => String::new(),
},
_ => String::new(),
};
let options = SliceOptions {
kind,
name: text("Nm "),
url: text("url"),
target: text("null"),
message: text("Msge"),
alt: text("altTag"),
text: cell_text(text("cellText"), html),
background,
..Default::default()
};
out.push(PsdSlice { rect, options: for_kind(options) });
}
Ok(out)
}
/// `o` with only the options of its slice type: an Image slice's cell text isn't shown, and a No
/// Image slice has no link, target, message or alternative text.
fn for_kind(mut o: SliceOptions) -> SliceOptions {
if o.kind == SliceKind::Image {
o.text.clear();
} else {
for s in [&mut o.url, &mut o.target, &mut o.message, &mut o.alt] {
s.clear();
}
}
o
}
/// A cell's text as the HTML a No Image slice shows: plain text has `&`, `<` and `>` escaped.
fn cell_text(text: String, html: bool) -> String {
if html { text } else { text.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;") }
}
/// The longest text one string, key or class ID holds (UTF-16 units or bytes).
const MAX_TEXT: usize = 65_536;
/// An error unless `n`, the length a string or ID declares, is at most [`MAX_TEXT`].
fn text_len(n: usize) -> Result<usize, String> {
if n > MAX_TEXT {
return Err(format!("a text in the slices has more than the {MAX_TEXT} characters read"));
}
Ok(n)
}
/// A Unicode string: a count of UTF-16 units, then the units; trailing NULs dropped.
fn unicode(r: &mut Reader<'_>) -> Result<String, String> {
let n = text_len(r.u32()? as usize)?;
let bytes = r.take(n.checked_mul(2).ok_or(TRUNCATED)?)?;
let units: Vec<u16> = bytes.as_chunks::<2>().0.iter().map(|u| u16::from_be_bytes(*u)).collect();
Ok(String::from_utf16_lossy(&units).trim_end_matches('\0').to_string())
}
/// A descriptor's value, as far as the slices need it.
#[derive(Clone, Debug, PartialEq)]
enum Value {
Long(i32),
Double(f64),
Bool(bool),
Text(String),
/// An enumeration's type and value.
Enum(String, String),
Object(Vec<(String, Value)>),
List(Vec<Value>),
/// A value of another type, read past.
Other,
}
/// The value with key `key`.
fn field<'a>(d: &'a [(String, Value)], key: &str) -> Option<&'a Value> {
d.iter().find(|(k, _)| k == key).map(|(_, v)| v)
}
/// A whole number: a long, or a finite double rounded.
fn number(v: Option<&Value>) -> Option<i64> {
match v? {
Value::Long(n) => Some(i64::from(*n)),
Value::Double(d) if d.is_finite() && d.abs() < 1e12 => Some(d.round() as i64),
_ => None,
}
}
/// A key or class ID: a length, then that many characters, or four when the length is 0.
fn id(r: &mut Reader<'_>) -> Result<String, String> {
let n = text_len(r.u32()? as usize)?;
let b = r.take(if n == 0 { 4 } else { n })?;
Ok(String::from_utf8_lossy(b).into_owned())
}
/// A class: its Unicode name, then its ID.
fn class(r: &mut Reader<'_>) -> Result<(), String> {
unicode(r)?;
id(r)?;
Ok(())
}
/// A descriptor: its class, the count of its items, then each item's key, type and value.
fn descriptor(r: &mut Reader<'_>, depth: usize, budget: &mut usize) -> Result<Vec<(String, Value)>, String> {
if depth > MAX_DEPTH {
return Err("the slices' descriptor nests too deep".into());
}
class(r)?;
let count = r.u32()? as usize;
let mut out = vec![];
for _ in 0..count {
let key = id(r)?;
let kind = r.array::<4>()?;
out.push((key, value(r, &kind, depth, budget)?));
}
Ok(out)
}
/// A value of type `kind`.
fn value(r: &mut Reader<'_>, kind: &[u8; 4], depth: usize, budget: &mut usize) -> Result<Value, String> {
*budget = budget.checked_sub(1).ok_or(TOO_MANY_VALUES)?;
Ok(match kind {
b"long" => Value::Long(r.u32()? as i32),
b"doub" => Value::Double(f64::from_be_bytes(r.array()?)),
b"UntF" => {
r.take(4)?;
Value::Double(f64::from_be_bytes(r.array()?))
}
b"UnFl" => {
r.take(4)?;
Value::Double(f64::from(f32::from_be_bytes(r.array()?)))
}
b"bool" => Value::Bool(r.u8()? != 0),
b"TEXT" => Value::Text(unicode(r)?),
b"enum" => Value::Enum(id(r)?, id(r)?),
b"Objc" | b"GlbO" => Value::Object(descriptor(r, depth + 1, budget)?),
b"VlLs" => {
if depth >= MAX_DEPTH {
return Err("the slices' descriptor nests too deep".into());
}
let count = r.u32()? as usize;
let mut items = vec![];
for _ in 0..count {
let kind = r.array::<4>()?;
items.push(value(r, &kind, depth + 1, budget)?);
}
Value::List(items)
}
b"comp" => {
r.take(8)?;
Value::Other
}
b"type" | b"GlbC" => {
class(r)?;
Value::Other
}
b"tdta" | b"alis" => {
let n = r.u32()? as usize;
r.take(n)?;
Value::Other
}
b"obj " => {
let count = r.u32()? as usize;
for _ in 0..count {
*budget = budget.checked_sub(1).ok_or(TOO_MANY_VALUES)?;
match &r.array::<4>()? {
b"prop" => {
class(r)?;
id(r)?;
}
b"Clss" => class(r)?,
b"Enmr" => {
class(r)?;
id(r)?;
id(r)?;
}
b"rele" => {
class(r)?;
r.take(4)?;
}
b"Idnt" | b"indx" => {
r.take(4)?;
}
b"name" => {
class(r)?;
unicode(r)?;
}
k => return Err(format!("the slices' descriptor has a reference of an unknown kind ({})", String::from_utf8_lossy(k))),
}
}
Value::Other
}
k => return Err(format!("the slices' descriptor has a value of an unknown type ({})", String::from_utf8_lossy(k))),
})
}
/// The slices of PSD or PSB file `bytes` laid over `canvas`, the box its canvas takes (points),
/// each clipped to the canvas, with warnings: for slices that lie wholly outside it or have no
/// size, and for slices that can't be read (the file comes in without them). Empty, without a
/// warning, when the file stores no slices.
pub(crate) fn over(bytes: &[u8], canvas: Rect) -> (Vec<(Rect, SliceOptions)>, Vec<String>) {
match read(bytes) {
Ok(None) => (vec![], vec![]),
Ok(Some(s)) => laid_over(&s.slices, s.size, canvas),
Err(e) => (vec![], vec![format!("the slices can't be read ({e}) and were left out")]),
}
}
/// `slices` of a canvas of `size` pixels laid over `canvas` (points).
fn laid_over(slices: &[PsdSlice], (w, h): (u32, u32), canvas: Rect) -> (Vec<(Rect, SliceOptions)>, Vec<String>) {
let (sx, sy) = (canvas.width() / f64::from(w.max(1)), canvas.height() / f64::from(h.max(1)));
let mut out = vec![];
for s in slices {
let [l, t, r, b] = s.rect;
let (l, r) = (l.clamp(0, i64::from(w)), r.clamp(0, i64::from(w)));
let (t, b) = (t.clamp(0, i64::from(h)), b.clamp(0, i64::from(h)));
if l < r && t < b {
let x = |v: i64| canvas.x0 + v as f64 * sx;
let y = |v: i64| canvas.y0 + v as f64 * sy;
out.push((Rect::new(x(l), y(t), x(r), y(b)), s.options.clone()));
}
}
let warnings = match slices.len() - out.len() {
0 => vec![],
1 => vec!["1 slice lies outside the canvas or has no size and was left out".to_string()],
n => vec![format!("{n} slices lie outside the canvas or have no size and were left out")],
};
(out, warnings)
}
/// How many slices of PSD or PSB file `bytes` come in (`document.psdInfo`): 0 when it stores none
/// or they can't be read.
pub(crate) fn count(bytes: &[u8]) -> usize {
match read(bytes) {
Ok(Some(Stored { slices, size: (w, h) })) => laid_over(&slices, (w, h), Rect::new(0.0, 0.0, f64::from(w), f64::from(h))).0.len(),
_ => 0,
}
}
+12 -3
View File
@@ -1239,14 +1239,23 @@ fn revert(s: &mut Session, _: &Value) -> Result<Value> {
let bytes = read_file(&path).map_err(|e| super::access::naming_in_error(e, &path))?;
let Loaded { mut doc, native_pdf_compatible, .. } = load(&path, &bytes)?.with_grid_prefs(&s.prefs).with_unit_prefs(&s.prefs);
doc.template = false;
// The color management policies apply as when the file is opened.
let (notes, policy_changed) = crate::cmd::colormgmt::apply_open_policies(&mut doc);
// Back to the saved file: its recovery copy has nothing left to recover.
let uid = s.doc()?.uid;
crate::cmd::recovery::forget(s, uid);
s.replace_document(index, doc);
if policy_changed {
s.doc_mut()?.mark_unsaved();
}
if let Some(pdf) = native_pdf_compatible {
s.doc_mut()?.save_options.insert("pdfCompatible".into(), json!(pdf));
}
Ok(json!({ "path": path }))
let mut reply = json!({ "path": path });
if !notes.is_empty() {
reply["warnings"] = json!(notes);
}
Ok(reply)
}
/// `file.formatOptions`: a writable format's options with the values Save would use.
@@ -1345,7 +1354,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
"New from Template…",
["File"],
Some("Cmd+Shift+N"),
"{path} or {name, dataBase64}: open a template (or any readable file) as a new untitled document → {index, title, format, warnings}",
"{path} or {name, dataBase64}: open a template (or any readable file) as a new untitled document; the color management policies of edit.colorSettings apply as at document.open → {index, title, format, warnings (with the policies' notes)}",
always,
super::load::new_from_template
),
@@ -1354,7 +1363,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
"Revert",
["File"],
Some("F12"),
"{confirmed?: bool (the app's own: it asks first, then runs the command with it)} discard the changes: reload the saved file into the same tab (history cleared; the tab keeps its place and view). Saved, modified documents only; if the file can't be read the document is left as it is → {path}. The app asks first (a confirm dialog) unless confirmed: true",
"{confirmed?: bool (the app's own: it asks first, then runs the command with it)} discard the changes: reload the saved file into the same tab (history cleared; the tab keeps its place and view). Saved, modified documents only; if the file can't be read the document is left as it is → {path, warnings?: the notes of the color management policies of edit.colorSettings, which apply as at document.open}. The app asks first (a confirm dialog) unless confirmed: true",
can_revert,
revert
),
+19
View File
@@ -202,6 +202,25 @@ fn a_16_bit_cmyk_tiff_with_alpha_places_at_16_bits() {
}
}
/// An opaque 16-bit CMYK TIFF's inks are rounded to the nearest 8-bit level, chunky and planar, and
/// the stored image keeps its 16-bit samples.
#[test]
fn an_opaque_16_bit_cmyk_tiffs_inks_are_rounded_to_8_bits() {
let c = vec![0u16, 200, 0x80FF, 0x8100, 65535, 32896];
let m = vec![511u16, 65535, 0, 128, 129, 0];
let none = vec![0u16; 6];
let channels = [c, m, none.clone(), none];
let want: Vec<u8> = [[0, 2, 0, 0], [1, 255, 0, 0], [128, 0, 0, 0], [128, 0, 0, 0], [255, 1, 0, 0], [128, 0, 0, 0]].concat();
let chunky_bytes = hand_tiff(5, 16, &[], false, &channels);
let chunky = raster_image(&chunky_bytes).unwrap();
assert_eq!(chunky.blob.cmyk().map(|i| i.data), Some(want.clone()), "chunky: the inks");
assert_eq!(*chunky.blob.bytes, chunky_bytes, "chunky: the file is stored as it is");
let planar = raster_image(&hand_tiff(5, 16, &[], true, &channels)).unwrap();
assert_eq!(planar.blob.cmyk().map(|i| i.data), Some(want), "planar: the inks");
let stored = image::load_from_memory(&planar.blob.bytes).unwrap();
assert_eq!(stored.color(), image::ColorType::Rgb16, "planar: the stored image keeps 16-bit samples");
}
/// A CMYK or grayscale TIFF whose alpha follows another extra sample opens and places with that
/// alpha, at 8 and 16 bits, chunky and planar; a CMYK one is not kept as inks. A CMYK one with two
/// more samples than its ExtraSamples values count does too.
+187 -1
View File
@@ -1,7 +1,8 @@
//! Opening PSD and PSB files with their layers as layers (`document.open`'s `layers` and
//! `hiddenLayers`): names, visibility, opacity, blend modes, groups, layer masks, clipped layers
//! and blend modes VectorCraft lacks merged, 16-bit and PSB files, VectorCraft's own PSD export
//! read back, and damaged files read without a panic.
//! read back, and damaged files read without a panic. Placing them with their layers as objects
//! (`file.place`'s `layers` and `hiddenLayers`), and `document.psdInfo`.
use serde_json::{Value, json};
use vectorcraft_color::BlendMode;
@@ -967,10 +968,12 @@ fn damaged_layers_never_panic() {
let mut f = good.clone();
f[i] = v;
let _ = psdlayers::import(&f, true, 1 << 30, cms);
let _ = psdlayers::info(&f);
}
}
for n in 0..good.len() {
let _ = psdlayers::import(&good[..n], true, 1 << 30, cms);
let _ = psdlayers::info(&good[..n]);
}
// A layer past the memory allowed is refused, not read.
assert!(psdlayers::import(&good, true, 64, cms).unwrap_err().contains("too large"));
@@ -1015,3 +1018,186 @@ fn merges_keep_pass_through_groups_blending_with_what_is_below() {
assert!(px.iter().zip([75, 150, 38, 255]).all(|(&a, b)| a.abs_diff(b) <= 1), "{px:?}");
}
}
/// `file.place` of `f` with `p` into a new 100 × 100 pt document, centred on (50, 50).
fn place(f: &[u8], p: Value) -> (Session, crate::cmd::Result<Value>) {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
let r = s.execute("file.place", &merge(json!({"name": "layers.psd", "dataBase64": vectorcraft_format::base64_encode(f), "at": [50, 50]}), p));
(s, r)
}
/// The one object `file.place` placed.
fn placed(s: &Session, r: &Value) -> Node {
let id = vectorcraft_doc::NodeId(r["ids"][0].as_u64().unwrap());
s.doc().unwrap().doc.node(id).unwrap().clone()
}
#[test]
fn placing_with_layers_embeds_the_layers_as_one_group() {
let f = file(8, 8, &stack(), 8, false, false);
let (mut s, r) = place(&f, json!({"layers": true}));
let r = r.unwrap();
assert_eq!((&r["format"], &r["linked"], &r["warnings"]), (&json!("psd"), &json!(false), &json!([HIDDEN_1])), "{r}");
let g = placed(&s, &r);
assert!(matches!(g.kind, NodeKind::Group { clip: false, .. }), "one group, unclipped: its layers lie on the canvas");
assert_eq!(name(&g), "layers.psd");
let kids = g.children().unwrap();
assert_eq!(kids.iter().map(|c| name(c)).collect::<Vec<_>>(), ["Bottom", "Folder", "Screens", "Faded"], "bottom first");
// A layer is its image, where the merged image would put it: the 8 pt canvas centred on (50, 50).
let ((x, y), px) = pixels_of(&s, &kids[0]);
assert_eq!(((x, y), px.dimensions(), px.get_pixel(3, 3).0), ((46.0, 46.0), (8, 8), [255, 0, 0, 255]));
// A group is a group of its layers; a pass-through group isn't isolated, another mode's is.
let folder = &kids[1];
assert!(matches!(folder.kind, NodeKind::Group { .. }) && !folder.isolate && folder.blend == BlendMode::Normal);
let inner = folder.children().unwrap();
assert_eq!(inner.iter().map(|c| name(c)).collect::<Vec<_>>(), ["Inner A"], "the hidden layer is left out");
assert!(matches!(inner[0].kind, NodeKind::Image(_)) && inner[0].blend == BlendMode::Multiply && (inner[0].opacity - 128.0 / 255.0).abs() < 1e-6);
let screens = &kids[2];
assert!(screens.isolate && screens.blend == BlendMode::Screen);
let ((x, y), px) = pixels_of(&s, &screens.children().unwrap()[0]);
assert_eq!(((x, y), px.get_pixel(0, 0).0), ((48.0, 48.0), [10, 20, 30, 128]));
assert!((kids[3].opacity - 102.0 / 255.0).abs() < 1e-6, "fill opacity: {}", kids[3].opacity);
// One undo step.
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.layers.iter().all(|l| l.children().is_none_or(Vec::is_empty)));
// Hidden layers come in hidden when asked for.
let (s, r) = place(&f, json!({"layers": true, "hiddenLayers": true}));
let r = r.unwrap();
assert_eq!(r["warnings"], json!([]), "{r}");
let g = placed(&s, &r);
let inner: Vec<(&str, bool)> = g.children().unwrap()[1].children().unwrap().iter().map(|c| (name(c), c.visible)).collect();
assert_eq!(inner, [("Inner A", true), ("Inner B", false)]);
// Without `layers`, the merged image, in the same box.
let (s, r) = place(&f, json!({}));
let r = r.unwrap();
let flat = placed(&s, &r);
let ((x, y), px) = pixels_of(&s, &flat);
assert_eq!(((x, y), px.dimensions(), px.get_pixel(0, 0).0), ((46.0, 46.0), (8, 8), [128, 128, 128, 255]), "{r}");
}
#[test]
fn placed_layers_keep_their_masks_and_are_clipped_to_the_canvas() {
let mut masked = layer("Masked", [0, 0, 8, 8], [255, 255, 255, 255]);
masked.mask = Some(([2, 2, 4, 4], 0, 0, vec![255, 128, 0, 255]));
let (s, r) = place(&file(8, 8, &[masked], 8, false, false), json!({"layers": true}));
let g = placed(&s, &r.unwrap());
let kids = g.children().unwrap();
assert!(matches!(kids[0].kind, NodeKind::Image(_)), "the layer is its image");
let m = kids[0].extras().mask.as_deref().expect("the layer mask as the image's opacity mask");
let ((x, y), px) = pixels_of(&s, &m.art);
assert_eq!(((x, y), px.dimensions(), m.clip), ((48.0, 48.0), (2, 2), true), "the mask moves with its layer");
// A layer that reaches past the canvas: the group is clipped to the canvas, as the merged
// image shows only the canvas.
let over = layer("Over", [-2, -2, 6, 6], [0, 255, 0, 255]);
let (s, r) = place(&file(8, 8, &[layer("Bottom", [0, 0, 8, 8], [255, 0, 0, 255]), over], 8, false, false), json!({"layers": true}));
let r = r.unwrap();
let g = placed(&s, &r);
assert!(matches!(g.kind, NodeKind::Group { clip: true, .. }), "{r}");
let kids = g.children().unwrap();
let b = kids[0].geometric_bounds_with(&s.doc().unwrap().doc.symbol_boxes()).unwrap();
assert!(matches!(kids[0].kind, NodeKind::Path { clipping: true, .. }));
assert_eq!((b.x0, b.y0, b.x1, b.y1), (46.0, 46.0, 54.0, 54.0), "the clipping path is the canvas");
assert_eq!(kids[1..].iter().map(|c| name(c)).collect::<Vec<_>>(), ["Bottom", "Over"]);
assert_eq!(pixels_of(&s, &kids[2]).0, (44.0, 44.0));
// A hidden layer that reaches past the canvas clips the group too, so showing it later draws
// only the canvas. Left out, it doesn't.
let mut hidden = layer("Hidden", [-2, -2, 6, 6], [0, 255, 0, 255]);
hidden.hidden = true;
let f = file(8, 8, &[layer("Bottom", [0, 0, 8, 8], [255, 0, 0, 255]), hidden], 8, false, false);
let (s, r) = place(&f, json!({"layers": true, "hiddenLayers": true}));
let r = r.unwrap();
let g = placed(&s, &r);
assert!(matches!(g.kind, NodeKind::Group { clip: true, .. }), "{r}");
assert_eq!(g.children().unwrap()[1..].iter().map(|c| (name(c), c.visible)).collect::<Vec<_>>(), [("Bottom", true), ("Hidden", false)]);
let (s, r) = place(&f, json!({"layers": true}));
assert!(matches!(placed(&s, &r.unwrap()).kind, NodeKind::Group { clip: false, .. }));
}
#[test]
fn placing_with_layers_never_links_and_refuses_values_it_cant_use() {
let f = file(8, 8, &stack(), 8, false, false);
let dir = std::env::temp_dir().join(format!("vc-psd-place-layers-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("layers.psd");
std::fs::write(&path, &f).unwrap();
let from_path = |p: Value| {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
let r = s.execute("file.place", &merge(json!({"path": path.to_str().unwrap()}), p));
(s, r)
};
// From a path, `layers` embeds: `link` is then false unless given, and `link: true` is refused.
let (s, r) = from_path(json!({"layers": true}));
let r = r.unwrap();
assert_eq!(r["linked"], json!(false), "{r}");
assert!(matches!(placed(&s, &r).kind, NodeKind::Group { .. }));
let (s, r) = from_path(json!({"layers": true, "link": true}));
assert!(r.unwrap_err().to_string().contains("a linked PSD file is placed as its flat image"));
assert!(s.doc().unwrap().doc.layers.iter().all(|l| l.children().is_none_or(Vec::is_empty)), "nothing is placed");
// Without `layers` the file is linked as before.
let (s, r) = from_path(json!({}));
let r = r.unwrap();
assert_eq!(r["linked"], json!(true), "{r}");
assert!(matches!(placed(&s, &r).kind, NodeKind::Image(_)));
// A file without layers is embedded as its merged image, without a warning.
let flat = {
let mut f = file(2, 2, &[layer("x", [0, 0, 2, 2], [9, 9, 9, 255])], 8, false, false);
let at = 26 + 8;
let len = u32::from_be_bytes(f[at..at + 4].try_into().unwrap()) as usize;
f.splice(at..at + 4 + len, 0u32.to_be_bytes());
f
};
std::fs::write(&path, &flat).unwrap();
let (s, r) = from_path(json!({"layers": true}));
let r = r.unwrap();
assert_eq!((&r["linked"], &r["warnings"]), (&json!(false), &json!([])), "{r}");
assert!(matches!(placed(&s, &r).kind, NodeKind::Image(_)));
// Layers that are all left out: the merged image, with the importer's warnings.
let mut gone = layer("Gone", [0, 0, 2, 2], [9, 9, 9, 255]);
gone.hidden = true;
let (s, r) = place(&file(2, 2, &[gone], 8, false, false), json!({"layers": true}));
let r = r.unwrap();
assert_eq!((&r["linked"], r["warnings"].as_array().map(Vec::len)), (&json!(false), Some(1)), "{r}");
assert!(matches!(placed(&s, &r).kind, NodeKind::Image(_)));
// Layers that can't be read: the merged image, with a warning.
let mut cut = file(4, 4, &[layer("x", [0, 0, 4, 4], [9, 9, 9, 255])], 8, false, false);
let at = cut.windows(4).position(|w| w == [0, 0, 0, 18]).unwrap();
cut[at..at + 4].copy_from_slice(&1000u32.to_be_bytes());
let (s, r) = place(&cut, json!({"layers": true}));
let r = r.unwrap();
assert!(r["warnings"][0].as_str().unwrap().starts_with("the layers can't be read"), "{r}");
assert!(matches!(placed(&s, &r).kind, NodeKind::Image(_)));
// Values the command can't use are errors, and nothing is placed.
for (p, why) in [
(json!({"hiddenLayers": true}), "hiddenLayers applies to a PSD file placed with layers: true"),
(json!({"layers": true, "link": true}), "a linked PSD file is placed as its flat image"),
(json!({"layers": "yes"}), "layers must be true or false"),
(json!({"layers": true, "hiddenLayers": 1}), "hiddenLayers must be true or false"),
] {
let (s, r) = place(&f, p.clone());
assert!(r.unwrap_err().to_string().contains(why), "{p}");
assert!(s.doc().unwrap().doc.layers.iter().all(|l| l.children().is_none_or(Vec::is_empty)), "{p}");
}
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let q = s.execute("file.place.queue", &json!({"paths": [path.to_str().unwrap()], "layers": "yes"}));
assert!(q.unwrap_err().to_string().contains("`layers` must be true or false"));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn psd_info_counts_the_layers_without_opening_the_file() {
let mut s = Session::new();
let info = |s: &mut Session, f: &[u8]| s.execute("document.psdInfo", &json!({"dataBase64": vectorcraft_format::base64_encode(f)}));
let r = info(&mut s, &file(8, 8, &stack(), 8, false, false)).unwrap();
assert_eq!(r, json!({"format": "psd", "width": 8, "height": 8, "layers": 5, "hiddenLayers": 1, "groups": 2, "slices": 0}));
let r = info(&mut s, &file(4, 2, &[layer("x", [0, 0, 2, 2], [9, 9, 9, 255])], 16, true, true)).unwrap();
assert_eq!((&r["format"], &r["width"], &r["height"], &r["layers"]), (&json!("psb"), &json!(4), &json!(2), &json!(1)), "{r}");
// Layer records that can't be read count as no layers: the file opens as its merged image.
let mut cut = file(4, 4, &[layer("x", [0, 0, 4, 4], [9, 9, 9, 255])], 8, false, false);
let at = cut.windows(4).position(|w| w == [0, 0, 0, 18]).unwrap();
cut[at..at + 4].copy_from_slice(&1000u32.to_be_bytes());
assert_eq!(info(&mut s, &cut).unwrap()["layers"], json!(0));
assert!(info(&mut s, b"not a psd").unwrap_err().to_string().contains("not a PSD file"));
}
@@ -0,0 +1,459 @@
//! A PSD or PSB file's slices (image resource 1050) opened and placed as user slices
//! (`document.open`'s and `file.place`'s `importSlices`): version 6 records and version 7 and 8
//! descriptors, built from the Photoshop File Formats Specification's "Slices resource format";
//! their options, slices outside the canvas, damaged or unknown slice data, placing scaled and
//! moved in one undo step, linked files, and `document.psdInfo`'s count.
use serde_json::{Value, json};
use vectorcraft_doc::{SliceKind, SliceOptions};
use vectorcraft_geom::Rect;
use crate::Session;
/// A Unicode string: a count of UTF-16 units, then the units.
fn ustr(s: &str) -> Vec<u8> {
let units: Vec<u16> = s.encode_utf16().collect();
let mut b = (units.len() as u32).to_be_bytes().to_vec();
b.extend(units.iter().flat_map(|u| u.to_be_bytes()));
b
}
/// One version 6 slice record.
#[derive(Clone)]
struct S {
/// 0: generated automatically, 1: follows a layer, 2: made by the user.
origin: u32,
name: &'static str,
/// 0: No Image, 1: Image.
kind: u32,
/// Left, top, right, bottom (px).
rect: [i32; 4],
url: &'static str,
target: &'static str,
message: &'static str,
alt: &'static str,
html: bool,
cell: &'static str,
/// Alpha, red, green, blue.
argb: [u8; 4],
}
fn user(name: &'static str, rect: [i32; 4]) -> S {
S { origin: 2, name, kind: 1, rect, url: "", target: "", message: "", alt: "", html: false, cell: "", argb: [0; 4] }
}
/// Resource 1050 in version 6, each record followed by `trailing` (a Photoshop 7 descriptor, or
/// nothing).
fn v6(slices: &[S], trailing: &[u8]) -> Vec<u8> {
let mut b = 6u32.to_be_bytes().to_vec();
b.extend([0u8; 16]);
b.extend(ustr("group"));
b.extend((slices.len() as u32).to_be_bytes());
for (i, s) in slices.iter().enumerate() {
b.extend((i as u32 + 1).to_be_bytes());
b.extend(0u32.to_be_bytes());
b.extend(s.origin.to_be_bytes());
if s.origin == 1 {
b.extend(7u32.to_be_bytes());
}
b.extend(ustr(s.name));
b.extend(s.kind.to_be_bytes());
for v in s.rect {
b.extend(v.to_be_bytes());
}
for t in [s.url, s.target, s.message, s.alt] {
b.extend(ustr(t));
}
b.push(u8::from(s.html));
b.extend(ustr(s.cell));
b.extend([0u8; 8]);
b.extend(s.argb);
b.extend(trailing);
}
b
}
/// A descriptor value.
enum D {
Long(i32),
Doub(f64),
Bool(bool),
Text(&'static str),
Enum(&'static str, &'static str),
Obj(Vec<(&'static str, D)>),
List(Vec<D>),
}
/// A key or class ID: a four-character one with length 0, else its length and characters.
fn key(k: &str) -> Vec<u8> {
let mut b = if k.len() == 4 { 0u32 } else { k.len() as u32 }.to_be_bytes().to_vec();
b.extend(k.as_bytes());
b
}
/// A descriptor: an empty class name, the class ID, the count, then each item.
fn descriptor(items: &[(&str, D)]) -> Vec<u8> {
let mut b = ustr("");
b.extend(key("null"));
b.extend((items.len() as u32).to_be_bytes());
for (k, v) in items {
b.extend(key(k));
b.extend(value(v));
}
b
}
/// A value's type, then the value.
fn value(v: &D) -> Vec<u8> {
let mut b = vec![];
match v {
D::Long(n) => {
b.extend(b"long");
b.extend(n.to_be_bytes());
}
D::Doub(d) => {
b.extend(b"doub");
b.extend(d.to_be_bytes());
}
D::Bool(on) => {
b.extend(b"bool");
b.push(u8::from(*on));
}
D::Text(t) => {
b.extend(b"TEXT");
b.extend(ustr(t));
}
D::Enum(t, e) => {
b.extend(b"enum");
b.extend(key(t));
b.extend(key(e));
}
D::Obj(items) => {
b.extend(b"Objc");
b.extend(descriptor(items));
}
D::List(items) => {
b.extend(b"VlLs");
b.extend((items.len() as u32).to_be_bytes());
for i in items {
b.extend(value(i));
}
}
}
b
}
/// A bounds object: top, left, bottom and right.
fn bounds(l: i32, t: i32, r: i32, b: i32) -> D {
D::Obj(vec![("Top ", D::Long(t)), ("Left", D::Long(l)), ("Btom", D::Long(b)), ("Rght", D::Long(r))])
}
/// Resource 1050 in `version` 7 or 8 holding `slices`.
fn v7(version: u32, slices: Vec<D>) -> Vec<u8> {
let mut b = version.to_be_bytes().to_vec();
b.extend(16u32.to_be_bytes());
b.extend(descriptor(&[("bounds", bounds(0, 0, 20, 20)), ("baseName", D::Text("poster")), ("slices", D::List(slices))]));
b
}
/// A 20 × 20 px PSD file written by `document.export`, with its layers (two) or flat.
fn psd(layers: bool) -> Vec<u8> {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 20, "height": 20})).unwrap();
s.execute("paint.setFill", &json!({ "color": "#ff0000" })).unwrap();
s.execute("shape.rectangle", &json!({"x": 2, "y": 2, "width": 10, "height": 10})).unwrap();
s.execute("layer.new", &json!({ "name": "Top" })).unwrap();
s.execute("shape.rectangle", &json!({"x": 8, "y": 8, "width": 10, "height": 10})).unwrap();
let r = s.execute("document.export", &json!({"format": "psd", "layers": layers})).unwrap();
vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap()
}
/// `psd` with image resource `id` holding `data` added to its image resources.
fn with_resource(psd: &[u8], id: u16, data: &[u8]) -> Vec<u8> {
let u32_at = |at: usize| u32::from_be_bytes(psd[at..at + 4].try_into().unwrap()) as usize;
let colors = 26 + 4 + u32_at(26);
let len = u32_at(colors);
let mut block = b"8BIM".to_vec();
block.extend(id.to_be_bytes());
block.extend([0, 0]);
block.extend((data.len() as u32).to_be_bytes());
block.extend(data);
block.resize(block.len().next_multiple_of(2), 0);
let mut out = psd[..colors].to_vec();
out.extend(((len + block.len()) as u32).to_be_bytes());
out.extend(&psd[colors + 4..colors + 4 + len]);
out.extend(block);
out.extend(&psd[colors + 4 + len..]);
out
}
fn b64(f: &[u8]) -> String {
vectorcraft_format::base64_encode(f)
}
fn open(f: &[u8], extra: Value) -> (Session, Value) {
let mut s = Session::new();
let mut p = json!({"name": "poster.psd", "dataBase64": b64(f)});
for (k, v) in extra.as_object().unwrap() {
p[k] = v.clone();
}
let r = s.execute("document.open", &p).unwrap();
(s, r)
}
/// The document's user slices: rectangle and options.
fn slices(s: &Session) -> Vec<(Rect, SliceOptions)> {
s.doc().unwrap().doc.slices.iter().map(|x| (x.rect, x.options.clone())).collect()
}
fn warnings(r: &Value) -> Vec<String> {
r["warnings"].as_array().map(|a| a.iter().filter_map(|w| w.as_str().map(str::to_string)).collect()).unwrap_or_default()
}
#[test]
fn version_6_slices_open_as_user_slices_with_their_options() {
let linked = S { origin: 1, url: "https://example.com/", target: "_blank", message: "Go", alt: "Logo", ..user("logo", [2, 4, 10, 8]) };
let plain = S { kind: 0, cell: "a < b & c", argb: [0, 0, 0, 0], ..user("note", [10, 10, 20, 20]) };
let html = S { kind: 0, html: true, cell: "<b>Hi</b>", argb: [255, 0x12, 0x34, 0x56], ..user("", [0, 12, 6, 18]) };
let matte = S { argb: [0, 1, 2, 3], cell: "ignored", ..user("matte", [12, 0, 18, 6]) };
let auto = S { origin: 0, ..user("auto", [0, 0, 20, 2]) };
let data = v6(&[linked, plain, html, matte, auto], &[]);
for layers in [true, false] {
let f = with_resource(&psd(layers), 1050, &data);
// Either conversion brings the slices in; the automatic one stays out.
for p in [json!({}), json!({"layers": false})] {
let (s, r) = open(&f, p);
assert!(warnings(&r).is_empty(), "{r}");
let got = slices(&s);
assert_eq!(got.len(), 4, "{got:?}");
assert_eq!(got[0].0, Rect::new(2.0, 4.0, 10.0, 8.0));
assert_eq!(
got[0].1,
SliceOptions {
kind: SliceKind::Image,
name: "logo".into(),
url: "https://example.com/".into(),
target: "_blank".into(),
message: "Go".into(),
alt: "Logo".into(),
..Default::default()
}
);
// Plain cell text is escaped as the HTML a No Image cell shows; HTML stays as it is.
assert_eq!((got[1].1.kind, got[1].1.text.as_str(), got[1].1.background.as_str()), (SliceKind::NoImage, "a &lt; b &amp; c", ""));
assert_eq!((got[2].1.text.as_str(), got[2].1.background.as_str(), got[2].1.name.as_str()), ("<b>Hi</b>", "#123456", ""));
// An Image slice's cell text isn't kept; a background with no alpha is the matte.
assert_eq!((got[3].1.text.as_str(), got[3].1.background.as_str()), ("", "matte"));
// Web output lays them out with auto slices around them.
let layout = s.doc().unwrap().doc.slice_layout();
assert_eq!(layout.iter().filter(|a| a.source == vectorcraft_doc::SliceSource::User).count(), 4);
assert!(layout.iter().any(|a| a.source == vectorcraft_doc::SliceSource::Auto));
}
}
}
#[test]
fn photoshop_7_descriptors_after_version_6_records_are_read_past() {
let trailing = [16u32.to_be_bytes().to_vec(), descriptor(&[("topOutset", D::Long(0))])].concat();
let f = with_resource(&psd(false), 1050, &v6(&[user("a", [0, 0, 5, 5]), user("b", [5, 5, 10, 10])], &trailing));
let (s, r) = open(&f, json!({}));
assert!(warnings(&r).is_empty(), "{r}");
let names: Vec<String> = slices(&s).into_iter().map(|(_, o)| o.name).collect();
assert_eq!(names, ["a", "b"]);
}
#[test]
fn version_6_descriptors_share_one_budget_of_values() {
// Each record's descriptor holds 2,501 values, well under the 200,000 one descriptor may hold;
// 100 of them hold 250,100 together.
let list = D::List((0..2_500).map(D::Long).collect());
let trailing = [16u32.to_be_bytes().to_vec(), descriptor(&[("many", list)])].concat();
let records: Vec<S> = (0..100).map(|_| user("a", [0, 0, 5, 5])).collect();
// Alone, one such descriptor is read past.
let (s, r) = open(&with_resource(&psd(false), 1050, &v6(&records[..1], &trailing)), json!({}));
assert!(warnings(&r).is_empty(), "{r}");
assert_eq!(slices(&s).len(), 1);
// Together they are refused, and the file opens without slices.
let (s, r) = open(&with_resource(&psd(false), 1050, &v6(&records, &trailing)), json!({}));
let w = warnings(&r);
assert!(w.iter().any(|w| w.starts_with("the slices can't be read (") && w.contains("too many values")), "{w:?}");
assert!(slices(&s).is_empty());
}
#[test]
fn version_7_and_8_descriptors_open_as_user_slices() {
let slice = |origin: &'static str, kind: &'static str, l: i32, t: i32, r: i32, b: i32| {
D::Obj(vec![
("sliceID", D::Long(1)),
("groupID", D::Long(0)),
("origin", D::Enum("ESliceOrigin", origin)),
("Type", D::Enum("ESliceType", kind)),
("bounds", bounds(l, t, r, b)),
("Nm ", D::Text("cell")),
("url", D::Text("https://example.com/")),
("null", D::Text("_top")),
("Msge", D::Text("msg")),
("altTag", D::Text("alt")),
("cellTextIsHTML", D::Bool(false)),
("cellText", D::Text("x>y")),
("bgColorType", D::Enum("ESliceBGColorType", "Clr ")),
("bgColor", D::Obj(vec![("Rd ", D::Doub(255.0)), ("Grn ", D::Doub(128.0)), ("Bl ", D::Long(0))])),
])
};
for version in [7, 8] {
let data = v7(
version,
vec![slice("userGenerated", "Img ", 1, 2, 9, 10), slice("autoGenerated", "Img ", 0, 0, 20, 1), slice("layer", "noImage", 10, 10, 15, 15)],
);
let (s, r) = open(&with_resource(&psd(true), 1050, &data), json!({}));
assert!(warnings(&r).is_empty(), "{r}");
let got = slices(&s);
assert_eq!(got.len(), 2, "{got:?}");
assert_eq!(got[0].0, Rect::new(1.0, 2.0, 9.0, 10.0));
assert_eq!(
got[0].1,
SliceOptions {
name: "cell".into(),
url: "https://example.com/".into(),
target: "_top".into(),
message: "msg".into(),
alt: "alt".into(),
background: "#ff8000".into(),
..Default::default()
}
);
assert_eq!((got[1].1.kind, got[1].1.text.as_str(), got[1].1.url.as_str()), (SliceKind::NoImage, "x&gt;y", ""));
}
}
#[test]
fn import_slices_false_leaves_them_out_and_must_be_a_switch() {
let f = with_resource(&psd(true), 1050, &v6(&[user("a", [0, 0, 5, 5])], &[]));
let (s, _) = open(&f, json!({"importSlices": false}));
assert!(slices(&s).is_empty());
let mut s = Session::new();
let e = s.execute("document.open", &json!({"name": "poster.psd", "dataBase64": b64(&f), "importSlices": "yes"})).unwrap_err();
assert!(e.to_string().contains("importSlices must be true or false"), "{e}");
}
#[test]
fn slices_that_cant_be_read_warn_and_the_file_still_opens() {
let good = v6(&[user("a", [0, 0, 5, 5])], &[]);
let mut bad_descriptor = v7(7, vec![]);
bad_descriptor[4..8].copy_from_slice(&15u32.to_be_bytes());
let cases: Vec<(Vec<u8>, &str)> = vec![
(good[..good.len() - 3].to_vec(), "cut short"),
([9u32.to_be_bytes().to_vec(), good[4..].to_vec()].concat(), "version 9"),
(bad_descriptor, "version other than 16"),
// The count follows the version, the bounds and the group's name.
([&good[..34], &u32::MAX.to_be_bytes()[..], &good[38..]].concat(), "more than the 10000 read"),
(v7(8, vec![D::Long(3)]), "other than a slice"),
// A whole record whose name is longer than a slice's text may be.
(v6(&[user("a".repeat(65_537).leak(), [0, 0, 5, 5])], &[]), "more than the 65536 characters read"),
];
for (data, why) in cases {
for layers in [true, false] {
let (s, r) = open(&with_resource(&psd(layers), 1050, &data), json!({}));
let w = warnings(&r);
assert!(w.iter().any(|w| w.starts_with("the slices can't be read (") && w.contains(why)), "{why}: {w:?}");
assert!(slices(&s).is_empty());
// The art still came in.
assert!(s.doc().unwrap().doc.art_bounds().is_some(), "{why}");
}
}
// A name of the longest length read comes in.
let longest = v6(&[user("a".repeat(65_536).leak(), [0, 0, 5, 5])], &[]);
let (s, r) = open(&with_resource(&psd(false), 1050, &longest), json!({}));
assert!(warnings(&r).is_empty(), "{r}");
assert_eq!(slices(&s).first().map(|(_, o)| o.name.len()), Some(65_536));
// A file without slices opens without a word about them.
let (s, r) = open(&psd(true), json!({}));
assert!(warnings(&r).is_empty() && slices(&s).is_empty(), "{r}");
}
#[test]
fn slices_are_clipped_to_the_canvas_and_those_outside_it_are_left_out() {
let data = v6(&[user("over", [-5, -5, 5, 30]), user("out", [25, 0, 30, 5]), user("empty", [4, 4, 4, 9]), user("in", [1, 1, 2, 2])], &[]);
let (s, r) = open(&with_resource(&psd(false), 1050, &data), json!({}));
assert_eq!(warnings(&r), ["2 slices lie outside the canvas or have no size and were left out"]);
let got: Vec<(Rect, String)> = slices(&s).into_iter().map(|(r, o)| (r, o.name)).collect();
assert_eq!(got, [(Rect::new(0.0, 0.0, 5.0, 20.0), "over".to_string()), (Rect::new(1.0, 1.0, 2.0, 2.0), "in".to_string())]);
}
#[test]
fn placed_embedded_files_bring_their_slices_with_the_art_in_one_undo_step() {
let data = v6(&[user("a", [0, 0, 10, 10]), user("b", [10, 10, 20, 20])], &[]);
for layers in [false, true] {
let f = with_resource(&psd(layers), 1050, &data);
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
// Fitted into a 40 pt square at (100, 60): twice the size.
let r = s.execute("file.place", &json!({"name": "poster.psd", "dataBase64": b64(&f), "rect": [100, 60, 40, 40], "layers": layers})).unwrap();
assert_eq!(r["slices"].as_array().map(Vec::len), Some(2), "{r}");
let got: Vec<Rect> = slices(&s).into_iter().map(|(r, _)| r).collect();
assert_eq!(got, [Rect::new(100.0, 60.0, 120.0, 80.0), Rect::new(120.0, 80.0, 140.0, 100.0)]);
// The placed art stays selected, and one undo takes the art and the slices away.
assert_eq!(s.doc().unwrap().selection.objects.len(), 1);
s.execute("edit.undo", &json!({})).unwrap();
assert!(slices(&s).is_empty());
assert!(s.doc().unwrap().doc.art_bounds().is_none());
// Centered on `at` at 100%.
s.execute("file.place", &json!({"name": "poster.psd", "dataBase64": b64(&f), "at": [100, 100], "layers": layers})).unwrap();
assert_eq!(slices(&s)[0].0, Rect::new(90.0, 90.0, 100.0, 100.0));
// `importSlices: false` places the art alone.
let r = s.execute("file.place", &json!({"name": "poster.psd", "dataBase64": b64(&f), "importSlices": false, "layers": layers})).unwrap();
assert!(r.get("slices").is_none());
assert_eq!(slices(&s).len(), 2);
}
}
#[test]
fn linked_files_are_placed_without_their_slices() {
let dir = std::env::temp_dir().join(format!("vc-psd-slices-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("poster.psd");
std::fs::write(&path, with_resource(&psd(false), 1050, &v6(&[user("a", [0, 0, 5, 5])], &[]))).unwrap();
let path = path.to_string_lossy().into_owned();
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
let r = s.execute("file.place", &json!({ "path": path })).unwrap();
assert_eq!((&r["linked"], r.get("slices")), (&json!(true), None));
assert!(slices(&s).is_empty());
let e = s.execute("file.place", &json!({ "path": path, "importSlices": true })).unwrap_err();
assert!(e.to_string().contains("importSlices: a linked PSD file is placed as its flat image"), "{e}");
// Embedded from its path, they come in.
let r = s.execute("file.place", &json!({ "path": path, "link": false })).unwrap();
assert_eq!((&r["linked"], r["slices"].as_array().map(Vec::len)), (&json!(false), Some(1)));
// file.place.info counts what a place would bring in.
let info = s.execute("file.place.info", &json!({ "path": path, "link": false })).unwrap();
assert_eq!(info["slices"], json!(1));
let info = s.execute("file.place.info", &json!({ "path": path })).unwrap();
assert_eq!(info["slices"], json!(0));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn psd_info_counts_the_slices_that_come_in() {
let mut s = Session::new();
let info = |s: &mut Session, f: &[u8]| s.execute("document.psdInfo", &json!({"dataBase64": b64(f)})).unwrap();
let data = v6(&[user("a", [0, 0, 5, 5]), S { origin: 0, ..user("auto", [5, 5, 20, 20]) }, user("out", [30, 30, 40, 40])], &[]);
assert_eq!(info(&mut s, &with_resource(&psd(false), 1050, &data))["slices"], json!(1));
assert_eq!(info(&mut s, &with_resource(&psd(true), 1050, &data[..data.len() - 4]))["slices"], json!(0));
assert_eq!(info(&mut s, &psd(true))["slices"], json!(0));
}
#[test]
fn damaged_slices_never_panic() {
let data = v7(8, vec![D::Obj(vec![("origin", D::Enum("ESliceOrigin", "userGenerated")), ("bounds", bounds(0, 0, 5, 5))])]);
let base = with_resource(&psd(false), 1050, &data);
let start = base.windows(data.len()).position(|w| w == data).unwrap();
for at in (start - 12..start + data.len()).step_by(3) {
for v in [0u8, 0x7f, 0xff] {
let mut f = base.clone();
f[at] = v;
let mut s = Session::new();
let _ = s.execute("document.open", &json!({"name": "poster.psd", "dataBase64": b64(&f)}));
let _ = s.execute("document.psdInfo", &json!({"dataBase64": b64(&f)}));
}
}
}
+1 -1
View File
@@ -51,7 +51,7 @@ mod options;
mod presets;
mod report;
pub(crate) use analysis::{outlined_text, see_through_images};
pub(crate) use analysis::{carry, outlined_text, see_through_images};
pub(crate) use apply::{expand_transparent_tile_images, flatten_document, flatten_expanded, has_transparent_tile_images};
#[cfg(test)]
pub(crate) use apply::{with_clips_ignored, with_every_region};
+1 -1
View File
@@ -56,7 +56,7 @@ pub(crate) fn outlined_text(n: &Node) -> Option<Node> {
}
/// `to` (art made from `from`) with `from`'s name, transparency, opacity mask and effects.
fn carry(from: &Node, mut to: Node) -> Node {
pub(crate) fn carry(from: &Node, mut to: Node) -> Node {
to.name = from.name.clone();
to.opacity = from.opacity;
to.blend = from.blend;
+40 -5
View File
@@ -10,9 +10,10 @@ use vectorcraft_color::Paint;
use vectorcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind, TextKind, TextObject, TextRun};
use vectorcraft_geom::Rect;
use super::panelcmds::listed_artboards;
use super::select::on_artboard;
use super::typecmd::refresh_bounds;
use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, index, str_param};
use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, str_param};
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -39,7 +40,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Select Text by Font",
[],
None,
"{family, style?, scope?: \"document\" (default) | {artboards: [index, …] (0-based)} | {layers: [id, …] (layers or sublayers)}} select the text objects using the font, the instances of the symbols whose art uses it, the text whose inline graphics draw such a symbol, the objects painted with a pattern whose art uses it (by a visible fill or stroke whose opacity is above 0, a stroke wider than 0) and the objects whose opacity mask uses it (in type that is not hidden or locked in that art), that are not hidden or locked, in a hidden or locked group or layer, or on a locked artboard; a pattern swatch that nothing paints, or a layer's mask, selects nothing; with scope, only those on one of the artboards (as All on Active Artboard takes them: their bounds overlap it or their center is on it) or in one of the layers → {count}",
"{family, style?, scope?: \"document\" (default) | {artboards: [index, …] (0-based whole numbers)} | {layers: [id, …] (layers or sublayers)}} select the text objects using the font, the instances of the symbols whose art uses it, the text whose inline graphics draw such a symbol, the objects painted with a pattern whose art uses it (by a visible fill or stroke whose opacity is above 0, a stroke wider than 0) and the objects whose opacity mask uses it (in type that is not hidden or locked in that art), that are not hidden or locked, in a hidden or locked group or layer, or on a locked artboard; a pattern swatch that nothing paints, or a layer's mask, selects nothing; with scope, only those on one of the artboards (as All on Active Artboard takes them: their bounds overlap it or their center is on it) or in one of the layers → {count}",
has_doc,
select_font
),
@@ -1080,9 +1081,9 @@ impl FontScope {
Some(_) => Err(bad(C, format!("`scope.{key}` must be a non-empty array"))),
}
};
if let Some(a) = list("artboards")? {
let rect = |v: &Value| v.as_u64().and_then(|i| d.artboards.get(index(i))).map(|b| b.rect);
return a.iter().map(|v| rect(v).ok_or_else(|| bad(C, format!("no such artboard {v}")))).collect::<Result<_>>().map(Self::Artboards);
if let (Some(v), Some(_)) = (scope.get("artboards"), list("artboards")?) {
let indices = listed_artboards(d, v, "scope.artboards", C)?;
return Ok(Self::Artboards(indices.into_iter().filter_map(|i| d.artboards.get(i)).map(|b| b.rect).collect()));
}
if let Some(a) = list("layers")? {
let layer = |v: &Value| v.as_u64().map(NodeId).filter(|id| d.node(*id).is_some_and(Node::is_layer));
@@ -1292,6 +1293,40 @@ mod tests {
}
}
/// Select Text by Font refuses a `scope.artboards` entry that is not a whole number, naming the
/// parameter, before it looks at the artboards, and leaves the selection as it was; a whole
/// number past the last artboard is `no such artboard`.
#[test]
fn select_font_refuses_artboards_that_are_not_whole_numbers() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
s.execute("artboard.new", &json!({"x": 300, "y": 0, "width": 200, "height": 200})).unwrap();
let text = |s: &mut Session, x: i32| {
NodeId(s.execute("text.create", &json!({"x": x, "y": 50, "text": "Serif", "font": "Source Serif 4"})).unwrap()["id"].as_u64().unwrap())
};
let first = text(&mut s, 10);
let second = text(&mut s, 350);
let run =
|s: &mut Session, artboards: Value| s.execute("select.font", &json!({"family": "Source Serif 4", "scope": {"artboards": artboards}}));
let malformed = "invalid parameters for `select.font`: `scope.artboards` must contain whole numbers";
for (artboards, want) in [
(json!(["1"]), malformed),
(json!([-1]), malformed),
(json!([1.5]), malformed),
(json!([true]), malformed),
(json!([null]), malformed),
(json!([9, "1"]), malformed),
(json!([9]), "no such artboard"),
(json!([1, 9]), "no such artboard"),
] {
s.execute("select.set", &json!({"ids": [first.0]})).unwrap();
assert_eq!(run(&mut s, artboards.clone()).unwrap_err().to_string(), want, "{artboards}");
assert_eq!(s.doc().unwrap().selection.objects, [first], "{artboards}");
}
run(&mut s, json!([1, 1])).unwrap();
assert_eq!(s.doc().unwrap().selection.objects, [second]);
}
/// Select Text by Font with a layer scope takes the text in the layers, their sublayers
/// included, and still leaves out hidden and locked text.
#[test]
+3 -3
View File
@@ -41,7 +41,7 @@ pub(crate) use data::{apply_csv, parse_csv};
#[cfg(test)]
use data::{fmt_value, to_csv};
pub use edit::{GraphTypeSubject, graph_type_subject};
pub(crate) use edit::{graphs_holding, keep_placed};
pub(crate) use edit::{edit_graph_art, graphs_holding, keep_placed};
pub use generate::calculated_axes;
#[cfg(test)]
use generate::generate;
@@ -50,8 +50,8 @@ use series::default_series_color;
pub(crate) use series::{
LabelTextChange, capture_label_anti_alias, capture_label_changes, capture_label_object_opacity, capture_label_paragraphs, capture_label_rows,
capture_label_styles, capture_label_text, capture_series_object_opacity, capture_series_opacity, capture_series_top_paints, is_design_art,
label_object_opacity_differs, labels_in, paint_targets, series_group_marks, series_group_marks_where, series_groups_among, series_marks_in,
series_object_opacity_differs, shown_paint,
label_object_opacity_differs, labels_in, paint_targets, plain_labels, series_group_marks, series_group_marks_where, series_groups_among,
series_marks_in, series_object_opacity_differs, shown_paint,
};
#[cfg(test)]
+17
View File
@@ -88,6 +88,23 @@ pub(crate) fn keep_placed(d: &mut Document, graphs: &[(NodeId, Option<Rect>, Opt
}
}
/// Let `edit` change the art of graph node `n` (a copy outside any document) without the next redraw reading the
/// change as a move or scale: the plot rect first takes any move or scale of the art since it was generated, as a
/// redraw measures it, and the bounds the graph keeps become those of the changed art (also for a graph that had none,
/// so a later move is followed).
pub(crate) fn edit_graph_art(n: &mut Node, edit: impl FnOnce(&mut Node)) {
let moved = measured(n).and_then(|(placed, now)| n.extras().graph.as_deref().and_then(|g| carried(g.rect, placed, now)));
if let (Some(rect), Some(g)) = (moved, n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) {
g.rect = rect;
}
edit(n);
let children = n.children();
let (whole, art) = (children.and_then(|c| children_bounds(c)), children.and_then(|c| art_bounds(c)));
if let Some(g) = n.extras_if_any().and_then(|e| e.graph.as_deref_mut()) {
(g.placed, g.placed_art) = (whole, art);
}
}
/// Regenerate graph `id` from `spec` (adjusting the plot rect for any move/scale since the last
/// generation, which is recorded in the spec's `placed_art` bounds, or `placed` in graphs saved without them).
pub(super) fn regenerate(d: &mut Document, id: NodeId, mut spec: GraphSpec) -> Result<()> {
+1 -1
View File
@@ -293,7 +293,7 @@ fn bar_rect(horizontal: bool, a: f64, w: f64, v0: f64, v1: f64) -> Rect {
}
/// White text on a dark wedge.
fn is_dark(fill: &Paint) -> bool {
pub(super) fn is_dark(fill: &Paint) -> bool {
fill.color().is_some_and(|c| {
let [r, g, b] = c.to_rgb();
0.2126 * r + 0.7152 * g + 0.0722 * b < 0.5
+456 -26
View File
@@ -1,8 +1,13 @@
use vectorcraft_color::BlendMode;
use vectorcraft_geom::Shape;
use super::axis::radar_axis;
use super::labels::{Role, filled_lines_on, label_para, label_style};
use super::{Arc, CharStyle, Color, Document, GraphKind, GraphSpec, HashMap, Node, NodeId, NodeKind, Paint, Rect, Result, Session, Value, bad};
use super::generate::is_dark;
use super::labels::{Role, filled_lines_on, label_para, label_size, label_style};
use super::{
Affine, Arc, CharStyle, Color, Document, GraphKind, GraphSpec, HashMap, Node, NodeId, NodeKind, Paint, Rect, Result, Session, Value, bad,
refresh_bounds,
};
// ---------- generation ----------
@@ -639,35 +644,460 @@ fn graph_labels(doc: &Document) -> HashMap<NodeId, (NodeId, Role)> {
let mut out = HashMap::new();
doc.walk(|n| {
let Some(g) = n.extras().graph.as_deref() else { return };
for c in n.children().into_iter().flatten() {
match (&c.kind, c.name.as_deref(), c.extras().series_index) {
(NodeKind::Text(_), _, _) if g.kind == GraphKind::Pie => {
out.insert(c.id, (n.id, Role::Category));
}
(NodeKind::Group { .. }, Some("Legend"), None) => {
for t in c.children().into_iter().flatten().filter(|t| matches!(t.kind, NodeKind::Text(_))) {
out.insert(t.id, (n.id, Role::Legend));
}
}
(NodeKind::Group { .. }, Some("Axes"), None) => {
let texts: Vec<NodeId> =
c.children().into_iter().flatten().filter(|t| matches!(t.kind, NodeKind::Text(_))).map(|t| t.id).collect();
let cats = match g.kind {
GraphKind::Scatter => 0,
GraphKind::Radar => texts.len(),
_ => g.categories.iter().take(g.rows.len().max(1)).filter(|c| !c.is_empty()).count().min(texts.len()),
};
for (i, t) in texts.iter().enumerate() {
out.insert(*t, (n.id, if i + cats >= texts.len() { Role::Category } else { Role::Value }));
}
}
_ => {}
}
for (id, role) in own_labels(n, g) {
out.insert(id, (n.id, role));
}
});
out
}
/// The generated labels of graph `n` (with spec `g`) and what each shows, as [`graph_labels`] finds them.
fn own_labels(n: &Node, g: &GraphSpec) -> Vec<(NodeId, Role)> {
let mut out = vec![];
for c in n.children().into_iter().flatten() {
match (&c.kind, c.name.as_deref(), c.extras().series_index) {
(NodeKind::Text(_), _, _) if g.kind == GraphKind::Pie => {
out.push((c.id, Role::Category));
}
(NodeKind::Group { .. }, Some("Legend"), None) => {
for t in c.children().into_iter().flatten().filter(|t| matches!(t.kind, NodeKind::Text(_))) {
out.push((t.id, Role::Legend));
}
}
(NodeKind::Group { .. }, Some("Axes"), None) => {
let texts: Vec<NodeId> = c.children().into_iter().flatten().filter(|t| matches!(t.kind, NodeKind::Text(_))).map(|t| t.id).collect();
let cats = match g.kind {
GraphKind::Scatter => 0,
GraphKind::Radar => texts.len(),
_ => g.categories.iter().take(g.rows.len().max(1)).filter(|c| !c.is_empty()).count().min(texts.len()),
};
for (i, t) in texts.iter().enumerate() {
out.push((*t, if i + cats >= texts.len() { Role::Category } else { Role::Value }));
}
}
_ => {}
}
}
out
}
/// Set the labels of graph node `n` (a copy outside any document) in a new graph's label formats: the character and
/// paragraph formats the graph keeps for them are dropped, with the mark that its legend labels' fill is their own, and
/// each label takes its kind's default character style, unscaled (keeping its rotation), and default paragraph
/// attributes, keeping its alignment and where it starts. A pie's legend label on a dark wedge is white, as a redraw
/// sets it. Every other object in the graph goes to `other`. The rest of the graph's art, and what its labels say, stay
/// as they are.
pub(crate) fn plain_labels(n: &mut Node, other: &dyn Fn(&mut Node)) {
fn plain(l: &mut vectorcraft_doc::LabelStyles) {
(l.values, l.categories, l.legend) = (None, None, None);
(l.values_para, l.categories_para, l.legend_para) = (None, None, None);
l.legend_fill_set = false;
}
// Which legend labels a redraw sets on a dark wedge, read while the labels are as the graph drew them.
let on_dark = match n.extras().graph.as_deref() {
Some(g) if g.kind == GraphKind::Pie && g.legend && g.pie_legend_in_wedges => {
let mut redraw = g.clone();
plain(&mut redraw.label_styles);
redrawn_on_dark(n, &redraw).unwrap_or_else(|| legend_on_dark(n, g))
}
_ => HashMap::new(),
};
let Some(g) = n.extras_if_any().and_then(|e| e.graph.as_deref_mut()) else { return };
plain(&mut g.label_styles);
let Some(g) = n.extras().graph.as_deref() else { return };
let roles: HashMap<NodeId, Role> = own_labels(n, g).into_iter().collect();
struct Plain<'a> {
roles: HashMap<NodeId, Role>,
other: &'a dyn Fn(&mut Node),
}
fn visit(c: &mut Node, p: &Plain) {
let role = p.roles.get(&c.id).copied();
if let (Some(role), NodeKind::Text(t)) = (role, &mut c.kind) {
let plain = vectorcraft_doc::LabelStyles::default();
let justify = t.para_at(0).justify;
let text = t.plain_text();
t.runs = vec![vectorcraft_doc::TextRun { text, style: label_style(&plain, role), inline: None }];
let mut para = label_para(&plain, role);
para.justify = justify;
t.set_all_paras(para);
// The label loses its scale and keeps its rotation (and a reflection), so it stays turned with the
// rest of a rotated graph's art.
let [a, b, c, d, _, _] = t.xf.as_coeffs();
let flip = if a * d - b * c < 0.0 { Affine::scale_non_uniform(1.0, -1.0) } else { Affine::IDENTITY };
t.xf = Affine::translate(t.xf.translation()) * Affine::rotate(b.atan2(a)) * flip;
refresh_bounds(t);
return;
}
// The Axes and Legend groups, and any other group of the graph's own, are looked through.
if role.is_none() && matches!(c.kind, NodeKind::Group { .. }) && c.extras().graph.is_none() {
for k in c.children_mut().into_iter().flatten() {
visit(Arc::make_mut(k), p);
}
return;
}
(p.other)(c);
}
let p = Plain { roles, other };
for c in n.children_mut().into_iter().flatten() {
visit(Arc::make_mut(c), &p);
}
set_legend_fills(n, &on_dark);
}
/// Give each legend label of graph `n` in `on_dark` white characters (`true`) or black ones.
fn set_legend_fills(n: &mut Node, on_dark: &HashMap<NodeId, bool>) {
if on_dark.is_empty() {
return;
}
// The legend labels are in the graph's Legend group.
for c in n.children_mut().into_iter().flatten() {
if !c.children().into_iter().flatten().any(|t| on_dark.contains_key(&t.id)) {
continue;
}
for t in Arc::make_mut(c).children_mut().into_iter().flatten() {
let Some(dark) = on_dark.get(&t.id) else { continue };
let fill = Paint::solid(if *dark { Color::WHITE } else { Color::BLACK });
if matches!(&t.kind, NodeKind::Text(text) if text.runs.iter().all(|r| r.style.fill == fill)) {
continue;
}
if let NodeKind::Text(text) = &mut Arc::make_mut(t).kind {
text.runs.iter_mut().for_each(|r| r.style.fill = fill.clone());
}
}
}
}
/// The legend labels of graph `n`: the type in its Legend group, as [`own_labels`] finds them.
fn legend_labels(n: &Node) -> Vec<&Node> {
legend_in(n.children().map_or(&[], |c| c.as_slice()))
}
/// The type in the Legend group among a graph's `children`.
fn legend_in(children: &[Arc<Node>]) -> Vec<&Node> {
children
.iter()
.filter(|c| matches!(c.kind, NodeKind::Group { .. }) && c.name.as_deref() == Some("Legend") && c.extras().series_index.is_none())
.flat_map(|c| c.children().into_iter().flatten())
.map(|t| t.as_ref())
.filter(|t| matches!(t.kind, NodeKind::Text(_)))
.collect()
}
/// A pie wedge: its series, its outline and whether the fill it shows is dark.
struct Wedge {
series: u32,
bounds: vectorcraft_geom::Rect,
path: vectorcraft_geom::BezPath,
dark: bool,
}
/// The wedges of pie `n` (the paths of its series groups), in paint order.
fn pie_wedges(n: &Node) -> Vec<Wedge> {
wedges_in(n.children().map_or(&[], |c| c.as_slice()))
}
/// The wedges among a pie's `children`, as [`pie_wedges`] finds them.
fn wedges_in(children: &[Arc<Node>]) -> Vec<Wedge> {
let mut wedges = vec![];
for c in children {
let Some(series) = c.extras().series_index else { continue };
c.walk(&mut |m| {
if let NodeKind::Path { path, .. } = &m.kind {
let path = path.to_bezpath();
wedges.push(Wedge { series, bounds: path.bounding_box(), path, dark: is_dark(&shown_paint(m, true).0) });
}
});
}
wedges
}
/// The text of the label of each series of graph `g` in its wedges, with the series that show it (several when series
/// share a name).
fn series_by_label(g: &GraphSpec) -> HashMap<String, Vec<u32>> {
let mut names: HashMap<String, Vec<u32>> = HashMap::new();
for (s, name) in g.series.iter().enumerate() {
if let Ok(s) = u32::try_from(s) {
names.entry(name.replace('|', "\n")).or_default().push(s);
}
}
names
}
/// The legend labels of pie `n`, each with whether a redraw from spec `g` sets it on a dark wedge, by the fill each wedge
/// of `n` shows now. The pie is drawn again apart from any document, which places each label in its wedge as `g` sets
/// it and tells each label's series. Each redrawn wedge takes the fill of the wedge of `n` of its series with the same
/// bounds, and each legend label of `n` is the nearest redrawn one with the same text, places measured in the bounds of
/// all the wedges. `None` when a wedge or a label has no counterpart of its own, or a wedge out of its place in paint
/// order has several (art or labels added, removed, edited or restyled since the pie was drawn).
fn redrawn_on_dark(n: &Node, g: &GraphSpec) -> Option<HashMap<NodeId, bool>> {
use vectorcraft_geom::kurbo::ParamCurveNearest;
// Each series name that shows a label is drawn with a mark and its index after it, which moves no line, so each
// redrawn label tells its series.
const MARK: char = '\u{1f}';
let mut marked = g.clone();
for (s, name) in marked.series.iter_mut().enumerate() {
if !super::labels::blank_label(name) {
name.push(MARK);
name.push_str(&s.to_string());
}
}
let legend = legend_labels(n);
let mut scratch = Document::new(1.0, 1.0);
let art = super::generate::generate(&mut scratch, &marked);
let redrawn = legend_in(&art);
let now = pie_wedges(n);
let mut wedges = wedges_in(&art);
if redrawn.len() != legend.len() || wedges.len() != now.len() {
return None;
}
// Places are measured in the bounds of all the wedges, since the pie may have been moved or resized since it was drawn.
let frame = |ws: &[Wedge]| ws.iter().map(|w| w.bounds).reduce(|a, b| a.union(b));
let (Some(was), Some(is)) = (frame(&wedges), frame(&now)) else { return None };
let at = |f: vectorcraft_geom::Rect, p: vectorcraft_geom::Point| ((p.x - f.x0) / f.width().max(1e-9), (p.y - f.y0) / f.height().max(1e-9));
let corners = |f: vectorcraft_geom::Rect, w: &Wedge| {
let (a, b) = (at(f, vectorcraft_geom::Point::new(w.bounds.x0, w.bounds.y0)), at(f, vectorcraft_geom::Point::new(w.bounds.x1, w.bounds.y1)));
[a.0, a.1, b.0, b.1]
};
// A wedge and a redrawn one are the same wedge when they show the same series with the same bounds. Wedges are
// compared this many times in all, labels included; past it, nothing is matched.
let mut budget: usize = 1 << 20;
let mut copied_wedges: HashMap<u32, Vec<(usize, [f64; 4])>> = HashMap::new();
for (k, w) in now.iter().enumerate() {
copied_wedges.entry(w.series).or_default().push((k, corners(is, w)));
}
// The wedge in the same place in paint order when its bounds are the same, else the one of the series whose bounds
// are nearest, when they are the same to within 0.001 of the whole; each copied wedge is matched once.
let off = |c: &[f64; 4], d: &[f64; 4]| c.iter().zip(d).map(|(p, q)| (p - q).abs()).fold(0.0, f64::max);
let mut used = vec![false; now.len()];
let mut fills = Vec::with_capacity(wedges.len());
for (i, r) in wedges.iter().enumerate() {
let c = corners(was, r);
let k = match now.get(i) {
Some(w) if w.series == r.series && off(&c, &corners(is, w)) <= 1e-7 => i,
_ => {
let group = copied_wedges.get(&r.series)?;
if budget < group.len() {
return None;
}
budget -= group.len();
let (k, d) = group.iter().map(|(k, d)| (*k, off(&c, d))).min_by(|a, b| a.1.total_cmp(&b.1))?;
if d > 1e-3 {
return None;
}
k
}
};
if std::mem::replace(used.get_mut(k)?, true) {
return None;
}
fills.push(now.get(k)?.dark);
}
for (r, dark) in wedges.iter_mut().zip(fills) {
r.dark = dark;
}
// Each label's words and origin, in the frame of its pie's wedges.
let place = |labels: &[&Node], f| {
labels
.iter()
.map(|t| match &t.kind {
NodeKind::Text(text) => Some((text.plain_text(), at(f, text.xf * vectorcraft_geom::Point::ORIGIN))),
_ => None,
})
.collect::<Option<Vec<_>>>()
};
let (Some(copied), Some(mut drawn)) = (place(&legend, is), place(&redrawn, was)) else { return None };
// The series of each redrawn label, and its words without the mark.
let mut series = Vec::with_capacity(drawn.len());
for (words, _) in &mut drawn {
let (said, s) = words.rsplit_once(MARK)?;
let s: usize = s.parse().ok()?;
let name = g.series.get(s)?;
if *said != name.replace('|', "\n") {
return None;
}
series.push((s, name.as_str()));
words.truncate(said.len());
}
// Each label is the nearest redrawn label that says the same, and no two labels are the same redrawn one.
let mut same: HashMap<&str, Vec<usize>> = HashMap::new();
for (j, (words, _)) in drawn.iter().enumerate() {
same.entry(words.as_str()).or_default().push(j);
}
let mut taken = vec![false; drawn.len()];
let mut matched = Vec::with_capacity(copied.len());
for (words, p) in &copied {
let group = same.get(words.as_str())?;
if budget < group.len() {
return None;
}
budget -= group.len();
let away = |j: &usize| drawn.get(*j).map_or(f64::INFINITY, |(_, q)| (p.0 - q.0).powi(2) + (p.1 - q.1).powi(2));
let j = *group.iter().min_by(|a, b| away(a).total_cmp(&away(b)))?;
if std::mem::replace(taken.get_mut(j)?, true) {
return None;
}
matched.push(j);
}
let mut by_series: HashMap<u32, Vec<&Wedge>> = HashMap::new();
for w in &wedges {
by_series.entry(w.series).or_default().push(w);
}
// A redraw puts the middle of a label's lines (its series name split at `|` and line breaks, at the default leading)
// 0.35 of the label size below a point inside its wedge, at the label's origin across. The label is on the nearest
// wedge of its series to that point, the one holding it. When the wedges of the series are all dark or all light, the
// place does not matter.
let uniform: HashMap<u32, Option<bool>> =
by_series.iter().map(|(s, ws)| (*s, ws.first().map(|w| w.dark).filter(|d| ws.iter().all(|w| w.dark == *d)))).collect();
let ls = label_size(&g.label_styles, Role::Legend);
let mut out = HashMap::new();
for (t, j) in legend.iter().zip(matched) {
let (Some(r), Some((s, name))) = (redrawn.get(j), series.get(j).copied()) else { return None };
let s = u32::try_from(s).ok()?;
if let Some(Some(dark)) = uniform.get(&s) {
out.insert(t.id, *dark);
continue;
}
let NodeKind::Text(text) = &r.kind else { return None };
let middle = name.split(['|', '\n']).count().saturating_sub(1) as f64 * ls * 1.2 / 2.0;
let point = text.xf * vectorcraft_geom::Point::new(0.0, middle - ls * 0.35);
let named = by_series.get(&s)?;
if budget < named.len() {
return None;
}
budget -= named.len();
// The nearest wedge, measured exactly only while its bounds are nearer than the nearest one found so far.
let to_box = |b: vectorcraft_geom::Rect| {
let (dx, dy) = ((b.x0 - point.x).max(point.x - b.x1).max(0.0), (b.y0 - point.y).max(point.y - b.y1).max(0.0));
dx * dx + dy * dy
};
let away = |w: &Wedge| {
if w.path.contains(point) { 0.0 } else { w.path.segments().map(|seg| seg.nearest(point, 1e-3).distance_sq).fold(f64::INFINITY, f64::min) }
};
let mut by_box: Vec<(f64, &Wedge)> = named.iter().map(|w| (to_box(w.bounds), *w)).collect();
by_box.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut best: Option<(f64, bool)> = None;
for (near, w) in by_box {
if best.is_some_and(|b| near >= b.0) {
break;
}
let d = away(w);
if best.is_none_or(|b| d < b.0) {
best = Some((d, w.dark));
}
}
out.insert(t.id, best?.1);
}
Some(out)
}
/// The legend labels of graph `n` (a pie with Legends in Wedges, its labels where it drew them), each with whether a
/// redraw sets it on a dark wedge, by the fill each wedge shows now. A label is placed by the point a redraw centers it
/// on, which lies inside its wedge: the wedge holding it of the series the label names (the nearest one when none holds
/// it), or of any series when the label names none. When the wedges of the series a label names are all dark or all
/// light, the label's place does not matter.
fn legend_on_dark(n: &Node, g: &GraphSpec) -> HashMap<NodeId, bool> {
wedge_contrast(&legend_labels(n), &pie_wedges(n), g)
}
/// The point a redraw centers legend label `text` of series `name` on, in a pie of spec `g` whose legend labels are
/// `ls` points: 0.35 of the label size above where a redraw (`label` in `labels.rs`) puts the middle of the label's
/// lines, at the label's origin across. The lines are those of the series name, split at `|` and line breaks, at the
/// default leading; with kept paragraph attributes, a redraw puts the middle of the first and last baselines there
/// instead, when each of those lines that holds characters has one baseline and the first and last hold characters.
fn label_anchor(text: &vectorcraft_doc::TextObject, name: &str, g: &GraphSpec, ls: f64) -> vectorcraft_geom::Point {
let lines: Vec<&str> = name.split(['|', '\n']).collect();
let ys: Vec<f64> = text.baselines().map(|(a, _)| a.y).collect();
let set = lines.iter().filter(|l| !l.is_empty()).count();
let ends_set = lines.first().is_some_and(|l| !l.is_empty()) && lines.last().is_some_and(|l| !l.is_empty());
let middle = match (ys.first(), ys.last()) {
(Some(first), Some(last)) if g.label_styles.legend_para.is_some() && ends_set && ys.len() == set => (first + last) / 2.0,
_ => lines.len().saturating_sub(1) as f64 * ls * 1.2 / 2.0,
};
text.xf * vectorcraft_geom::Point::new(0.0, middle - ls * 0.35)
}
/// [`legend_on_dark`] for legend labels `legend` of a pie of spec `g` with wedges `wedges`.
fn wedge_contrast(legend: &[&Node], wedges: &[Wedge], g: &GraphSpec) -> HashMap<NodeId, bool> {
use vectorcraft_geom::kurbo::ParamCurveNearest;
let mut out = HashMap::new();
if legend.is_empty() {
return out;
}
let mut by_series: HashMap<u32, Vec<&Wedge>> = HashMap::new();
for w in wedges {
by_series.entry(w.series).or_default().push(w);
}
// For each label text, the wedges of the series that show it, and how many of them are dark.
let named_wedges: HashMap<String, (Vec<&Wedge>, usize)> = series_by_label(g)
.into_iter()
.map(|(name, series)| {
let named: Vec<&Wedge> = series.iter().filter_map(|s| by_series.get(s)).flatten().copied().collect();
let darks = named.iter().filter(|w| w.dark).count();
(name, (named, darks))
})
.collect();
let none = (vec![], 0);
// For each label text, the first series name that shows it (names that differ only in `|` against a line break of
// their own show the same text, and nothing in the label tells them apart).
let mut series_names: HashMap<String, &str> = HashMap::new();
for name in &g.series {
series_names.entry(name.replace('|', "\n")).or_insert(name.as_str());
}
let ls = label_size(&g.label_styles, Role::Legend);
// The search for the nearest wedge looks at this many wedges in all; past it, a label takes the contrast most of the
// wedges of its series show.
let mut budget: usize = 1 << 20;
for t in legend {
let NodeKind::Text(text) = &t.kind else { continue };
let id = t.id;
let words = text.plain_text();
let (named, darks) = named_wedges.get(&words).unwrap_or(&none);
let darks = *darks;
if (darks == 0 || darks == named.len())
&& let Some(w) = named.first()
{
out.insert(id, w.dark);
continue;
}
let name = series_names.get(&words).map_or_else(|| words.replace('\n', "|"), |s| (*s).to_owned());
let at = label_anchor(text, &name, g, ls);
if named.is_empty() {
let cost = wedges.len();
let dark = budget >= cost && wedges.iter().any(|w| w.dark && w.bounds.contains(at) && w.path.contains(at));
budget = budget.saturating_sub(cost);
out.insert(id, dark);
continue;
}
if budget < named.len() {
out.insert(id, darks * 2 > named.len());
continue;
}
budget -= named.len();
// The nearest wedge, measured exactly only while its bounds are nearer than the nearest one found so far.
let to_box = |r: vectorcraft_geom::Rect| {
let (dx, dy) = ((r.x0 - at.x).max(at.x - r.x1).max(0.0), (r.y0 - at.y).max(at.y - r.y1).max(0.0));
dx * dx + dy * dy
};
let away = |w: &Wedge| {
if w.path.contains(at) { 0.0 } else { w.path.segments().map(|s| s.nearest(at, 1e-3).distance_sq).fold(f64::INFINITY, f64::min) }
};
let mut by_box: Vec<(f64, &Wedge)> = named.iter().map(|w| (to_box(w.bounds), *w)).collect();
by_box.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut best = (f64::INFINITY, darks * 2 > named.len());
for (near, w) in by_box {
if near >= best.0 {
break;
}
let d = away(w);
if d < best.0 {
best = (d, w.dark);
}
}
out.insert(id, best.1);
}
out
}
/// Keep the anti-aliasing method `method` given to graph labels `ids` for every label of their kind, as
/// [`capture_label_styles`] keeps a formatting change. Returns whether a kept method changed.
pub(crate) fn capture_label_anti_alias(doc: &mut Document, ids: &[NodeId], method: vectorcraft_doc::text::TypeAntiAlias) -> bool {
+33 -11
View File
@@ -292,8 +292,22 @@ fn guide_moves(s: &Session, p: &Value, r: Rect, h: Option<&str>, v: Option<&str>
.collect())
}
/// The key anchor ([`vectorcraft_doc::Selection::key_anchor`]) when Align and Distribute act on the
/// selected anchor points and align to the key ([`align_to`]): the path and the point.
fn key_anchor(s: &Session, p: &Value) -> Option<(NodeId, (usize, usize))> {
(align_to(s, p) == Some("key")).then(|| s.doc().ok()?.selection.key_anchor).flatten()
}
/// Where the key anchor of [`key_anchor`] is.
fn key_anchor_point(s: &Session, p: &Value) -> Option<Point> {
key_anchor(s, p)?;
let st = s.doc().ok()?;
st.selection.key_anchor_point(&st.doc)
}
/// [`align`] of the selected anchor points (whole paths among them give all theirs): each point
/// moves to the edge or centre of the points' bounds, or of the artboard with Align To Artboard.
/// moves to the edge or centre of the points' bounds, of the artboard with Align To Artboard, or to
/// the key anchor when aligning to it.
fn align_anchors(s: &mut Session, p: &Value, ids: &[NodeId], (h, v): (Option<&str>, Option<&str>)) -> Result<Value> {
let targets = crate::cmd::path::anchor_targets(s)?;
let d = &s.doc()?.doc;
@@ -304,9 +318,10 @@ fn align_anchors(s: &mut Session, p: &Value, ids: &[NodeId], (h, v): (Option<&st
let Some(bounds) = pts.iter().skip(1).fold(pts.first().map(|q| Rect::from_points(*q, *q)), |b, q| b.map(|b| b.union_pt(*q))) else {
return ok();
};
// shortcut: no key anchor yet, so Align To Key Object aligns to the points' bounds as Selection does
// (or to a key guide).
let (r, along_x, along_y) = align_reference(s, p, ids, Measure::default(), Some(bounds))?;
// Aligning to the key, the points go to the key anchor, which stays where it is. Without one,
// a key object aligns them to their bounds as Selection does (a key guide lines them up on it).
let own = key_anchor_point(s, p).map_or(bounds, |k| Rect::from_points(k, k));
let (r, along_x, along_y) = align_reference(s, p, ids, Measure::default(), Some(own))?;
let (h, v) = (h.filter(|_| along_x), v.filter(|_| along_y));
let target = |q: Point| {
let x = match h {
@@ -410,9 +425,10 @@ fn apply_spaced(s: &mut Session, label: &str, p: &Value, ids: &[NodeId], horiz:
/// [`distribute`] and [`distribute_spacing`] of the selected anchor points (whole paths among them
/// give all theirs): the points between the first and the last along the axis are spaced evenly,
/// or `gap` apart from the first. A point has no size, so every Distribute option of an axis
/// gives the same result.
fn distribute_anchors(s: &mut Session, label: &str, horiz: bool, gap: Option<f64>) -> Result<Value> {
/// or `gap` apart from the first. With a gap and a key anchor `key` among them, the key anchor stays
/// where it is and the others are placed `gap` apart outward from it, in their order along the
/// axis. A point has no size, so every Distribute option of an axis gives the same result.
fn distribute_anchors(s: &mut Session, label: &str, horiz: bool, gap: Option<f64>, key: Option<(NodeId, (usize, usize))>) -> Result<Value> {
let targets = crate::cmd::path::anchor_targets(s)?;
let d = &s.doc()?.doc;
let along = |q: Point| if horiz { q.x } else { q.y };
@@ -431,8 +447,11 @@ fn distribute_anchors(s: &mut Session, label: &str, horiz: bool, gap: Option<f64
return ok();
}
let step = gap.unwrap_or((last - first) / (pts.len() - 1) as f64);
// The point the spacing starts from: the key anchor with a gap, else the first point.
let (from, at_from) =
gap.and(key).and_then(|(kid, (ksi, kai))| pts.iter().position(|q| (q.0, q.1, q.2) == (kid, ksi, kai))).map_or((0, first), |k| (k, pts[k].3));
let moves: Vec<(NodeId, usize, usize, f64)> =
pts.iter().enumerate().map(|(i, &(id, si, ai, at))| (id, si, ai, first + step * i as f64 - at)).collect();
pts.iter().enumerate().map(|(i, &(id, si, ai, at))| (id, si, ai, at_from + step * (i as f64 - from as f64) - at)).collect();
if moves.iter().all(|m| m.3.abs() <= 1e-9) {
return ok();
}
@@ -466,11 +485,13 @@ pub(super) fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
let m = measure(s, p, "object.distribute")?;
let board = distribute_to(s, p, &ids, m, "object.distribute")?;
let spacing = spacing(p, "object.distribute")?;
// Anchor points selected with Direct Selection are distributed themselves, as Align aligns them.
// Anchor points selected with Direct Selection are distributed themselves, as Align aligns them;
// with a key anchor, a spacing places them from it.
if !s.doc()?.selection.anchors.is_empty() {
let key = key_anchor(s, p);
return match direction(p, "object.distribute")? {
(None, None) => Err(bad("object.distribute", "give horizontal or vertical")),
(h, _) => distribute_anchors(s, "Distribute", h.is_some(), None),
(h, _) => distribute_anchors(s, "Distribute", h.is_some(), spacing.filter(|_| key.is_some()), key),
};
}
let (horiz, key): (bool, fn(&Rect, bool) -> f64) = match direction(p, "object.distribute")? {
@@ -543,7 +564,8 @@ pub(super) fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
let board = distribute_to(s, p, &ids, m, "object.distributeSpacing")?;
let spacing = spacing(p, "object.distributeSpacing")?;
if !s.doc()?.selection.anchors.is_empty() {
return distribute_anchors(s, "Distribute Spacing", horiz, spacing);
let key = key_anchor(s, p);
return distribute_anchors(s, "Distribute Spacing", horiz, spacing, key);
}
// The selected guides across the axis are spaced with the objects, as items with no size (#1597).
let mut items = spaced_items(s, &ids, m, horiz)?;
+3 -3
View File
@@ -260,7 +260,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Align",
["Window", "Align"],
None,
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", to?: \"selection\"|\"artboard\"|\"key\" (default: the key object when the selection has one, select.key, else the selection; a key guide, select.key {guide}, stays put and the objects line up on its line: a vertical guide sets where left, center and right go and vertical alignment leaves them, a horizontal one the other way round; aligning to the key, the other selected ruler guides move too: a vertical guide to the key's left edge, center or right edge, a horizontal one to its top, center or bottom; aligning to the artboard moves selected guides to its edges too (#3463), and with only guides selected aligning to the selection moves them to their collective bounds), artboard?: index (0-based; with to: \"artboard\", the artboard to align to; the app passes the active one; default: the artboard under the center of the selection, else the first), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} With anchor points selected (Direct Selection), the points align instead: to their own bounds, or to the artboard (#1180)",
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", to?: \"selection\"|\"artboard\"|\"key\" (default: the key object when the selection has one, select.key, else the selection; a key guide, select.key {guide}, stays put and the objects line up on its line: a vertical guide sets where left, center and right go and vertical alignment leaves them, a horizontal one the other way round; aligning to the key, the other selected ruler guides move too: a vertical guide to the key's left edge, center or right edge, a horizontal one to its top, center or bottom; aligning to the artboard moves selected guides to its edges too (#3463), and with only guides selected aligning to the selection moves them to their collective bounds), artboard?: index (0-based; with to: \"artboard\", the artboard to align to; the app passes the active one; default: the artboard under the center of the selection, else the first), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} With anchor points selected (Direct Selection), the points align instead: to their own bounds, to the artboard (#1180), or, aligning to the key with a key anchor (select.key {id, anchor}, the last point a Shift-click added), to the key anchor, which stays put",
has_selection_or_guides,
align
),
@@ -269,7 +269,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Distribute",
["Window", "Align"],
None,
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", spacing?: pt (used with a key, select.key: the key object, or the key guide, select.key {guide}, stays put and the lines are placed this far apart, outward from its line, in their order on that line; 0 puts them all on its line; a key guide along the axis leaves everything where it is; not used without a key or with anchor points selected), to?: \"selection\"|\"artboard\"|\"key\" (default: the key while there is one, else the selection; \"key\" without spacing distributes as \"selection\" does; not used with anchor points selected, or with spacing and a key object; with a key guide, \"selection\" or \"artboard\" distributes without it as the key), artboard?: index (0-based; with to: \"artboard\", the artboard to distribute across; the app passes the active one; default: as object.align's), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} space the selected objects' left edges, centers or right edges (top edges, centers or bottom edges) in their order on that line, with the selected ruler guides across the axis (vertical ones for horizontal; #1597): a guide has no size, so its edges and center are its line, and guides along the axis stay. Without spacing and a key they are spaced evenly, the first and last stay put, and fewer than three don't move; with spacing and a key, see spacing (two are enough); with to: \"artboard\", the first one's left (top) edge goes to the artboard's left (top) edge, the last one's right (bottom) edge goes to the artboard's right (bottom) edge, and two are enough. With anchor points selected (Direct Selection), the points are spaced evenly between the first and the last along the axis instead (#1636)",
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", spacing?: pt (used with a key, select.key: the key object, or the key guide, select.key {guide}, stays put and the lines are placed this far apart, outward from its line, in their order on that line; 0 puts them all on its line; a key guide along the axis leaves everything where it is; not used without a key, or with anchor points selected and no key anchor), to?: \"selection\"|\"artboard\"|\"key\" (default: the key while there is one, else the selection; \"key\" without spacing distributes as \"selection\" does; with anchor points selected, with \"selection\" or \"artboard\" the key anchor is not the key and all the points are spaced as without one; not used with spacing and a key object; with a key guide, \"selection\" or \"artboard\" distributes without it as the key), artboard?: index (0-based; with to: \"artboard\", the artboard to distribute across; the app passes the active one; default: as object.align's), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} space the selected objects' left edges, centers or right edges (top edges, centers or bottom edges) in their order on that line, with the selected ruler guides across the axis (vertical ones for horizontal; #1597): a guide has no size, so its edges and center are its line, and guides along the axis stay. Without spacing and a key they are spaced evenly, the first and last stay put, and fewer than three don't move; with spacing and a key, see spacing (two are enough); with to: \"artboard\", the first one's left (top) edge goes to the artboard's left (top) edge, the last one's right (bottom) edge goes to the artboard's right (bottom) edge, and two are enough. With anchor points selected (Direct Selection), the points are spaced evenly between the first and the last along the axis instead (#1636); with spacing and a key anchor (select.key {id, anchor}), the key anchor stays put and the others are placed spacing apart, outward from it, in their order along the axis",
can_distribute,
distribute
),
@@ -278,7 +278,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Distribute Spacing",
["Window", "Align"],
None,
"{axis?: \"horizontal\"|\"vertical\" (default: \"horizontal\"), spacing?: pt (the key object, select.key, or the key guide, select.key {guide}, stays put and the others are spaced from it; a key guide along the axis leaves everything where it is), to?: \"selection\"|\"artboard\"|\"key\" (used without spacing and without anchor points selected, and with a key guide; default: the key while there is one, else the selection; \"key\" without spacing spaces as \"selection\" does), artboard?: index (0-based; with to: \"artboard\", the artboard to space across; the app passes the active one; default: as object.align's), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} without spacing, make the gaps between the selected objects equal, in the order of their left (top) edges, with the selected ruler guides across the axis as items with no size (#1597; guides along the axis stay): the first item's left (top) edge and the last one's right (bottom) edge stay put, or with to: \"artboard\" go to the artboard's left (top) and right (bottom) edges. With anchor points selected (Direct Selection), the points are spaced evenly along the axis instead, or `spacing` apart from the first (#1636)",
"{axis?: \"horizontal\"|\"vertical\" (default: \"horizontal\"), spacing?: pt (the key object, select.key, or the key guide, select.key {guide}, stays put and the others are spaced from it; a key guide along the axis leaves everything where it is), to?: \"selection\"|\"artboard\"|\"key\" (used without spacing, and with a key guide; with anchor points selected, with \"selection\" or \"artboard\" the key anchor is not the key and all the points are spaced as without one; default: the key while there is one, else the selection; \"key\" without spacing spaces as \"selection\" does), artboard?: index (0-based; with to: \"artboard\", the artboard to space across; the app passes the active one; default: as object.align's), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} without spacing, make the gaps between the selected objects equal, in the order of their left (top) edges, with the selected ruler guides across the axis as items with no size (#1597; guides along the axis stay): the first item's left (top) edge and the last one's right (bottom) edge stay put, or with to: \"artboard\" go to the artboard's left (top) and right (bottom) edges. With anchor points selected (Direct Selection), the points are spaced evenly along the axis instead, or `spacing` apart from the first (#1636), or with a key anchor (select.key {id, anchor}) `spacing` apart outward from the key anchor, which stays put",
can_distribute,
distribute_spacing
),
File diff suppressed because one or more lines are too long
+6 -1
View File
@@ -369,6 +369,12 @@ pub fn check_effect_params(id: &str, effect: &str, params: &Value) -> Result<()>
{
return Err(bad(id, "`origin` must be [x, y] in points"));
}
if effect == vectorcraft_render::effects::SVG_FILTER
&& let Some(axes) = given.get("axes")
&& vectorcraft_render::effects::svg_filter::valid_axes(axes).is_none()
{
return Err(bad(id, "`axes` must be [a, b, c, d], each from -1000 to 1000, with a·d - b·c at least 0.001 from 0"));
}
// Its name is the filter's id.
if effect == vectorcraft_render::effects::SVG_FILTER && given.contains_key("name") && !given.contains_key("xml") {
return Err(bad(id, "an SVG filter is named by its id: give `xml` with `name`"));
@@ -529,7 +535,6 @@ pub const UNCHECKED: &[&str] = &[
"assets.settings.set",
"attributes.info",
"attributes.set",
"brush.freehand",
"css.export",
"css.generate",
"css.selection",
+19 -15
View File
@@ -5,7 +5,7 @@ use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_doc::{Document, Node, NodeId, NodeKind, Scaling, Selection};
use vectorcraft_geom::{Anchor, AnchorKind, PathData, Point, SubPath, Vec2};
use vectorcraft_geom::{Anchor, AnchorKind, Anchors, PathData, Point, SubPath, Vec2};
use super::create::anchor_from_json;
use super::draw2::usize_opt;
@@ -544,10 +544,12 @@ fn merge_coincident(sp: &mut SubPath, picked: impl Fn(usize) -> bool, join_kind:
&& before > 2
&& picked(0)
&& picked(before - 1)
&& let [first, .., last] = sp.anchors.as_mut_slice()
&& same(first, last)
&& let (Some(&first), Some(&last)) = (sp.anchors.first(), sp.anchors.last())
&& same(&first, &last)
{
first.h_in = last.h_in;
if let Some(first) = sp.anchors.first_mut() {
first.h_in = last.h_in;
}
sp.anchors.pop();
if let Some(smooth) = join_kind {
join_point(sp, 0, smooth);
@@ -565,10 +567,12 @@ fn merge_coincident(sp: &mut SubPath, picked: impl Fn(usize) -> bool, join_kind:
for i in (1..len).rev() {
if picked(i - 1)
&& picked(i)
&& let Some([a, b]) = anchors.get_mut(i - 1..=i)
&& same(a, b)
&& let (Some(&a), Some(&b)) = (anchors.get(i - 1), anchors.get(i))
&& same(&a, &b)
{
a.h_out = b.h_out;
if let Some(a) = anchors.get_mut(i - 1) {
a.h_out = b.h_out;
}
if let Some(slot) = gone.get_mut(i) {
*slot = true;
}
@@ -603,7 +607,7 @@ fn merge_coincident(sp: &mut SubPath, picked: impl Fn(usize) -> bool, join_kind:
}
/// [`join_point`] on anchor `ai` of `anchors` with its neighbors given by index.
fn join_point_between(anchors: &mut [Anchor], ai: usize, prev: Option<usize>, next: Option<usize>, smooth: bool) {
fn join_point_between(anchors: &mut Anchors, ai: usize, prev: Option<usize>, next: Option<usize>, smooth: bool) {
let at = |j: Option<usize>| j.and_then(|j| anchors.get(j)).map(|a| a.p);
let (prev, next) = (at(prev), at(next));
if let Some(a) = anchors.get_mut(ai) {
@@ -1565,7 +1569,7 @@ enum Stretch {
/// anchor, so the two handles of a smooth anchor stay in line with it. That share is below 0 or
/// above 1 for a handle long enough beside the span. A closed subpath with no anchor that stays
/// moves whole when it has a focal point.
fn stretch_weights(anchors: &[Anchor], closed: bool, roles: &[Stretch]) -> Vec<[f64; 3]> {
fn stretch_weights(anchors: &Anchors, closed: bool, roles: &[Stretch]) -> Vec<[f64; 3]> {
let n = anchors.len();
let mut shares = vec![[0.0; 3]; n];
let role = |i: usize| match roles.get(i) {
@@ -1891,10 +1895,10 @@ mod tests {
&& before > 2
&& picked(0)
&& picked(before - 1)
&& let [first, .., last] = sp.anchors.as_mut_slice()
&& same(first, last)
&& let (Some(&first), Some(&last)) = (sp.anchors.first(), sp.anchors.last())
&& same(&first, &last)
{
first.h_in = last.h_in;
sp.anchors[0].h_in = last.h_in;
sp.anchors.pop();
if let Some(smooth) = join_kind {
join_point(sp, 0, smooth);
@@ -1903,10 +1907,10 @@ mod tests {
for i in (1..sp.anchors.len()).rev() {
if picked(i - 1)
&& picked(i)
&& let Some([a, b]) = sp.anchors.get_mut(i - 1..=i)
&& same(a, b)
&& let (Some(&a), Some(&b)) = (sp.anchors.get(i - 1), sp.anchors.get(i))
&& same(&a, &b)
{
a.h_out = b.h_out;
sp.anchors[i - 1].h_out = b.h_out;
sp.anchors.remove(i);
if let Some(smooth) = join_kind {
join_point(sp, i - 1, smooth);
File diff suppressed because one or more lines are too long
+128 -4
View File
@@ -334,10 +334,19 @@ pub(crate) fn raster_image(d: &mut Document, n: &Node, below: bool, members: boo
/// The image Object › Rasterize makes of `n` with the options of `n`'s Effect › Rasterize, when
/// that is its only visible raster effect and is its own (not a fill's or a stroke's): made of `n`
/// without that effect and without its transparency, which stays on the object. `None` otherwise,
/// or when the image can't be made.
/// without that effect and without its transparency, which stays on the object. When Transform
/// effects after the Rasterize effect move its image ([`vectorcraft_render::rasterize_stage`]),
/// the image is made of the art before them and moved by them, as the canvas draws it. `None`
/// otherwise, when those Transform effects flatten the image, or when the image can't be made.
fn rasterize_effect_image(d: &mut Document, n: &Node) -> Option<Node> {
let [RasterFx::Rasterize(r)] = raster_fx(n)[..] else { return None };
let staged = vectorcraft_render::rasterize_stage(d, n);
if let Some((_, map)) = &staged
&& !(map.determinant().is_finite() && map.determinant().abs() > 1e-12)
{
return None;
}
let n = staged.as_ref().map_or(n, |(stage, _)| stage);
let at = n.appearance.effects.iter().position(|e| e.visible && e.id == effects::RASTERIZE)?;
let p = json!({
"ppi": r.ppi.unwrap_or(d.raster_effects_ppi),
@@ -354,7 +363,11 @@ fn rasterize_effect_image(d: &mut Document, n: &Node) -> Option<Node> {
// shortcut: expanded Rasterize effects stay RGB, as the object keeps its own opacity mask and the
// image's transparency would need one too; upgrade when CMYK images carry alpha.
let image = super::menucmds::rasterize_art(d, vec![whole], &p, "object.rasterize", false).ok()?;
Some(image.into_node(d))
let mut image = image.into_node(d);
if let Some((_, map)) = staged {
image.transform(map, false);
}
Some(image)
}
/// `v` (an object without its raster effects) replaced by `image` of the object with them: the
@@ -473,7 +486,9 @@ fn imaged(n: &Node, which: Which) -> bool {
let live = filters
&& pixels == 1
&& effects::svg_filter::writes_live(&n.appearance.effects)
&& fx.iter().any(|f| matches!(f, RasterFx::Pixel(effects::PixelFx::Svg(s)) if s.in_points() && (!s.measured() || fx.len() == 1) && s.writes_as_svg11()));
&& fx
.iter()
.any(|f| matches!(f, RasterFx::Pixel(effects::PixelFx::Svg(s)) if (!s.measured() || fx.len() == 1) && s.writes_as_svg11() && s.opens_again()));
(pixels > 0 && !live)
|| (fx.iter().any(|f| matches!(f, RasterFx::DropShadow { darkness: Some(_), .. })) && (cmyk || !simple_rgb_shadow_source(n, &fx)))
}
@@ -589,6 +604,112 @@ fn walk_all(out: &mut Document, nodes: &[Arc<Node>], report: &mut FlattenReport)
.then(|| nodes.iter().map(|n| walk(out, n, report).map(Arc::new).unwrap_or_else(|| n.clone())).collect())
}
/// `n` as an image when its Rasterize image moves with the Transform effects after it
/// ([`vectorcraft_render::rasterize_stage`]): the image is made of the art before those Transform
/// effects and moved by them, as the canvas draws it, which baking the geometry effects first would
/// lose. A map that flattens the image leaves an empty group, as the canvas draws nothing. Inside
/// groups, layers and opacity masks, the members are looked at the same way. `None` when nothing in
/// `n` changes; hidden objects and template layers stay as they are.
fn staged(out: &mut Document, n: &Node, report: &mut FlattenReport) -> Option<Node> {
if !n.visible || matches!(n.kind, NodeKind::Layer { template: true, .. }) {
return None;
}
if let Some((_, map)) = vectorcraft_render::rasterize_stage(out, n) {
let mut v = n.clone();
strip_raster(&mut v, false);
let det = map.determinant();
if !(det.is_finite() && det.abs() > 1e-12) {
v.appearance = Default::default();
v.kind = NodeKind::Group { children: vec![], clip: false };
return Some(v);
}
let image = raster_image(out, n, false, false)?;
report.record(n, &image);
let mut m = replace_with_image(v, image);
staged_mask(out, &mut m, report);
return Some(m);
}
let mut m = n.clone();
let mut changed = staged_mask(out, &mut m, report);
if is_container(n)
&& let Some(ch) = staged_all(out, n.children()?, report)
&& let Some(slot) = m.children_mut()
{
*slot = ch;
changed = true;
}
changed.then_some(m)
}
/// The art of `n`'s opacity mask with [`staged`] applied; `true` when it changed.
fn staged_mask(out: &mut Document, n: &mut Node, report: &mut FlattenReport) -> bool {
if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut())
&& let Some(art) = staged(out, &m.art, report)
{
m.art = Arc::new(art);
return true;
}
false
}
/// Does `n`, or anything inside it or its opacity mask, list a Rasterize effect whose image the
/// Transform effects after it move ([`effects::rasterize_stage`])?
fn holds_stage(n: &Node) -> bool {
effects::rasterize_stage(&n.appearance.effects).is_some()
|| n.extras().mask.as_ref().is_some_and(|m| holds_stage(&m.art))
|| n.children().is_some_and(|ch| ch.iter().any(|c| holds_stage(c)))
}
/// A copy of `doc` with [`staged`] applied in its layers, symbol definitions and pattern tiles,
/// `None` when nothing changes.
fn staged_document(doc: &Document, report: &mut FlattenReport) -> Option<Document> {
let holds = doc.layers.iter().any(|n| holds_stage(n))
|| doc.symbols.iter().any(|s| holds_stage(&s.art))
|| doc.patterns.iter().any(|p| p.art.iter().any(|n| holds_stage(n)));
if !holds {
return None;
}
let mut out = doc.clone();
let mut changed = false;
if let Some(layers) = staged_all(&mut out, &doc.layers, report) {
out.layers = layers;
changed = true;
}
for i in 0..out.symbols.len() {
if let Some(art) = out.symbols.get(i).map(|s| s.art.clone()).and_then(|a| staged(&mut out, &a, report))
&& let Some(s) = out.symbols.get_mut(i)
{
s.art = Arc::new(art);
changed = true;
}
}
for i in 0..out.patterns.len() {
if let Some(art) = out.patterns.get(i).map(|p| p.art.clone()).and_then(|a| staged_all(&mut out, &a, report))
&& let Some(p) = out.patterns.get_mut(i)
{
p.art = art;
changed = true;
}
}
changed.then_some(out)
}
/// `nodes` with [`staged`] applied, `None` when none changes.
fn staged_all(out: &mut Document, nodes: &[Arc<Node>], report: &mut FlattenReport) -> Option<Vec<Arc<Node>>> {
let mut changed = false;
let all = nodes
.iter()
.map(|n| match staged(out, n, report) {
Some(m) => {
changed = true;
Arc::new(m)
}
None => n.clone(),
})
.collect();
changed.then_some(all)
}
/// A copy of `doc` with raster effects turned into images at the document's raster effects
/// resolution, or `None` when there are none: on every kind of object (paths, groups and layers,
/// type, images, symbol instances, live objects) and on single fills and strokes, in the layers,
@@ -616,6 +737,9 @@ fn flatten(doc: &Document, report: &mut FlattenReport) -> Option<Document> {
if !any(&doc.layers) && !doc.symbols.iter().any(|s| needs(&s.art, which)) && !doc.patterns.iter().any(|p| any(&p.art)) {
return None;
}
// Rasterize images that Transform effects after them move, before those effects are baked.
let pre = staged_document(doc, report);
let doc = pre.as_ref().unwrap_or(doc);
// Geometry effects first, so the vector objects kept above shadows are final.
let baked = effects::bake_document(doc);
let src = baked.as_ref().unwrap_or(doc);
+3 -2
View File
@@ -999,8 +999,9 @@ impl RecoveryJob {
pub fn write(&self) -> std::result::Result<Value, String> {
let store = self.store.as_ref();
let doc = self.save.doc();
// A linked image keeps its preview, as a save writes it.
let linked = doc.linked_only_images();
// A linked image keeps its preview, as a save writes it (the images in the stored brushes'
// art counted too).
let linked = super::links::with_brush_art(doc).linked_only_images();
let mut lean = Document::clone(doc);
let blobs = std::mem::take(&mut lean.images);
let enc = self.save.encode_doc(&lean).map_err(|e| e.to_string())?;
+66 -10
View File
@@ -8,9 +8,10 @@ use vectorcraft_doc::{Document, LockedArtboards, Node, NodeId, NodeKind};
use vectorcraft_tools::builder::is_live_paint;
use super::object::can_align;
use super::panelcmds::artboard_at;
use super::wand::{fill_of, stroke_of};
use super::{
CommandSpec, Result, Session, bad, bool_or, checked_id, checked_ids_param, cmd, color_value, has_doc, has_selection, index, ok, str_param,
CommandSpec, Result, Session, bad, bool_or, checked_id, checked_ids_param, cmd, color_value, has_doc, has_selection, index_opt, ok, str_param,
switch_or, text_param,
};
use crate::DocState;
@@ -31,7 +32,7 @@ pub fn specs() -> Vec<CommandSpec> {
"All on Active Artboard",
["Select"],
Some("Cmd+Alt+A"),
"{artboard?: index (0-based; the app passes the active one; default: the first)}",
"{artboard?: index (0-based whole number; the app passes the active one; default, or null: the first)}",
has_doc,
all_on_artboard
),
@@ -81,11 +82,19 @@ pub fn specs() -> Vec<CommandSpec> {
"Cancel Key Object",
[],
None,
"{id?: a selected top-level object (not a layer, a compound-path member or an object inside another selected object); one of two or more or the only one with ruler guides selected (the Selection tool: a click on one object of a selection of several, or of one with guides; on the key again: no id), guide?: index (a selected ruler guide, guide.list, with one or more objects selected: the Selection tool's click on a selected guide; Align lines the objects up on it and the guide stays put)} the key object Align aligns to and Distribute Spacing spaces from; without `id` or `guide` it cancels the key, as does a selection that holds only the key and objects inside it (one object, or a group key and its own members) and no ruler guide, or a key guide with no object selected or taken out of the selection; any other id or guide is an error that leaves the key as it was → {key, guide?} (the resulting key object, or null; guide: the key guide, left out without one)",
"{id?: a selected top-level object (not a layer, a compound-path member or an object inside another selected object); one of two or more or the only one with ruler guides selected (the Selection tool: a click on one object of a selection of several, or of one with guides; on the key again: no id), guide?: index (a selected ruler guide, guide.list, with one or more objects selected: the Selection tool's click on a selected guide; Align lines the objects up on it and the guide stays put), anchor?: [subpath, anchor] with `id` the path (an anchor point selected with Direct Selection beside at least one other point becomes the key anchor: Align and Distribute move the other points relative to it and it stays put; select.anchors add or toggle of one point makes it the key anchor, as a Shift-click does)} the key object Align aligns to and Distribute Spacing spaces from; without `id` or `guide` it cancels the key, as does a selection that holds only the key and objects inside it (one object, or a group key and its own members) and no ruler guide, or a key guide with no object selected or taken out of the selection, or a key anchor that is deselected, deleted or left as the only selected point; any other id, guide or anchor is an error that leaves the key as it was → {key, guide?, keyAnchor?} (the resulting key object, or null; guide: the key guide, left out without one; keyAnchor: {id, anchor}, left out without one)",
has_doc,
key
),
cmd!("select.anchors", "Select Anchors", [], None, "{id, anchors: [[subpath, anchor]…], mode: \"set\"|\"add\"|\"toggle\"}", has_doc, anchors),
cmd!(
"select.anchors",
"Select Anchors",
[],
None,
"{id, anchors: [[subpath, anchor]…], mode: \"set\"|\"add\"|\"toggle\"}; add or toggle that selects one point makes it the key anchor while another point is selected too (see select.key)",
has_doc,
anchors
),
cmd!(
"select.segments",
"Select Segments",
@@ -221,7 +230,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Appearance Attribute",
["Select", "Same"],
None,
"{item?: appearance item index} the objects sharing an appearance attribute of the first selected object: its fill or stroke `item` (default: the Appearance panel's active item), else its first effect, else its topmost fill (compound paths as select.same.fillColor)",
"{item?: appearance item index (0-based whole number; default, or null: the Appearance panel's active item)} the objects sharing an appearance attribute of the first selected object: its fill or stroke `item` (default: the Appearance panel's active item), else its first effect, else its topmost fill (compound paths as select.same.fillColor)",
has_selection,
same_attribute
),
@@ -420,7 +429,7 @@ fn all(s: &mut Session, _: &Value) -> Result<Value> {
fn all_on_artboard(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let i = p.get("artboard").and_then(Value::as_u64).map_or(0, index);
let i = artboard_at(&st.doc, p, "artboard", "select.allOnArtboard")?.unwrap_or(0);
let r = st.doc.artboards.get(i).map(|a| a.rect).ok_or_else(|| bad("select.allOnArtboard", "no such artboard"))?;
let symbols = st.doc.symbol_boxes();
let ids: Vec<NodeId> =
@@ -670,6 +679,9 @@ fn key(s: &mut Session, p: &Value) -> Result<Value> {
}
None => None,
};
if let Some(v) = p.get("anchor").filter(|v| !v.is_null()) {
return key_anchor(s, id, guide, v);
}
if let Some(g) = guide {
let sel = &s.doc()?.selection;
if id.is_some() {
@@ -684,6 +696,7 @@ fn key(s: &mut Session, p: &Value) -> Result<Value> {
}
s.select(|_, sel| {
sel.key = None;
sel.key_anchor = None;
sel.key_guide = Some(gid);
})?;
return key_result(s);
@@ -700,17 +713,52 @@ fn key(s: &mut Session, p: &Value) -> Result<Value> {
s.select(|_, sel| {
sel.key = id;
sel.key_guide = None;
sel.key_anchor = None;
})?;
key_result(s)
}
/// `{key}`, with `guide` when a guide is the key.
/// `select.key {id, anchor: [subpath, anchor]}`: anchor point `anchor` of path `id`, selected with
/// Direct Selection beside at least one other point, becomes the key anchor
/// ([`vectorcraft_doc::Selection::key_anchor`]).
fn key_anchor(s: &mut Session, id: Option<NodeId>, guide: Option<usize>, v: &Value) -> Result<Value> {
const C: &str = "select.key";
let r = match checked_refs(Some(&Value::Array(vec![v.clone()])), C)?.as_slice() {
[r] => *r,
_ => return Err(bad(C, "`anchor` must be a [subpath, anchor] pair")),
};
if guide.is_some() {
return Err(bad(C, "give `anchor` or `guide`, not both"));
}
let id = id.ok_or_else(|| bad(C, "`anchor` needs `id`, the path the point is on"))?;
let st = s.doc()?;
let sel = &st.selection;
let on_path = st.doc.node(id).and_then(|n| n.path_data()).is_some_and(|p| p.subpaths.get(r.0).is_some_and(|sp| r.1 < sp.anchors.len()));
if !on_path || sel.segments.contains_key(&id) || !sel.anchors.get(&id).is_some_and(|set| set.contains(&r)) {
return Err(bad(C, format!("anchor [{}, {}] of object {id} is not a selected anchor point", r.0, r.1)));
}
// Check the new key on a copy first, so a request that fails leaves the key as it was.
let mut trial = sel.clone();
trial.key_anchor = Some((id, r));
trial.prune(&st.doc);
if trial.key_anchor.is_none() {
return Err(bad(C, "select another anchor point to align to the key anchor"));
}
s.select(|_, sel| sel.key_anchor = Some((id, r)))?;
key_result(s)
}
/// `{key}`, with `guide` when a guide is the key and `keyAnchor` (`{id, anchor}`) when an anchor
/// point is.
fn key_result(s: &Session) -> Result<Value> {
let st = s.doc()?;
let mut out = json!({ "key": st.selection.key });
if let Some(g) = st.selection.key_guide.and_then(|id| super::docmenu::guide_indexes(&st.doc, &[id]).first().copied()) {
out["guide"] = json!(g);
}
if let Some((id, (si, ai))) = st.selection.key_anchor {
out["keyAnchor"] = json!({ "id": id.0, "anchor": [si, ai] });
}
Ok(out)
}
@@ -843,15 +891,23 @@ fn anchors(s: &mut Session, p: &Value) -> Result<Value> {
if whole && let Some(path) = d.node(id).and_then(|n| n.path_data()) {
e.extend(path.anchors().map(|(s, i, _)| (s, i)));
}
// One point that add or toggle selects becomes the key anchor (a Shift-click with Direct
// Selection); `prune` drops it unless another point is selected too.
let single = refs.len() == 1;
let mut newest = None;
for r in refs {
if mode == "toggle" && e.contains(&r) {
e.remove(&r);
} else {
e.insert(r);
} else if e.insert(r) && single {
newest = Some(r);
}
}
if e.is_empty() {
sel.remove(id);
} else if mode != "set"
&& let Some(r) = newest
{
sel.key_anchor = Some((id, r));
}
})?;
ok()
@@ -1162,10 +1218,10 @@ enum Attribute {
fn same_attribute(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "select.same.appearanceAttribute";
let item = index_opt(p, "item", cmd)?.or(s.appearance_item());
let st = s.doc()?;
let (refn, _) = same_reference(st, cmd)?;
let ap = &refn.appearance;
let item = p.get("item").and_then(Value::as_u64).map(index).or(s.appearance_item());
let attr = match (item.and_then(|i| ap.items.get(i)), ap.effects.first()) {
(Some(it), _) => Attribute::Item(it.clone()),
(None, Some(e)) => Attribute::Effect(e.clone()),
+28 -3
View File
@@ -93,7 +93,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Merge Swatches",
["Window", "Swatches"],
None,
"{names: [two or more solid-colour swatches], name?: the merged swatch's name (one swatch kept)} keep the first (its name and colour) and delete the others; paints linked to them take its colour and link to it (or unlink when it isn't global), as one undo step → {name, merged: [deleted names], relinked}",
"{names: [two or more solid-colour swatches], name?: the merged swatch's name (one swatch kept)} keep the first (its name and colour) and delete the others; paints linked to them take its colour and link to it (or unlink when it isn't global), and gradient stops linked to them drop their process plates, as one undo step → {name, merged: [deleted names], relinked}",
has_doc,
swatch_merge
),
@@ -688,6 +688,11 @@ impl Relink {
/// batch, and a canceled batch preview, put back the paints the batch started with. A change is
/// recorded with the step just made, so Undo and Redo put these paints back with the document.
pub(super) fn map_default_paints(s: &mut Session, f: &mut LinkFn) -> Result<()> {
map_default_paints_by(s, &mut |p| p.map_links(f))
}
/// [`map_default_paints`] with `f` applied to each whole paint; `f` returns true when it changed it.
fn map_default_paints_by(s: &mut Session, f: &mut dyn FnMut(&mut Paint) -> bool) -> Result<()> {
if s.in_interaction() && s.batch_stash.is_none() {
return Ok(());
}
@@ -699,7 +704,7 @@ pub(super) fn map_default_paints(s: &mut Session, f: &mut LinkFn) -> Result<()>
});
let mut changed = false;
for p in [&mut pd.fill, &mut pd.stroke].into_iter().chain(template) {
changed |= p.map_links(f);
changed |= f(p);
}
if changed {
super::colormgmt::record_new_art_colors(s, start)?;
@@ -1124,17 +1129,37 @@ fn swatch_merge(s: &mut Session, p: &Value) -> Result<Value> {
*l = link.clone();
true
};
// A stop linked to a merged swatch loses its process plates, which printed with that ink.
let mut unplate = |p: &Paint| {
let mut q = p.clone();
drop_stop_plates(&mut q, &merged_names).then_some(q)
};
let colorized_ok = super::paint::check_colorized(d).is_ok();
let relinked = s.edit("Merge Swatches", |d, _| {
take_swatches(d, merged, |_| true);
map_all_paints(d, &mut unplate);
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)?;
map_default_paints_by(s, &mut |p| {
let unplated = drop_stop_plates(p, &merged_names);
p.map_links(&mut relink) | unplated
})?;
Ok(json!({"name": keep, "merged": merged, "relinked": relinked}))
}
/// Clear the process plates of `p`'s gradient stops linked to a swatch in `names`. True when any
/// stop had plates.
fn drop_stop_plates(p: &mut Paint, names: &HashSet<&str>) -> bool {
let Paint::Gradient(g) = p else { return false };
g.gradient
.stops
.iter_mut()
.filter(|st| st.swatch.as_deref().is_some_and(|l| names.contains(l)))
.fold(false, |ch, st| st.plates.take().is_some() | ch)
}
fn swatch_ungroup(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "swatch.ungroup";
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?.to_string();
+1 -1
View File
@@ -384,7 +384,7 @@ fn weather_icons() -> SymbolLibrary {
Anchor { p: Point::new(5.0, 16.0), h_in: Point::new(-6.0, 16.0), h_out: Point::new(0.0, 8.0), kind: AnchorKind::Corner },
Anchor { p: Point::new(-4.0, -1.0), h_in: Point::new(-4.0, 6.0), h_out: Point::new(-4.0, -8.0), kind: AnchorKind::Smooth },
];
let moon = PathData::single(SubPath { anchors: moon_anchors, closed: true });
let moon = PathData::single(SubPath::new(moon_anchors, true));
// Helper for cloud contour
let cloud_subpath = |offset_y: f64, scale: f64| -> SubPath {
+24
View File
@@ -1652,6 +1652,30 @@ fn paragraph_style_options_edit_applies_to_users_and_is_one_saved_undo_step() {
}
}
/// A paragraph style can hold a Standard composer: it applies to its paragraphs, redefining and
/// resetting treat it like any attribute, and the style and the paragraphs keep it through a save.
#[test]
fn paragraph_styles_keep_a_standard_composer_through_a_save() {
use vectorcraft_doc::Composer;
let (mut s, id) = session_with_text("One\nTwo");
s.execute("paraStyle.new", &json!({"name": "Legacy", "attrs": {"composer": "standardSingleLine"}})).unwrap();
s.execute("paraStyle.apply", &json!({"name": "Legacy", "id": id, "start": 0, "end": 0})).unwrap();
assert_eq!(paras(&s, id).iter().map(|p| p.composer).collect::<Vec<_>>(), [Composer::StandardSingleLine, Composer::EveryLine]);
let doc = s.doc().unwrap().doc.clone();
let saved = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap();
let style = saved.para_styles.iter().find(|d| d.name == "Legacy").unwrap();
assert_eq!(style.attrs["composer"], json!("standardSingleLine"));
let mut composers = vec![];
saved.walk(|n| {
if let vectorcraft_doc::NodeKind::Text(t) = &n.kind {
composers.extend(t.paragraph_styles().iter().map(|p| p.composer));
}
});
assert_eq!(composers, [Composer::StandardSingleLine, Composer::EveryLine]);
s.execute("paraStyle.setAttrs", &json!({"name": "Legacy", "attrs": {"composer": "standardEveryLine"}})).unwrap();
assert_eq!(paras(&s, id)[0].composer, Composer::StandardEveryLine, "an edited style reaches its paragraphs");
}
/// A changed paragraph attribute outside the range `text.setFormat` keeps it to is an error for
/// `paraStyle.edit`, `paraStyle.setAttrs` and `paraStyle.new`, with no change to the document; the
/// ends of each range are accepted, and an out-of-range value the style already has can be kept.
+7 -5
View File
@@ -41,7 +41,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Create Outlines",
["Type"],
Some("Cmd+Shift+O"),
"{} convert the selected text to groups of compound paths (one per glyph); on type scaled unevenly, sheared or flattened, each glyph's stroke becomes a filled path over it, shaped as the canvas strokes it; the type object's own fills and strokes (not its characters') go on paths of its whole outline, below or above the glyphs as its Characters row places them; a frame of threaded text leaves its thread with the text it shows; a dimension whose label is outlined becomes a plain group of its art → {ids}",
"{} convert the selected text to groups of compound paths (one per glyph); on type scaled unevenly, sheared or flattened, each glyph's stroke becomes a filled path over it, shaped as the canvas strokes it; the type object's own fills and strokes (not its characters') go on paths of its whole outline, below or above the glyphs as its Characters row places them; the group keeps the object's transparency, opacity mask and effects, once; a frame of threaded text leaves its thread with the text it shows; a dimension whose label is outlined becomes a plain group of its art → {ids}",
has_selection,
create_outlines
),
@@ -743,11 +743,13 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result<Value> {
if children.is_empty() {
continue;
}
// The group takes the object's name, transparency, opacity mask and effects, which
// apply to the type as a whole: once, not to each glyph. It is marked as type's
// outlines, so geometry effects move its placed gradients with the whole outline, as
// they do on the type.
let gid = d.alloc_id();
let mut g = Node::group(gid, children);
g.opacity = n.opacity;
g.blend = n.blend;
g.name = n.name.clone();
let mut g = super::flatten::carry(&n, Node::group(gid, children));
g.extras_mut().type_outlines = true;
d.insert(par, idx, g)?;
out.push(gid);
if roots.contains(&tid) {
+7 -10
View File
@@ -138,7 +138,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Paste without Formatting",
["Edit"],
Some("Cmd+Alt+V"),
"{center?, dx?, dy?, swatchConflict?} paste as edit.paste; pasted text takes the default character style (one run) and leaves its character and paragraph styles behind, as do the labels of pasted graphs → {ids, added, merged, renamed}",
"{center?, dx?, dy?, swatchConflict?} paste as edit.paste; pasted text takes the default character style (one run) and leaves its character and paragraph styles behind, as do the labels of pasted graphs, which take a new graph's label character style and paragraph attributes (keeping their alignment); a pasted graph no longer keeps its labels' character and paragraph formats, so a later redraw sets them the same way → {ids, added, merged, renamed}",
has_clipboard,
paste_plain
),
@@ -1767,15 +1767,12 @@ fn strip_formatting(n: &mut vectorcraft_doc::Node) {
t.set_all_paras(Default::default());
refresh_bounds(t);
}
// A graph keeps the names of its labels' character and paragraph styles for each redraw.
if let Some(g) = n.extras_if_any().and_then(|e| e.graph.as_deref_mut()) {
let l = &mut g.label_styles;
for st in [&mut l.values, &mut l.categories, &mut l.legend].into_iter().flatten() {
st.style_name = None;
}
for pa in [&mut l.values_para, &mut l.categories_para, &mut l.legend_para].into_iter().flatten() {
pa.style_name = None;
}
// A graph keeps its labels' character and paragraph formats for each redraw: they go with the
// labels' own formatting, and the labels are set in a new graph's label formats. The graph's generated
// bounds follow its changed labels, so the next redraw does not read them as a move.
if n.extras().graph.is_some() {
super::graph::edit_graph_art(n, |n| super::graph::plain_labels(n, &strip_formatting));
return;
}
if let Some(ch) = n.children_mut() {
for c in ch.iter_mut() {
+70 -17
View File
@@ -245,39 +245,92 @@ fn define(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"name": name, "kind": kind.label()}))
}
/// Whether the object `id`, bound to the variable `name`, can stay bound to it once the variable
/// is of `kind`: a text variable drives type that holds no other text variable, an image variable
/// drives an image object, a graph variable drives a graph root, and a visibility variable drives
/// any object. Locks and hidden layers don't count, as they don't unbind anything else either.
fn keeps_binding(doc: &Document, id: NodeId, name: &str, kind: VariableKind) -> bool {
let Some(n) = doc.node(id) else { return false };
match kind {
VariableKind::Text => {
matches!(n.kind, NodeKind::Text(_))
&& !doc.variables.bindings.get(&id).is_some_and(|bound| {
doc.variables.variables.iter().any(|v| v.kind == VariableKind::Text && v.name != name && bound.contains(&v.name))
})
}
VariableKind::Visibility => true,
VariableKind::Image => matches!(n.kind, NodeKind::Image(_)),
VariableKind::Graph => n.extras().graph.is_some(),
VariableKind::Unknown => false,
}
}
/// Variable Options: rename a variable (`newName`), change its kind (`kind`), or both, in one undo
/// step, carrying its bindings and its values in every data set to the new name. A kind change
/// unbinds the objects that can't hold the new kind ([`keeps_binding`]) and drops the variable's
/// value in every data set. A call that changes nothing adds no undo step.
fn rename(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "variable.rename";
let from = var_name(p, C)?;
let to = named(p.get("newName").and_then(Value::as_str), "newName", C)?;
let st = s.doc()?;
if st.doc.variables.variable(&from).is_none() {
return Err(bad(C, format!("no variable named `{from}`")));
// An omitted key is no change; a null, like any other value that isn't a name or a kind, is refused.
let to = match p.get("newName") {
None => None,
Some(v) => Some(named(v.as_str(), "newName", C)?),
};
let kind = match p.get("kind") {
None => None,
Some(v) => Some(v.as_str().and_then(VariableKind::parse).ok_or_else(|| bad(C, "`kind` must be `text`, `visibility`, `image` or `graph`"))?),
};
if to.is_none() && kind.is_none() {
return Err(bad(C, "missing `newName` or `kind`"));
}
if st.doc.variables.variable(&to).is_some() {
let doc = &s.doc()?.doc;
let Some(old) = doc.variables.variable(&from) else {
return Err(bad(C, format!("no variable named `{from}`")));
};
let to = to.unwrap_or_else(|| from.clone());
if to != from && doc.variables.variable(&to).is_some() {
return Err(bad(C, format!("variable `{to}` already exists")));
}
s.edit("Rename Variable", |d, _| {
let v = d.variables.variables.iter_mut().find(|v| v.name == from).ok_or_else(|| bad(C, format!("no variable named `{from}`")))?;
let kind = kind.unwrap_or(old.kind);
let retyped = kind != old.kind;
let unbound: Vec<NodeId> =
if retyped { doc.variables.objects_of(&from).into_iter().filter(|id| !keeps_binding(doc, *id, &from, kind)).collect() } else { Vec::new() };
let label = if retyped { "Variable Options" } else { "Rename Variable" };
let dropped = edit_variables(s, label, |vars| {
let v = vars.variables.iter_mut().find(|v| v.name == from).ok_or_else(|| bad(C, format!("no variable named `{from}`")))?;
v.name = to.clone();
for bound in d.variables.bindings.values_mut() {
v.kind = kind;
for id in &unbound {
vars.unbind(*id, &from);
}
for bound in vars.bindings.values_mut() {
if bound.remove(&from) {
bound.insert(to.clone());
}
}
for ds in &mut d.variables.datasets {
if let Some(value) = ds.values.remove(&from) {
ds.values.insert(to.clone(), value);
let mut dropped = 0;
for ds in &mut vars.datasets {
match ds.values.remove(&from) {
// A rename alone carries every value, one that doesn't fit the kind included; a kind
// change drops them all, as none was given for the new kind.
Some(value) if !retyped => {
ds.values.insert(to.clone(), value);
}
Some(_) => dropped += 1,
None => {}
}
}
Ok(())
Ok(dropped)
})?;
// The highlight names a variable, so it follows the rename rather than going stale.
// The highlight names a variable, so it follows the rename rather than going stale. A call
// that leaves the name as it was leaves the revision alone too.
let st = s.doc_mut()?;
if st.variables_highlight.as_deref() == Some(from.as_str()) {
if to != from && st.variables_highlight.as_deref() == Some(from.as_str()) {
st.variables_highlight = Some(to.clone());
st.revision += 1;
}
st.revision += 1;
Ok(json!({"from": from, "name": to}))
Ok(json!({"from": from, "name": to, "kind": kind.label(), "unbound": unbound.iter().map(|i| i.0).collect::<Vec<_>>(), "droppedValues": dropped}))
}
/// Make Text / Visibility / Image / Graph Dynamic: define a variable of that kind and bind the
@@ -837,7 +890,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Rename Variable",
["Window", "Variables"],
None,
"{name, newName} rename a variable, with its bindings and its values in every dataset; refused while the variables are locked",
"{name, newName?, kind?: text|visibility|image|graph} Variable Options: rename a variable, with its bindings and its values in every dataset, change its kind, or both, in one undo step (none when the name and the kind are the ones it has). A kind change unbinds every object that can't hold the new kind (a text variable keeps type that has no other text variable, an image variable keeps image objects, a graph variable keeps graph roots, a visibility variable keeps every object) and drops the variable's values in every dataset; locked and hidden objects keep their binding when they can hold it. Needs `newName` or `kind`, and a null or empty one is refused; refused while the variables are locked → {from, name, kind, unbound: [id…], droppedValues}",
variables_unlocked,
rename
),
+2
View File
@@ -308,6 +308,8 @@ fn document_base(s: &Session) -> Value {
"key": st.selection.key.map(|k| k.0),
// The ruler guide that is the key instead (`select.key {guide}`): an index into `guide.list`.
"keyGuide": st.selection.key_guide.and_then(|id| crate::cmd::guide_indexes(d, &[id]).first().copied()),
// The anchor point that is the key instead (`select.key {id, anchor}`): {id, anchor}.
"keyAnchor": st.selection.key_anchor.map(|(id, (si, ai))| json!({"id": id.0, "anchor": [si, ai]})),
"history": st.history.undo.iter().map(|h| h.label.clone()).collect::<Vec<_>>(),
"redo": st.history.redo.iter().rev().map(|h| h.label.clone()).collect::<Vec<_>>(),
"objects": d.node_count(),
+2
View File
@@ -245,6 +245,8 @@ pub struct Session {
/// keyboard: those files, and the files they link to, are read under these roots, so a queue
/// an agent loaded never reads beyond what it was given.
pub(crate) place_queue_roots: Option<std::sync::Arc<file_access::AutomationRoots>>,
/// How many times the place cursor has been loaded ([`Session::place_queue_loads`]).
pub(crate) place_queue_loads: u64,
/// While [`Session::import_image_read`] runs `clipboard.importImage`: the image read for it,
/// which the command loads instead of reading `dataBase64`; `None` otherwise.
pub(crate) image_hand_off: Option<cmd::clipboard::ImageRead>,
+17 -2
View File
@@ -59,8 +59,23 @@ fn rect_in_range(r: vectorcraft_geom::Rect) -> bool {
/// margin passes without being measured: its curves stay inside their control points. Any other
/// object, and a path with a point outside that margin, is measured.
pub(crate) fn objects_in_range(d: &Document, ids: &[NodeId]) -> bool {
let paths = d.index_paths(ids.iter().copied());
ids.iter().all(|id| paths.get(id).and_then(|p| d.node_at(p)).is_none_or(|n| points_in_range(n) || n.geometric_bounds().is_none_or(rect_in_range)))
if ids.is_empty() {
return true;
}
let node_ok = |n: &vectorcraft_doc::Node| points_in_range(n) || n.geometric_bounds().is_none_or(rect_in_range);
// A cached document or a few ids: look each one up. Otherwise one walk of the tree, with no
// path vectors allocated.
if d.has_lookup_cache() || ids.len() <= 16 {
return ids.iter().all(|id| d.node(*id).is_none_or(node_ok));
}
let want: std::collections::HashSet<NodeId> = ids.iter().copied().collect();
let mut ok = true;
d.walk(|n| {
if ok && want.contains(&n.id) && !node_ok(n) {
ok = false;
}
});
ok
}
/// Coordinates at most this far from 0 keep a curve's measured bounds within [`MAX_COORD`]
+1
View File
@@ -113,6 +113,7 @@ impl Session {
place_hand_off: None,
queue_hand_off: None,
place_queue_roots: None,
place_queue_loads: 0,
image_hand_off: None,
trace_hand_off: None,
place_cursor_requests: false,
+70 -4
View File
@@ -86,6 +86,7 @@ mod inline;
mod interactivity;
mod intertwine;
mod journal;
mod keyanchor;
mod keyguide;
mod knockout;
mod labspots;
@@ -1544,6 +1545,7 @@ fn shape_commands_refuse_a_number_or_boolean_given_as_text() {
("shape.flare", json!({"cx": 100, "cy": 100, "x2": "300", "y2": 300}), "x2"),
("shape.flare", with(&flare, "direction", json!("45")), "direction"),
("shape.flare", with(&flare, "seed", json!("7")), "seed"),
("shape.flare", with(&flare, "ringSeed", json!("7")), "ringSeed"),
("shape.spiral", with(&spiral, "decay", json!("80")), "decay"),
("shape.spiral", with(&spiral, "segments", json!("10")), "segments"),
]
@@ -2131,7 +2133,7 @@ mod pen_interactions {
gesture(&mut s, &[(400.0, 500.0)], Mods::default(), v);
let extended = path(&s, id);
assert_eq!(extended.anchor_count(), 3);
assert_eq!(extended.subpaths[0].anchors[..2], reversed.subpaths[0].anchors);
assert_eq!(extended.subpaths[0].anchors.to_vec()[..2], reversed.subpaths[0].anchors);
assert!(!s.in_interaction() && !s.tool_busy());
assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 1);
}
@@ -2166,7 +2168,7 @@ mod pen_interactions {
gesture(&mut s, &[(300.0, 400.0)], Mods::default(), v);
let extended = path(&s, id);
assert_eq!(extended.anchor_count(), 3);
assert_eq!(extended.subpaths[0].anchors[..2], converted.subpaths[0].anchors);
assert_eq!(extended.subpaths[0].anchors.to_vec()[..2], converted.subpaths[0].anchors);
assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 1);
}
@@ -2225,7 +2227,7 @@ mod pen_interactions {
}
let extended = path(&s, id);
assert_eq!(extended.anchor_count(), 3);
assert_eq!(extended.subpaths[0].anchors[..2], before.subpaths[0].anchors);
assert_eq!(extended.subpaths[0].anchors.to_vec()[..2], before.subpaths[0].anchors);
let a = extended.subpaths[0].anchors[2];
assert_eq!(a.p, Point::new(320.0, 450.0));
assert_eq!(a.h_in, Point::new(290.0, 430.0), "Alt freezes the incoming handle and Space translates it");
@@ -3268,6 +3270,70 @@ fn flare_rotation_turns_only_the_rays() {
}
}
/// `shape.flare`'s `ringSeed` places the rings along the path anew and keeps their count and
/// sizes and the other parts; the same `ringSeed` draws the same flare, neighboring ones differ,
/// and a seed that is not a whole number of at least 0 (for `ringSeed`, below 2^53) is refused.
#[test]
fn flare_ring_seed_moves_only_the_rings() {
let mut s = session();
let parts = |s: &mut Session, ring_seed: Option<u64>| {
let mut p = json!({"cx": 200, "cy": 200, "diameter": 60, "rays": 9, "rings": 6, "x2": 400, "y2": 300, "seed": 3});
if let Some(r) = ring_seed {
p["ringSeed"] = json!(r);
}
let id = NodeId(s.execute("shape.flare", &p).unwrap()["id"].as_u64().unwrap());
let NodeKind::Group { children, .. } = &s.doc().unwrap().doc.node(id).unwrap().kind else { panic!("flare is a group") };
children.iter().map(|c| (c.name.clone(), c.geometric_bounds().unwrap())).collect::<Vec<_>>()
};
let plain = parts(&mut s, None);
let placed = parts(&mut s, Some(11));
assert_eq!(placed, parts(&mut s, Some(11)));
assert_eq!(plain.len(), placed.len());
let mut moved = 0;
for ((name, a), (_, b)) in plain.iter().zip(&placed) {
if name.as_deref() == Some("Ring") {
assert!((a.width() - b.width()).abs() < 1e-9, "a ring keeps its size");
moved += usize::from(a.center().distance(b.center()) > 1e-6);
} else {
assert_eq!(a, b, "{name:?}");
}
}
assert_eq!(moved, 6, "every ring moves");
assert_ne!(parts(&mut s, Some(0)), parts(&mut s, Some(1)), "neighboring seeds place the rings differently");
let undo = s.doc().unwrap().history.undo.len();
for bad in [json!(-1), json!(2.5), json!(true), json!(1u64 << 53)] {
assert!(s.execute("shape.flare", &json!({"cx": 0, "cy": 0, "ringSeed": bad})).is_err(), "{bad}");
}
for bad in [json!(-1), json!(2.5), json!(true)] {
assert!(s.execute("shape.flare", &json!({"cx": 0, "cy": 0, "seed": bad})).is_err(), "{bad}");
}
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
}
/// Through the session, Ctrl/Cmd in the Flare tool's center drag keeps the center's size while the
/// drag sizes the halo, and the flare drawn keeps both.
#[test]
fn flare_tool_ctrl_keeps_the_center_while_the_drag_sizes_the_halo() {
let mut s = session();
let v = ViewInfo::default();
let cmd = vectorcraft_tools::Mods { cmd: true, ..Default::default() };
s.select_tool("flare", v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Down, 200.0, 200.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 240.0, 200.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 280.0, 200.0).with_mods(cmd), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 280.0, 200.0).with_mods(cmd), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Down, 500.0, 400.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 500.0, 400.0), v).unwrap();
let st = s.doc().unwrap();
let g = st.doc.node(st.selection.objects[0]).unwrap();
let NodeKind::Group { children, .. } = &g.kind else { panic!("flare is a group") };
let width = |name: &str| children.iter().find(|c| c.name.as_deref() == Some(name)).unwrap().geometric_bounds().unwrap().width();
// The center as the key found it (80); the halo as a 160-point center with 20 % growth would
// have it: 80 × 1.2 × 1.6 = 153.6 radius.
assert!((width("Center") - 80.0).abs() < 0.5, "{}", width("Center"));
assert!((width("Halo") - 2.0 * 153.6).abs() < 0.5, "{}", width("Halo"));
}
#[test]
fn reshape_moves_the_grabbed_point_and_its_neighbourhood() {
let mut s = session();
@@ -3315,7 +3381,7 @@ fn reshape_open_path(s: &mut Session, anchors: Value) -> NodeId {
/// The anchors of the path's first subpath.
fn reshape_anchors(s: &Session, id: NodeId) -> Vec<vectorcraft_geom::Anchor> {
s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().subpaths[0].anchors.clone()
s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().subpaths[0].anchors.to_vec()
}
/// The share of the drag an anchor takes at `u` of the way from an end to the grabbed point.
+57 -3
View File
@@ -3,7 +3,7 @@
use serde_json::json;
use crate::cmd::actionset::{AiaStep, read_aia};
use crate::cmd::actionset::{AiaData, AiaStep, AiaValue, read_aia};
use crate::*;
/// `text` as a bracketed hexadecimal value.
@@ -37,7 +37,12 @@ fn a_set_reads_its_actions_and_steps() {
let names: Vec<&str> = set.actions.iter().map(|a| a.name.as_str()).collect();
assert_eq!(names, ["First", "二番目"]);
let step = |a: usize, s: usize| set.actions[a].steps[s].clone();
let join = AiaStep { name: "sample_join".into(), label: Some("Join".into()), on: Some(1), params: 3, declared: Some(3), line: 9 };
let values = vec![
AiaValue { key: Some(1), kind: Some("unit real".into()), value: None },
AiaValue { key: None, kind: Some("ustring".into()), value: Some(AiaData::Text(b"a } brace".to_vec())) },
AiaValue { key: None, kind: Some("raw".into()), value: None },
];
let join = AiaStep { name: "sample_join".into(), label: Some("Join".into()), on: Some(1), params: 3, values, declared: Some(3), line: 9 };
assert_eq!(step(0, 0), join);
assert_eq!((step(1, 0).name.as_str(), step(1, 0).label.clone(), step(1, 0).on), ("sample)one", None, Some(0)));
assert_eq!((step(1, 1).label.as_deref(), step(1, 1).on, step(1, 1).line), (Some("Second Step"), None, 39));
@@ -60,6 +65,55 @@ fn recorded_values_are_counted_and_checked() {
assert!(set(&[value("one"), "/value [ 1 zz ]".to_string()]).truncated, "a second value that isn't hexadecimal");
}
/// `actionSet.read` answers with each recorded value's key, type and value: a number, text in
/// parentheses or hexadecimal text that is UTF-8 with its length in bytes, and null for text that
/// isn't UTF-8 and for other values. Fields may come in any order, and a value with the wrong byte
/// count still marks the set truncated.
#[test]
fn recorded_values_come_back_with_their_keys_and_types() {
let mut s = Session::new();
let text = format!(
"/version 3 /action-1 {{ /event-1 {{ /internalName (sample_values) /parameterCount 6 \
/parameter-1 {{ /key 1952807028 /type (ustring) /value {note} }} \
/parameter-2 {{ /value 1 /type (boolean) /key 2003792484 }} \
/parameter-3 {{ /type (ustring) /value [ 2 c328 ] /key 7 }} \
/parameter-4 {{ /key 99999999999999999999 /type (real) /value 4.5 }} \
/parameter-5 {{ /key 3 /type (text) /value (plain) }} \
/parameter-6 {{ }} }} }}",
note = hex("Größe ✓")
);
let r = s.execute("actionSet.read", &json!({ "data": text })).unwrap();
assert_eq!(r["truncated"], false);
assert_eq!(
r["actions"][0]["steps"][0]["values"],
json!([
{"key": 1952807028, "type": "ustring", "value": "Größe ✓", "bytes": 11},
{"key": 2003792484, "type": "boolean", "value": 1},
{"key": 7, "type": "ustring", "value": null, "bytes": 2},
{"key": null, "type": "real", "value": null},
{"key": 3, "type": "text", "value": "plain", "bytes": 5},
{"key": null, "type": null, "value": null},
])
);
let bad = "/version 3 /action-1 { /event-1 { /parameter-1 { /key 1 /type (ustring) /value [ 3 4142 ] } } }";
assert_eq!(s.execute("actionSet.read", &json!({ "data": bad })).unwrap()["truncated"], true);
}
/// At most 100,000 recorded values are kept in a set: the others are still counted, so a step's
/// `values` can be shorter than its `params`.
#[test]
fn recorded_values_past_the_cap_are_counted_but_not_kept() {
let step = format!("/event-1 {{ {} }} ", "/parameter-1 { /key 1 /value 0 } ".repeat(1000));
let action = format!("/action-1 {{ {} }} ", step.repeat(50));
let set = read_aia(format!("/version 3 {}", action.repeat(3)).as_bytes()).unwrap();
assert!(!set.truncated);
let steps = || set.actions.iter().flat_map(|a| &a.steps);
assert_eq!(steps().map(|s| s.params).sum::<usize>(), 150_000);
assert_eq!(steps().map(|s| s.values.len()).sum::<usize>(), 100_000);
assert_eq!(set.actions[2].steps[0].values.len(), 0);
assert_eq!(set.actions[1].steps[49].values[999], AiaValue { key: Some(1), kind: None, value: Some(AiaData::Int(0)) });
}
/// `actionSet.read` takes exactly one of `path`, `data` and `dataBase64`, and text for the text
/// params; any other value is an error.
#[test]
@@ -97,7 +151,7 @@ fn read_command_answers_with_the_set() {
let text = sample().replacen(&format!("/name {}", hex("Prep Sätze")), "", 1);
let r = s.execute("actionSet.read", &json!({ "data": text, "name": "/sets/Mine.aia" })).unwrap();
assert_eq!(r["set"], "Mine");
assert_eq!(r["actions"][1]["steps"][0], json!({"name": "sample)one", "label": null, "on": false, "params": 0, "line": 34}));
assert_eq!(r["actions"][1]["steps"][0], json!({"name": "sample)one", "label": null, "on": false, "params": 0, "values": [], "line": 34}));
assert_eq!(r["actions"][0]["steps"][0]["on"], true);
assert_eq!(r["actions"][0]["steps"][0]["label"], "Join");
let blank = format!("/version 3 /action-1 {{ /event-1 {{ /internalName (x) /localizedName {} }} }}", hex(" "));
+43 -2
View File
@@ -1,6 +1,7 @@
//! An edit of one anchor of a long selected path does not measure the path's segments
//! ([`vectorcraft_geom::path::measured_segments`]) to check that it stays within the canvas, and
//! an edit that leaves the anchor as it was records no undo step.
//! ([`vectorcraft_geom::path::measured_segments`]) to check that it stays within the canvas, its
//! undo step keeps the chunk of anchors it changed rather than a copy of every anchor, and an edit
//! that leaves the anchor as it was records no undo step.
use std::collections::BTreeSet;
use std::sync::Arc;
@@ -74,6 +75,46 @@ fn one_anchor_edit_of_a_long_path_measures_few_segments() {
}
}
/// Most bytes the undo step of one anchor edit may keep, however many anchors the path has: about
/// one chunk of anchors and the node, with room for the chunk tables of 200,000 anchors.
const MOST_STEP_BYTES: usize = 48 * 1024;
#[test]
fn one_anchor_edit_of_a_long_path_keeps_one_chunk_in_its_undo_step() {
for n in [10_000, 200_000] {
let (mut s, id) = long_path(n);
let original = s.doc().unwrap().doc.clone();
let edits = [
("path.moveAnchor", json!({"id": id.0, "anchor": 5, "x": 3, "y": 50})),
("path.setHandle", json!({"id": id.0, "anchor": 5, "which": "out", "x": 4, "y": 40})),
("path.moveAnchors", json!({"dx": 1, "dy": 0})),
("object.nudge", json!({"dx": 1, "dy": 0})),
];
let mut docs = vec![];
for (cmd, p) in &edits {
s.execute(cmd, p).unwrap();
let step = s.doc().unwrap().history.undo.last().unwrap().bytes;
assert!(step <= MOST_STEP_BYTES, "{cmd} on {n} anchors kept {step} bytes in its undo step");
docs.push(s.doc().unwrap().doc.clone());
}
assert_eq!(anchor(&s, id, 5).p, Point::new(5.0, 50.0));
assert_eq!(anchor(&s, id, 5).h_out, Point::new(6.0, 40.0));
assert_eq!(anchor(&s, id, n - 1), original.node(id).unwrap().path_data().unwrap().subpaths[0].anchors[n - 1]);
// Undo gives back each version exactly, and redo the edits, each step as small.
for k in (0..edits.len()).rev() {
s.execute("edit.undo", &json!({})).unwrap();
let expected = if k == 0 { &original } else { &docs[k - 1] };
assert!(s.doc().unwrap().doc == *expected, "undo of {} on {n} anchors", edits[k].0);
let redo = s.doc().unwrap().history.redo.last().unwrap().bytes;
assert!(redo <= MOST_STEP_BYTES, "redo of {} on {n} anchors keeps {redo} bytes", edits[k].0);
}
for (k, doc) in docs.iter().enumerate() {
s.execute("edit.redo", &json!({})).unwrap();
assert!(s.doc().unwrap().doc == *doc, "redo of {} on {n} anchors", edits[k].0);
}
}
}
/// A handle past the canvas is measured: the edit stands while the curve stays within the canvas,
/// and is refused once the curve leaves it.
#[test]
+99
View File
@@ -1002,6 +1002,105 @@ fn brush_freehand_refuses_a_brush_or_style_it_cannot_use() {
}
}
/// A `brush.freehand` call outside an interaction draws the path and paints it with the brush in
/// one undo step: one Undo removes the path and gives back the earlier selection, and one Redo
/// brings back the brushed path. Continuing a path with a brush is one step too.
#[test]
fn brush_freehand_direct_call_has_one_undo_step() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
select(&mut s, &[a]);
let before = s.doc().unwrap().doc.clone();
let undo = s.doc().unwrap().history.undo.len();
let r = s.execute("brush.freehand", &json!({"points": [[10, 10], [60, 40], [120, 10]], "style": "brush", "brush": "Charcoal"})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
assert_eq!(brush_of(&s, id).as_deref(), Some("Charcoal"));
s.execute("edit.undo", &json!({})).unwrap();
assert!(*s.doc().unwrap().doc == *before, "one Undo removes the brushed path");
assert_eq!(selected(&s), [a], "Undo gives back the earlier selection");
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(brush_of(&s, id).as_deref(), Some("Charcoal"), "Redo brings back the brush");
assert_eq!(selected(&s), [id]);
let undo = s.doc().unwrap().history.undo.len();
let p = json!({"points": [[120, 10], [160, 60], [200, 10]], "extend": {"id": id.0, "end": "end"}, "brush": "Dots"});
assert_eq!(s.execute("brush.freehand", &p).unwrap()["id"], json!(id.0));
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "continuing is one undo step");
assert_eq!(brush_of(&s, id).as_deref(), Some("Dots"));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(brush_of(&s, id).as_deref(), Some("Charcoal"), "one Undo takes back the stroke and its brush");
let undo = s.doc().unwrap().history.undo.len();
s.execute("brush.freehand", &json!({"points": [[10, 100], [60, 140], [120, 100]], "style": "pencil", "brush": "Charcoal"})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
assert_eq!(s.doc().unwrap().history.undo.last().map(|e| e.label.as_str()), Some("Paintbrush"), "a stroke with a brush is a Paintbrush step");
}
/// `brush.freehand` refuses a brush for a path with no stroke (a continued path, a redrawn one
/// and a new filled pencil path while the stroke is None) instead of reporting it painted: the
/// document, the selection and the undo history stay as they were. Without a brush the path is
/// drawn as `path.freehand` draws it.
#[test]
fn brush_freehand_refuses_a_brush_on_a_path_without_a_stroke() {
let mut s = session();
s.execute("select.none", &json!({})).unwrap();
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
let drawn = json!([[10, 10], [60, 20], [90, 80], [20, 90]]);
let p = NodeId(s.execute("path.freehand", &json!({"points": drawn, "fill": true})).unwrap()["id"].as_u64().unwrap());
assert!(node(&s, p).appearance.stroke_paint().is_none(), "the path has no stroke");
// The redraw below replaces a section of the path rather than drawing a new one.
let redraw = json!({"points": [[10, 10], [40, 30], [60, 20]], "redraw": {"id": p.0}});
assert_eq!(s.execute("path.freehand", &redraw).unwrap()["id"], json!(p.0));
s.execute("edit.undo", &json!({})).unwrap();
select(&mut s, &[p]);
let extend = json!({"id": p.0, "end": "end"});
for (what, mut params) in [
("continued", json!({"points": [[20, 90], [0, 120], [-10, 150]], "extend": extend})),
("continued by one point", json!({"points": [[20, 90]], "extend": extend})),
("redrawn", redraw.clone()),
("new filled pencil", json!({"points": [[100, 100], [150, 120], [180, 180]], "style": "pencil", "fill": true})),
] {
params["brush"] = json!("Charcoal");
let (before, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len());
let e = s.execute("brush.freehand", &params).expect_err(&format!("{what}: refused"));
assert_eq!(e.to_string(), "invalid parameters for `brush.freehand`: cannot apply brush `Charcoal`: path has no stroke", "{what}");
assert!(*s.doc().unwrap().doc == *before, "{what}: the document stays");
assert_eq!(selected(&s), [p], "{what}: the selection stays");
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "{what}: no undo step");
}
let r = s.execute("brush.freehand", &json!({"points": [[20, 90], [0, 120], [-10, 150]], "extend": extend, "brush": null})).unwrap();
assert_eq!(r["id"], json!(p.0));
assert_eq!(brush_of(&s, p), None, "without a brush the path is continued as it is");
}
/// `brush.freehand` takes the keys of `path.freehand` and `brush`, and refuses any other key with
/// the nearest key it takes, before it draws.
#[test]
fn brush_freehand_refuses_unknown_keys() {
let spec = crate::cmd::find_command("brush.freehand").unwrap();
let mut keys = crate::cmd::params::accepted_keys(spec.params).expect("the description names every key");
keys.sort_unstable();
assert_eq!(keys, ["brush", "closed", "connect", "extend", "fidelity", "fill", "fit", "keepSelected", "points", "redraw", "style"]);
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
select(&mut s, &[a]);
let points = json!([[10, 10], [60, 40], [120, 10]]);
let (before, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len());
let e = s.execute("brush.freehand", &json!({"points": points, "brsuh": "Charcoal"})).expect_err("a misspelled key is refused");
assert!(e.to_string().contains("unknown parameter `brsuh`; did you mean `brush`?"), "{e}");
assert!(*s.doc().unwrap().doc == *before, "the document stays");
assert_eq!(selected(&s), [a], "the selection stays");
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step");
let every = json!({
"points": points, "fidelity": 2.0, "fit": true, "closed": false, "style": "brush", "fill": false, "keepSelected": true,
"extend": null, "connect": null, "redraw": null, "brush": "Charcoal"
});
let id = NodeId(s.execute("brush.freehand", &every).unwrap()["id"].as_u64().unwrap());
assert_eq!(brush_of(&s, id).as_deref(), Some("Charcoal"));
}
/// The Paintbrush skips a brush deleted after it was chosen: a deleted tool brush gives way to
/// the document's current brush, and with neither the stroke is drawn without a brush.
#[test]
+246 -24
View File
@@ -3576,9 +3576,9 @@ fn live_paint_bucket_scopes_follow_the_stroke_an_edge_draws() {
assert_eq!(r["count"], json!(clear.len() + top.len() + hidden.len()));
}
/// `livePaint.strokeEdge` paints an edge's topmost stroke that is shown, so the edge then draws
/// with the paint; an edge whose strokes are all hidden takes a new stroke above them. Painting
/// again changes nothing.
/// `livePaint.strokeEdge` leaves an edge one stroke, shown, with the paint, so the edge then draws
/// with it, whether its topmost shown stroke sat under a hidden one or every stroke was hidden.
/// Painting again changes nothing.
#[test]
fn live_paint_stroke_edge_paints_the_shown_stroke() {
let mut s = session();
@@ -3613,8 +3613,107 @@ fn live_paint_stroke_edge_paints_the_shown_stroke() {
paint(&mut s, at);
assert_eq!(history_state(&s).0, depth, "painting {id} again");
}
assert_eq!(node(&s, m).appearance.items.len(), 2, "the hidden stroke stays as it was");
assert_eq!(node(&s, l).appearance.items.len(), 2, "a new stroke above the hidden one");
assert_eq!(node(&s, m).appearance.items.len(), 1, "one stroke in place of the hidden and the shown one");
assert_eq!(node(&s, l).appearance.items.len(), 1, "one shown stroke in place of the hidden one");
}
/// A face with two fills that draw, under a hidden top fill: the Bucket replaces every fill with
/// one shown, fully opaque fill of the paint, keeps the face's strokes, opacity and their order,
/// and a click with the paint the face then has records nothing. A fill of none leaves no fill
/// that draws. The route that makes the objects into a group first paints the same way.
#[test]
fn live_paint_bucket_replaces_every_fill_row() {
use vectorcraft_doc::appearance::{FillLayer, StrokeLayer};
let mut s = session();
let g = live_squares(&mut s, &[0.0]);
let [f] = faces_at(&s, g, &[(25.0, 25.0)])[..] else { panic!("one face") };
let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0));
let blue = Paint::solid(Color::rgb(0.0, 0.0, 1.0));
let green = Paint::solid(Color::from_hex("#00ff00").unwrap());
let stroke = StrokeLayer::new(blue.clone(), 3.0);
s.edit("t", |d, _| {
let n = d.node_mut(f).unwrap();
n.opacity = 0.5;
let mut half = FillLayer::new(blue.clone());
half.opacity = 0.5;
let mut hidden = FillLayer::new(green.clone());
hidden.visible = false;
n.appearance.items = vec![
AppearanceItem::Fill(FillLayer::new(red.clone())),
AppearanceItem::Stroke(stroke.clone()),
AppearanceItem::Fill(half),
AppearanceItem::Fill(hidden),
];
Ok(())
})
.unwrap();
let depth = history_state(&s).0;
s.execute("livePaint.fill", &json!({"group": g.0, "point": [25, 25], "color": "#00ff00"})).unwrap();
assert_eq!(history_state(&s).0, depth + 1, "a hidden top fill with the paint does not stop the fill");
let n = node(&s, f);
assert_eq!(n.appearance.items, vec![AppearanceItem::Stroke(stroke.clone()), AppearanceItem::Fill(FillLayer::new(green.clone()))]);
assert_eq!(n.opacity, 0.5, "the face keeps its own opacity");
s.recent_colors.clear();
s.execute("livePaint.fill", &json!({"group": g.0, "point": [25, 25], "color": "#00ff00"})).unwrap();
assert_eq!((history_state(&s).0, s.recent_colors.len()), (depth + 1, 0), "painting again records nothing");
s.execute("edit.undo", &json!({})).unwrap();
s.execute("livePaint.fill", &json!({"group": g.0, "point": [25, 25], "none": true})).unwrap();
let n = node(&s, f);
assert!(!n.appearance.has_drawn_fill(), "no fill draws after a fill of none");
assert_eq!(n.appearance.items, vec![AppearanceItem::Stroke(stroke), AppearanceItem::Fill(FillLayer::new(Paint::None))]);
// Objects made into a Live Paint group by the click: each object's two fills become one.
let mut s = session();
let id = rect(&mut s, 0.0, 0.0, 50.0, 50.0);
s.edit("t", |d, _| {
d.node_mut(id).unwrap().appearance.items.insert(0, AppearanceItem::Fill(FillLayer::new(red.clone())));
Ok(())
})
.unwrap();
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
let r = s.execute("livePaint.fill", &json!({"point": [25, 25], "color": "#00ff00"})).unwrap();
let face = NodeId(r["face"].as_u64().unwrap());
let fills: Vec<Paint> = node(&s, face).appearance.items.iter().filter(|i| i.is_fill()).map(|i| i.paint().clone()).collect();
assert_eq!(fills, vec![green]);
}
/// An edge with two strokes that draw: `livePaint.strokeEdge` replaces them with one stroke of the
/// paint and width, keeping the topmost one's dashes; a stroke of none leaves no stroke that
/// draws, and `livePaint.info` then lists that one row.
#[test]
fn live_paint_stroke_edge_replaces_every_stroke_row() {
use vectorcraft_doc::appearance::{Dash, StrokeLayer};
let mut s = session();
let g = live_squares(&mut s, &[0.0, 50.0]);
let middle = edges_in(&s, g, Rect::new(50.0, 0.0, 50.0, 50.0));
let [m] = middle[..] else { panic!("one middle edge") };
let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0));
let green = Paint::solid(Color::from_hex("#00ff00").unwrap());
let dash = Dash { pattern: vec![4.0, 2.0], offset: 0.0, align_corners: false };
s.edit("t", |d, _| {
let mut top = StrokeLayer::new(green.clone(), 2.0);
top.opacity = 0.5;
top.dash = Some(dash.clone());
d.node_mut(m).unwrap().appearance.items = vec![AppearanceItem::Stroke(StrokeLayer::new(red.clone(), 6.0)), AppearanceItem::Stroke(top)];
Ok(())
})
.unwrap();
let depth = history_state(&s).0;
s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [50, 25], "color": "#00ff00", "width": 2})).unwrap();
assert_eq!(history_state(&s).0, depth + 1, "a half-transparent top stroke with the paint does not stop the stroke");
let mut want = StrokeLayer::new(green.clone(), 2.0);
want.dash = Some(dash);
assert_eq!(node(&s, m).appearance.items, vec![AppearanceItem::Stroke(want)]);
s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [50, 25], "color": "#00ff00", "width": 2})).unwrap();
assert_eq!(history_state(&s).0, depth + 1, "painting again records nothing");
s.execute("edit.undo", &json!({})).unwrap();
s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [50, 25], "none": true, "width": 2})).unwrap();
assert!(!node(&s, m).appearance.has_drawn_stroke(), "no stroke draws after a stroke of none");
let info = s.execute("livePaint.info", &json!({"group": g.0})).unwrap();
let edge = info["edges"].as_array().unwrap().iter().find(|e| e["id"] == json!(m.0)).unwrap().clone();
let rows = edge["appearance"].as_array().unwrap();
assert_eq!(rows.len(), 1);
assert_eq!((&rows[0]["kind"], &rows[0]["drawn"], &edge["stroke"]), (&json!("stroke"), &json!(false), &json!(Paint::None.label())));
}
/// With Cmd held, the Live Paint Bucket's presses go to the selection tool Cmd lends, and the
@@ -5341,8 +5440,9 @@ fn add_image(s: &mut Session, r: &vectorcraft_trace::Raster, xf: Affine) -> Node
.unwrap()
}
/// The traced paths of an Image Trace object: its children but the image and the seam fills.
fn traced_paths(n: &Node) -> Vec<Arc<Node>> {
n.children().unwrap().iter().filter(|c| !matches!(c.kind, NodeKind::Image(_))).cloned().collect()
n.children().unwrap().iter().filter(|c| !matches!(c.kind, NodeKind::Image(_)) && c.name.as_deref() != Some("Seam Fills")).cloned().collect()
}
#[test]
@@ -5588,6 +5688,57 @@ fn a_trace_run_applies_only_in_its_own_document() {
}
}
/// A trace prepared before File › Revert doesn't apply after it, even when the document keeps its
/// uid and the restored image, object and settings are the same: both trace commands drop the
/// trace handed in and trace the restored image again, as one undo step. Reverting another
/// document, or a revert that fails, leaves the run current, and a trace prepared after the revert
/// applies as usual.
#[test]
fn a_trace_run_prepared_before_a_revert_does_not_apply_after_it() {
let p = json!({"params": {"ignoreWhite": true}});
for command in ["imageTrace.make", "imageTrace.makeAndExpand"] {
let mut direct = session();
image_doc(&mut direct);
let want = direct.execute(command, &p).unwrap();
let mut s = session();
let img = image_doc(&mut s);
let a = s.active_index().unwrap();
let copy = (*s.doc().unwrap().doc).clone();
let run = cmd::TraceRun::prepare(&s, command, &p).unwrap();
let traced = run.run(&|_, _| true).unwrap();
let b = s.add_document(copy.clone(), None);
assert!(s.replace_document(b, copy.clone()));
assert!(s.set_active(a));
assert!(run.current(&s) && !run.reverted(&s), "{command}: another document reverted");
assert!(!s.replace_document(s.documents().len(), copy.clone()));
assert!(run.current(&s) && !run.reverted(&s), "{command}: a revert that failed");
let (uid, reverts) = (s.doc().unwrap().uid, s.doc().unwrap().reverts);
assert!(s.replace_document(a, copy));
assert_eq!(s.doc().unwrap().uid, uid, "{command}: the tab keeps its uid");
assert_eq!(s.doc().unwrap().reverts, reverts + 1, "{command}");
assert!(!run.current(&s), "{command}: reverted");
assert!(run.reverted(&s), "{command}");
traced.hand_in(&mut s);
let traces = cmd::TraceRun::traces_on_this_thread();
let got = s.execute(run.command(), run.params()).unwrap();
assert_eq!(cmd::TraceRun::traces_on_this_thread(), traces + 1, "{command}: traced again");
assert!(s.trace_hand_off.is_none(), "{command}: dropped");
assert_eq!(got, want, "{command}");
let g = NodeId(got["id"].as_u64().unwrap());
assert_eq!(node(&s, g), node(&direct, g), "{command}");
s.execute("edit.undo", &json!({})).unwrap();
assert!(matches!(node(&s, img).kind, NodeKind::Image(_)), "{command}: one undo step");
// A trace prepared after the revert is handed in and applied without tracing again.
let after = cmd::TraceRun::prepare(&s, command, run.params()).unwrap();
let traced = after.run(&|_, _| true).unwrap();
assert!(after.current(&s) && !after.reverted(&s), "{command}: prepared after the revert");
traced.hand_in(&mut s);
let traces = cmd::TraceRun::traces_on_this_thread();
s.execute(after.command(), after.params()).unwrap();
assert_eq!(cmd::TraceRun::traces_on_this_thread(), traces, "{command}: the trace handed in applied");
}
}
/// A plug-in run that calls Image Trace and then fails leaves a trace handed in for the command in
/// place, as it leaves the document.
#[test]
@@ -5961,6 +6112,73 @@ fn image_trace_release_and_expand() {
assert!(s.execute("imageTrace.release", &json!({})).is_err());
}
/// Abutting with Fills: the strokes along the edges the paths share sit in a "Seam Fills" group
/// under the paths, in the lower path's color, and stay under them through Expand by color.
#[test]
fn image_trace_abutting_seam_fills_sit_under_the_paths() {
// Red beside blue, the edge between them anti-aliased.
let art = vectorcraft_trace::Raster::from_fn(40, 20, |x, _| {
let share = (16.4 - f64::from(x)).clamp(0.0, 1.0);
let mix = |a: f64, b: f64| (a * share + b * (1.0 - share)).round() as u8;
[mix(200.0, 30.0), mix(40.0, 90.0), mix(50.0, 200.0), 255]
});
let seams = |n: &Node| n.children().unwrap().iter().find(|c| c.name.as_deref() == Some("Seam Fills")).cloned();
let made = |method: &str, cmd: &str, by_color: bool| {
let mut s = session();
let img = add_image(&mut s, &art, Affine::scale(2.0));
let params = json!({"mode": "color", "palette": "limited", "colors": 2, "method": method});
let mut p = json!({ "params": params });
if by_color {
p["byColor"] = json!(true);
}
let r = s.execute(cmd, &p).unwrap();
(s, img, r)
};
let (mut s, _, r) = made("abutting", "imageTrace.make", false);
assert_eq!(r["paths"], 2, "{r}");
let g = NodeId(r["id"].as_u64().unwrap());
let n = node(&s, g);
let kids = n.children().unwrap();
assert_eq!(kids.len(), 4, "the image, the seam fills and two paths");
assert!(matches!(kids[0].kind, NodeKind::Image(_)));
let group = seams(&n).unwrap();
assert!(Arc::ptr_eq(&group, &kids[1]), "the seam fills come under the paths");
let strokes = group.children().unwrap();
assert_eq!(strokes.len(), 1);
assert_eq!(strokes[0].appearance.fill_paint(), Paint::None);
let AppearanceItem::Stroke(layer) = &strokes[0].appearance.items[0] else { panic!("{:?}", strokes[0].appearance) };
assert_eq!((layer.cap, layer.join, layer.width), (LineCap::Butt, LineJoin::Round, 3.0), "1.5 image pixels, the image scaled 2×");
assert_eq!(layer.paint, kids[2].appearance.fill_paint(), "the lower path's color");
// The stroke runs along the edge, from the image's top to its bottom.
let bb = outline(&strokes[0]).0.bounds().unwrap();
assert!((bb.center().x - 32.8).abs() < 1.0 && bb.y0.abs() < 1e-9 && (bb.y1 - 40.0).abs() < 1e-9, "{bb:?}");
// Expand by color keeps the seam fills under the color groups, renamed too; Release gives the
// image back.
s.execute("layer.setProps", &json!({"id": group.id.0, "name": "Edges"})).unwrap();
s.execute("imageTrace.expand", &json!({"id": g.0, "byColor": true})).unwrap();
let n = node(&s, g);
let names: Vec<_> = n.children().unwrap().iter().map(|c| c.name.clone().unwrap_or_default()).collect();
assert_eq!(names.len(), 3, "{names:?}");
assert_eq!(names[0], "Edges", "{names:?}");
assert_eq!(n.children().unwrap()[0].id, group.id);
let (mut s, img, _) = made("abutting", "imageTrace.make", false);
assert_eq!(ids(&s.execute("imageTrace.release", &json!({})).unwrap()), vec![img]);
// Make and Expand by color too.
let (s, _, r) = made("abutting", "imageTrace.makeAndExpand", true);
let n = node(&s, NodeId(r["id"].as_u64().unwrap()));
assert_eq!(n.children().unwrap()[0].name.as_deref(), Some("Seam Fills"));
// Overlapping paths are stacked: no seam fills.
let (s, _, r) = made("overlapping", "imageTrace.make", false);
assert!(seams(&node(&s, NodeId(r["id"].as_u64().unwrap()))).is_none());
// An image stretched 3× across and squeezed to half height: 1.5 pixels at the larger scale.
let mut s = session();
add_image(&mut s, &art, Affine::scale_non_uniform(3.0, 0.5));
let r = s.execute("imageTrace.make", &json!({"params": {"mode": "color", "palette": "limited", "colors": 2, "method": "abutting"}})).unwrap();
let group = seams(&node(&s, NodeId(r["id"].as_u64().unwrap()))).unwrap();
let AppearanceItem::Stroke(layer) = &group.children().unwrap()[0].appearance.items[0] else { panic!("{group:?}") };
assert!((layer.width - 4.5).abs() < 1e-9, "{}", layer.width);
}
/// Expand by color: each color's paths in a group of their own, named by the color, the groups
/// stacked by the lowest path in each and the art drawn as before; Release to Layers then gives a
/// sublayer for each color.
@@ -5995,7 +6213,11 @@ fn image_trace_expand_by_color_groups_each_colors_paths() {
};
let (flat, fg) = expand(false);
let (mut s, g) = expand(true);
let paths = node(&flat, fg).children().unwrap().clone();
let kids = node(&flat, fg).children().unwrap().clone();
// Abutting: the seam fills' group comes first, under every path, with byColor too.
let seams = usize::from(method == "abutting");
assert!(kids.iter().take(seams).all(|c| c.name.as_deref() == Some("Seam Fills")), "{method}");
let paths = kids[seams..].to_vec();
// Without byColor (the default) the paths sit in the trace's group, with no group of their own.
assert!(paths.iter().all(|p| !matches!(p.kind, NodeKind::Group { .. })), "{method}");
let (default, dg) = {
@@ -6004,7 +6226,7 @@ fn image_trace_expand_by_color_groups_each_colors_paths() {
let r = s.execute("imageTrace.makeAndExpand", &json!({"params": params})).unwrap();
(s, NodeId(r["id"].as_u64().unwrap()))
};
assert!(*node(&default, dg).children().unwrap() == paths, "{method}: byColor is false when not given");
assert!(*node(&default, dg).children().unwrap() == kids, "{method}: byColor is false when not given");
// The colors in the order they first come in, bottom to top.
let mut order: Vec<String> = vec![];
for p in &paths {
@@ -6019,10 +6241,11 @@ fn image_trace_expand_by_color_groups_each_colors_paths() {
// Overlapping fills the ring whole and draws the field inside it above the ring: that path starts a second
// group of the field's color, above the black one.
let want: Vec<String> = if method == "overlapping" { order.iter().chain(order.first()).cloned().collect() } else { order.clone() };
assert_eq!(names(&n), want, "{method}");
assert_eq!(names(&n)[seams..], want, "{method}");
assert!(n.children().unwrap().iter().take(seams).all(|c| *c == kids[0]), "{method}: the seam fills stay as they are");
// Each group holds its color's paths, and in turn they are the paths of the trace without
// byColor, in their order.
let groups = n.children().unwrap();
let groups = &n.children().unwrap()[seams..];
for c in groups {
assert!(matches!(c.kind, NodeKind::Group { .. }), "{method}");
assert!(c.children().unwrap().iter().all(|m| Some(hex(m)) == c.name), "{method}: {:?}", c.name);
@@ -6033,8 +6256,8 @@ fn image_trace_expand_by_color_groups_each_colors_paths() {
// Release to Layers: a sublayer for each color, in the same order.
let r = s.execute("layer.releaseToLayers", &json!({"id": g.0})).unwrap();
let subs = ids(&r);
assert_eq!(subs.len(), want.len(), "{method}");
for (sub, name) in subs.iter().zip(&want) {
assert_eq!(subs.len(), seams + want.len(), "{method}");
for (sub, name) in subs[seams..].iter().zip(&want) {
let l = node(&s, *sub);
assert!(matches!(l.kind, NodeKind::Layer { .. }), "{method}");
assert_eq!(names(&l), std::slice::from_ref(name), "{method}");
@@ -6051,7 +6274,7 @@ fn image_trace_expand_by_color_groups_each_colors_paths() {
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
let mut got = names(&node(&s, g));
got.sort();
assert_eq!(got, ["#000000", "#c82832", "#f0f0dc"]);
assert_eq!(got, ["#000000", "#c82832", "#f0f0dc", "Seam Fills"]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(node(&s, g).name.as_deref(), Some("Image Trace"));
@@ -6086,7 +6309,7 @@ fn image_trace_expand_by_color_groups_each_colors_paths() {
});
add_image(&mut s, &stripes, Affine::IDENTITY);
let g = NodeId(s.execute("imageTrace.make", &json!({"params": grouped})).unwrap()["id"].as_u64().unwrap());
assert!(node(&s, g).children().unwrap().iter().all(|c| !matches!(c.kind, NodeKind::Group { .. })));
assert!(traced_paths(&node(&s, g)).iter().all(|c| !matches!(c.kind, NodeKind::Group { .. })));
let e = s.execute("imageTrace.expand", &json!({"byColor": true})).unwrap_err().to_string();
assert!(e.contains("Auto Grouping"), "{e}");
assert_eq!(node(&s, g).name.as_deref(), Some("Image Trace"));
@@ -6103,8 +6326,7 @@ fn image_trace_expand_by_color_groups_each_colors_paths() {
add_image(&mut s, &r, Affine::IDENTITY);
let limited = json!({"mode": "color", "palette": "limited", "colors": 3});
let g = NodeId(s.execute("imageTrace.make", &json!({"params": limited})).unwrap()["id"].as_u64().unwrap());
let paths: Vec<(u64, String)> =
node(&s, g).children().unwrap().iter().filter(|c| !matches!(c.kind, NodeKind::Image(_))).map(|c| (c.id.0, hex(c))).collect();
let paths: Vec<(u64, String)> = traced_paths(&node(&s, g)).iter().map(|c| (c.id.0, hex(c))).collect();
let mut colors: Vec<&str> = paths.iter().map(|(_, h)| h.as_str()).collect();
colors.sort();
colors.dedup();
@@ -6116,14 +6338,14 @@ fn image_trace_expand_by_color_groups_each_colors_paths() {
s.execute("imageTrace.expand", &json!({"id": g.0, "byColor": true})).unwrap();
let mut got = names(&node(&s, g));
got.sort();
assert_eq!(got, ["#28a03c", "Ink", "Sky 50%"]);
assert_eq!(got, ["#28a03c", "Ink", "Seam Fills", "Sky 50%"]);
// A swatch named as another color's #rrggbb still gets a group of its own.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("swatch.edit", &json!({"name": "Ink", "newName": "#28a03c"})).unwrap();
s.execute("imageTrace.expand", &json!({"id": g.0, "byColor": true})).unwrap();
let mut got = names(&node(&s, g));
got.sort();
assert_eq!(got, ["#28a03c", "#28a03c", "Sky 50%"]);
assert_eq!(got, ["#28a03c", "#28a03c", "Seam Fills", "Sky 50%"]);
// Gradient fills share one group, and a stroked line goes by its stroke.
let ramp = vectorcraft_trace::Raster::from_fn(100, 60, |x, _| {
@@ -6192,7 +6414,7 @@ fn image_trace_expand_by_color_groups_each_colors_paths() {
add_image(&mut s, &stripes, Affine::IDENTITY);
let limited = json!({"mode": "color", "palette": "limited", "colors": 3, "method": "abutting"});
let g = NodeId(s.execute("imageTrace.make", &json!({"params": limited})).unwrap()["id"].as_u64().unwrap());
let paths: Vec<Arc<Node>> = node(&s, g).children().unwrap().iter().filter(|c| !matches!(c.kind, NodeKind::Image(_))).cloned().collect();
let paths = traced_paths(&node(&s, g));
assert_eq!(paths.len(), 3);
let (first, middle, last) = (&paths[0], &paths[1], &paths[2]);
s.execute("paint.setFill", &json!({"ids": [last.id.0], "color": hex(first)})).unwrap();
@@ -6220,7 +6442,7 @@ fn image_trace_expand_by_color_groups_each_colors_paths() {
for how in ["move", "transform", "shadow", "beside"] {
let (flat, _) = edited(false, how);
let (s, g) = edited(true, how);
assert_eq!(names(&node(&s, g)).len(), 3, "{how}");
assert_eq!(names(&node(&s, g)).len(), 4, "{how}: the seam fills and three colors");
assert!(shot(&s) == shot(&flat), "{how}: the edited art changed");
}
@@ -6230,14 +6452,14 @@ fn image_trace_expand_by_color_groups_each_colors_paths() {
add_image(&mut s, &stripes, Affine::IDENTITY);
let limited = json!({"mode": "color", "palette": "limited", "colors": 3});
let g = NodeId(s.execute("imageTrace.make", &json!({"params": limited})).unwrap()["id"].as_u64().unwrap());
let paths: Vec<u64> = node(&s, g).children().unwrap().iter().filter(|c| !matches!(c.kind, NodeKind::Image(_))).map(|c| c.id.0).collect();
let paths: Vec<u64> = traced_paths(&node(&s, g)).iter().map(|c| c.id.0).collect();
assert_eq!(paths.len(), 3);
s.execute("paint.setFill", &json!({"ids": [paths[0]], "swatch": "Sky", "tint": 50.1})).unwrap();
s.execute("paint.setFill", &json!({"ids": [paths[1], paths[2]], "swatch": "Sky", "tint": 50.4})).unwrap();
s.execute("imageTrace.expand", &json!({"id": g.0, "byColor": true})).unwrap();
let groups = node(&s, g).children().unwrap().clone();
assert_eq!(names(&node(&s, g)), ["Sky 50%", "Sky 50%"]);
assert_eq!(groups.iter().map(|c| c.children().unwrap().len()).collect::<Vec<_>>(), [1, 2]);
assert_eq!(names(&node(&s, g)), ["Seam Fills", "Sky 50%", "Sky 50%"]);
assert_eq!(groups.iter().skip(1).map(|c| c.children().unwrap().len()).collect::<Vec<_>>(), [1, 2]);
}
#[test]
@@ -6289,7 +6511,7 @@ fn image_trace_make_and_expand_and_color_presets() {
assert_eq!(res["paths"], 4);
let g = node(&s, NodeId(res["id"].as_u64().unwrap()));
assert!(g.name.is_none());
assert_eq!(g.children().unwrap().len(), 4);
assert_eq!(traced_paths(&g).len(), 4);
assert!(s.execute("imageTrace.make", &json!({"preset": "No Such Preset"})).is_err());
assert!(s.execute("imageTrace.make", &json!({"params": {"mode": "rainbow"}})).is_err());
}
+720 -1
View File
@@ -531,7 +531,726 @@ fn a_pasted_graph_brings_and_follows_its_label_styles_and_paints() {
new_doc(&mut s);
let v = s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap();
assert!(doc(&s).char_styles.is_empty() && doc(&s).para_styles.is_empty(), "{v}");
assert_eq!(graph_links(&s, ids_of(&v)[0]), (brand, None, None));
// The value labels' red characters stay behind too; the series fill and the legend rows keep Brand.
assert_eq!(graph_links(&s, ids_of(&v)[0]), (vec!["Brand".to_string(); 2], None, None));
}
/// Each label of graph `id` (the text of its Axes and Legend groups) with its size and its first paragraph's format.
fn graph_label_looks(s: &Session, id: NodeId) -> Vec<(String, f64, vectorcraft_doc::ParaStyle)> {
let mut out = vec![];
node(s, id).walk(&mut |n| {
if let NodeKind::Text(t) = &n.kind {
out.push((t.plain_text(), t.first_style().size, t.para_at(0).clone()));
}
});
out
}
/// Paste without Formatting sets a pasted graph's labels in a new graph's label size and paragraph attributes, and the
/// graph keeps them when it is drawn again: the character and paragraph formats its labels keep stay behind with their
/// text formatting.
#[test]
fn a_graph_pasted_without_formatting_keeps_plain_labels_when_drawn_again() {
let mut s = session();
let csv = ",2024,2025\nQ1,3,2\nQ2,5,4";
let g = id_of(&s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 120, "height": 80, "csv": csv})).unwrap());
let fresh = graph_label_looks(&s, g);
sel(&mut s, &[g]);
s.execute("text.setStyle", &json!({"size": 14})).unwrap();
s.execute("text.setFormat", &json!({"spaceAfter": 6})).unwrap();
assert!(graph_label_looks(&s, g).iter().all(|(_, size, para)| *size == 14.0 && para.space_after == 6.0));
// The labels as drawn are also set in a character style.
s.execute("charStyle.new", &json!({"name": "Emphasis", "attrs": {"size": 14.0}})).unwrap();
let mut labels = vec![];
node(&s, g).walk(&mut |n| {
if matches!(n.kind, NodeKind::Text(_)) {
labels.push(n.id);
}
});
for id in labels {
if let Some(NodeKind::Text(t)) = doc_mut(&mut s).node_mut(id).map(|n| &mut n.kind) {
t.runs.iter_mut().for_each(|r| r.style.style_name = Some("Emphasis".into()));
}
}
copy(&mut s, &[g]);
assert_eq!(s.clipboard.char_styles.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(), ["Emphasis"]);
// Where a redraw of the copied graph puts its plot.
s.execute("graph.setData", &json!({"id": g.0, "csv": csv})).unwrap();
let plot = node(&s, g).extras().graph.as_deref().unwrap().rect;
new_doc(&mut s);
let steps = s.doc().unwrap().history.undo.len();
let v = s.execute("edit.pasteWithoutFormatting", &json!({"dx": 10, "dy": 20})).unwrap();
let p = ids_of(&v)[0];
assert!(doc(&s).char_styles.is_empty(), "the labels' character style stays behind: {v}");
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "one undo step");
assert_eq!(s.doc().unwrap().history.undo.last().map(|e| e.label.as_str()), Some("Paste without Formatting"));
let l = node(&s, p).extras().graph.as_deref().unwrap().label_styles.clone();
assert_eq!((l.values, l.categories, l.legend), (None, None, None));
assert_eq!((l.values_para, l.categories_para, l.legend_para), (None, None, None));
let pasted = graph_label_looks(&s, p);
assert_eq!(pasted, fresh, "the labels are set as a new graph's are");
s.execute("graph.setData", &json!({"id": p.0, "csv": csv})).unwrap();
assert_eq!(graph_label_looks(&s, p), pasted, "and stay so when the graph is drawn again");
let r = node(&s, p).extras().graph.as_deref().unwrap().rect;
let off = [r.x0 - plot.x0 - 10.0, r.y0 - plot.y0 - 20.0, r.x1 - plot.x1 - 10.0, r.y1 - plot.y1 - 20.0];
assert!(off.iter().all(|d| d.abs() < 1e-6), "the plot moves by the offset and keeps its size: {plot:?} → {r:?}");
s.execute("graph.setType", &json!({"id": p.0, "type": "column"})).unwrap();
assert_eq!(graph_label_looks(&s, p), pasted, "a change of type draws them the same way");
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert!(doc(&s).node(p).is_none(), "one undo takes the paste back");
}
/// Paste without Formatting leaves the rest of a pasted graph's art as it was copied: a column painted by hand keeps its
/// paint, and nothing is drawn again.
#[test]
fn a_graph_pasted_without_formatting_keeps_its_art() {
let mut s = session();
let csv = ",2024,2025\nQ1,3,2\nQ2,5,4";
let g = id_of(&s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 120, "height": 80, "csv": csv})).unwrap());
let red = Paint::solid(hex("ff0000"));
let mut column = None;
node(&s, g).walk(&mut |n| {
if column.is_none() && matches!(n.kind, NodeKind::Path { .. }) && n.appearance.fill_paint() != Paint::None {
column = Some(n.id);
}
});
let column = column.unwrap();
let n = doc_mut(&mut s).node_mut(column).unwrap();
n.appearance = vectorcraft_doc::Appearance::basic(red.clone(), Paint::None, 0.0).into();
/// Every object of graph `id` that is not type: its kind's name, its fill and its bounds.
fn art(s: &Session, id: NodeId) -> Vec<(String, Paint, Option<Rect>)> {
let mut out = vec![];
node(s, id).walk(&mut |n| {
if !matches!(n.kind, NodeKind::Text(_)) {
out.push((format!("{:?}", std::mem::discriminant(&n.kind)), n.appearance.fill_paint(), n.geometric_bounds()));
}
});
out
}
let copied = art(&s, g);
copy(&mut s, &[g]);
new_doc(&mut s);
let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({"dx": 15, "dy": 25})).unwrap())[0];
let pasted = art(&s, p);
let dx = pasted[0].2.unwrap().x0 - copied[0].2.unwrap().x0;
let dy = pasted[0].2.unwrap().y0 - copied[0].2.unwrap().y0;
let moved: Vec<_> = copied.into_iter().map(|(k, f, b)| (k, f, b.map(|b| b + vectorcraft_geom::Vec2::new(dx, dy)))).collect();
assert_eq!(pasted.len(), moved.len());
for (a, b) in pasted.iter().zip(&moved) {
assert_eq!((&a.0, &a.1), (&b.0, &b.1));
let (a, b) = (a.2.unwrap(), b.2.unwrap());
assert!((a.x0 - b.x0).abs() + (a.y0 - b.y0).abs() + (a.x1 - b.x1).abs() + (a.y1 - b.y1).abs() < 1e-6, "{a:?} {b:?}");
}
assert!(pasted.iter().any(|(_, f, _)| *f == red), "the column keeps the paint it was given");
}
/// The labels of a scaled graph pasted without formatting are set at a new graph's label size, unscaled, as a redraw
/// sets them.
#[test]
fn a_scaled_graph_pasted_without_formatting_gets_unscaled_labels() {
let mut s = session();
let csv = ",2024\nQ1,3\nQ2,5";
let g = id_of(&s.execute("graph.create", &json!({"type": "column", "x": 50, "y": 50, "width": 120, "height": 80, "csv": csv})).unwrap());
let fresh = graph_label_looks(&s, g)[0].1;
sel(&mut s, &[g]);
s.execute("object.scale", &json!({"sx": 200, "sy": 200})).unwrap();
/// Each label's size as drawn: its character size times the scale of its transform.
fn drawn_sizes(s: &Session, id: NodeId) -> Vec<f64> {
let mut out = vec![];
node(s, id).walk(&mut |n| {
if let NodeKind::Text(t) = &n.kind {
out.push(t.first_style().size * t.xf.determinant().abs().sqrt());
}
});
out
}
assert!(drawn_sizes(&s, g).iter().all(|z| (z - fresh * 2.0).abs() < 1e-6), "{:?}", drawn_sizes(&s, g));
copy(&mut s, &[g]);
new_doc(&mut s);
let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0];
assert!(drawn_sizes(&s, p).iter().all(|z| (z - fresh).abs() < 1e-6), "{:?}", drawn_sizes(&s, p));
s.execute("graph.setData", &json!({"id": p.0, "csv": csv})).unwrap();
assert!(drawn_sizes(&s, p).iter().all(|z| (z - fresh).abs() < 1e-6), "{:?}", drawn_sizes(&s, p));
// A rotated graph's labels keep their rotation, so they stay turned with the rest of its art.
/// Each label's rotation in degrees: the angle of its transform's x axis.
fn angles(s: &Session, id: NodeId) -> Vec<f64> {
let mut out = vec![];
node(s, id).walk(&mut |n| {
if let NodeKind::Text(t) = &n.kind {
let [a, b, ..] = t.xf.as_coeffs();
out.push(b.atan2(a).to_degrees());
}
});
out
}
let r = id_of(&s.execute("graph.create", &json!({"type": "column", "x": 300, "y": 50, "width": 120, "height": 80, "csv": csv})).unwrap());
sel(&mut s, &[r]);
s.execute("object.scale", &json!({"sx": 200, "sy": 200})).unwrap();
s.execute("object.rotate", &json!({"angle": 30})).unwrap();
let turned = angles(&s, r);
assert!(turned.iter().all(|a| (a.abs() - 30.0).abs() < 1e-6), "{turned:?}");
copy(&mut s, &[r]);
new_doc(&mut s);
let q = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0];
assert_eq!(angles(&s, q).len(), turned.len());
assert!(angles(&s, q).iter().zip(&turned).all(|(a, b)| (a - b).abs() < 1e-6), "{:?} against {turned:?}", angles(&s, q));
assert!(drawn_sizes(&s, q).iter().all(|z| (z - fresh).abs() < 1e-6), "{:?}", drawn_sizes(&s, q));
}
/// A pie's legend labels in their wedges keep a redraw's contrast when pasted without formatting: white on a dark
/// wedge, black on a light one.
#[test]
fn pie_labels_in_wedges_pasted_without_formatting_stay_readable() {
let mut s = session();
let csv = ",North,South\nP,1,1";
let g = id_of(&s.execute("graph.create", &json!({"type": "pie", "x": 50, "y": 50, "width": 200, "height": 200, "csv": csv})).unwrap());
s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges"})).unwrap();
/// Each text of graph `id` with its characters' fill.
fn fills(s: &Session, id: NodeId) -> Vec<(String, Paint)> {
let mut out = vec![];
node(s, id).walk(&mut |n| {
if let NodeKind::Text(t) = &n.kind {
out.push((t.plain_text(), t.first_style().fill.clone()));
}
});
out
}
let drawn = fills(&s, g);
assert!(drawn.contains(&("North".into(), Paint::solid(Color::WHITE))), "the first wedge is black: {drawn:?}");
sel(&mut s, &[g]);
s.execute("text.setStyle", &json!({"size": 14})).unwrap();
copy(&mut s, &[g]);
new_doc(&mut s);
let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0];
assert_eq!(fills(&s, p), drawn);
s.execute("graph.setData", &json!({"id": p.0, "csv": csv})).unwrap();
assert_eq!(fills(&s, p), drawn);
}
/// A pie whose legend labels were given a black fill of their own loses that mark when pasted without formatting, so a
/// later change to a legend label's size does not bring the black fill back on the dark wedge.
#[test]
fn a_legend_fill_of_its_own_stays_behind_when_pasted_without_formatting() {
let mut s = session();
let csv = ",North,South\nP,1,1";
let g = id_of(&s.execute("graph.create", &json!({"type": "pie", "x": 50, "y": 50, "width": 200, "height": 200, "csv": csv})).unwrap());
s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges"})).unwrap();
/// The legend label of graph `id` that says `name`.
fn label(s: &Session, id: NodeId, name: &str) -> (NodeId, Paint) {
let mut out = None;
node(s, id).walk(&mut |n| {
if let NodeKind::Text(t) = &n.kind
&& t.plain_text() == name
{
out = Some((n.id, t.first_style().fill.clone()));
}
});
out.unwrap()
}
let (north, fill) = label(&s, g, "North");
assert_eq!(fill, Paint::solid(Color::WHITE), "white on the black wedge");
s.execute("text.setStyle", &json!({"ids": [north.0], "fill": "#000000"})).unwrap();
assert!(node(&s, g).extras().graph.as_deref().unwrap().label_styles.legend_fill_set);
copy(&mut s, &[g]);
new_doc(&mut s);
let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0];
assert!(!node(&s, p).extras().graph.as_deref().unwrap().label_styles.legend_fill_set);
assert_eq!(label(&s, p, "North").1, Paint::solid(Color::WHITE), "pasted as a redraw sets it");
let (north, _) = label(&s, p, "North");
s.execute("text.setStyle", &json!({"ids": [north.0], "size": 11})).unwrap();
s.execute("graph.setData", &json!({"id": p.0, "csv": csv})).unwrap();
assert_eq!(label(&s, p, "North").1, Paint::solid(Color::WHITE), "still white when the pie is drawn again");
}
/// A two-line legend label in a narrow dark wedge, whose text sits off the wedge's middle line, stays white when its graph
/// is pasted without formatting.
#[test]
fn a_legend_label_in_a_narrow_wedge_pasted_without_formatting_stays_readable() {
let mut s = session();
// The first wedge fills a quarter from the top; the dark fourth series ("Narrow", series 3) follows it for 0.2 degrees,
// narrower than the label's distance from the wedge's middle line.
let csv = ",A,B,C,Narrow|,E\nP,9,0,0,0.02,26.98";
let g = id_of(&s.execute("graph.create", &json!({"type": "pie", "x": 50, "y": 50, "width": 200, "height": 200, "csv": csv})).unwrap());
s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges"})).unwrap();
fn narrow(s: &Session, id: NodeId) -> Paint {
let mut out = None;
node(s, id).walk(&mut |n| {
if let NodeKind::Text(t) = &n.kind
&& t.plain_text().starts_with("Narrow")
{
out = Some(t.first_style().fill.clone());
}
});
out.unwrap()
}
assert_eq!(narrow(&s, g), Paint::solid(Color::WHITE));
copy(&mut s, &[g]);
new_doc(&mut s);
let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0];
assert_eq!(narrow(&s, p), Paint::solid(Color::WHITE));
s.execute("graph.setData", &json!({"id": p.0, "csv": csv})).unwrap();
assert_eq!(narrow(&s, p), Paint::solid(Color::WHITE));
// A standard legend beside the pie stays black.
let g = id_of(&s.execute("graph.create", &json!({"type": "pie", "x": 50, "y": 50, "width": 200, "height": 200, "csv": csv})).unwrap());
s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "standard"})).unwrap();
assert_eq!(narrow(&s, g), Paint::solid(Color::BLACK));
copy(&mut s, &[g]);
new_doc(&mut s);
let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0];
assert_eq!(narrow(&s, p), Paint::solid(Color::BLACK));
// Two series with one name: each label keeps its own wedge's contrast, though the second label lies nearer the
// middle of the first (dark) wedge's bounds than of its own.
fn xs(s: &Session, id: NodeId) -> Vec<Paint> {
let mut out = vec![];
node(s, id).walk(&mut |n| {
if let NodeKind::Text(t) = &n.kind
&& t.plain_text() == "X"
{
out.push(t.first_style().fill.clone());
}
});
out
}
let csv = ",X,X,Y\nP,8,1,89";
let g = id_of(&s.execute("graph.create", &json!({"type": "pie", "x": 0, "y": 0, "width": 2000, "height": 2000, "csv": csv})).unwrap());
s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges"})).unwrap();
let drawn = xs(&s, g);
assert_eq!(drawn, vec![Paint::solid(Color::WHITE), Paint::solid(Color::BLACK)]);
copy(&mut s, &[g]);
new_doc(&mut s);
let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0];
assert_eq!(xs(&s, p), drawn);
}
/// Two-line legend labels of a light and a dark series with one name keep a redraw's colors when pasted without
/// formatting, though the narrow light wedge's label reaches into the dark wedge after it.
#[test]
fn shared_name_labels_in_a_narrow_wedge_pasted_without_formatting_keep_their_colors() {
let mut s = session();
let csv = ",A,B,Q|,Q|\nP,9,0,0.02,26.98";
let g = id_of(&s.execute("graph.create", &json!({"type": "pie", "x": 50, "y": 50, "width": 200, "height": 200, "csv": csv})).unwrap());
s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges"})).unwrap();
fn qs(s: &Session, id: NodeId) -> Vec<Paint> {
let mut out = vec![];
node(s, id).walk(&mut |n| {
if let NodeKind::Text(t) = &n.kind
&& t.plain_text().starts_with('Q')
{
out.push(t.first_style().fill.clone());
}
});
out
}
let drawn = qs(&s, g);
assert_eq!(drawn.len(), 2);
assert_ne!(drawn[0], drawn[1], "one light and one dark series");
copy(&mut s, &[g]);
new_doc(&mut s);
let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0];
assert_eq!(qs(&s, p), drawn);
// Names that hold a line break of their own (a redraw sets them on lines centered on the point, as it sets `|`) keep
// them too.
for name in ["Q\nQ", "Q\nQ\nQ\nQ\nQ\nQ\nQ\nQ\nQ"] {
new_doc(&mut s);
let named = json!({"type": "pie", "x": 50, "y": 50, "width": 200, "height": 200, "series": ["A", "B", name, name], "categories": ["P"], "rows": [[9, 0, 0.02, 26.98]]});
let g = id_of(&s.execute("graph.create", &named).unwrap());
s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges"})).unwrap();
let broken = qs(&s, g);
assert_eq!(broken.len(), 2);
assert_ne!(broken[0], broken[1], "names with line breaks");
copy(&mut s, &[g]);
new_doc(&mut s);
let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0];
assert_eq!(qs(&s, p), broken, "names with {} line breaks", name.matches('\n').count());
}
// So do labels whose names show the same text, one with a line break of its own and one with `|`.
new_doc(&mut s);
let named = json!({"type": "pie", "x": 50, "y": 50, "width": 200, "height": 200, "series": ["Q\n", "B", "Q|", "Q|"], "categories": ["P"], "rows": [[9, 0, 0.02, 26.98]]});
let g = id_of(&s.execute("graph.create", &named).unwrap());
s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges"})).unwrap();
// The fills of the Q labels by their place from left to right and top to bottom.
fn placed(s: &Session, id: NodeId) -> Vec<Paint> {
let mut out = vec![];
node(s, id).walk(&mut |n| {
if let NodeKind::Text(t) = &n.kind
&& t.plain_text().starts_with('Q')
{
let o = t.xf.translation();
out.push((o.x, o.y, t.first_style().fill.clone()));
}
});
out.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.total_cmp(&b.1)));
out.into_iter().map(|(_, _, f)| f).collect()
}
let alike = placed(&s, g);
assert_eq!(alike.len(), 3);
copy(&mut s, &[g]);
new_doc(&mut s);
let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0];
assert_eq!(placed(&s, p), alike, "names that show the same text");
// The narrow light wedge's label (the second) brought to the front keeps them too.
new_doc(&mut s);
let g = id_of(&s.execute("graph.create", &named).unwrap());
s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges"})).unwrap();
let legend = node(&s, g).children().unwrap().iter().find(|c| c.name.as_deref() == Some("Legend")).unwrap().clone();
let labels = legend.children().unwrap();
assert_eq!(labels.len(), 3);
s.execute("object.arrange.bringToFront", &json!({"ids": [labels[1].id.0]})).unwrap();
copy(&mut s, &[g]);
new_doc(&mut s);
let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0];
assert_eq!(placed(&s, p), alike, "names that show the same text, labels reordered");
// Labels given another size, which leaves them where they were drawn, keep them too.
new_doc(&mut s);
let g = id_of(&s.execute("graph.create", &json!({"type": "pie", "x": 50, "y": 50, "width": 200, "height": 200, "csv": csv})).unwrap());
s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges"})).unwrap();
sel(&mut s, &[g]);
s.execute("text.setStyle", &json!({"size": 14})).unwrap();
copy(&mut s, &[g]);
new_doc(&mut s);
let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0];
assert_eq!(qs(&s, p), drawn, "resized labels");
// Large labels of four series of one name, drawn again at that size, keep the colors that drawing gave them.
new_doc(&mut s);
let four = ",Q|,Q|,Q|,Q|\nP,1,1,1,1";
let g = id_of(&s.execute("graph.create", &json!({"type": "pie", "x": 50, "y": 50, "width": 200, "height": 200, "csv": four})).unwrap());
s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges"})).unwrap();
sel(&mut s, &[g]);
s.execute("text.setStyle", &json!({"size": 200})).unwrap();
s.execute("graph.setData", &json!({"id": g.0, "csv": four})).unwrap();
let large = placed(&s, g);
assert_eq!(large.len(), 4);
assert!(large.contains(&Paint::solid(Color::WHITE)) && large.contains(&Paint::solid(Color::BLACK)), "{large:?}");
copy(&mut s, &[g]);
new_doc(&mut s);
let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0];
assert_eq!(placed(&s, p), large, "large labels drawn again");
// A scaled and rotated pie keeps them too.
new_doc(&mut s);
let g = id_of(&s.execute("graph.create", &json!({"type": "pie", "x": 50, "y": 50, "width": 200, "height": 200, "csv": csv})).unwrap());
s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges"})).unwrap();
sel(&mut s, &[g]);
s.execute("object.scale", &json!({"sx": 400, "sy": 400})).unwrap();
s.execute("object.rotate", &json!({"angle": 30})).unwrap();
let drawn = qs(&s, g);
assert_ne!(drawn[0], drawn[1]);
copy(&mut s, &[g]);
new_doc(&mut s);
let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0];
assert_eq!(qs(&s, p), drawn);
}
/// The legend labels of a pie that kept a fill of their own (red, or black on every wedge), scaled, with two series of
/// one name, are pasted without formatting in the colors a redraw gives them.
#[test]
fn recolored_wedge_labels_pasted_without_formatting_match_a_redraw() {
for fill in ["#ff0000", "#000000"] {
recolored_wedge_labels_match_a_redraw(fill);
}
}
fn recolored_wedge_labels_match_a_redraw(fill: &str) {
let mut s = session();
let csv = ",A,B,Q|,Q|\nP,9,0,0.02,26.98";
let g = id_of(&s.execute("graph.create", &json!({"type": "pie", "x": 50, "y": 50, "width": 200, "height": 200, "csv": csv})).unwrap());
s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges"})).unwrap();
sel(&mut s, &[g]);
s.execute("text.setStyle", &json!({"fill": fill})).unwrap();
s.execute("object.scale", &json!({"sx": 400, "sy": 400})).unwrap();
fn legend(s: &Session, id: NodeId) -> Vec<(String, Paint)> {
let mut out = vec![];
node(s, id).walk(&mut |n| {
if let NodeKind::Text(t) = &n.kind
&& ["A", "Q"].iter().any(|p| t.plain_text().starts_with(p))
{
out.push((t.plain_text(), t.first_style().fill.clone()));
}
});
out
}
assert!(legend(&s, g).iter().all(|(_, f)| *f == Paint::solid(hex(fill))), "{fill}: {:?}", legend(&s, g));
copy(&mut s, &[g]);
new_doc(&mut s);
let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0];
let pasted = legend(&s, p);
s.execute("graph.setData", &json!({"id": p.0, "csv": csv})).unwrap();
let redrawn = legend(&s, p);
assert_eq!(pasted, redrawn, "{fill}");
assert!(
redrawn.iter().any(|(_, f)| *f == Paint::solid(Color::WHITE)) && redrawn.iter().any(|(_, f)| *f == Paint::solid(Color::BLACK)),
"{fill}: {redrawn:?}"
);
}
/// A wedge whose fill was changed after its label was drawn gives the label, pasted without formatting, the color a
/// redraw gives it.
#[test]
fn a_repainted_wedge_sets_its_label_color_when_pasted_without_formatting() {
let mut s = session();
let csv = ",North,South\nP,1,1";
let g = id_of(&s.execute("graph.create", &json!({"type": "pie", "x": 50, "y": 50, "width": 200, "height": 200, "csv": csv})).unwrap());
s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges"})).unwrap();
s.execute("graph.setData", &json!({"id": g.0, "csv": csv})).unwrap();
fn labels(s: &Session, id: NodeId) -> Vec<(String, Paint)> {
let mut out = vec![];
node(s, id).walk(&mut |n| {
if let NodeKind::Text(t) = &n.kind
&& ["North", "South"].contains(&t.plain_text().as_str())
{
out.push((t.plain_text(), t.first_style().fill.clone()));
}
});
out
}
// Each wedge takes the other's contrast: the dark one (its label white) white, the light one black.
let drawn = labels(&s, g);
let white = |name: &str| drawn.iter().any(|(t, f)| t == name && *f == Paint::solid(Color::WHITE));
let mut wedges = vec![];
for c in node(&s, g).children().unwrap().iter() {
let Some(series) = c.extras().series_index else { continue };
let dark = white(["North", "South"][series as usize]);
c.walk(&mut |m| {
if let NodeKind::Path { .. } = m.kind {
wedges.push((m.id, dark));
}
});
}
assert!(wedges.iter().any(|w| w.1) && wedges.iter().any(|w| !w.1), "{drawn:?}");
for (id, dark) in &wedges {
s.execute("paint.setFill", &json!({"ids": [id.0], "color": if *dark { "#ffffff" } else { "#000000" }})).unwrap();
}
let before = labels(&s, g);
copy(&mut s, &[g]);
new_doc(&mut s);
let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0];
let pasted = labels(&s, p);
s.execute("graph.setData", &json!({"id": p.0, "csv": csv})).unwrap();
assert_eq!(pasted, labels(&s, p), "copied as {before:?}");
}
/// When one pie's wedge of a series is repainted and another pie's wedge of that series is not, each pasted legend label
/// takes the contrast of the wedge it is in.
#[test]
fn wedges_of_one_series_painted_apart_keep_their_label_colors_when_pasted_without_formatting() {
let mut s = session();
fn north(s: &Session, id: NodeId) -> Vec<Paint> {
let mut out = vec![];
node(s, id).walk(&mut |n| {
if let NodeKind::Text(t) = &n.kind
&& t.plain_text() == "North"
{
out.push(t.first_style().fill.clone());
}
});
out
}
// Two pies side by side, and two stacked pies of one series (a disk on a larger one, and on one nearly its size).
for (csv, position) in [(",North,South\nP,1,1\nQ,1,1", "even"), (",North\nP,1\nQ,3", "stacked"), (",North\nP,1000\nQ,999", "stacked")] {
// The second time, the repainted wedge is brought to the front of its series first.
let mut pasted = vec![];
for reorder in [false, true] {
pasted.push(paste_painted_apart(&mut s, csv, position, reorder, north));
}
assert_eq!(pasted[0], pasted[1], "{csv}");
}
// A series name of nine lines, from line breaks a quoted CSV cell keeps, on two stacked pies: each label is centered
// on its own ring, as a redraw centers it.
fn tall(s: &Session, id: NodeId) -> Vec<Paint> {
let mut out = vec![];
node(s, id).walk(&mut |n| {
if let NodeKind::Text(t) = &n.kind
&& t.plain_text() == ["N"; 9].join("\n")
{
out.push(t.first_style().fill.clone());
}
});
out
}
let csv = format!(",\"{}\"\nP,1\nQ,3", ["N"; 9].join("\n"));
paste_painted_apart(&mut s, &csv, "stacked", false, tall);
}
/// Paste without Formatting a pie graph made from `csv` with pies at `position` and Legends in Wedges, after its first
/// wedge of the first series takes the other contrast and, with `reorder`, comes to the front of its series. Returns
/// what `north` reads from the pasted graph, after checking the two values differ.
fn paste_painted_apart(s: &mut Session, csv: &str, position: &str, reorder: bool, north: fn(&Session, NodeId) -> Vec<Paint>) -> Vec<Paint> {
new_doc(s);
let g = id_of(&s.execute("graph.create", &json!({"type": "pie", "x": 50, "y": 50, "width": 400, "height": 200, "csv": csv})).unwrap());
s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges", "piePosition": position})).unwrap();
let drawn = north(s, g);
assert_eq!(drawn.len(), 2);
assert_eq!(drawn[0], drawn[1]);
// Repaint the first North wedge with the contrast its label shows, so it takes the other contrast.
let mut first = None;
for c in node(s, g).children().unwrap().iter() {
if c.extras().series_index == Some(0) && first.is_none() {
c.walk(&mut |m| {
if first.is_none() && matches!(m.kind, NodeKind::Path { .. }) {
first = Some(m.id);
}
});
}
}
let first = first.unwrap();
let color = if drawn[0] == Paint::solid(Color::WHITE) { "#ffffff" } else { "#000000" };
s.execute("paint.setFill", &json!({"ids": [first.0], "color": color})).unwrap();
if reorder {
s.execute("object.arrange.bringToFront", &json!({"ids": [first.0]})).unwrap();
}
copy(s, &[g]);
new_doc(s);
let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0];
let labels = north(s, p);
assert_ne!(labels[0], labels[1], "drawn as {drawn:?}, {position}, reorder {reorder}");
labels
}
/// Legend labels of series that share a name, brought into another order, each keep the contrast of the wedge they are
/// in when the pie is pasted without formatting.
#[test]
fn reordered_shared_name_labels_keep_their_colors_when_pasted_without_formatting() {
let mut s = session();
let g = id_of(&s.execute("graph.create", &json!({"type": "pie", "x": 50, "y": 50, "width": 200, "height": 200, "csv": ",X,X\nP,1,1"})).unwrap());
s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges"})).unwrap();
// Each label's fill, by its place from left to right and top to bottom.
fn labels(s: &Session, id: NodeId) -> (Vec<NodeId>, Vec<Paint>) {
let mut out = vec![];
node(s, id).walk(&mut |n| {
if let NodeKind::Text(t) = &n.kind
&& t.plain_text() == "X"
{
let o = t.xf.translation();
out.push((o.x, o.y, n.id, t.first_style().fill.clone()));
}
});
out.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.total_cmp(&b.1)));
out.into_iter().map(|(_, _, id, f)| (id, f)).unzip()
}
let (ids, drawn) = labels(&s, g);
assert_eq!(drawn.len(), 2);
assert_ne!(drawn[0], drawn[1]);
let first = node(&s, g).children().unwrap().iter().find(|c| c.name.as_deref() == Some("Legend")).unwrap().children().unwrap()[0].id;
assert!(ids.contains(&first));
s.execute("object.arrange.bringToFront", &json!({"ids": [first.0]})).unwrap();
copy(&mut s, &[g]);
new_doc(&mut s);
let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0];
assert_eq!(labels(&s, p).1, drawn);
}
/// A wedge's hidden fill does not set its legend label's color: the fill it shows does.
#[test]
fn a_hidden_wedge_fill_does_not_color_its_label() {
let mut s = session();
let csv = ",North,South\nP,1,1";
let g = id_of(&s.execute("graph.create", &json!({"type": "pie", "x": 50, "y": 50, "width": 200, "height": 200, "csv": csv})).unwrap());
s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges"})).unwrap();
let mut n = node(&s, g).clone();
// A hidden black fill above the light South wedge's fill.
for c in n.children_mut().unwrap().iter_mut().filter(|c| c.extras().series_index == Some(1)) {
for w in std::sync::Arc::make_mut(c).children_mut().unwrap().iter_mut() {
let w = std::sync::Arc::make_mut(w);
if let Some(vectorcraft_doc::AppearanceItem::Fill(f)) = w.appearance.items.iter().find(|it| it.is_fill()).cloned() {
let hidden = vectorcraft_doc::FillLayer { paint: Paint::solid(Color::BLACK), visible: false, ..f };
w.appearance.items.push(vectorcraft_doc::AppearanceItem::Fill(hidden));
}
}
}
crate::cmd::graph::plain_labels(&mut n, &|_| {});
let mut south = None;
n.walk(&mut |m| {
if let NodeKind::Text(t) = &m.kind
&& t.plain_text() == "South"
{
south = Some(t.first_style().fill.clone());
}
});
assert_eq!(south, Some(Paint::solid(Color::BLACK)));
}
/// A graph inside a group, and a graph inside a graph, get plain labels too.
#[test]
fn graphs_in_groups_pasted_without_formatting_get_plain_labels() {
let mut s = session();
let csv = ",2024\nQ1,3\nQ2,5";
let g = id_of(&s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 120, "height": 80, "csv": csv})).unwrap());
let fresh = graph_label_looks(&s, g);
sel(&mut s, &[g]);
s.execute("text.setStyle", &json!({"size": 14})).unwrap();
let r = rect(&mut s, 10.0, 10.0, 20.0, 20.0);
sel(&mut s, &[g, r]);
let group = id_of(&s.execute("object.group", &json!({})).unwrap());
// A copy of the graph placed inside it, as a file may hold one.
let inner = {
let d = doc_mut(&mut s);
let mut copy = d.node(g).unwrap().clone();
copy = d.reid(&copy);
let id = copy.id;
d.node_mut(g).unwrap().children_mut().unwrap().push(std::sync::Arc::new(copy));
id
};
copy(&mut s, &[group]);
new_doc(&mut s);
let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0];
let mut graphs = vec![];
node(&s, p).walk(&mut |n| {
if let Some(spec) = n.extras().graph.as_deref() {
graphs.push((n.id, spec.label_styles.clone()));
}
});
assert_eq!(graphs.len(), 2, "{inner:?}");
for (id, l) in graphs {
assert_eq!((l.values, l.categories, l.legend, l.values_para, l.categories_para, l.legend_para), (None, None, None, None, None, None));
let looks = graph_label_looks(&s, id);
assert!(looks.iter().all(|(_, size, _)| *size == fresh[0].1), "{looks:?}");
}
}
/// A graph without its generated bounds (as a file may hold it) is drawn again where Paste without Formatting put it.
#[test]
fn a_graph_without_generated_bounds_pasted_without_formatting_follows_the_offset() {
let mut s = session();
let csv = ",2024\nQ1,3\nQ2,5";
let g = id_of(&s.execute("graph.create", &json!({"type": "bar", "x": 50, "y": 60, "width": 100, "height": 80, "csv": csv})).unwrap());
let plot = {
let mut extras = doc_mut(&mut s).node_mut(g).unwrap().extras_mut();
let spec = extras.graph.as_mut().unwrap();
spec.placed = None;
spec.rect
};
copy(&mut s, &[g]);
let v = s.execute("edit.pasteWithoutFormatting", &json!({"dx": 30, "dy": -5})).unwrap();
let p = ids_of(&v)[0];
s.execute("graph.setData", &json!({"id": p.0, "csv": csv})).unwrap();
let r = node(&s, p).extras().graph.as_deref().unwrap().rect;
let off = [r.x0 - plot.x0 - 30.0, r.y0 - plot.y0 + 5.0, r.x1 - plot.x1 - 30.0, r.y1 - plot.y1 + 5.0];
assert!(off.iter().all(|d| d.abs() < 1e-6), "{plot:?} → {r:?}");
// A graph saved with the bounds of all its art but not those of its art without type, drawn with 30 pt labels: the
// labels set at 9 pt do not read as a move or scale when it is drawn again.
let g = id_of(&s.execute("graph.create", &json!({"type": "bar", "x": 300, "y": 60, "width": 100, "height": 80, "csv": csv})).unwrap());
sel(&mut s, &[g]);
s.execute("text.setStyle", &json!({"size": 30})).unwrap();
s.execute("graph.setData", &json!({"id": g.0, "csv": csv})).unwrap();
let plot = {
let mut extras = doc_mut(&mut s).node_mut(g).unwrap().extras_mut();
let spec = extras.graph.as_mut().unwrap();
spec.placed_art = None;
spec.rect
};
copy(&mut s, &[g]);
let v = s.execute("edit.pasteWithoutFormatting", &json!({"dx": 30, "dy": -5})).unwrap();
let p = ids_of(&v)[0];
s.execute("graph.setData", &json!({"id": p.0, "csv": csv})).unwrap();
let r = node(&s, p).extras().graph.as_deref().unwrap().rect;
let off = [r.x0 - plot.x0 - 30.0, r.y0 - plot.y0 + 5.0, r.x1 - plot.x1 - 30.0, r.y1 - plot.y1 + 5.0];
assert!(off.iter().all(|d| d.abs() < 1e-6), "without the bounds of the art without type: {plot:?} → {r:?}");
}
#[test]
+1 -1
View File
@@ -40,7 +40,7 @@ fn close(a: f64, b: f64) -> bool {
fn segment_angles(p: &PathData) -> Vec<f64> {
p.subpaths
.iter()
.flat_map(|sp| sp.anchors.windows(2).map(|w| w[1].p - w[0].p))
.flat_map(|sp| sp.anchors.iter().zip(sp.anchors.iter().skip(1)).map(|(a, b)| b.p - a.p))
.filter(|v| v.hypot() > 1e-9)
.map(|v| (-v.y).atan2(v.x).to_degrees().rem_euclid(90.0))
.collect()
+5 -5
View File
@@ -890,7 +890,7 @@ fn curvature_rubber_band_is_the_path_the_click_makes() {
s.pointer(&PointerEvent::new(PointerKind::Up, 450.0, 400.0), v).unwrap();
let made = path(&s, id).subpaths[0].clone();
assert_eq!(made.anchors.len(), 4);
let tail = vectorcraft_geom::SubPath::new(made.anchors[1..].to_vec(), false);
let tail = vectorcraft_geom::SubPath::new(made.anchors.to_vec()[1..].to_vec(), false);
assert_eq!(preview, bez(&tail));
}
@@ -1218,7 +1218,7 @@ fn scissors_cut_ellipse_end_drags_alone() {
s.pointer(&PointerEvent::new(PointerKind::Up, cut.x + 8.0, cut.y + 8.0).with_mods(m), v).unwrap();
assert_eq!(undo_steps(&s), undo + 1);
let after = path(&s, id);
let (old, new) = (&sp.anchors, &after.subpaths[0].anchors);
let (old, new) = (&sp.anchors.to_vec(), &after.subpaths[0].anchors.to_vec());
assert_eq!(new.len(), old.len());
assert_eq!(new[1..], old[1..], "only the dragged end moves (at anchor: {at_anchor}, {m:?})");
let d = Point::new(cut.x + 8.0, cut.y + 8.0) - cut;
@@ -1572,7 +1572,7 @@ fn a_short_smooth_drag_leaves_the_rest_of_long_segments_alone() {
drag(&mut s, "smooth", &[(195.0, 194.0), (205.0, 194.0)], Mods::default());
let p = path(&s, id);
assert_ne!(p, before, "the bump is smoothed");
let a = &p.subpaths[0].anchors;
let a = &p.subpaths[0].anchors.to_vec();
let (first, second, last) = (a[0], a[1], a[a.len() - 1]);
assert!(first.p == Point::new(50.0, 200.0) && !first.has_out() && !second.has_in(), "the left side is a line still: {first:?} {second:?}");
assert!(last.p == Point::new(350.0, 200.0) && !last.has_in() && !a[a.len() - 2].has_out(), "and the right one: {last:?}");
@@ -1783,7 +1783,7 @@ fn smoothing_over_a_closed_paths_first_anchor_keeps_its_start() {
assert_ne!(after, before, "the bump is smoothed");
assert_starts_nearest(&after, old, 12.0);
let kept = &after.subpaths[0].anchors;
for a in &before.subpaths[0].anchors[2..=10] {
for a in &before.subpaths[0].anchors.to_vec()[2..=10] {
assert!(kept.iter().any(|k| k.p == a.p), "{:?} is out of reach and stays: {kept:?}", a.p);
}
s.execute("edit.undo", &json!({})).unwrap();
@@ -3885,7 +3885,7 @@ fn pen_cmd_selected_anchors_are_what_copy_and_cut_take() {
assert!(left.is_empty());
assert!(s.doc().unwrap().selection.objects.is_empty());
assert_eq!(s.clipboard.nodes.len(), 1);
assert_eq!(s.clipboard.nodes[0].path_data().unwrap().subpaths[0].anchors, original.subpaths[0].anchors[1..3]);
assert_eq!(s.clipboard.nodes[0].path_data().unwrap().subpaths[0].anchors, original.subpaths[0].anchors.to_vec()[1..3]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(paths(&s), vec![(id, original.clone())]);
assert_eq!(s.doc().unwrap().selection, picked);

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